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<TEngine>: quadrants, overflow,
oversize parts, lower-number priority, etch marks, sequential plate
indices, determinism, cancellation and stop reasons, for every engine.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 09:29:26 -04:00
co-authored by Codex Claude Opus 5.5
parent 54e8a0461b
commit c691381f30
5 changed files with 275 additions and 0 deletions
+135
View File
@@ -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;
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
{
[Fact]
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(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<NestJobProgress>();
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<OperationCanceledException>(() => 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<OperationCanceledException>(() => 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<NestJobProgress> action) : IProgress<NestJobProgress>
{ public void Report(NestJobProgress value) => action(value); }
}
+26
View File
@@ -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);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
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));
}
+50
View File
@@ -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);
}
}
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.5.3" />
</ItemGroup>
</Project>
+56
View File
@@ -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;
}
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
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;
}