diff --git a/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs new file mode 100644 index 0000000..f39eab5 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs @@ -0,0 +1,129 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestJobResultBuilderTests +{ + [Fact] + public void CommitsAssignIndicesAccountingAndCumulativeProgress() + { + var job = Job(); + var progress = new Reports(); + var builder = new NestJobResultBuilder(job, progress); + var finite = job.Plates[0]; + var unlimited = job.Plates[1]; + + Assert.False(builder.IsComplete); + Assert.Equal(0, builder.Placed("a")); + Assert.Equal(0, builder.SheetsUsed(unlimited)); + Assert.Equal(0, builder.AddSheet(unlimited, new[] { ("b", 1.0, 2.0, 0.5), ("a", 3.0, 4.0, 1.0) })); + var snapshot = builder.Build(NestJobStopReason.NoPlacementFound); + Assert.Equal(1, builder.AddSheet(finite, new[] { ("a", 5.0, 6.0, 1.5), ("b", 7.0, 8.0, 2.0) })); + Assert.Equal(2, builder.AddSheet(unlimited, new[] { ("a", 9.0, 10.0, 2.5) })); + var result = builder.Build(NestJobStopReason.StockExhausted); + + Assert.True(builder.IsComplete); + Assert.Equal(3, builder.Placed("a")); + Assert.Equal(2, builder.Placed("b")); + Assert.Equal(2, builder.SheetsUsed(unlimited)); + Assert.Equal(1, builder.SheetsUsed(finite)); + Assert.Equal(new[] { 0, 1, 2 }, result.Plates.Select(p => p.PlateIndex)); + Assert.Equal(new[] { "unlimited", "finite", "unlimited" }, result.Plates.Select(p => p.StockId)); + var poses = result.Plates.SelectMany(p => p.Placements).ToArray(); + Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == "a").Select(p => p.InstanceIndex)); + Assert.Equal(new[] { 0, 1 }, poses.Where(p => p.PartId == "b").Select(p => p.InstanceIndex)); + Assert.Equal(new NestJobPlacement("b", 0, 1, 2, 0.5), poses[0]); + Assert.Equal(new NestJobPlacement("a", 2, 9, 10, 2.5), poses[^1]); + Assert.Equal(new[] { new PartFulfillment("a", 3, 3, 0), new PartFulfillment("b", 2, 2, 0) }, result.Fulfillment); + Assert.All(result.Fulfillment, f => Assert.Equal(f.Requested, f.Placed + f.Unplaced)); + Assert.Equal(new[] { new StockUsage("finite", 1, 1), new StockUsage("unlimited", 2, null) }, result.StockUsage); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(NestJobStopReason.Completed, result.StopReason); + Assert.Equal(new[] + { + new NestJobProgress(NestJobStage.PlateCommitted, "unlimited", 0, 1, 2), + new NestJobProgress(NestJobStage.PlateCommitted, "finite", 1, 2, 4), + new NestJobProgress(NestJobStage.PlateCommitted, "unlimited", 2, 3, 5), + }, progress.Values); + Assert.Single(snapshot.Plates); + Assert.Equal(new PartFulfillment("a", 3, 1, 2), snapshot.Fulfillment[0]); + Assert.Equal(new StockUsage("unlimited", 1, null), snapshot.StockUsage[1]); + } + + [Theory] + [InlineData(NestJobStopReason.StockExhausted)] + [InlineData(NestJobStopReason.NoPlacementFound)] + [InlineData(NestJobStopReason.PlateLimitReached)] + public void IncompleteBuildPreservesStopReason(NestJobStopReason reason) + { + var result = new NestJobResultBuilder(Job()).Build(reason); + + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(reason, result.StopReason); + Assert.Empty(result.Plates); + Assert.Equal(new PartFulfillment("a", 3, 0, 3), result.Fulfillment[0]); + Assert.Equal(new StockUsage("finite", 0, 2), result.StockUsage[0]); + Assert.Equal(new StockUsage("unlimited", 0, null), result.StockUsage[1]); + } + + [Fact] + public void OverproductionRejectsWholeSheetWithoutConsumingIndicesOrReportingProgress() + { + var job = Job(); + var progress = new Reports(); + var builder = new NestJobResultBuilder(job, progress); + var stock = job.Plates[0]; + builder.AddSheet(stock, new[] { ("b", 0.0, 0.0, 0.0) }); + + Assert.Throws(() => builder.AddSheet(stock, + new[] { ("a", 0.0, 0.0, 0.0), ("b", 0.0, 0.0, 0.0), ("b", 0.0, 0.0, 0.0) })); + + Assert.Equal(0, builder.Placed("a")); + Assert.Equal(1, builder.Placed("b")); + Assert.Equal(1, builder.SheetsUsed(stock)); + Assert.Single(progress.Values); + Assert.Equal(1, builder.AddSheet(stock, new[] { ("b", 0.0, 0.0, 0.0) })); + var result = builder.Build(NestJobStopReason.StockExhausted); + Assert.Equal(1, Assert.Single(result.Plates[1].Placements).InstanceIndex); + Assert.Equal(new StockUsage("finite", 2, 0), result.StockUsage[0]); + Assert.Throws(() => builder.AddSheet(stock, new[] { ("a", 0.0, 0.0, 0.0) })); + } + + [Fact] + public void UnknownPartOrForeignStockCannotChangeAccounting() + { + var job = Job(); + var builder = new NestJobResultBuilder(job); + Assert.Throws(() => builder.AddSheet(job.Plates[0], new[] { ("unknown", 0.0, 0.0, 0.0) })); + Assert.Throws(() => builder.AddSheet(new NestPlateStock("finite", new Size(10, 10)), + new[] { ("a", 0.0, 0.0, 0.0) })); + Assert.Empty(builder.Build(NestJobStopReason.NoPlacementFound).Plates); + Assert.Equal(0, builder.SheetsUsed(job.Plates[0])); + } + + [Fact] + public void EmptyDemandIsComplete() + { + var builder = new NestJobResultBuilder(new NestJob(Array.Empty(), Array.Empty())); + Assert.True(builder.IsComplete); + var result = builder.Build(NestJobStopReason.NoPlacementFound); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(NestJobStopReason.Completed, result.StopReason); + } + + private static NestJob Job() + { + var geometry = PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(1, 1)); + return new NestJob( + new[] { new NestJobPart("a", geometry, 3), new NestJobPart("b", geometry, 2) }, + new[] { new NestPlateStock("finite", new Size(10, 10), 2), new NestPlateStock("unlimited", new Size(10, 10)) } + ); + } + + private sealed class Reports : IProgress + { + public List Values { get; } = new(); + public void Report(NestJobProgress value) => Values.Add(value); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobResultBuilder.cs b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs new file mode 100644 index 0000000..da5e1b9 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Jobs; + +/// Assembles committed sheets and quantity accounting without choosing or validating poses. +public sealed class NestJobResultBuilder +{ + private readonly NestJob job; + private readonly IProgress progress; + private readonly Dictionary parts; + private readonly Dictionary stocks; + private readonly Dictionary placed; + private readonly Dictionary used; + private readonly List sheets = new(); + private int committedParts; + + /// Creates an empty result builder for the job and optional progress reporter. + public NestJobResultBuilder(NestJob job, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(job); + this.job = job; + this.progress = progress; + parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); + stocks = job.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); + placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal); + used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); + } + + /// + /// Commits a sheet in call order, assigning zero-based instance indices per part across sheets. + /// Reports PlateCommitted and returns the new zero-based PlateIndex. Stock must belong to the job. + /// Unknown parts, overproduction, and exhausted stock are rejected before accounting changes. + /// + public int AddSheet( + NestPlateStock stock, + IEnumerable<(string PartId, double X, double Y, double Rotation)> placements + ) + { + ArgumentNullException.ThrowIfNull(placements); + RequireStock(stock); + if (stock.Quantity.HasValue && used[stock.Id] >= stock.Quantity.Value) + throw new InvalidOperationException("Stock is exhausted."); + + var counts = new Dictionary(placed, StringComparer.Ordinal); + var poses = new List(); + foreach (var pose in placements) + { + if (pose.PartId == null || !parts.TryGetValue(pose.PartId, out var part)) + throw new ArgumentException("Unknown part ID.", nameof(placements)); + var index = counts[pose.PartId]; + if (index >= part.Quantity) + throw new InvalidOperationException("Placement exceeds the requested quantity."); + poses.Add(new NestJobPlacement(pose.PartId, index, pose.X, pose.Y, pose.Rotation)); + counts[pose.PartId]++; + } + + var plateIndex = sheets.Count; + sheets.Add(new NestJobPlateResult(plateIndex, stock, poses)); + foreach (var count in counts) + placed[count.Key] = count.Value; + used[stock.Id]++; + committedParts += poses.Count; + progress?.Report(new NestJobProgress( + NestJobStage.PlateCommitted, stock.Id, plateIndex, sheets.Count, committedParts)); + return plateIndex; + } + + /// Number of physical sheets committed from this job's stock. + public int SheetsUsed(NestPlateStock stock) + { + RequireStock(stock); + return used[stock.Id]; + } + + /// Number of committed instances of the requirement ID. + public int Placed(string partId) => placed[partId]; + + /// True when every requested instance has been committed. + public bool IsComplete => job.Parts.All(p => placed[p.Id] == p.Quantity); + + /// + /// Returns a detached accounting snapshot in commit/input order. Complete jobs use Completed; + /// otherwise the result is Incomplete and uses the supplied stop reason. + /// + public NestJobResult Build(NestJobStopReason stopReason) + { + var complete = IsComplete; + return new NestJobResult( + complete ? NestJobStatus.Complete : NestJobStatus.Incomplete, + complete ? NestJobStopReason.Completed : stopReason, + sheets, + job.Parts.Select(p => new PartFulfillment( + p.Id, p.Quantity, placed[p.Id], p.Quantity - placed[p.Id])), + job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])) + ); + } + + private void RequireStock(NestPlateStock stock) + { + ArgumentNullException.ThrowIfNull(stock); + if (!stocks.TryGetValue(stock.Id, out var owned) || !ReferenceEquals(stock, owned)) + throw new ArgumentException("Stock must belong to the job.", nameof(stock)); + } +}