Files
OpenNest/OpenNest.FrontEnd.Tests/NestingPipelineTests.cs
T
ajandClaude Opus 5.5 f597b06024 fix(console): autonest each drawing's required quantity by default
--autonest without --quantity nested one copy of each drawing, so a
118-part job of 67 drawings solved as 67 parts and reported success.
Default to the required quantity less copies on other plates, which the
run keeps; drawings without one are left out, as on the desktop, unless
no drawing has one. --quantity still overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:43:04 -05:00

551 lines
24 KiB
C#

using System.Reflection;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Mcp;
using OpenNest.Mcp.Tools;
using static OpenNest.FrontEnd.Tests.ConsoleHost;
namespace OpenNest.FrontEnd.Tests;
[CollectionDefinition("FrontEndRegistry", DisableParallelization = true)]
public class FrontEndRegistryCollection { }
[Collection("FrontEndRegistry")]
public class NestingPipelineTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-frontends-" + Guid.NewGuid());
public NestingPipelineTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
private static Drawing Square()
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
return new Drawing("square", OpenNest.Converters.ConvertGeometry.ToProgram(shape));
}
private static NestSession Session(bool occupied = false)
{
var session = new NestSession { Nest = new Nest("fixture") };
session.Nest.Drawings.Add(Square());
var plate = session.Nest.CreatePlate();
plate.Size = new Size(20, 30);
plate.PartSpacing = 0.2;
plate.EdgeSpacing = new Spacing(0.5, 0.5, 0.5, 0.5);
plate.Quantity = 7;
if (occupied)
plate.Parts.Add(new Part(session.Nest.Drawings.Single()));
return session;
}
private static string Engine(Func<NestJob, CancellationToken, NestJobResult> solve)
{
var name = "FrontEnd-" + Guid.NewGuid();
NestingEngineRegistry.Register(name, "test", () => new Stub(solve));
return name;
}
private sealed class Stub(Func<NestJob, CancellationToken, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job, token);
}
private static NestJobResult Result(NestJob job, string mode)
{
var poses = mode switch
{
"overlap" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0), new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0) },
"unknown" => new[] { new NestJobPlacement("ghost", 0, 1, 1, 0) },
"nan" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, double.NaN, 1, 0) },
_ => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) },
};
var sheets = mode == "empty" ? Array.Empty<NestJobPlateResult>()
: mode == "multi" ? new[] { new NestJobPlateResult(0, job.Plates[0], poses), new NestJobPlateResult(1, job.Plates[0], poses) }
: new[] { new NestJobPlateResult(0, job.Plates[0], poses) };
// Deliberately lying metadata: front ends must count actual poses.
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, sheets,
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 999, 0)),
new[] { new StockUsage(job.Plates[0].Id, 999, 0) });
}
[Theory]
[InlineData("overlap", false, 2)]
[InlineData("overlap", true, 0)]
[InlineData("multi", true, 2)]
[InlineData("unknown", true, 2)]
[InlineData("nan", true, 2)]
public void Console_RefusesInvalidWithoutOverwritingUnlessFaithfullyKeepable(string mode, bool allow, int expected)
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
File.WriteAllText(output, "must survive rejection");
var engine = Engine((job, _) => Result(job, mode));
var args = new List<string> { input, "--autonest", "--engine", engine, "--quantity", "2", "--output", output };
if (allow) args.Add("--allow-invalid");
Assert.Equal(expected, RunConsole(args.ToArray()));
Assert.Equal(original, File.ReadAllBytes(input));
if (expected == 2)
Assert.Equal("must survive rejection", File.ReadAllText(output));
else
{
var nest = new NestReader(output).Read();
Assert.Equal(2, nest.Plates[0].Parts.Count);
Assert.Equal(1, nest.Plates[0].Quantity);
Assert.Equal(2, nest.Drawings.Single().Quantity.Nested);
}
}
[Fact]
public void Console_EmptyResultDoesNotOverwriteOutputOrOriginal()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
File.WriteAllText(output, "keep existing output");
var engine = Engine((job, _) => Result(job, "empty"));
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
Assert.Equal(original, File.ReadAllBytes(input));
Assert.Equal("keep existing output", File.ReadAllText(output));
}
[Fact]
public void Console_CanceledSolveDoesNotSave()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((_, _) => throw new OperationCanceledException());
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
Assert.False(File.Exists(output));
}
[Fact]
public void Console_KeepPartsOnOccupiedSheetRejectsBeforeSolve()
{
var input = Path.Combine(directory, "input.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var original = File.ReadAllBytes(input);
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
Assert.Equal(2, RunConsole(input, "--autonest", "--keep-parts", "--engine", engine));
Assert.Equal(original, File.ReadAllBytes(input));
}
[Fact]
public void Console_ValidCommitPreservesSettingsAndUsesOnePhysicalSheet()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((job, _) =>
{
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
return Result(job, "valid");
});
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
var plate = Assert.Single(new NestReader(output).Read().Plates);
Assert.Single(plate.Parts);
Assert.Equal(1, plate.Quantity);
Assert.Equal(0.2, plate.PartSpacing);
Assert.Equal(new Spacing(0.5, 0.5, 0.5, 0.5), plate.EdgeSpacing);
}
/// <summary>
/// Saves a nest of drawings named with their required quantities, runs --autonest and
/// returns the demand the engine saw, largest first.
/// </summary>
private int[] AutoNestDemand(Action<Nest>? arrange, string[] options, int exit = 0, params (string Name, int Required)[] drawings)
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
var nest = new Nest("fixture");
foreach (var (name, required) in drawings)
{
var drawing = Square();
drawing.Name = name;
drawing.Quantity.Required = required;
nest.Drawings.Add(drawing);
}
var target = nest.CreatePlate();
target.Size = new Size(20, 30);
target.Parts.Add(new Part(nest.Drawings.First()));
arrange?.Invoke(nest);
Assert.True(new NestWriter(nest).Write(input));
var demand = Array.Empty<int>();
var engine = Engine((job, _) =>
{
demand = job.Parts.Select(p => p.Quantity).OrderDescending().ToArray();
return Result(job, "valid");
});
var args = new[] { input, "--autonest", "--engine", engine, "--output", output }.Concat(options).ToArray();
Assert.Equal(exit, RunConsole(args));
return demand;
}
private static void OccupiedSheet(Nest nest, string drawing, int copies, int sheets)
{
var plate = nest.CreatePlate();
plate.Size = new Size(40, 40);
plate.Quantity = sheets;
var source = nest.Drawings.Single(d => d.Name == drawing);
for (var i = 0; i < copies; i++)
plate.Parts.Add(new Part(source, new Vector(i * 3, 0)));
}
[Fact]
public void Console_AutoNestDefaultsToEachDrawingsRequiredQuantity()
{
var demand = AutoNestDemand(null, [], 0, ("a", 5), ("b", 3));
Assert.Equal(new[] { 5, 3 }, demand);
}
[Fact]
public void Console_AutoNestCountsCopiesOnOtherPlatesButNotTheTarget()
{
// Two copies on each of two sheets; the target's copy of "a" is replaced, so it does not count.
var demand = AutoNestDemand(nest => OccupiedSheet(nest, "b", 2, 2), [], 0, ("a", 5), ("b", 6), ("c", 0));
Assert.Equal(new[] { 5, 2 }, demand);
}
[Fact]
public void Console_AutoNestSkipsDrawingsAlreadyFilledElsewhere()
{
var demand = AutoNestDemand(nest => OccupiedSheet(nest, "b", 3, 1), [], 0, ("a", 2), ("b", 3));
Assert.Equal(new[] { 2 }, demand);
}
[Fact]
public void Console_AutoNestNestsOneOfEachWhenNothingIsRequired()
{
var demand = AutoNestDemand(null, [], 0, ("a", 0), ("b", 0));
Assert.Equal(new[] { 1, 1 }, demand);
}
[Fact]
public void Console_AutoNestQuantityOverridesRequiredQuantities()
{
var demand = AutoNestDemand(nest => OccupiedSheet(nest, "b", 3, 1), ["--quantity", "2"], 0, ("a", 5), ("b", 3), ("c", 0));
Assert.Equal(new[] { 2, 2, 2 }, demand);
}
[Fact]
public void Console_AutoNestDrawingLimitsTheJobToItsRequiredQuantity()
{
var demand = AutoNestDemand(null, ["--drawing", "b"], 0, ("a", 5), ("b", 3));
Assert.Equal(new[] { 3 }, demand);
}
[Fact]
public void Console_AutoNestWithEverythingElsewhereSavesNothing()
{
var demand = AutoNestDemand(nest => OccupiedSheet(nest, "a", 2, 1), [], 2, ("a", 2));
Assert.Empty(demand);
Assert.False(File.Exists(Path.Combine(directory, "output.nest")));
}
[Fact]
public void Console_AutoNestPlacesEveryRequiredPart()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
var session = Session();
session.Nest.Drawings.Single().Quantity.Required = 3;
var other = Square();
other.Name = "other";
other.Quantity.Required = 4;
session.Nest.Drawings.Add(other);
// Empty plates are not persisted, so seed the target with a part to be replaced.
session.GetPlate(0).Parts.Add(new Part(other));
Assert.True(new NestWriter(session.Nest).Write(input));
Assert.Equal(0, RunConsole(input, "--autonest", "--check-overlaps", "--output", output));
var nest = new NestReader(output).Read();
Assert.Equal(7, nest.Plates.Sum(p => p.Parts.Count * p.Quantity));
Assert.All(nest.Drawings, d => Assert.Equal(0, d.Quantity.Remaining));
}
[Fact]
public void Console_MaxSheetsCommitsEveryLayoutAfterTheTarget()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
var session = Session(true);
// Empty plates are not persisted; an occupied one shows later plates are untouched.
var later = session.Nest.CreatePlate();
later.Size = new Size(40, 40);
later.Parts.Add(new Part(session.Nest.Drawings.Single()));
Assert.True(new NestWriter(session.Nest).Write(input));
var engine = Engine((job, _) =>
{
var stock = Assert.Single(job.Plates);
Assert.Null(stock.Quantity);
Assert.Equal(3, job.Options.MaxPlates);
var poses = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) };
var other = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 5, 5, 0) };
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, poses), new NestJobPlateResult(1, stock, other) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 2, 0)),
new[] { new StockUsage(stock.Id, 2, 0) });
});
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine, "--quantity", "2",
"--max-sheets", "3", "--check-overlaps", "--output", output));
var nest = new NestReader(output).Read();
Assert.Equal(3, nest.Plates.Count);
Assert.All(nest.Plates.Take(2), plate =>
{
Assert.Single(plate.Parts);
Assert.Equal(1, plate.Quantity);
Assert.Equal(new Size(20, 30), plate.Size);
Assert.Equal(0.2, plate.PartSpacing);
Assert.Equal(new Spacing(0.5, 0.5, 0.5, 0.5), plate.EdgeSpacing);
});
Assert.Single(nest.Plates[2].Parts);
Assert.Equal(new Size(40, 40), nest.Plates[2].Size);
}
[Fact]
public void Console_StockOffersEachSizeFromTheTargetTemplate()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((job, _) =>
{
Assert.Equal(new[] { new Size(10, 12), new Size(20, 30) }, job.Plates.Select(p => p.Size));
Assert.Equal(new double?[] { 5, 9 }, job.Plates.Select(p => p.Cost));
Assert.All(job.Plates, p => Assert.Equal(0.2, p.PartSpacing));
return Result(job, "valid");
});
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine,
"--stock", "10x12:5, 20x30:9", "--output", output));
var plate = Assert.Single(new NestReader(output).Read().Plates);
Assert.Equal(new Size(10, 12), plate.Size);
Assert.Single(plate.Parts);
}
[Theory]
[InlineData("--max-sheets", "0", true)]
[InlineData("--stock", "10x", true)]
[InlineData("--stock", "10x12:-1", true)]
[InlineData("--max-sheets", "2", false)]
public void Console_RejectsBadSheetOptionsBeforeSolve(string option, string value, bool autonest)
{
var input = Path.Combine(directory, "input.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve"));
var args = new List<string> { input, option, value, "--engine", autonest ? engine : "Fill", "--no-save" };
if (autonest) args.Add("--autonest");
Assert.Equal(1, RunConsole(args.ToArray()));
}
[Theory]
[InlineData("overlap", false, false)]
[InlineData("overlap", true, true)]
[InlineData("multi", true, false)]
[InlineData("unknown", true, false)]
[InlineData("nan", true, false)]
[InlineData("valid", false, true)]
public void Mcp_OnlyCommitsFaithfulSingleSheet(string mode, bool allow, bool committed)
{
var session = Session();
var plate = session.GetPlate(0);
var engine = Engine((job, _) =>
{
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
return Result(job, mode);
});
var tool = new NestingTools(session);
var result = CallMcp(tool, engine, allow);
if (committed)
{
Assert.NotEmpty(plate.Parts);
Assert.Equal(1, plate.Quantity);
Assert.All(plate.Parts, p => Assert.Same(session.Nest.Drawings.Single(), p.BaseDrawing));
Assert.Equal(plate.Parts.Count, session.Nest.Drawings.Single().Quantity.Nested);
}
else
{
Assert.Contains("Error", result);
Assert.Empty(plate.Parts);
Assert.Equal(7, plate.Quantity);
}
if (mode != "valid") Assert.Contains("Violation", result);
}
private static string CallMcp(NestingTools tool, string engine, bool allow = false, CancellationToken token = default)
{
// Reflection lets RED exercise the old implementation before optional parameters exist.
var method = typeof(NestingTools).GetMethod(nameof(NestingTools.AutoNestPlate))!;
var args = method.GetParameters().Select(p => p.Name switch
{
"plateIndex" => (object)0,
"drawingNames" => "square",
"quantities" => "2",
"engine" => engine,
"allow_invalid" => allow,
"cancellationToken" => token,
_ => p.DefaultValue,
}).ToArray();
try { return (string)method.Invoke(tool, args)!; }
catch (TargetInvocationException ex) when (ex.InnerException is OperationCanceledException)
{ throw ex.InnerException; }
}
[Fact]
public void Mcp_OccupiedSheetRejectsBeforeSolve()
{
var session = Session(true);
var original = session.GetPlate(0).Parts[0];
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
Assert.Contains("occupied", CallMcp(new NestingTools(session), engine));
Assert.Same(original, Assert.Single(session.GetPlate(0).Parts));
}
[Fact]
public void Mcp_CancellationDoesNotCommit()
{
var session = Session();
using var cts = new CancellationTokenSource();
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
Assert.ThrowsAny<OperationCanceledException>(() => CallMcp(new NestingTools(session), engine, token: cts.Token));
Assert.Empty(session.GetPlate(0).Parts);
}
[Fact]
public void UnknownEngineDoesNotWriteOrCommit()
{
var session = Session();
Assert.Contains("unknown", CallMcp(new NestingTools(session), "missing-engine"), StringComparison.OrdinalIgnoreCase);
Assert.Empty(session.GetPlate(0).Parts);
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(session.Nest).Write(input));
Assert.Equal(1, RunConsole(input, "--autonest", "--engine", "missing-engine", "--output", output));
Assert.False(File.Exists(output));
}
[Fact]
public void Console_DxfOnlyFillCountsPlacedPartsOnASingleSheet()
{
var input = Path.Combine(directory, "square.dxf");
var output = Path.Combine(directory, "output.nest");
Dxf.ExportProgram(Square().Program, input);
Assert.Equal(0, RunConsole(input, "--size", "20x30", "--quantity", "6", "--output", output));
var nest = new NestReader(output).Read();
var plate = nest.Plates.Single();
Assert.Equal(1, plate.Quantity);
Assert.Equal(6, plate.Parts.Count);
Assert.Equal(plate.Parts.Count, nest.Drawings.Single().Quantity.Nested);
}
private NestRequest Request(string engine)
{
var path = Path.Combine(directory, "square.dxf");
Dxf.ExportProgram(Square().Program, path);
var request = new NestRequest
{
Parts = [new NestRequestPart { Id = "external-id", DxfPath = path, Quantity = 2, AllowRotation = false }],
Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }],
};
typeof(NestRequest).GetProperty("Engine")?.SetValue(request, engine);
return request;
}
[Theory]
[InlineData("valid", 1, "Valid")]
[InlineData("empty", 0, "Valid")]
[InlineData("overlap", 2, "Invalid")]
[InlineData("unknown", 0, "Unrepresentable")]
[InlineData("nan", 0, "Unrepresentable")]
public async Task Api_PluginSelectionDerivesCountsPreservesIdsAndReportsValidation(string mode, int placed, string validation)
{
var called = false;
var engine = Engine((job, _) => { called = true; return Result(job, mode); });
var response = await NestRunner.RunAsync(Request(engine));
Assert.True(called);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal("external-id", fulfillment.PartId);
Assert.Equal(placed, fulfillment.Placed);
Assert.Equal(2 - placed, fulfillment.Unplaced);
Assert.Equal(placed == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete, response.Status);
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(response)?.ToString());
Assert.Equal(placed > 0 ? 1 : 0, Assert.Single(response.StockUsage).Used);
Assert.Equal(placed, response.Nest.Plates.Sum(p => p.Parts.Count));
Assert.Equal("external-id", Assert.Single(response.Nest.Drawings).Name);
var archive = Path.Combine(directory, "result.nestquote");
await response.SaveAsync(archive);
var loaded = await NestResponse.LoadAsync(archive);
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(loaded)?.ToString());
}
[Fact]
public async Task Mcp_SessionGateSerializesCallsBeforeCheckingOccupiedTarget()
{
var session = Session();
var tool = new NestingTools(session);
var engine = Engine((job, _) => Result(job, "valid"));
using var gate = new SessionToolGate();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var first = gate.RunAsync(async token =>
{
entered.SetResult();
await release.Task;
return CallMcp(tool, engine, token: token);
}, CancellationToken.None).AsTask();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
var second = gate.RunAsync(token => new ValueTask<string>(CallMcp(tool, engine, token: token)),
CancellationToken.None).AsTask();
try
{
Assert.False(second.IsCompleted);
}
finally
{
release.TrySetResult();
}
Assert.Contains("success", await first.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Contains("occupied", await second.WaitAsync(TimeSpan.FromSeconds(5)));
Assert.Single(session.GetPlate(0).Parts);
}
[Fact]
public void Console_CanceledOrdinaryFillDoesNotOverwriteOutput()
{
var input = Path.Combine(directory, "input.nest");
var output = Path.Combine(directory, "output.nest");
Assert.True(new NestWriter(Session(true).Nest).Write(input));
File.WriteAllText(output, "existing output");
using var cts = new CancellationTokenSource();
cts.Cancel();
var run = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("RunCore", BindingFlags.Static | BindingFlags.NonPublic)!;
var error = Assert.Throws<TargetInvocationException>(() => run.Invoke(null,
new object[] { new[] { input, "--quantity", "1", "--output", output }, cts.Token }));
Assert.IsAssignableFrom<OperationCanceledException>(error.InnerException);
Assert.Equal("existing output", File.ReadAllText(output));
}
[Fact]
public async Task Api_UnknownEngineAndCancellationPropagate()
{
await Assert.ThrowsAsync<NotSupportedException>(() => NestRunner.RunAsync(Request("missing-engine")));
using var cts = new CancellationTokenSource();
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => NestRunner.RunAsync(Request(engine), token: cts.Token));
}
}