From c691381f307b99f2bd68057abece0a485c3728c8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 09:29:26 -0400 Subject: [PATCH] test(engines): add a shared engine test kit and contract tests Each engine's tests carried its own copy of the job/shape helpers and validation, and they had drifted: Gpt6Astra linked NestValidator.cs as source (which stopped compiling once the host moved its checks into NestLayoutCheck), the others referenced OpenNest.Benchmark. The kit provides JobBuilder, Shapes, LayoutAssert (backed by the host's public NestLayoutCheck) and EngineContractTests: quadrants, overflow, oversize parts, lower-number priority, etch marks, sequential plate indices, determinism, cancellation and stop reasons, for every engine. Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 --- Engine.Testing/EngineContractTests.cs | 135 ++++++++++++++++++ Engine.Testing/JobBuilder.cs | 26 ++++ Engine.Testing/LayoutAssert.cs | 50 +++++++ Engine.Testing/OpenNest.Engine.Testing.csproj | 8 ++ Engine.Testing/Shapes.cs | 56 ++++++++ 5 files changed, 275 insertions(+) create mode 100644 Engine.Testing/EngineContractTests.cs create mode 100644 Engine.Testing/JobBuilder.cs create mode 100644 Engine.Testing/LayoutAssert.cs create mode 100644 Engine.Testing/OpenNest.Engine.Testing.csproj create mode 100644 Engine.Testing/Shapes.cs diff --git a/Engine.Testing/EngineContractTests.cs b/Engine.Testing/EngineContractTests.cs new file mode 100644 index 0000000..0c211ea --- /dev/null +++ b/Engine.Testing/EngineContractTests.cs @@ -0,0 +1,135 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using Xunit; +using static OpenNest.Engine.Testing.JobBuilder; +using static OpenNest.Engine.Testing.Shapes; + +namespace OpenNest.Engine.Testing; + +/// Host contract only; engines retain their own packing-quality regressions. +public abstract class EngineContractTests where TEngine : INestingEngine, new() +{ + [Fact] + public void ContractPublicConstructor() => Assert.IsAssignableFrom(Activator.CreateInstance(typeof(TEngine))); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ContractQuadrants(int quadrant) + { + var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)], + [Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ContractOverflowIndicesAndProgress() + { + var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]); + var commits = new List(); + var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); })); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(3, result.Plates.Count); + Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex)); + Assert.Equal(3, commits.Count); + Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex)); + Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates)); + Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts)); + Assert.All(result.StockUsage, s => Assert.Null(s.Remaining)); + } + + [Fact] + public void ContractOversize() + { + var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); + } + + [Fact] + public void ContractLowerNumberPriorityWins() + { + var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)], + [Stock("s", 10, 10, quantity: 1)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId); + } + + [Fact] + public void ContractEtchOutsideSheetIsIgnored() + { + var etched = NotchedPartWithEtch(); + etched.Codes.Add(new RapidMove(5, 5)); + etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe }); + var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ContractDeterminism() + { + NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)], + [Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]); + var engine = new TEngine(); + var job = Build(); + var first = engine.Solve(job); + var second = engine.Solve(job); + var third = new TEngine().Solve(Build()); + foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result); + Assert.Equal(Describe(first), Describe(second)); + Assert.Equal(Describe(first), Describe(third)); + } + + [Fact] + public void ContractCancellationThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]); + Assert.ThrowsAny(() => new TEngine().Solve(job, token: cancellation.Token)); + } + + [Fact] + public void ContractCancellationDuringSolveThrows() + { + using var cancellation = new CancellationTokenSource(); + var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]); + var progress = new Capture(p => + { + if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel(); + }); + Assert.ThrowsAny(() => new TEngine().Solve(job, progress, cancellation.Token)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ContractStockAndPlateLimits(bool plateLimit) + { + var job = Job([Part("p", Rectangle(8, 8), 3)], + [Stock("s", 10, 10, quantity: plateLimit ? null : 1)], + new NestJobOptions(maxPlates: plateLimit ? 1 : null)); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Single(result.Plates); + Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason); + } + + private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result); + private sealed class Capture(Action action) : IProgress + { public void Report(NestJobProgress value) => action(value); } +} diff --git a/Engine.Testing/JobBuilder.cs b/Engine.Testing/JobBuilder.cs new file mode 100644 index 0000000..afeed24 --- /dev/null +++ b/Engine.Testing/JobBuilder.cs @@ -0,0 +1,26 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Testing; + +public static class JobBuilder +{ + public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) => + new(parts, stock, options); + + public static NestJobPart Part(string id, Program program, int quantity, + RotationPolicy? rotation = null, int priority = 0) => + new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation); + + /// Y extent. + /// X extent. + public static NestPlateStock Stock(string id, double width, double length, double spacing = 0, + Spacing edge = default, int quadrant = 1, int? quantity = null) => + new(id, new Size(width, length), quantity, spacing, edge, quadrant); + + public static NestJobPart Rectangle(string id, double w, double h, int count, + RotationPolicy? rotation = null, double x = 0, double y = 0) => + Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)), + count, rotation ?? RotationPolicy.Fixed(0)); +} diff --git a/Engine.Testing/LayoutAssert.cs b/Engine.Testing/LayoutAssert.cs new file mode 100644 index 0000000..c68dc0a --- /dev/null +++ b/Engine.Testing/LayoutAssert.cs @@ -0,0 +1,50 @@ +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using Xunit; + +namespace OpenNest.Engine.Testing; + +public static class LayoutAssert +{ + public static void Valid(NestJob job, NestJobResult result) + { + var violations = NestLayoutCheck.Violations(job, result); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex)); + foreach (var f in result.Fulfillment) + Assert.Equal(f.Requested, f.Placed + f.Unplaced); + foreach (var sheet in result.Plates) + { + var s = sheet.Stock; + var work = s.WorkArea; + foreach (var pose in sheet.Placements) + { + var part = job.Parts.Single(p => p.Id == pose.PartId); + Assert.True(part.Rotation.Allows(pose.Rotation)); + var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry)) + .Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray(); + foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); } + var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom), + R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top)); + Assert.True(b.L >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7 + && b.R <= work.Right + 1e-7 && b.T <= work.Top + 1e-7); + } + } + foreach (var part in job.Parts) + { + var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray(); + Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order()); + var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id); + Assert.Equal(placed.Length, fulfillment.Placed); + Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced); + } + foreach (var usage in result.StockUsage) + { + var stock = job.Plates.Single(s => s.Id == usage.StockId); + Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used); + Assert.Equal(stock.Quantity - usage.Used, usage.Remaining); + Assert.True(usage.Remaining is null or >= 0); + } + } +} diff --git a/Engine.Testing/OpenNest.Engine.Testing.csproj b/Engine.Testing/OpenNest.Engine.Testing.csproj new file mode 100644 index 0000000..e3dbb42 --- /dev/null +++ b/Engine.Testing/OpenNest.Engine.Testing.csproj @@ -0,0 +1,8 @@ + + + false + + + + + diff --git a/Engine.Testing/Shapes.cs b/Engine.Testing/Shapes.cs new file mode 100644 index 0000000..502d5fc --- /dev/null +++ b/Engine.Testing/Shapes.cs @@ -0,0 +1,56 @@ +using OpenNest.CNC; + +namespace OpenNest.Engine.Testing; + +public static class Shapes +{ + public static Program Polyline(params (double X, double Y)[] points) + { + var program = new Program(); + program.Codes.Add(new RapidMove(points[0].X, points[0].Y)); + foreach (var (x, y) in points.Skip(1)) + program.Codes.Add(new LinearMove(x, y)); + program.Codes.Add(new LinearMove(points[0].X, points[0].Y)); + return program; + } + + public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h)); + + public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h)); + + public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h)); + + public static Program Disc(double r) + { + var program = new Program(); + program.Codes.Add(new RapidMove(r, 0)); + program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW)); + program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW)); + return program; + } + + /// Stadium: two semicircular ends joined by straight sides, offset from the origin. + public static Program Obround(double length, double width) + { + var r = width / 2; + var program = new Program(); + program.Codes.Add(new RapidMove(1 + r, 1)); + program.Codes.Add(new LinearMove(1 + length - r, 1)); + program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW)); + program.Codes.Add(new LinearMove(1 + r, 1 + width)); + program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW)); + return program; + } + public static Program NotchedPartWithEtch() + { + var p = new Program(); + p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6); + p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0); + p.MoveTo(7.5, 5); + p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe }); + return p; + } + + public static Program Ring(double outerDiameter, double innerDiameter) => + new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program; +}