Files
OpenNest/OpenNest.Tests/Data/LedgerStockAssignmentTests.cs
T
ajandClaude Opus 5.5 15750949dd feat(nest): plates name the ledger item each sheet is cut from
Auto Nest on MaterialLedger stock now records which inventory item each
physical sheet uses, so ML can hold exactly those items instead of matching
by size.

- Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded).
  Core invalidates it: a resize clears it (turning the sheet keeps it), a lower
  Quantity truncates it, a nest material/grade/thickness change clears every
  plate's, ClearAll clears it and new plates never copy it.
- Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment
  deals each ledger row's codes to its plates in Nest.Plates order (bundles
  repeat their code; clamp-removal variants use their base row). Rejected,
  discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock
  pairs committed plates with their solved stock; the engine stays ledger-free.
- Plate list tooltip: "Stock: CODE (location)"; locations are display-only.
- nest.json: optional plate stockItemCodes, omitted when empty, read leniently.
- /api/nests/{id}/sheets: new plates[] { index, width, length, quantity,
  stockItemCodes }; sheets[] unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00

170 lines
6.6 KiB
C#

using OpenNest.Data.MaterialLedger;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Data;
/// <summary>
/// After Auto Nest commits, each plate on ledger stock takes the next codes of its row in nest
/// order; nothing else touches codes, so a rejected proposal leaves them as they were.
/// </summary>
public class LedgerStockAssignmentTests
{
private static LedgerStockRow Row(string id, double width, double length, params string[] codes) =>
new(id, new Size(width, length), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false,
codes, Array.Empty<string>());
[Fact]
public void Plates_DequeueTheirRowsCodes_InNestOrder_AndBundlesRepeatTheirCode()
{
var drops = Row("ledger:1", 30, 52.5, "DROP-1182", "DROP-1190", "DROP-1201");
var bundle = Row("ledger:2", 60, 120, "SHEET-456", "SHEET-456", "SHEET-456");
var nest = new Nest();
var first = Add(nest, 1);
var second = Add(nest, 2);
var third = Add(nest, 2);
var hand = Add(nest, 1);
hand.StockItemCodes = ["STALE"];
// Committed out of nest order: codes still follow Nest.Plates.
LedgerStockAssignment.Assign(nest.Plates,
[(third, "ledger:2"), (first, "ledger:1"), (second, "ledger:1"), (hand, "option-0")],
[drops, bundle]);
Assert.Equal(["DROP-1182"], first.StockItemCodes);
Assert.Equal(["DROP-1190", "DROP-1201"], second.StockItemCodes);
Assert.Equal(["SHEET-456", "SHEET-456"], third.StockItemCodes);
Assert.Empty(hand.StockItemCodes);
}
[Fact]
public void ARowThatRunsOut_LeavesTheRemainingSheetsUncoded()
{
var nest = new Nest();
var first = Add(nest, 2);
var second = Add(nest, 1);
LedgerStockAssignment.Assign(nest.Plates, [(first, "ledger:1"), (second, "ledger:1")],
[Row("ledger:1", 30, 52.5, "DROP-1")]);
Assert.Equal(["DROP-1"], first.StockItemCodes);
Assert.Empty(second.StockItemCodes);
}
[Fact]
public void ASheetSolvedWithClampsRemoved_TakesItsBaseRowsCodes()
{
var nest = new Nest();
var plate = Add(nest, 1);
LedgerStockAssignment.Assign(nest.Plates, [(plate, "ledger:3::removed:clamp-2")],
[Row("ledger:3", 48, 96, "SHEET-9")]);
Assert.Equal(["SHEET-9"], plate.StockItemCodes);
Assert.Equal("ledger:3", LedgerStockAssignment.BaseStockId("ledger:3::removed:clamp-2,clamp-3"));
}
[Fact]
public void CommittedProposal_CodesThePlatesItWasSolvedOn()
{
var (nest, drawing, manager) = Workspace();
using (manager)
{
var rows = new[] { Row("ledger:1", 30, 52.5, "DROP-1", "DROP-2") };
var result = Solve(drawing, overlap: false);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
LedgerStockAssignment.Assign(nest.Plates,
NestPipelineCommit.PairWithStock(result, applied).Select(p => (p.Plate, p.Stock.Id)), rows);
Assert.Equal(["DROP-1"], Assert.Single(applied).StockItemCodes);
}
}
[Fact]
public void RejectedProposal_LeavesCodesUntouched()
{
var (nest, drawing, manager) = Workspace();
using (manager)
{
var empty = nest.Plates.Single();
empty.StockItemCodes = ["SHEET-OLD"];
var result = Solve(drawing, overlap: true);
Assert.False(result.IsValid);
// Auto Nest assigns only after a commit; without consent there is none.
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Equal(["SHEET-OLD"], empty.StockItemCodes);
Assert.Empty(empty.Parts);
}
}
[Fact]
public void Describe_NamesEachCodeWithItsLocation_CountsRuns_AndNotesUncodedSheets()
{
var plate = new Plate(60, 120) { Quantity = 4 };
Assert.Null(LedgerStockAssignment.Describe(plate, null));
plate.StockItemCodes = ["SHEET-456", "SHEET-456", "DROP-1182"];
var locations = new Dictionary<string, string> { ["SHEET-456"] = "A1" };
Assert.Equal("Stock: SHEET-456 (A1) x2, DROP-1182, 1 uncoded", LedgerStockAssignment.Describe(plate, locations));
}
[Fact]
public void Mapper_KeepsEachItemsLocationForDisplay()
{
var mapping = LedgerStockMapper.Map(
[
new LedgerStockItem("D-1", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular,
"R1", "Rack 1", null, null, DateTime.UtcNow),
new LedgerStockItem("D-2", 1, LedgerItemKinds.Drop, 24, 36, 1, LedgerItemShapes.Rectangular,
"R2", "Rack 2", null, null, DateTime.UtcNow),
], Units.Inches);
var row = Assert.Single(mapping.Rows);
Assert.Equal("R1", row.ItemLocations["D-1"]);
Assert.Equal("R2", row.ItemLocations["D-2"]);
}
private static Plate Add(Nest nest, int quantity)
{
var plate = new Plate(30, 52.5) { Quantity = quantity };
nest.Plates.Add(plate);
return plate;
}
private static (Nest Nest, Drawing Drawing, PlateManager Manager) Workspace()
{
var nest = new Nest();
var drawing = new RectangleShape { Name = "SYN", Length = 4, Width = 2 }.GetDrawing();
nest.Drawings.Add(drawing);
nest.CreatePlate();
return (nest, drawing, new PlateManager(nest));
}
private static NestPipelineResult Solve(Drawing drawing, bool overlap) =>
NestPipeline.Run(new OnFirstStock(overlap), "stub", new NestPipelineRequest("stub",
[new NestItem { Drawing = drawing, Quantity = 2 }],
[new NestPlateStock("ledger:1", new Size(30, 52.5), 2, 0.25, new Spacing(1, 1), 1)]));
// Two copies on one sheet of the first stock row; overlapping when asked, so validation fails.
private sealed class OnFirstStock(bool overlap) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default) => new(
NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, job.Plates[0],
[
new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0),
new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0),
])],
[new PartFulfillment(job.Parts[0].Id, 2, 2, 0)],
[new StockUsage(job.Plates[0].Id, 1, 1)]);
}
}