Skip to content
Open
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
146 changes: 146 additions & 0 deletions CSharpMath.Core.Tests/Atom/LongDivisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System;
using System.Linq;
using System.Reflection;
using CSharpMath.Atom;
using CSharpMath.Atom.Atoms;
using Xunit;

namespace CSharpMath.Core.AtomTests {
public class LongDivisionTests {
static string CellText(MathList cell) => cell.Count == 0 ? "" : cell[0] switch {
Number number => number.Nucleus,
Underline underline => CellText(underline.InnerList),
Overline overline => CellText(overline.InnerList),
Inner inner => CellText(inner.InnerList),
IMathListContainer container => CellText(Assert.Single(container.InnerLists)),
_ => cell.DebugString
};
static LongDivision Parse(string source) {
var result = LaTeXParser.MathListFromLaTeX(source);
Assert.Null(result.Error);
var (list, _) = result;
return Assert.IsType<LongDivision>(list[0]);
}

[Theory]
[InlineData("12345", "13", "949", 8)]
[InlineData("123", "1234", "0", 123)]
[InlineData("31415926", "2", "15707963", 0)]
[InlineData("81", "3", "27", 0)]
[InlineData("1132", "99", "11", 43)]
[InlineData("86491", "94", "920", 11)]
public void ComputesRepresentativeDivisions(string numerator, string denominator, string quotient, int remainder) {
var atom = new LongDivision(numerator, denominator);
Assert.Equal(quotient, atom.QuotientText);
Assert.Equal(remainder.ToString(), atom.Remainder);
Assert.NotEmpty(atom.Steps);
}

[Fact]
public void ParsesAsSemanticAtomAndRoundTrips() {
var atom = Parse(@"\longdiv{00123}{0007}");
Assert.Equal("123", atom.Numerator);
Assert.Equal("7", atom.Denominator);
Assert.Equal(@"\longdiv{123}{7}", LaTeXParser.MathListToLaTeX(new MathList(atom)).ToString());
Assert.Equal(atom, atom.Clone(false));
}

[Theory]
[InlineData(@"\longdiv{12}{0}")]
[InlineData(@"\longdiv{-12}{3}")]
[InlineData(@"\longdiv{12.5}{3}")]
[InlineData(@"\longdiv{12}{abc}")]
[InlineData(@"\longdiv{123456789012345678901234567890}{3}")]
public void RejectsInvalidOperands(string source) {
var result = LaTeXParser.MathListFromLaTeX(source);
Assert.NotNull(result.Error);
Assert.Contains("longdiv", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public void ErrorDoesNotPoisonFollowingParse() {
Assert.NotNull(LaTeXParser.MathListFromLaTeX(@"\longdiv{1}{0}").Error);
var result = LaTeXParser.MathListFromLaTeX(@"\longdiv{81}{3}");
Assert.Null(result.Error);
var (list, _) = result;
Assert.IsType<LongDivision>(list[0]);
}

[Theory]
[InlineData(@"\longdiv{12}{3", "missing } for denominator", 14)]
[InlineData(@"\longdiv{{12}}{3}", "must be a nonnegative", 14)]
[InlineData(@"\longdiv{12}{x}", "must be a nonnegative", 15)]
public void MalformedOperandsReportSourceContext(string source, string detail, int expectedPosition) {
var result = LaTeXParser.MathListFromLaTeX(source);
Assert.NotNull(result.Error);
Assert.Contains(detail, result.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("\u2191 (pos " + expectedPosition + ")", result.Error);
Assert.Contains(source.TrimEnd('}'), result.Error);
}

[Fact]
public void TraceRetainsZeroDigitsAndPlaceColumns() {
var atom = new LongDivision("12345", "13");
Assert.Equal(new[] { 0, 0, 9, 4, 9 }, atom.Steps.Select(s => s.QuotientDigit));
Assert.Equal(new[] { 0, 1, 2, 3, 4 }, atom.Steps.Select(s => s.DecimalColumn));
Assert.Equal("8", atom.Steps.Last().Remainder);
Assert.Equal(atom.Remainder, atom.Steps.Last().Remainder);
}

[Theory]
[InlineData("3", "5", "0", "3")]
[InlineData("0", "7", "0", "0")]
[InlineData("1005", "5", "201", "0")]
public void HandlesShortAndExactDivisions(string numerator, string denominator, string quotient, string remainder) {
var atom = new LongDivision(numerator, denominator);
Assert.Equal(quotient, atom.QuotientText);
Assert.Equal(remainder, atom.Remainder);
Assert.Equal(numerator.Length, atom.Steps.Count);
}

[Fact]
public void LayoutHasBracketRuleAndOneFinalRemainder() {
var atom = new LongDivision("12345", "13");
var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!;
var dividend = layout.Cells[1][1];
Assert.Equal("LongDivisionHeader", dividend[0].GetType().Name);
var header = Assert.IsAssignableFrom<IMathListContainer>(dividend[0]);
var digits = Assert.IsType<Number>(Assert.Single(Assert.Single(header.InnerLists)));
Assert.Equal("12345", digits.Nucleus);
Assert.Equal("949", CellText(layout.Cells[0][1]));
Assert.Equal(ColumnAlignment.Right, layout.GetAlignment(1));
Assert.Equal(1, layout.Cells.Count(row => row.Any(cell => CellText(cell) == atom.Remainder)));
}

[Fact]
public void LayoutPreservesProductPlaceValuesAndRunningTotals() {
var atom = new LongDivision("12345", "13");
var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!;
Assert.Equal(new[] { "949", "12345", "11700", "645", "520", "125", "117", "8" },
layout.Cells.Select(row => CellText(row[1])));
}

[Fact]
public void LayoutRetainsInternalZeroStepsWithoutRowsForZeroProducts() {
var atom = new LongDivision("1005", "5");
Assert.Contains(atom.Steps, step => step.QuotientDigit == 0);
var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!;
Assert.Equal("201", CellText(layout.Cells[0][1]));
Assert.Equal(new[] { "201", "1005", "1000", "5", "5", "0" },
layout.Cells.Select(row => CellText(row[1])));
Assert.Equal(1, layout.Cells.Skip(2).Count(row => row.Any(cell => CellText(cell) == "0")));
}

[Theory]
[InlineData("81", "3", new[] { "27", "81", "60", "21", "21", "0" })]
[InlineData("3", "5", new[] { "0", "3", "3" })]
[InlineData("0", "7", new[] { "0", "0", "0" })]
[InlineData("86491", "94", new[] { "920", "86491", "84600", "1891", "1880", "11" })]
public void LayoutShowsExactlyOneFinalRemainder(string numerator, string denominator, string[] expected) {
var atom = new LongDivision(numerator, denominator);
var layout = (Table)typeof(LongDivision).GetMethod("CreateLayout", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(atom, null)!;
Assert.Equal(expected, layout.Cells.Select(row => CellText(row[1])));
Assert.Equal(atom.Remainder, CellText(layout.Cells.Last()[1]));
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
164 changes: 164 additions & 0 deletions CSharpMath.Rendering.Tests/TestLongDivisionRendering.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Linq;
using CSharpMath.Avalonia;
using CSharpMath.SkiaSharp;
using SkiaSharp;
using Xunit;
using AvaloniaMathPainter = CSharpMath.Avalonia.MathPainter;
using SkiaMathPainter = CSharpMath.SkiaSharp.MathPainter;

namespace CSharpMath.Rendering.Tests {
public sealed class TestLongDivisionRendering {
[Theory]
[InlineData("12345", "13")]
[InlineData("1005", "5")]
[InlineData("3", "5")]
[InlineData("0", "7")]
[InlineData("9999999999999999999999999999", "1")]
public void SkiaProducesMeasuredLongDivision(string numerator, string denominator) {
using var stream = new SkiaMathPainter { LaTeX = $@"\longdiv{{{numerator}}}{{{denominator}}}" }.DrawAsStream();
Assert.NotNull(stream);
Assert.True(stream!.Length > 0);
}

[Theory]
[InlineData("12345", "13")]
[InlineData("1005", "5")]
[InlineData("3", "5")]
[InlineData("0", "7")]
[InlineData("9999999999999999999999999999", "1")]
public void AvaloniaProducesMeasuredLongDivision(string numerator, string denominator) {
global::Avalonia.Skia.SkiaPlatform.Initialize();
using var stream = new MemoryStream();
new AvaloniaMathPainter {
LaTeX = $@"\longdiv{{{numerator}}}{{{denominator}}}"
}.DrawAsPng(stream);
Assert.True(stream.Length > 0);
}

[Fact]
public void ScalingChangesGeometryWithoutChangingSemanticRows() {
var painter = new SkiaMathPainter { LaTeX = @"\longdiv{12345}{13}" };
var normal = painter.Measure(2000);
painter.FontSize *= 2;
var large = painter.Measure(2000);
Assert.True(large.Width > normal.Width);
Assert.True(large.Height > normal.Height);
var result = CSharpMath.Atom.LaTeXParser.MathListFromLaTeX(@"\longdiv{12345}{13}");
Assert.Null(result.Error);
var (list, _) = result;
var atom = Assert.IsType<CSharpMath.Atom.Atoms.LongDivision>(list[0]);
Assert.Equal(new[] { 0, 1, 2, 3, 4 }, atom.Steps.Select(s => s.DecimalColumn));
}

[Theory]
[InlineData(@"\longdiv{12345}{13}", "display")]
[InlineData(@"\text{answer: }\longdiv{1005}{5}", "text")]
[InlineData(@"x_{\longdiv{3}{5}}", "script")]
public void DisplayTextAndScriptRemainNonEmptyWhenScaled(string latex, string kind) {
var painter = new SkiaMathPainter { LaTeX = latex };
var before = painter.Measure(2000);
painter.FontSize *= 0.5f;
var half = painter.Measure(2000);
painter.FontSize *= 4;
var twice = painter.Measure(2000);
Assert.True(before.Width > 0 && before.Height > 0, kind);
Assert.True(half.Width > 0 && half.Height > 0, kind);
Assert.True(twice.Width > half.Width && twice.Height > half.Height, kind);
}

[Theory]
[InlineData(@"\longdiv{12345}{13}", CSharpMath.Atom.LineStyle.Text)]
[InlineData(@"\longdiv{12345}{13}", CSharpMath.Atom.LineStyle.Script)]
public void LongDivisionBarConnectsToDelimiterInTextAndScriptStyles(string latex, CSharpMath.Atom.LineStyle style) {
using var skia = RenderSkia(latex, style);
AssertBarConnectsToDelimiter(skia, latex);
global::Avalonia.Skia.SkiaPlatform.Initialize();
using var avalonia = new MemoryStream();
new AvaloniaMathPainter { LaTeX = latex, LineStyle = style }.DrawAsPng(avalonia);
avalonia.Position = 0;
AssertBarConnectsToDelimiter(avalonia, latex);
}

private static MemoryStream RenderSkia(string latex, CSharpMath.Atom.LineStyle style) {
using var rendered = new SkiaMathPainter { LaTeX = latex, LineStyle = style }.DrawAsStream();
var copy = new MemoryStream();
rendered!.CopyTo(copy);
copy.Position = 0;
return copy;
}

private static void AssertBarConnectsToDelimiter(Stream png, string latex) {
using var bitmap = SKBitmap.Decode(png);
Assert.NotNull(bitmap);
var dark = new bool[bitmap!.Width, bitmap.Height];
for (var y = 0; y < bitmap.Height; y++)
for (var x = 0; x < bitmap.Width; x++) {
var color = bitmap.GetPixel(x, y);
dark[x, y] = color.Alpha > 32 && color.Red < 190 && color.Green < 190 && color.Blue < 190;
}

var bestY = -1;
var bestStart = 0;
var bestLength = 0;
for (var y = 0; y < bitmap.Height; y++) {
var start = -1;
for (var x = 0; x <= bitmap.Width; x++) {
if (x < bitmap.Width && dark[x, y]) {
if (start < 0) start = x;
} else if (start >= 0) {
if (x - start > bestLength) {
bestY = y;
bestStart = start;
bestLength = x - start;
}
start = -1;
}
}
}
Assert.True(bestLength >= 8, $"No overbar found for {latex}.");

var ruleBottom = bestY;
for (var y = bestY + 1; y < bitmap.Height; y++) {
var run = LongestDarkRun(dark, bitmap.Width, y);
if (run.Length < bestLength * 0.8) break;
ruleBottom = y;
}

// The connected component must reach below the rule near its left endpoint.
var visited = new bool[bitmap.Width, bitmap.Height];
var pending = new System.Collections.Generic.Queue<(int X, int Y)>();
for (var x = bestStart; x < bestStart + bestLength; x++) {
visited[x, bestY] = true;
pending.Enqueue((x, bestY));
}
var reachesBelow = false;
while (pending.Count > 0) {
var (x, y) = pending.Dequeue();
if (y > ruleBottom + Math.Max(3, bitmap.Height / 12) && x <= bestStart + Math.Max(8, bitmap.Height / 8)) reachesBelow = true;
foreach (var (nx, ny) in new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) })
if (nx >= 0 && nx < bitmap.Width && ny >= 0 && ny < bitmap.Height && dark[nx, ny] && !visited[nx, ny]) {
visited[nx, ny] = true;
pending.Enqueue((nx, ny));
}
}
Assert.True(reachesBelow, $"Overbar is not connected to the delimiter for {latex}.");
}

private static (int Start, int Length) LongestDarkRun(bool[,] dark, int width, int y) {
var bestStart = 0;
var bestLength = 0;
var start = -1;
for (var x = 0; x <= width; x++) {
if (x < width && dark[x, y]) {
if (start < 0) start = x;
} else if (start >= 0) {
if (x - start > bestLength) (bestStart, bestLength) = (start, x - start);
start = -1;
}
}
return (bestStart, bestLength);
}
}
}
7 changes: 6 additions & 1 deletion CSharpMath.Rendering.Tests/TestRenderingMathData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public sealed class TestRenderingMathData : TestRenderingSharedData<TestRenderin
public const string FunctionDomainCodomain = @"f\colon\mathbb N\rightarrow\mathbb N";

public const string IntPlusFraction = @"1+\frac23";
public const string LongDivisionRemainder = @"\longdiv{12345}{13}";
public const string LongDivisionInternalZero = @"\longdiv{1005}{5}";
public const string LongDivisionExact = @"\longdiv{81}{3}";
public const string LongDivisionShort = @"\longdiv{3}{5}";
public const string LongDivisionTrailingZero = @"\longdiv{86491}{94}";
public const string Integral = @"\int_{0}^{\infty}e^x \,dx=\oint_0^{\Delta}5\Gamma";
public const string IntegralColorBoxCorrect = @"\colorbox{red}{\int^\colorbox{yellow}\infty_\colorbox{purple}{-\infty}}\colorbox{green}x\ \colorbox{blue}{dx}";
public const string IntegralColorBoxWrong = @"\colorbox{red}\int^\colorbox{yellow}\infty_\colorbox{purple}{-\infty}\colorbox{green}x\ \colorbox{blue}{dx}";
Expand Down Expand Up @@ -135,4 +140,4 @@ public sealed class TestRenderingMathData : TestRenderingSharedData<TestRenderin

public const string VectorProjection = @"Proj_\vec{v}\vec{u}=|\vec u|\cos\theta\times\frac\vec v{|\vec v|}=|\vec u|\frac{\vec u \cdot \vec v}{|\vec u||\vec v|}\times\frac\vec v{|\vec v|}\\\text{Suppose \mathit{u} and \mathit v are unit vectors, }Proj_\vec v\vec u = (\vec u\cdot\vec v)\vec v";
}
}
}
Loading
Loading