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