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;
+}