using System; using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; namespace OpenNest.Engine.Tests.NestingEngines; /// Physical accounting/continuation contract, run independently for each repeating engine. public abstract class RepeatEngineSafetyTests { private protected abstract INestingEngine Engine(Action? packed = null, Action? batches = null); [Theory] [InlineData(5)] [InlineData(1)] public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount) { var job = Mixed(urgentCount, 8); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(urgentCount, batches[0].Copies); Assert.True(batches.Count > 1); Assert.Equal(urgentCount, result.Fulfillment.Single(f => f.PartId == "a").Placed); Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "b").Placed); Assert.All(result.Plates.Take(urgentCount), p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId))); Assert.All(result.Plates.Skip(urgentCount), p => Assert.All(p.Placements, v => Assert.Equal("b", v.PartId))); } [Fact] public void SmallDemandThatFitsTheRemnantIsPlacedBeforeRepeating() { var job = Mixed(10, 10); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(10, Assert.Single(batches).Copies); Assert.All(result.Plates, p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId))); } [Theory] [InlineData(2, null, NestJobStopReason.StockExhausted)] [InlineData(null, 2, NestJobStopReason.PlateLimitReached)] [InlineData(3, 2, NestJobStopReason.PlateLimitReached)] public void FiniteStockAndMaxPlatesCountPhysicalCopies(int? stockCount, int? cap, NestJobStopReason reason) { var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2, quantity: stockCount)], new NestJobOptions(maxPlates: cap)); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(reason, result.StopReason); Assert.Equal(2, result.Plates.Count); Assert.Equal(8, Assert.Single(result.Fulfillment).Placed); Assert.Equal(2, Assert.Single(batches).Copies); Assert.Equal(stockCount - 2, Assert.Single(result.StockUsage).Remaining); } [Fact] public void CompetingSameSizeStockEntriesCommitSinglyUntilOneRemains() { var job = Job([Rectangle("a", 9, 9, 16)], [Stock("one", 20, 20, spacing: 0.2, quantity: 2), Stock("two", 20, 20, spacing: 0.2, quantity: 2)]); var batches = new List(); var calls = 0; var result = Engine(() => calls++, batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(new[] { 1, 1, 2 }, batches.Select(b => b.Copies)); Assert.Equal(new[] { 2, 2, 1 }, batches.Select(b => b.AvailableStockEntries)); Assert.All(result.StockUsage, s => Assert.Equal(2, s.Used)); Assert.True(calls >= 5); // Two competing trials twice, then the genuinely sole remaining stock. } [Fact] public void SameNamedDrawingsRetainExactDistinctRequirementIds() { var a = new Drawing("same", Shapes.Rectangle(6, 9)); var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 }; var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]); var result = Engine().Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "a").Placed); Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "A").Placed); Assert.Equal(0, a.Quantity.Nested); Assert.Equal(0, b.Quantity.Nested); } [Fact] public void SalvageOffcutDoesNotPreventRepeatingCompletedJobSearch() { var stock = new NestPlateStock("s", new OpenNest.Geometry.Size(10, 10), null, 0.2, default, 1, cost: 20); var job = Job([Rectangle("a", 6, 9, 10)], [stock], new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(10, Assert.Single(batches).Copies); Assert.Equal(10, result.Plates.Count); Assert.Equal(162, NestJobCost.Evaluate(job, result), precision: 8); Assert.True(NestJobCost.NetSheetCost(job, result.Plates[0]) < stock.Cost); } [Fact] public void OversizedRequirementRemainsUnplacedWhileUsefulDemandRepeats() { var job = Job([Rectangle("huge", 100, 100, 4), Rectangle("small", 6, 9, 5)], [Stock("s", 10, 10, spacing: 0.2)]); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Incomplete, result.Status); Assert.Equal(4, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "small").Placed); Assert.Contains(batches, b => b.Copies == 5); } [Theory] [InlineData(false)] [InlineData(true)] public void NoFitOrUnavailableStockProducesNoEmptyRepeat(bool unavailable) { var job = Job([Rectangle("a", 100, 100, 2)], [Stock("s", 10, 10, quantity: unavailable ? 0 : null)]); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); LayoutAssert.Valid(job, result); Assert.Empty(result.Plates); Assert.Empty(batches); Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); } [Fact] public void CancellationAtRepeatDecisionCannotReturnAProposal() { using var cancellation = new CancellationTokenSource(); var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); var observed = 0; var engine = Engine(batches: b => { Assert.Equal(10, b.Copies); observed++; cancellation.Cancel(); }); Assert.Throws(() => engine.Solve(job, token: cancellation.Token)); Assert.Equal(1, observed); } [Theory] [InlineData(1)] [InlineData(10)] public void CancellationDuringPhysicalExpansionIncludingLastCopyIsObserved(int cancelAfter) { using var cancellation = new CancellationTokenSource(); var committed = new List(); var progress = new InlineProgress(p => { if (p.Stage != NestJobStage.PlateCommitted) return; committed.Add(p); if (p.CommittedPlates == cancelAfter) cancellation.Cancel(); }); var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); Assert.Throws(() => Engine().Solve(job, progress, cancellation.Token)); Assert.Equal(cancelAfter, committed.Count); Assert.Equal(cancelAfter * 4, committed.Last().CommittedParts); } [Fact] public void SuccessfulProgressRemainsPhysicalAndOrdered() { var committed = new List(); var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); var result = Engine().Solve(job, new InlineProgress(p => { if (p.Stage == NestJobStage.PlateCommitted) committed.Add(p); })); LayoutAssert.Valid(job, result); Assert.Equal(Enumerable.Range(1, 10), committed.Select(p => p.CommittedPlates)); Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 4), committed.Select(p => p.CommittedParts)); } [Fact] public void UnreadableGeometryIsNotTurnedIntoAnEmptyRepeat() { var job = Job([Part("unsupported", new OpenNest.CNC.Program(), 12)], [Stock("s", 10, 10)]); var batches = new List(); var result = Engine(batches: batches.Add).Solve(job); Assert.Equal(NestJobStatus.Incomplete, result.Status); Assert.Equal(12, Assert.Single(result.Fulfillment).Unplaced); Assert.Empty(result.Plates); Assert.Empty(batches); Assert.Equal(0, Assert.Single(result.StockUsage).Used); } [Fact] public void CancellationInsideTheRealSheetSearchNeverCertifiesARepeat() { using var cancellation = new CancellationTokenSource(); var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); var batches = new List(); var packed = 0; var progress = new InlineProgress(p => { if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel(); }); Assert.ThrowsAny(() => Engine(() => packed++, batches.Add).Solve(job, progress, cancellation.Token)); Assert.Equal(0, packed); Assert.Empty(batches); } private static NestJob Mixed(int a, int b) => Job( [Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)], [Stock("s", 10, 10, spacing: 0.2)]); private sealed class InlineProgress(Action report) : IProgress { public void Report(NestJobProgress value) => report(value); } }