mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 05:35:45 -05:00
Delta review fixes: - ReleaseRenest throws when a commit does not call finish, or calls it twice: either would leave ledger plates uncoded or count a sheet twice for later nests of the run. The ledger sheet count is the codes given. - A stage whose layout is not kept now lists the copies its layout missed with their own reasons (a part no sheet fits stays "does not fit"); only the copies it placed are "the proposal cannot be kept". - A valid ledger layout that places nothing says "Auto Nest placed nothing on it" rather than claiming no part fits. - ReleaseLedgerOffer rejects null rows; docs list every ledger note.
347 lines
14 KiB
C#
347 lines
14 KiB
C#
using OpenNest.Data;
|
|
using OpenNest.Data.MaterialLedger;
|
|
using OpenNest.Engine;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Shapes;
|
|
|
|
namespace OpenNest.Desktop.Tests;
|
|
|
|
public class ReleaseRenestTests
|
|
{
|
|
private const string Material = "Synthetic Steel";
|
|
|
|
private static Nest NestOf(double partSize, int quantity, string grade = "SYN-1")
|
|
{
|
|
var nest = new Nest("N26-0001")
|
|
{
|
|
Material = new Material(Material, grade),
|
|
Thickness = 0.25,
|
|
};
|
|
nest.PlateDefaults.Size = new Size(48, 96);
|
|
var drawing = new RectangleShape { Name = "SYN-A", Length = partSize, Width = partSize }
|
|
.GetDrawing();
|
|
drawing.Quantity.Required = quantity;
|
|
nest.Drawings.Add(drawing);
|
|
return nest;
|
|
}
|
|
|
|
private static LedgerStockItem Sheet(string code, decimal width, decimal length) =>
|
|
new(code, 1, LedgerItemKinds.FullSheet, width, length, 1, LedgerItemShapes.Rectangular,
|
|
"R1", "Rack 1", null, null, DateTime.UnixEpoch);
|
|
|
|
private static LedgerStockItem Bundle(string code, decimal width, decimal length,
|
|
int quantity) =>
|
|
new(code, 1, LedgerItemKinds.FullSheetBundle, width, length, quantity,
|
|
LedgerItemShapes.Rectangular, "B3", "Bay 3", null, null, DateTime.UnixEpoch);
|
|
|
|
private static LedgerStockResult Stock(params LedgerStockItem[] items) =>
|
|
new LedgerStockResult.Ok(Array.Empty<LedgerStockSpec>(), items);
|
|
|
|
private sealed class FakeLedger
|
|
{
|
|
public LedgerStockResult Stock { get; init; } = Stock();
|
|
public LedgerCodesInUse InUse { get; init; } =
|
|
LedgerCodesInUse.Known(Array.Empty<NestStockCodeUse>());
|
|
public List<(string Material, string Grade, double Thickness)> StockReads { get; } = new();
|
|
public int InUseReads { get; private set; }
|
|
|
|
public ReleaseLedgerStock Create() => new(
|
|
(material, grade, thickness, _) =>
|
|
{
|
|
StockReads.Add((material, grade, thickness));
|
|
return Task.FromResult(Stock);
|
|
},
|
|
_ =>
|
|
{
|
|
InUseReads++;
|
|
return Task.FromResult(InUse);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rectangles, except that a sheet <c>overlaps</c> picks gets every copy at one spot.
|
|
/// </summary>
|
|
private sealed class OverlapOn(Func<string, bool> overlaps) : INestingEngine
|
|
{
|
|
private readonly INestingEngine _inner = NestingEngineRegistry.Create("Rectangles");
|
|
|
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
|
CancellationToken token = default)
|
|
{
|
|
if (!overlaps(job.Plates[0].Id))
|
|
return _inner.Solve(job, progress, token);
|
|
var builder = new NestJobResultBuilder(job);
|
|
builder.AddSheet(job.Plates[0], Enumerable.Range(0, job.Parts[0].Quantity)
|
|
.Select(_ => (job.Parts[0].Id, 5.0, 5.0, 0.0)).ToArray());
|
|
return builder.Build(NestJobStopReason.Completed);
|
|
}
|
|
}
|
|
|
|
private static Task<ReleaseRenestResult> Release(ReleaseLedgerStock ledger, Nest nest,
|
|
INestingEngine engine = null) =>
|
|
ReleaseRenest.RunHeadlessAsync(nest, ledger,
|
|
engine ?? NestingEngineRegistry.Create("Rectangles"), "Rectangles", 0,
|
|
CancellationToken.None);
|
|
|
|
[Fact]
|
|
public async Task LedgerStockIsUsedFirst_EvenWhenTheDefaultSheetWouldBeSmaller()
|
|
{
|
|
// One 60x120 ledger sheet holds both parts; a single pass scored by area would take
|
|
// the smaller 48x96 Nest Defaults sheet instead.
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) };
|
|
var nest = NestOf(20, 2);
|
|
|
|
var result = await Release(ledger.Create(), nest);
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
var plate = Assert.Single(nest.Plates);
|
|
Assert.Equal(new Size(60, 120), plate.Size);
|
|
Assert.Equal(["S-1"], plate.StockItemCodes);
|
|
Assert.Equal(0, nest.Drawings.First().Quantity.Remaining);
|
|
Assert.EndsWith(" 1 sheet from MaterialLedger stock.", result.Note);
|
|
Assert.Equal("R1", result.LedgerLocations["S-1"]);
|
|
Assert.Equal((Material, "SYN-1", 0.25), Assert.Single(ledger.StockReads));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopiesTheLedgerCannotHold_TopUpOnTheNestDefaultsSheet_WithoutACode()
|
|
{
|
|
// Two free 40x40 sheets each hold one 30x30 part; the third copy needs a default sheet.
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 40, 40), Sheet("S-2", 40, 40)) };
|
|
var nest = NestOf(30, 3);
|
|
|
|
var result = await Release(ledger.Create(), nest);
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
Assert.Equal(0, nest.Drawings.First().Quantity.Remaining);
|
|
var coded = nest.Plates.Where(p => p.StockItemCodes.Count > 0).ToList();
|
|
Assert.Equal(["S-1", "S-2"], coded.SelectMany(p => p.StockItemCodes).Order());
|
|
Assert.All(coded, p => Assert.Equal(new Size(40, 40), p.Size));
|
|
var topUp = Assert.Single(nest.Plates.Where(p => p.StockItemCodes.Count == 0));
|
|
Assert.Equal(new Size(48, 96), topUp.Size);
|
|
Assert.StartsWith("Nested on 3 sheets, ", result.Note);
|
|
Assert.EndsWith(" 2 sheets from MaterialLedger stock.", result.Note);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALaterNestOfTheSameRun_DoesNotGetASheetAnEarlierNestTook()
|
|
{
|
|
// The release uploads every nest only after all are nested, so the server cannot
|
|
// report the first nest's codes yet; a substitute row can list the same sheet twice.
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) };
|
|
var run = ledger.Create();
|
|
await Release(run, NestOf(20, 2));
|
|
|
|
var second = NestOf(20, 2, grade: "SYN-2");
|
|
var result = await Release(run, second);
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
var plate = Assert.Single(second.Plates);
|
|
Assert.Empty(plate.StockItemCodes);
|
|
Assert.Equal(new Size(48, 96), plate.Size);
|
|
Assert.EndsWith(" MaterialLedger not used: no usable stock of Synthetic Steel SYN-2 0.25 in"
|
|
+ " (1 taken by earlier nests of this release).", result.Note);
|
|
Assert.Equal(1, run.Taken["S-1"]);
|
|
Assert.Equal(1, ledger.InUseReads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ABundleIsSharedSheetBySheet_AcrossNestsOfTheRun()
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Bundle("B-1", 60, 120, 10)) };
|
|
var run = ledger.Create();
|
|
await Release(run, NestOf(20, 2));
|
|
|
|
var offer = await run.OfferAsync(NestOf(20, 2, grade: "SYN-2"), CancellationToken.None);
|
|
|
|
Assert.Equal(9, Assert.Single(offer.Rows).Quantity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CodesOnOtherServerNests_AreLeftOut_AndNamedWhenNothingIsLeft()
|
|
{
|
|
var inUse = LedgerCodesInUse.Known(
|
|
[new NestStockCodeUse { ItemCode = "S-1", NestName = "N26-0099" }]);
|
|
var partly = new FakeLedger
|
|
{
|
|
Stock = Stock(Sheet("S-1", 60, 120), Sheet("S-2", 60, 120)),
|
|
InUse = inUse,
|
|
};
|
|
var all = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)), InUse = inUse };
|
|
|
|
var offer = await partly.Create().OfferAsync(NestOf(20, 2), CancellationToken.None);
|
|
var none = await all.Create().OfferAsync(NestOf(20, 2), CancellationToken.None);
|
|
|
|
Assert.Null(offer.NotUsed);
|
|
Assert.Equal(["S-2"], Assert.Single(offer.Rows).ItemCodes);
|
|
Assert.Equal("no usable stock of Synthetic Steel SYN-1 0.25 in (1 on other nests)",
|
|
none.NotUsed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoStockAtAll_IsCalledNoFreeStock()
|
|
{
|
|
var result = await Release(new FakeLedger().Create(), NestOf(20, 2));
|
|
|
|
Assert.EndsWith(" MaterialLedger not used: no free stock of Synthetic Steel SYN-1 0.25 in.",
|
|
result.Note);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("unreachable", "unreachable: No answer within 10 s")]
|
|
[InlineData("unauthorized", "it rejected the API key (Tools > Options)")]
|
|
[InlineData("unmapped", "no mapping for Synthetic Steel SYN-1 0.25 in")]
|
|
[InlineData("not configured", "not set up on this PC (Tools > Options)")]
|
|
public async Task AnUnusableLedger_NestsOnTheNestDefaultsSheet_AndSaysWhy(string state,
|
|
string why)
|
|
{
|
|
LedgerStockResult stock = state switch
|
|
{
|
|
"unreachable" => new LedgerStockResult.Unreachable("No answer within 10 s."),
|
|
"unauthorized" => new LedgerStockResult.Unauthorized(),
|
|
"unmapped" => new LedgerStockResult.Unmapped(),
|
|
_ => new LedgerStockResult.NotConfigured(),
|
|
};
|
|
var ledger = new FakeLedger { Stock = stock };
|
|
var nest = NestOf(20, 2);
|
|
|
|
var result = await Release(ledger.Create(), nest);
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
var plate = Assert.Single(nest.Plates);
|
|
Assert.Equal(new Size(48, 96), plate.Size);
|
|
Assert.Empty(plate.StockItemCodes);
|
|
Assert.EndsWith($" MaterialLedger not used: {why}.", result.Note);
|
|
Assert.Equal(0, ledger.InUseReads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UncheckedOtherNests_AreNamedInTheNote()
|
|
{
|
|
var ledger = new FakeLedger
|
|
{
|
|
Stock = Stock(Sheet("S-1", 60, 120)),
|
|
InUse = LedgerCodesInUse.Unknown("the nest server did not answer"),
|
|
};
|
|
|
|
var result = await Release(ledger.Create(), NestOf(20, 2));
|
|
|
|
Assert.EndsWith(" 1 sheet from MaterialLedger stock. Codes on other nests were not "
|
|
+ "checked: the nest server did not answer.", result.Note);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NothingToNest_DoesNotReadTheLedger()
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) };
|
|
|
|
var result = await Release(ledger.Create(), NestOf(20, 0));
|
|
|
|
Assert.Equal((false, NestRenest.NothingToNestNote), (result.Nested, result.Note));
|
|
Assert.Empty(ledger.StockReads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARejectedLedgerLayout_FallsToTheNestDefaults_AndSaysTheLedgerWasNotUsed()
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) };
|
|
var nest = NestOf(20, 2);
|
|
|
|
var result = await Release(ledger.Create(), nest,
|
|
new OverlapOn(LedgerStockMapper.IsLedgerId));
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
Assert.Equal(new Size(48, 96), Assert.Single(nest.Plates).Size);
|
|
Assert.EndsWith(" MaterialLedger not used: Auto Nest could not keep a layout on its stock.",
|
|
result.Note);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ARejectedDefaultsLayout_ListsEveryCopyItAskedFor()
|
|
{
|
|
// One 40x40 ledger sheet holds one of three copies; the defaults layout never validates.
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 40, 40)) };
|
|
var nest = NestOf(30, 3);
|
|
|
|
var result = await Release(ledger.Create(), nest,
|
|
new OverlapOn(id => !LedgerStockMapper.IsLedgerId(id)));
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
Assert.Equal(2, nest.Drawings.First().Quantity.Remaining);
|
|
Assert.Contains(" 2 copies not placed, left to nest: "
|
|
+ "SYN-A: 2 not placed (the proposal cannot be kept).", result.Note);
|
|
Assert.EndsWith(" 1 sheet from MaterialLedger stock.", result.Note);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CodesAreGivenInsideTheCommit_BeforeItsUndoStepCloses()
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }.Create();
|
|
var nest = NestOf(20, 2);
|
|
var codesAtClose = new List<string>();
|
|
|
|
await ReleaseRenest.RunAsync(nest, ledger, "Rectangles", 0,
|
|
request => Task.FromResult(NestPipeline.Run(NestingEngineRegistry.Create("Rectangles"),
|
|
"Rectangles", request, null, CancellationToken.None)),
|
|
(result, finish) =>
|
|
{
|
|
var plates = NestPipelineCommit.ApplyToPlates(result, nest.CreatePlate);
|
|
finish(plates);
|
|
// Where the live tab completes its undo step.
|
|
codesAtClose.AddRange(plates.SelectMany(p => p.StockItemCodes));
|
|
return plates;
|
|
},
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(["S-1"], codesAtClose);
|
|
}
|
|
|
|
/// <summary>Rectangles, except that a ledger sheet gets an empty layout.</summary>
|
|
private sealed class NothingOnLedger : INestingEngine
|
|
{
|
|
private readonly INestingEngine _inner = NestingEngineRegistry.Create("Rectangles");
|
|
|
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
|
CancellationToken token = default) =>
|
|
LedgerStockMapper.IsLedgerId(job.Plates[0].Id)
|
|
? new NestJobResultBuilder(job).Build(NestJobStopReason.Completed)
|
|
: _inner.Solve(job, progress, token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AValidLedgerLayoutThatPlacesNothing_SaysSo()
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) };
|
|
var nest = NestOf(20, 2);
|
|
|
|
var result = await Release(ledger.Create(), nest, new NothingOnLedger());
|
|
|
|
Assert.True(result.Nested, result.Note);
|
|
Assert.EndsWith(" No MaterialLedger stock used: Auto Nest placed nothing on it.",
|
|
result.Note);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(2)]
|
|
public async Task ACommitThatDoesNotFinishItsPlatesOnce_Throws(int finishes)
|
|
{
|
|
var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }.Create();
|
|
var nest = NestOf(20, 2);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => ReleaseRenest.RunAsync(nest,
|
|
ledger, "Rectangles", 0,
|
|
request => Task.FromResult(NestPipeline.Run(NestingEngineRegistry.Create("Rectangles"),
|
|
"Rectangles", request, null, CancellationToken.None)),
|
|
(result, finish) =>
|
|
{
|
|
var plates = NestPipelineCommit.ApplyToPlates(result, nest.CreatePlate);
|
|
for (var i = 0; i < finishes; i++)
|
|
finish(plates);
|
|
return plates;
|
|
},
|
|
CancellationToken.None));
|
|
}
|
|
}
|