diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs new file mode 100644 index 0000000..2cbbd5f --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs @@ -0,0 +1,90 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestPipelineCommitTests +{ + private sealed class StubEngine(bool overlap) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) => new( + NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, job.Plates[0], new[] + { + new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0), + new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0), + }) }, + new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) }, + new[] { new StockUsage(job.Plates[0].Id, 1, null) }); + } + + private static NestPipelineResult Result(Drawing drawing, bool overlap = false) => + NestPipeline.Run(new StubEngine(overlap), "stub", new NestPipelineRequest("stub", + new[] { new NestItem { Drawing = drawing, Quantity = 2 } }, + new[] { new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1), 1) })); + + [Fact] + public void AppliesValidatedSettingsToEmptyPlateAndNeverFillsOccupiedPlate() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var occupied = nest.CreatePlate(); + occupied.Parts.Add(new Part(drawing)); + var empty = nest.CreatePlate(); + empty.Quantity = 7; + empty.Quadrant = 3; + empty.Size = new Size(4, 8); + empty.EdgeSpacing = new Spacing(0, 0); + using var manager = new PlateManager(nest); + var result = Result(drawing); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + + Assert.Same(empty, Assert.Single(applied)); + Assert.Single(occupied.Parts); + Assert.Equal(2, empty.Parts.Count); + Assert.Equal(1, empty.Quantity); + Assert.Equal(1, empty.Quadrant); + Assert.Equal(result.Job.Plates[0].Size, empty.Size); + Assert.Equal(result.Job.Plates[0].EdgeSpacing, empty.EdgeSpacing); + Assert.Equal(0.25, empty.PartSpacing); + Assert.All(empty.Parts, p => Assert.Same(drawing, p.BaseDrawing)); + Assert.Equal(3, drawing.Quantity.Nested); + } + + [Fact] + public void InvalidResultRequiresExplicitConsentAndDiscardDoesNotMutateNest() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var empty = nest.CreatePlate(); + using var manager = new PlateManager(nest); + var result = Result(drawing, overlap: true); + Assert.False(result.IsValid); + + Assert.Throws(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager)); + Assert.Same(empty, Assert.Single(nest.Plates)); + Assert.Empty(empty.Parts); + Assert.Equal(0, drawing.Quantity.Nested); + + var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true); + Assert.Equal(2, Assert.Single(applied).Parts.Count); + } + + [Fact] + public void CancelledCommitDoesNotCreateAnyPlate() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + using var manager = new PlateManager(nest); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.ThrowsAny(() => + NestPipelineCommit.ApplyToEmptyPlates(Result(drawing), manager, token: cts.Token)); + Assert.Empty(nest.Plates); + } +} diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs new file mode 100644 index 0000000..cd59bb3 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace OpenNest.Engine.Jobs; + +/// Applies an explicitly accepted whole-job proposal to empty physical sheets only. +/// Caller must keep the nest and its drawings stable from request construction through commit. +public static class NestPipelineCommit +{ + public static IReadOnlyList ApplyToEmptyPlates( + NestPipelineResult result, + PlateManager manager, + bool allowInvalid = false, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(manager); + token.ThrowIfCancellationRequested(); + if (!result.CanKeep || (!result.IsValid && !allowInvalid)) + throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations."); + + var applied = new List(); + // Commit is synchronous on the caller's owning thread. Cancellation is checked + // before mutation, not partway through attachment (which would leave half a job). + manager.BeginBatch(); + try + { + foreach (var proposed in result.Plates) + { + if (proposed.Parts.Count == 0) + continue; + var plate = manager.GetOrCreateEmpty(); + plate.Size = proposed.Stock.Size; + plate.PartSpacing = proposed.Stock.PartSpacing; + plate.EdgeSpacing = proposed.Stock.EdgeSpacing; + plate.Quadrant = proposed.Stock.Quadrant; + plate.Quantity = 1; + plate.Parts.AddRange(proposed.Parts); + applied.Add(plate); + } + } + finally + { + manager.EndBatch(); + } + return applied.AsReadOnly(); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/NestValidationFormTests.cs b/OpenNest.WinForms.Tests/Forms/NestValidationFormTests.cs new file mode 100644 index 0000000..28556bc --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/NestValidationFormTests.cs @@ -0,0 +1,75 @@ +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using OpenNest.Forms; + +namespace OpenNest.WinForms.Tests.Forms; + +public class NestValidationFormTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DiscardIsDefaultAndMalformedOutputCannotBeKept(bool canKeep) + { + RunSta(() => + { + using var form = new NestValidationForm(new[] { "part A and part B violate spacing" }, canKeep); + var buttons = form.Controls.OfType().Single().Controls.OfType