Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,35 @@ public void TestOperatorName(string operatorname, string output) {
Assert.Equal(output, LaTeXParser.MathListToLaTeX(list).ToString());
}

/// <summary>
/// A letter in an operator name may be written as a command. AngouriMath emits
/// <c>\operatorname{\varphi}</c> for Euler's totient, which is the reason this exists.
/// </summary>
[Theory]
[InlineData(@"\varphi", "φ")]
[InlineData(@"\Gamma", "Γ")]
[InlineData(@"ma\chi ", "maχ")]
public void TestOperatorNameWithCommands(string operatorname, string name) {
var list = ParseLaTeX(@$"\operatorname{{{operatorname}}}");
Assert.Collection(list, CheckAtom<LargeOperator>(name));
var output = LaTeXParser.MathListToLaTeX(list).ToString();
Assert.Equal(@$"\operatorname{{{name}}} ", output);
// The name comes back out as the letter rather than as the command, so reading that is the
// round trip that matters -- and it is why the name is read with char.IsLetter.
Assert.Collection(ParseLaTeX(output), CheckAtom<LargeOperator>(name));
}

/// <summary><c>\bmod</c> is a binary operator whose nucleus is the word "mod".</summary>
[Fact]
public void TestModulo() {
var list = ParseLaTeX(@"x\bmod y");
Assert.Collection(list,
CheckAtom<Variable>("x"),
CheckAtom<BinaryOperator>("mod"),
CheckAtom<Variable>("y"));
Assert.Equal(@"x\bmod y", LaTeXParser.MathListToLaTeX(list).ToString());
}

[Theory]
[InlineData(@"\TeX")]
[InlineData(@"\left.\mathrm{T\! \raisebox{-4.5mu}{E}\mkern-2.25muX}\right.")]
Expand Down Expand Up @@ -1561,6 +1590,9 @@ public void TestHelpfulErrorMessage(string input, int index, string expected) {
InlineData(@"\operatorname {a|}", @"Error: Expected }
\operatorname {a|}
↑ (pos 16)"),
InlineData(@"\operatorname{\pm}", @"Error: Invalid command \pm in an operator name
\operatorname{\pm}
↑ (pos 17)"),
]
public void TestErrors(string badInput, string expected) {
var (list, actual) = LaTeXParser.MathListFromLaTeX(badInput);
Expand Down
102 changes: 102 additions & 0 deletions CSharpMath.Evaluation.Tests/AngouriMathLatexSweepTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AngouriMath;
using AngouriMath.Extensions;
using Xunit;

namespace CSharpMath.EvaluationTests {
using Atom;

/// <summary>
/// <see cref="Evaluation.Visualize"/> throws <c>InvalidCodePathException</c> on any LaTeX it
/// cannot read, and says why in its own source: "CSharpMath must handle all LaTeX coming from
/// AngouriMath or a bug is present!". Nothing checked that, on either side — AngouriMath's own
/// <c>Docs/Usage/Syntax.md</c> says the LaTeX round trip "is checked in someone else's
/// repository", meaning this one. So this sweeps every node AngouriMath can print and asserts
/// the LaTeX comes back through the parser. Each failure here is a crash waiting for a user.
/// </summary>
public class AngouriMathLatexSweepTests {
/// <param name="Strict">
/// False for nodes built by reflection: filling every argument with <c>x</c> can produce a node
/// that is not well-formed, so a throw out of <c>Latexize</c> is not evidence of a defect. The
/// hand-built shapes below are strict, because those are known-good expressions.
/// </param>
record Case(string Name, Entity Node, bool Strict);

static IEnumerable<Case> Nodes() {
var x = MathS.Var("x");
var y = MathS.Var("y");
foreach (var t in typeof(Entity).Assembly.GetTypes()
.Where(t => typeof(Entity).IsAssignableFrom(t) && !t.IsAbstract && !t.IsGenericTypeDefinition)
.OrderBy(t => t.Name, StringComparer.Ordinal)) {
Entity? built = null;
foreach (var ctor in t.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.OrderBy(c => c.GetParameters().Length)) {
var ps = ctor.GetParameters();
if (ps.Length is 0 or > 4) continue;
if (!ps.All(p => typeof(Entity).IsAssignableFrom(p.ParameterType))) continue;
try { built = (Entity)ctor.Invoke(ps.Select(_ => (object)x).ToArray()); break; } catch { }
}
if (built is not null) yield return new(t.Name, built, false);
}
// Shapes reflection cannot reach, listed by hand because they are exactly the ones whose
// LaTeX is unusual.
Case Strict(string name, Entity node) => new(name, node, true);
yield return Strict("Matrix", MathS.Vector(1, 2, 3));
yield return Strict("Matrix2x2", MathS.Matrix(new Entity[,] { { 1, 2 }, { 3, 4 } }));
yield return Strict("Piecewise", MathS.Piecewise((x, x > 0), (y, y > 0)));
yield return Strict("Integral", MathS.Integral(x, x));
yield return Strict("IntegralRanged", MathS.Integral(x, x, 0, 1));
yield return Strict("Derivative", MathS.Derivative(x, x));
yield return Strict("Limit", MathS.Limit(x, x, 0));
yield return Strict("Interval", new Entity.Set.Interval(0, true, 1, true));
yield return Strict("FiniteSet", new Entity.Set.FiniteSet(1, 2, 3));
yield return Strict("ConditionalSet", "{ x : x > 0 }".ToEntity());
yield return Strict("Integers", "ZZ".ToEntity());
yield return Strict("Reals", "RR".ToEntity());
yield return Strict("Complexes", "CC".ToEntity());
yield return Strict("Rationals", "QQ".ToEntity());
yield return Strict("Booleans", "BB".ToEntity());
yield return Strict("Rational", "3/2".ToEntity());
yield return Strict("ComplexNumber", MathS.Numbers.Create(1, 2));
yield return Strict("Factorial", MathS.Factorial(x));
yield return Strict("Union", MathS.Union("A".ToEntity(), "B".ToEntity()));
yield return Strict("Intersection", MathS.Intersection("A".ToEntity(), "B".ToEntity()));
yield return Strict("SetMinus", MathS.SetSubtraction("A".ToEntity(), "B".ToEntity()));
yield return Strict("In", "x in RR".ToEntity());
yield return Strict("Provided", "x provided x > 0".ToEntity());
yield return Strict("Apply", "apply(f, x)".ToEntity());
yield return Strict("Lambda", "lambda(x, x^2)".ToEntity());
yield return Strict("Domain", "domain(x, RR)".ToEntity());
yield return Strict("Signum", MathS.Signum(x));
yield return Strict("Abs", MathS.Abs(x));
yield return Strict("Modulo", MathS.Mod(x, y));
yield return Strict("EulerTotient", MathS.NumberTheory.Phi(x));
}

[Fact]
public void EveryAngouriMathNodeLatexizesIntoSomethingCSharpMathCanParse() {
var failures = new List<string>();
var checkedCount = 0;
foreach (var (name, node, strict) in Nodes()) {
string latex;
try {
latex = node.Latexize();
} catch (Exception e) {
if (strict) failures.Add($"{name}: Latexize threw {e.GetType().Name}: {e.Message}");
continue;
}
checkedCount++;
LaTeXParser.MathListFromLaTeX(latex)
.Match(_ => { }, err => failures.Add($"{name}: {latex}{Environment.NewLine} -> {err}"));
}
// A guard that checks nothing would also report no failures.
Assert.True(checkedCount > 50, $"only {checkedCount} nodes reached the parser");
Assert.True(failures.Count == 0,
$"checked {checkedCount} nodes, {failures.Count} unparseable:{Environment.NewLine} "
+ string.Join(Environment.NewLine + " ", failures));
}
}
}
29 changes: 23 additions & 6 deletions CSharpMath.Evaluation.Tests/EvaluationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ public void Numbers(string input, string converted, string output) =>
[InlineData(@"a / bc / d", @"\frac{\frac{a}{bc}}{d}", @"\frac{a}{bcd}")]
[InlineData(@"-2/\sin x/y", @"\frac{\frac{-2}{\sin \left( x\right) }}{y}", @"\frac{-2}{\sin \left( x\right) \cdot y}")]
public void BinaryOperators(string latex, string converted, string result) => Test(latex, converted, result);
/// <summary>
/// The two forms AngouriMath emits that had no reading here: <c>\bmod</c> for its modulo node
/// and <c>\operatorname{\varphi}</c> for Euler's totient. <c>\bmod</c> binds like multiplication
/// and division, which is AngouriMath's <c>Priority.Mul</c>, so the grouping cases below are
/// the point rather than decoration.
/// </summary>
[Theory]
[InlineData(@"x\bmod y", @"x\bmod y", @"x\bmod y")]
[InlineData(@"7\bmod 3", @"7\bmod 3", @"1")]
[InlineData(@"x+y\bmod z", @"x+y\bmod z", @"x+y\bmod z")]
[InlineData(@"x\bmod y+z", @"x\bmod y+z", @"x\bmod y+z")]
[InlineData(@"\operatorname{\varphi}(10)", @"\operatorname{φ} \left( 10\right) ", @"4")]
[InlineData(@"\operatorname{\varphi}(x)", @"\operatorname{φ} \left( x\right) ", @"\operatorname{φ} \left( x\right) ")]
public void AngouriMathOnlyForms(string latex, string converted, string result) => Test(latex, converted, result);
[Theory]
[InlineData(@"+i", @"\mathrm{i}", @"\mathrm{i}")]
[InlineData(@"-i", @"-\mathrm{i}", @"-\mathrm{i}")]
Expand Down Expand Up @@ -341,7 +355,7 @@ public void Numbers(string input, string converted, string output) =>
[InlineData(@"\sin \frac\pi2", @"\sin \left( \frac{\mathrm{\pi }}{2}\right) ", @"1")]
[InlineData(@"\sin \frac\pi2+1", @"\sin \left( \frac{\mathrm{\pi }}{2}\right) +1", @"2")]
[InlineData(@"\cos +x", @"\cos \left( x\right) ", @"\cos \left( x\right) ")]
[InlineData(@"\cos -x", @"\cos \left( -x\right) ", @"\cos \left( -x\right) ")]
[InlineData(@"\cos -x", @"\cos \left( -x\right) ", @"\cos \left( x\right) ")] // 2.2.0 uses evenness
[InlineData(@"\tan x\%", @"\tan \left( \frac{x}{100}\right) ", @"\tan \left( \frac{x}{100}\right) ")]
[InlineData(@"\tan x\%^2", @"\tan \left( \left( \frac{x}{100}\right) ^2\right) ", @"\tan \left( \left( \frac{x}{100}\right) ^2\right) ")]
[InlineData(@"\cot x\times y", @"\cot \left( x\right) \cdot y", @"\cot \left( x\right) \cdot y")]
Expand Down Expand Up @@ -391,7 +405,8 @@ public void Numbers(string input, string converted, string output) =>
[InlineData(@"\tan^{-1} x\%^2", @"\arctan \left( \left( \frac{x}{100}\right) ^2\right) ", @"\arctan \left( \left( \frac{x}{100}\right) ^2\right) ")]
[InlineData(@"\cot^{-1} x\times y", @"\arccot \left( x\right) \cdot y", @"\arccot \left( x\right) \cdot y")]
[InlineData(@"\cot^{-1} x/y", @"\frac{\arccot \left( x\right) }{y}", @"\frac{\arccot \left( x\right) }{y}")]
[InlineData(@"\cos^{-1} \arccos^{-1} x", @"\arccos \left( \cos \left( x\right) \right) ", @"x")]
// arccos(cos x) is x only on [0, pi]; AngouriMath 2.2.0 no longer claims it in general.
[InlineData(@"\cos^{-1} \arccos^{-1} x", @"\arccos \left( \cos \left( x\right) \right) ", @"\arccos \left( \cos \left( x\right) \right) ")]
[InlineData(@"\sin^1 x", @"\sin \left( x\right) ^1", @"\sin \left( x\right) ")]
[InlineData(@"\sin^{+1} x", @"\sin \left( x\right) ^1", @"\sin \left( x\right) ")]
[InlineData(@"\sin^{+-1} x", @"\sin \left( x\right) ^{-1}", @"\csc \left( x\right) ")]
Expand Down Expand Up @@ -869,9 +884,10 @@ public void SimpleArithmeticSyntax(string simpleSyntax, string latex) =>
[InlineData(@"\top\nleftrightarrow\bot", @"\top \veebar \bot ", @"\top ")]
[InlineData(@"\bot\nleftrightarrow\bot", @"\bot \veebar \bot ", @"\bot ")]
[InlineData(@"x=x", @"x=x", @"\top ")]
[InlineData(@"x\le x", @"x\leq x", @"\top ")]
[InlineData(@"x\leq x", @"x\leq x", @"\top ")]
[InlineData(@"x\leqslant x", @"x\leq x", @"\top ")]
// AngouriMath 2.2.0 carries the domain <= needs rather than asserting the tautology outright.
[InlineData(@"x\le x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")]
[InlineData(@"x\leq x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")]
[InlineData(@"x\leqslant x", @"x\leq x", @"\top \quad \mathrm{for}\quad x\in \mathbb{R}")]
[InlineData(@"x\neq y", @"x\neq y", @"x\neq y")] // Cannot simplify without knowing x and y
[InlineData(@"1<2", @"1<2", @"\top ")]
[InlineData(@"2<1", @"2<1", @"\bot ")]
Expand Down Expand Up @@ -951,7 +967,8 @@ public void ChainedComparisons(string latex, string converted, string result, st
[InlineData(@"-\operatorname{abs}(-1)", @"-\left| -1\right| ", @"-1")]
[InlineData(@"-\operatorname{abs}\left|-1\right|", @"-\left| \left| -1\right| \right| ", @"-1")]
[InlineData(@"-\left|1\right|^2", @"-\left| 1\right| ^2", @"-1")]
[InlineData(@"\operatorname{sgn}\operatorname{abs} x", @"\operatorname{sgn} \left( \left| x\right| \right) ", @"1")]
// AngouriMath 2.2.0 no longer answers 1 here: sgn(|x|) is 1 away from zero but 0 at zero.
[InlineData(@"\operatorname{sgn}\operatorname{abs} x", @"\operatorname{sgn} \left( \left| x\right| \right) ", @"\operatorname{sgn} \left( \left| x\right| \right) ")]
public void Abs(string latex, string converted, string result) => Test(latex, converted, result);
[Theory]
[InlineData(@"\lim_{x\to2}x+1", @"\lim _{x\rightarrow 2}x+1", @"3")]
Expand Down
2 changes: 1 addition & 1 deletion CSharpMath.Evaluation.Tests/InterpretTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class InterpretTests {
[InlineData(@"1+2", @"\underline\mathrm{Input}\\1+2\\\\\underline\mathrm{Simplified}\\3\\\\\underline\mathrm{Value\ (100\ digits)}\\3")]
[InlineData(@"1+\sqrt", @"\color{red}\text{Missing radicand}")]
[InlineData(@"1+\sqrt2", @"\underline\mathrm{Input}\\1+\sqrt{2}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2}\\\\\underline\mathrm{Value\ (100\ digits)}\\2.414213562373095048801688724209698078569671875376948073176679737990732478462107038850387534327641573")]
[InlineData(@"1+\sqrt{2x}", @"\underline\mathrm{Input}\\1+\sqrt{2 x}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2 x}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x}")]
[InlineData(@"1+\sqrt{2x}", @"\underline\mathrm{Input}\\1+\sqrt{2 x}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2} \sqrt{x}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x}")]
[InlineData(@"1+\sqrt{2xy}", @"\underline\mathrm{Input}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Simplified}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Expanded}\\1+\sqrt{2 x y}\\\\\underline\mathrm{Factorized}\\1+\sqrt{2 x y}")]
[InlineData(@"=1+\sqrt{2xy}", @"\color{red}\text{Missing left side of equation}")]
[InlineData(@"1+\sqrt{2xy}=", @"\color{red}\text{Missing right side of equation}")]
Expand Down
2 changes: 1 addition & 1 deletion CSharpMath.Evaluation/CSharpMath.Evaluation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

<ItemGroup>
<ProjectReference Include="..\CSharpMath\CSharpMath.csproj" />
<PackageReference Include="AngouriMath" Version="1.4.0" />
<PackageReference Include="AngouriMath" Version="2.2.0" />
</ItemGroup>

</Project>
20 changes: 18 additions & 2 deletions CSharpMath.Evaluation/Evaluation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,20 @@ enum Precedence {
Postfix
// Highest
}
public abstract record MathItem : ILatexiseable {
public abstract record MathItem : ILatexizeable {
private protected MathItem() { }
public abstract string Latexise();
// AngouriMath 2.0 renamed ILatexiseable.Latexise to ILatexizeable.Latexize. Implementing
// it explicitly keeps MathItem.Latexise as the public name here, so this package's own
// surface is unchanged by their rename.
string ILatexizeable.Latexize() => Latexise();
public static implicit operator MathItem(AngouriMath.Entity content) => new Entity(content);
public static explicit operator AngouriMath.Entity(MathItem item) => ((Entity)item).Content;
/// <summary>A real number, complex number, variable, function call, vector, matrix, higher-dimensional tensor, or set</summary>
public sealed record Entity : MathItem {
public Entity(AngouriMath.Entity content) => Content = content;
public AngouriMath.Entity Content { get; }
public override string Latexise() => Content.Latexise();
public override string Latexise() => Content.Latexize();
}
/// <summary>A linked list of comma-delimited items</summary>
public sealed record Comma : MathItem, IEnumerable<MathItem> {
Expand Down Expand Up @@ -494,6 +498,12 @@ string GreekToLaTeXCommandName(string n) =>
handleFunction = MathS.Signum;
handleFunctionInverse = arg => MathS.NaN;
goto handleFunction;
// Euler's totient, which AngouriMath writes \operatorname{\varphi}. It is not injective
// (φ(1) = φ(2) = 1), so there is no inverse to offer -- as for abs and sgn above.
case Atoms.LargeOperator { Nucleus: "φ" }:
handleFunction = MathS.NumberTheory.Phi;
handleFunctionInverse = arg => MathS.NaN;
goto handleFunction;
case Atoms.LargeOperator { Nucleus: "lim", Subscript: var limitSubscript }:
Entity limitVariable, limitTarget;
int limitSubscriptIndex = 0;
Expand Down Expand Up @@ -563,6 +573,12 @@ string GreekToLaTeXCommandName(string n) =>
handlePrecedence = Precedence.MultiplicationDivision;
handleBinary = (a, b) => a / b;
goto handleBinary;
// \bmod, which AngouriMath emits for its modulo node. It binds like multiplication and
// division there too (Priority.Mul), so a+b \bmod c is a+(b mod c) on both sides.
case Atoms.BinaryOperator { Nucleus: "mod" }:
handlePrecedence = Precedence.MultiplicationDivision;
handleBinary = MathS.Mod;
goto handleBinary;
case Atoms.Ordinary { Nucleus: "%" }:
handlePostfix = x => x / 100;
goto handlePostfix;
Expand Down
4 changes: 2 additions & 2 deletions CSharpMath.Evaluation/Interpret.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

namespace CSharpMath {
static partial class Evaluation {
static StringBuilder AppendLaTeX(this StringBuilder sb, AngouriMath.Core.ILatexiseable latex) =>
sb.Append(latex.Latexise());
static StringBuilder AppendLaTeX(this StringBuilder sb, AngouriMath.Core.ILatexizeable latex) =>
sb.Append(latex.Latexize());
static StringBuilder AppendLaTeXHeader(this StringBuilder sb, string header, bool includeNewlineBefore = true) {
if (includeNewlineBefore) sb.Append(@"\\\\");
return sb.Append(@"\underline\mathrm{").Append(header).Append(@"}\\");
Expand Down
37 changes: 37 additions & 0 deletions CSharpMath.Rendering.Tests/TestAngouriMathForms.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Xunit;

namespace CSharpMath.Rendering.Tests {
/// <summary>
/// <c>\bmod</c> is the first binary operator here whose nucleus is a word rather than a symbol,
/// and <c>\operatorname{φ}</c> the first operator name written with a non-ASCII letter. Both come
/// from AngouriMath. Glyph coverage is already asserted by
/// <see cref="TestCommandDisplay.CommandsAreDisplayable"/>; what is new is the layout, so this
/// measures rather than comparing against a baseline image.
/// </summary>
public class TestAngouriMathForms {
static System.Drawing.RectangleF Measure(string latex) {
var painter = new SkiaSharp.MathPainter { LaTeX = latex };
Assert.Null(painter.ErrorMessage);
return painter.Measure(FrontEnd.TextPainter<global::SkiaSharp.SKCanvas, global::SkiaSharp.SKColor>.DefaultCanvasWidth);
}

[Theory]
[InlineData(@"x\bmod y")]
[InlineData(@"\operatorname{φ}\left( x\right) ")]
[InlineData(@"\operatorname{φ}\left( x\bmod y\right) ")]
public void TheyLayOut(string latex) {
var measured = Measure(latex);
Assert.True(measured.Width > 0, $"zero width for {latex}");
Assert.True(measured.Height > 0, $"zero height for {latex}");
}

/// <summary>
/// The three letters of "mod" and the spacing around a binary operator are actually laid out,
/// rather than the atom occupying no room -- which a zero-width check alone would not catch,
/// since x and y are there either way.
/// </summary>
[Fact]
public void ModuloTakesUpRoom() =>
Assert.True(Measure(@"x\bmod y").Width > Measure(@"xy").Width * 2);
}
}
Loading
Loading