Files
OpenNest/OpenNest.FrontEnd.Tests/NestingPipelineTests.cs
T

352 lines
16 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_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<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));
}
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_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<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));
}
}