Files
OpenNest/OpenNest.Tests/Fill/FillExtentsTests.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

684 lines
29 KiB
C#

using System.Reflection;
using OpenNest.CNC;
using OpenNest.Engine.Strategies;
using OpenNest.Tests.BestFit;
using Xunit.Abstractions;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests.Fill;
[Collection(nameof(FillCacheCollection))]
public class FillExtentsTests
{
private readonly ITestOutputHelper output;
public FillExtentsTests(ITestOutputHelper output) => this.output = output;
public static IEnumerable<object[]> DifferentialCases()
{
foreach (var shape in new[] { "rectangle", "triangle", "concave", "arc" })
foreach (var spacing in new[] { 0.0, 0.5 })
foreach (var rotated in new[] { false, true })
yield return new object[] { shape, spacing, rotated };
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_MatchesFrozenLegacy_OrderedLayoutAndInputOwnership(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
if (rotated)
drawing.Program.Rotate(System.Math.PI / 2);
var before = Snapshot(drawing);
var workArea = new Box(3, 5, 45, 27);
var areaBefore = Bounds(workArea);
var angle = rotated ? System.Math.PI / 2 : 0;
var expectedProgress = new List<(List<Part> Parts, string Message)>();
var actualProgress = new List<(List<Part> Parts, string Message)>();
var expected = new LegacyFillExtents(workArea, spacing).Fill(drawing, angle,
reportProgress: (parts, message) => expectedProgress.Add((parts, message)));
var actual = new FillExtents(workArea, spacing).Fill(drawing, angle,
reportProgress: (parts, message) => actualProgress.Add((parts, message)));
AssertSameLayout(expected, actual);
Assert.Equal(expectedProgress.Count, actualProgress.Count);
for (var i = 0; i < expectedProgress.Count; i++)
{
Assert.Equal(expectedProgress[i].Message, actualProgress[i].Message);
AssertSameLayout(expectedProgress[i].Parts, actualProgress[i].Parts);
}
AssertValidLayout(actual, workArea);
Assert.Equal(before, Snapshot(drawing));
Assert.Equal(areaBefore, Bounds(workArea));
Assert.All(actual, part => Assert.NotSame(drawing.Program, part.Program));
// Offset clones intentionally share programs until rotation takes ownership.
for (var i = 0; i < actual.Count; i++)
for (var j = 0; j < actual.Count; j++)
Assert.Equal(ReferenceEquals(expected[i].Program, expected[j].Program),
ReferenceEquals(actual[i].Program, actual[j].Program));
var siblingPrograms = actual.Skip(1).Select(part => ProgramValues(part.Program)).ToArray();
actual[0].Rotate(0.125);
for (var i = 1; i < actual.Count; i++)
Assert.Equal(siblingPrograms[i - 1], ProgramValues(actual[i].Program));
Assert.Equal(before, Snapshot(drawing));
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_Thresholds_MatchPreChangePairFitAndColumnBranches(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var before = Snapshot(drawing);
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
// The reference BuildPair is identical to pre-Task-3 production. Task 2's
// finite/nonnegative pitch shortcut gives the same column, not different poses.
foreach (var axis in new[] { "length", "width", "column" })
{
foreach (var fits in new[] { false, true })
{
var delta = fits ? 1e-9 : -1e-9;
var length = axis == "length" ? bbox.Length - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var width = axis == "width" ? bbox.Width - OpenNest.Math.Tolerance.Epsilon + delta
: axis == "column" ? 2 * bbox.Width + spacing - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var area = new Box(roomy.X, roomy.Y, length, width);
var areaBefore = Bounds(area);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(axis == "column" || fits, expectedPair != null);
Assert.Equal(expectedPair == null, actualPair == null);
if (expectedPair != null)
{
AssertPairEqual(expectedPair, actualPair!);
var expectedColumn = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actualColumn = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
if (axis == "column")
Assert.Equal(fits ? 4 : 2, expectedColumn.Count);
AssertSameLayout(expectedColumn, actualColumn);
}
var expectedProgress = new List<List<Part>>();
var actualProgress = new List<List<Part>>();
var expected = legacy.Fill(drawing, angle, reportProgress: (parts, _) => expectedProgress.Add(parts));
var actual = filler.Fill(drawing, angle, reportProgress: (parts, _) => actualProgress.Add(parts));
AssertSameLayout(expected, actual);
Assert.Equal(expectedProgress.Count, actualProgress.Count);
for (var i = 0; i < expectedProgress.Count; i++)
AssertSameLayout(expectedProgress[i], actualProgress[i]);
Assert.Equal(areaBefore, Bounds(area));
Assert.Equal(before, Snapshot(drawing));
}
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_AdjacentDoubleThresholds_PreservePairAndColumnDecisions(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
foreach (var axis in new[] { "length", "width", "column" })
{
var threshold = (axis == "length" ? bbox.Length : axis == "width" ? bbox.Width
: 2 * bbox.Width + spacing) - OpenNest.Math.Tolerance.Epsilon;
var decisions = new HashSet<bool>();
foreach (var size in AdjacentDoubles(threshold))
{
var area = new Box(roomy.X, roomy.Y, axis == "length" ? size : 100,
axis == "length" ? 100 : size);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(expectedPair == null, actualPair == null);
if (axis != "column")
decisions.Add(expectedPair != null);
if (expectedPair == null)
continue;
AssertPairEqual(expectedPair, actualPair!);
var expected = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actual = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
AssertSameLayout(expected, actual);
if (axis == "column")
decisions.Add(expected.Count == 4);
}
Assert.Contains(false, decisions);
Assert.Contains(true, decisions);
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void TryShiftDirection_AdjacentWidthThresholds_PreserveAcceptance(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var area = new Box(3.1, 5.3, 100, 100);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
foreach (var shift in new[] { -0.3, 0.3 })
{
// Locate adjacent rejected/accepted doubles using only the frozen pre-change
// path; do not derive the oracle from the implementation under test.
var rejected = 0.0;
var accepted = 100.0;
for (var i = 0; i < 64; i++)
{
var middle = (rejected + accepted) / 2;
if (Invoke(legacy, "TryShiftDirection", expectedPair, shift, middle) == null)
rejected = middle;
else
accepted = middle;
}
Assert.Equal(accepted, System.Math.BitIncrement(rejected));
foreach (var width in new[] { rejected, accepted })
{
var expected = Invoke(legacy, "TryShiftDirection", expectedPair, shift, width);
var actual = Invoke(filler, "TryShiftDirection", actualPair, shift, width);
Assert.Equal(width == accepted, expected != null);
Assert.Equal(expected == null, actual == null);
if (expected != null)
AssertPairEqual(expected, actual!);
}
}
}
private static IEnumerable<double> AdjacentDoubles(double value)
{
yield return value;
var lower = value;
var upper = value;
for (var i = 0; i < 4; i++)
{
lower = System.Math.BitDecrement(lower);
upper = System.Math.BitIncrement(upper);
yield return lower;
yield return upper;
}
}
private static Box PairBounds(object pair) => (Box)pair.GetType().GetProperty("Bbox")!.GetValue(pair)!;
private static void AssertPairEqual(object expected, object actual)
{
Assert.Equal(Bounds(PairBounds(expected)), Bounds(PairBounds(actual)));
var expectedParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)expected.GetType().GetProperty(name)!.GetValue(expected)!).ToList();
var actualParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)actual.GetType().GetProperty(name)!.GetValue(actual)!).ToList();
AssertSameLayout(expectedParts, actualParts);
}
[Theory]
[InlineData(5, 5)]
[InlineData(15, 10)] // One rectangle fits, the pair does not.
public void Fill_NoFit_MatchesFrozenLegacy(double length, double width)
{
var drawing = MakeRect(10, 8);
var before = Snapshot(drawing);
var area = new Box(3, 5, length, width);
var expected = new LegacyFillExtents(area, 0.5).Fill(drawing);
var actual = new FillExtents(area, 0.5).Fill(drawing);
Assert.Empty(expected);
AssertSameLayout(expected, actual);
Assert.Equal(before, Snapshot(drawing));
}
[Fact]
public void Fill_PreCanceled_MatchesFrozenLegacyRatherThanThrowing()
{
var drawing = MakeFixture("triangle");
var before = Snapshot(drawing);
var area = new Box(3, 5, 45, 27);
var token = new CancellationToken(true);
var expected = new LegacyFillExtents(area, 0.5).Fill(drawing, token: token);
var actual = new FillExtents(area, 0.5).Fill(drawing, token: token);
Assert.NotEmpty(expected);
AssertSameLayout(expected, actual);
AssertValidLayout(actual, area);
Assert.Equal(before, Snapshot(drawing));
}
[Theory]
[InlineData("rectangle", 0.0, false)]
[InlineData("triangle", 0.5, false)]
[InlineData("concave", 0.5, false)]
[InlineData("arc", 0.5, false)]
[InlineData("triangle", 0.0, true)]
[InlineData("triangle", 0.5, true)]
public void ColumnAdjustment_UsesAdjustedOrOverlapFallbackWithinBounds(string shape, double spacing, bool fallback)
{
var drawing = MakeFixture(shape);
var area = new Box(3, 5, 45, 27);
var legacy = new LegacyFillExtents(area, spacing);
var angle = fallback ? System.Math.PI / 6 : 0;
var pair = Invoke(legacy, "BuildPair", drawing, angle);
var initial = (List<Part>)Invoke(legacy, "BuildColumn", pair);
var adjusted = (List<Part>)Invoke(legacy, "AdjustColumn", pair, initial, CancellationToken.None);
var overlap = FillHelpers.HasOverlappingParts(adjusted);
Assert.NotSame(initial, adjusted);
Assert.Equal(fallback, overlap);
output.WriteLine($"{shape}, spacing={spacing}: initial={initial.Count}, adjusted={adjusted.Count}, "
+ $"same={ReferenceEquals(initial, adjusted)}, overlap={overlap}");
var progress = new List<List<Part>>();
var actual = new FillExtents(area, spacing).Fill(drawing, angle,
reportProgress: (parts, _) => progress.Add(parts));
AssertSameLayout(overlap ? initial : adjusted, progress[1]);
AssertValidLayout(progress[1], area);
AssertValidLayout(actual, area);
}
[Theory]
[InlineData("rectangle")]
[InlineData("arc")]
public void Fill_KnownLegacyTightWidthOverrun_RemainsDifferentialOnly(string shape)
{
// Baseline already extends ~1e-5 beyond this exactly tiled width at zero spacing.
// Record that limitation separately; this optimization does not fix geometry.
var area = new Box(3, 5, 40, 27);
var drawing = MakeFixture(shape);
var expected = new LegacyFillExtents(area, 0).Fill(drawing);
var actual = new FillExtents(area, 0).Fill(drawing);
AssertSameLayout(expected, actual);
Assert.Contains(expected, part => part.Right > area.Right + 1e-6);
Assert.InRange(expected.Max(part => part.Right) - area.Right, 9e-6, 11e-6);
}
[Theory]
[InlineData(-0.25)]
[InlineData(-0.5)]
public void Fill_NegativeSpacing_PreservesLegacyResultsAndInputs(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("rectangle");
var before = Snapshot(drawing);
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
var actual = new FillExtents(area, spacing).Fill(drawing);
AssertSameLayout(expected, actual);
Assert.Equal(before, Snapshot(drawing));
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void Fill_NonfiniteSpacing_NoFitStillReturnsEmptyWithoutValidation(double spacing)
{
var area = new Box(3, 5, 1, 1);
var drawing = MakeFixture("rectangle");
Assert.Empty(new LegacyFillExtents(area, spacing).Fill(drawing));
Assert.Empty(new FillExtents(area, spacing).Fill(drawing));
}
[Theory]
[InlineData(-0.5)]
[InlineData(-10.0)]
[InlineData(double.NegativeInfinity)]
public void BuildColumn_UnsupportedNegativeSpacing_RetainsLegacyPitch(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("rectangle");
// Prepare finite valid pairs independently of unsupported spacing. This isolates
// the private column calculation without risking legacy nonfinite public tiling.
var legacyPair = Invoke(new LegacyFillExtents(area, 0), "BuildPair", drawing, 0.0);
var pair = Invoke(new FillExtents(area, 0), "BuildPair", drawing, 0.0);
var expected = (List<Part>)Invoke(new LegacyFillExtents(area, spacing), "BuildColumn", legacyPair);
var actual = (List<Part>)Invoke(new FillExtents(area, spacing), "BuildColumn", pair);
Assert.Equal(6, expected.Count);
AssertSameLayout(expected, actual);
Assert.Equal(8.0, actual[2].Bottom - actual[0].Bottom, 10);
}
#if DEBUG
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
public void Fill_BoundsWork_PerformsFewerUpdates(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("triangle");
PerfCounters.Reset();
try
{
// Task 2 removed boundary preparation, not Part.UpdateBounds calls, so
// this frozen baseline has the pre-Task-3 full-fill bounds-update count.
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
var before = PerfCounters.PartBoundsUpdates;
PerfCounters.Reset();
var actual = new FillExtents(area, spacing).Fill(drawing);
var after = PerfCounters.PartBoundsUpdates;
output.WriteLine($"bounds spacing={spacing}: before={before}, actual={after}, parts={actual.Count}");
AssertSameLayout(expected, actual);
Assert.Equal(13, before);
Assert.True(after < before, $"Expected fewer than {before} part bounds updates, actual {after}.");
Assert.Equal(10, after);
}
finally
{
PerfCounters.Reset();
}
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
public void BuildColumn_RepeatedCalls_DoNotPrepareBoundaries(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("triangle");
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
PerfCounters.Reset();
try
{
var legacyPair = Invoke(legacy, "BuildPair", drawing, 0.0);
PerfCounters.Reset();
var pair = Invoke(filler, "BuildPair", drawing, 0.0);
Assert.Equal(2, PerfCounters.PartBoundaryPreparations); // BuildPair must retain geometry.
PerfCounters.Reset();
var expected = new List<List<Part>>();
for (var i = 0; i < 4; i++)
expected.Add((List<Part>)Invoke(legacy, "BuildColumn", legacyPair));
Assert.Equal(8, PerfCounters.PartBoundaryPreparations);
PerfCounters.Reset();
for (var i = 0; i < 4; i++)
AssertSameLayout(expected[i], (List<Part>)Invoke(filler, "BuildColumn", pair));
Assert.Equal(0, PerfCounters.PartBoundaryPreparations);
}
finally
{
PerfCounters.Reset();
}
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
public void Fill_RebuildsColumns_OnlyBuildPairPreparesBoundaries(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("triangle");
PerfCounters.Reset();
try
{
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
var legacyPreparations = PerfCounters.PartBoundaryPreparations;
// Only BuildPair and BuildColumn construct PartBoundary in this pipeline.
// More than four proves AdjustColumn called BuildColumn again.
Assert.True(legacyPreparations > 4);
PerfCounters.Reset();
var actual = new FillExtents(area, spacing).Fill(drawing);
output.WriteLine($"spacing={spacing}: legacy boundaries={legacyPreparations}, "
+ $"BuildColumn calls={(legacyPreparations - 2) / 2}, actual boundaries={PerfCounters.PartBoundaryPreparations}, parts={actual.Count}");
AssertSameLayout(expected, actual);
AssertValidLayout(actual, area);
Assert.Equal(2, PerfCounters.PartBoundaryPreparations);
}
finally
{
PerfCounters.Reset();
}
}
#endif
internal static Drawing MakeFixture(string shape)
{
if (shape == "rectangle")
return MakeRect(10, 8);
if (shape == "triangle")
return MakeRightTriangle(10, 8);
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
if (shape == "concave")
{
foreach (var point in new[] { new Vector(10, 0), new Vector(10, 3),
new Vector(4, 3), new Vector(4, 8), new Vector(0, 8), new Vector(0, 0) })
program.Codes.Add(new LinearMove(point));
}
else
{
// Native CCW quarter arcs, not a polygonized or self-crossing approximation.
program.Codes.Add(new LinearMove(new Vector(9, 0)));
program.Codes.Add(new ArcMove(new Vector(10, 1), new Vector(9, 1), RotationType.CCW));
program.Codes.Add(new LinearMove(new Vector(10, 7)));
program.Codes.Add(new ArcMove(new Vector(9, 8), new Vector(9, 7), RotationType.CCW));
program.Codes.Add(new LinearMove(new Vector(0, 8)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
}
return new Drawing(shape, program);
}
internal static object Invoke(object target, string method, params object[] args) =>
target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(target, args)!;
internal static void AssertSameLayout(List<Part> expected, List<Part> actual)
{
Assert.Equal(expected.Count, actual.Count);
for (var i = 0; i < expected.Count; i++)
{
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal((expected[i].Location.X, expected[i].Location.Y),
(actual[i].Location.X, actual[i].Location.Y));
Assert.Equal(expected[i].Rotation, actual[i].Rotation);
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program));
}
}
internal static void AssertValidLayout(List<Part> parts, Box area)
{
Assert.NotEmpty(parts);
foreach (var part in parts)
{
Assert.True(part.BaseDrawing.Area > 0);
Assert.True(part.Left >= area.Left - 1e-6 && part.Right <= area.Right + 1e-6
&& part.Bottom >= area.Bottom - 1e-6 && part.Top <= area.Top + 1e-6);
Assert.All(new[] { part.Left, part.Right, part.Bottom, part.Top, part.Rotation },
value => Assert.True(double.IsFinite(value)));
}
Assert.False(FillHelpers.HasOverlappingParts(parts));
}
private static (double X, double Y, double Length, double Width) Bounds(Box box) =>
(box.X, box.Y, box.Length, box.Width);
private static object[] ProgramValues(Program program)
{
var values = new List<object> { program.Mode, program.Rotation, Bounds(program.BoundingBox()) };
foreach (var code in program.Codes)
{
values.Add(code.GetType());
if (code is Motion motion)
values.Add((motion.EndPoint.X, motion.EndPoint.Y));
if (code is LinearMove line)
values.Add(line.Layer);
if (code is ArcMove arc)
{
values.Add((arc.CenterPoint.X, arc.CenterPoint.Y));
values.Add(arc.Rotation);
values.Add(arc.Layer);
}
}
return values.ToArray();
}
private static object[] Snapshot(Drawing drawing) => new object[] { drawing, drawing.Area, drawing.Program }
.Concat(drawing.Program.Codes.Cast<object>()).Concat(ProgramValues(drawing.Program)).ToArray();
private static Drawing MakeRightTriangle(double w, double h)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("triangle", pgm);
}
private static Drawing MakeRect(double w, double h)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
[Fact]
public void Fill_Triangle_ReturnsPartsWithinWorkArea()
{
var workArea = new Box(0, 0, 120, 60);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRightTriangle(10, 8);
var parts = filler.Fill(drawing);
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Should place at least one part");
foreach (var part in parts)
{
Assert.True(
part.BoundingBox.Right <= workArea.Right + 0.01,
$"Part right edge {part.BoundingBox.Right} exceeds work area {workArea.Right}"
);
Assert.True(
part.BoundingBox.Top <= workArea.Top + 0.01,
$"Part top edge {part.BoundingBox.Top} exceeds work area {workArea.Top}"
);
}
}
[Fact]
public void Fill_PartTooLarge_ReturnsEmpty()
{
var workArea = new Box(0, 0, 5, 5);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRect(10, 10);
var parts = filler.Fill(drawing);
Assert.NotNull(parts);
Assert.Empty(parts);
}
[Fact]
public void Fill_Triangle_ColumnFillsHeight()
{
var workArea = new Box(0, 0, 120, 60);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRightTriangle(10, 8);
var parts = filler.Fill(drawing);
Assert.True(parts.Count > 0);
// The topmost part should be close to the work area top edge.
var topEdge = 0.0;
foreach (var part in parts)
{
if (part.BoundingBox.Top > topEdge)
topEdge = part.BoundingBox.Top;
}
// After adjustment, the gap should be small (within one part spacing).
var gap = workArea.Top - topEdge;
Assert.True(
gap < 1.0,
$"Gap of {gap:F2} is too large — adjustment should fill close to the top"
);
}
[Fact]
public void Fill_Triangle_FillsWidthWithMultipleColumns()
{
var workArea = new Box(0, 0, 120, 60);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRightTriangle(10, 8);
var parts = filler.Fill(drawing);
// With a 120-wide sheet and ~10-wide parts, we should get multiple columns.
Assert.True(
parts.Count >= 8,
$"Expected multiple columns but got only {parts.Count} parts"
);
// Verify all parts are within bounds.
foreach (var part in parts)
{
Assert.True(part.BoundingBox.Right <= workArea.Right + 0.01);
Assert.True(part.BoundingBox.Top <= workArea.Top + 0.01);
Assert.True(part.BoundingBox.Left >= workArea.Left - 0.01);
Assert.True(part.BoundingBox.Bottom >= workArea.Bottom - 0.01);
}
}
[Fact]
public void Fill_Rect_ReturnsNonEmpty()
{
var workArea = new Box(0, 0, 120, 60);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRect(15, 10);
var parts = filler.Fill(drawing);
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Rectangle should produce results");
}
[Fact]
public void Fill_NonZeroOriginWorkArea_PartsWithinBounds()
{
// Simulate a remnant sub-region with non-zero origin.
var workArea = new Box(30, 10, 80, 40);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRightTriangle(10, 8);
var parts = filler.Fill(drawing);
Assert.True(parts.Count > 0);
foreach (var part in parts)
{
Assert.True(
part.BoundingBox.Left >= workArea.Left - 0.01,
$"Part left {part.BoundingBox.Left} below work area left {workArea.Left}"
);
Assert.True(
part.BoundingBox.Bottom >= workArea.Bottom - 0.01,
$"Part bottom {part.BoundingBox.Bottom} below work area bottom {workArea.Bottom}"
);
Assert.True(part.BoundingBox.Right <= workArea.Right + 0.01);
Assert.True(part.BoundingBox.Top <= workArea.Top + 0.01);
}
}
[Fact]
public void Fill_RespectsCancellation()
{
var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
var workArea = new Box(0, 0, 120, 60);
var filler = new FillExtents(workArea, 0.5);
var drawing = MakeRightTriangle(10, 8);
var parts = filler.Fill(drawing, token: cts.Token);
Assert.NotNull(parts);
}
}