Files
OpenNest/OpenNest.FrontEnd.Tests/NestingPipelineTests.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

440 lines
20 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;
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) });
}
private static int RunConsole(params string[] args)
{
var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!;
return (int)method.Invoke(null, new object[] { args })!;
}
[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);
}
[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));
}
}