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 solve) { var name = "FrontEnd-" + Guid.NewGuid(); NestingEngineRegistry.Register(name, "test", () => new Stub(solve)); return name; } private sealed class Stub(Func solve) : INestingEngine { public NestJobResult Solve(NestJob job, IProgress? 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() : 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 { 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_CancelledSolveDoesNotSave() { 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); } [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(() => 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)); } 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(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_CancelledOrdinaryFillDoesNotOverwriteOutput() { 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(() => run.Invoke(null, new object[] { new[] { input, "--quantity", "1", "--output", output }, cts.Token })); Assert.IsAssignableFrom(error.InnerException); Assert.Equal("existing output", File.ReadAllText(output)); } [Fact] public async Task Api_UnknownEngineAndCancellationPropagate() { await Assert.ThrowsAsync(() => NestRunner.RunAsync(Request("missing-engine"))); using var cts = new CancellationTokenSource(); var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); }); await Assert.ThrowsAnyAsync(() => NestRunner.RunAsync(Request(engine), token: cts.Token)); } }