feat(api): accept complete nesting jobs and report fulfillment

Task 6 of the whole-job engine API: adapt the public NestRequest/NestRunner/
NestResponse surface to delegate to the whole-job runner instead of a manual
quantity loop.

- NestRequest: optional explicit Plates stock list (null keeps the legacy
  unlimited SheetSize fallback; empty list means no available stock),
  optional per-part Id (derived as part-{index} when absent), and an explicit
  PlacementStrategy that takes precedence over the legacy Strategy.
- NestRequestPlate: one physical-stock type (id, size, quantity, spacing,
  quadrant).
- NestRunner: imports each DXF once, propagates priority/rotation constraints,
  runs a single NestJobRunner solve, materializes ID/pose placements exactly
  once, and reports aggregate utilization as total placed part area over total
  physical sheet area.
- NestResponse: exposes status, stop reason, part fulfillment, stock usage,
  and plate-to-stock mapping; .nestquote save/load gains a schema version and
  reports completion as unknown for old archives lacking fulfillment metadata.
- Tests: extend the Api request/runner/persistence suites for legacy SheetSize,
  explicit mixed finite stock, stock exhaustion, weighted utilization, old
  archive loading, and new-archive round trips.

Verification: cross-compiles clean on net8.0-windows (Linux). The Api tests
require a Windows runner (net8.0-windows) and are NOT executed here; the
delegated engine logic is covered by the 70-test net8.0 Engine.Tests suite
(committed in Task 5). Windows runtime verification remains outstanding.
This commit is contained in:
aj
2026-09-18 06:15:04 -04:00
parent 2b0b962c8f
commit ad69023c17
8 changed files with 559 additions and 138 deletions
+32
View File
@@ -13,6 +13,8 @@ public class NestRequestTests
Assert.Empty(request.Parts);
Assert.Equal(60, request.SheetSize.Width);
Assert.Equal(120, request.SheetSize.Length);
Assert.Null(request.Plates);
Assert.Equal("Default", request.PlacementStrategy);
Assert.Equal("Steel, A1011 HR", request.Material);
Assert.Equal(0.06, request.Thickness);
Assert.Equal(0.1, request.Spacing);
@@ -38,8 +40,38 @@ public class NestRequestTests
{
var part = new NestRequestPart { DxfPath = "part.dxf" };
Assert.Null(part.Id);
Assert.Equal(1, part.Quantity);
Assert.True(part.AllowRotation);
Assert.Equal(0, part.Priority);
}
[Fact]
public void ExplicitPlates_PreserveStockSettings()
{
var request = new NestRequest
{
Plates =
[
new NestRequestPlate
{
Id = "remnant",
Size = new Size(24, 48),
Quantity = 3,
PartSpacing = 0.2,
EdgeSpacing = new Spacing(1, 2, 3, 4),
Quadrant = 3
}
]
};
var plate = Assert.Single(request.Plates!);
Assert.Equal("remnant", plate.Id);
Assert.Equal(24, plate.Size.Width);
Assert.Equal(48, plate.Size.Length);
Assert.Equal(3, plate.Quantity);
Assert.Equal(0.2, plate.PartSpacing);
Assert.Equal(new Spacing(1, 2, 3, 4), plate.EdgeSpacing);
Assert.Equal(3, plate.Quadrant);
}
}
@@ -1,42 +1,42 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Api;
public class NestResponsePersistenceTests
{
[Fact]
public async Task SaveAsync_LoadAsync_RoundTrips()
public async Task SaveAsync_LoadAsync_RoundTripsCompleteResponseMetadata()
{
var nest = new Nest("test-nest");
var plate = new Plate(new Size(60, 120));
var drawing = new Drawing("test-part");
nest.Drawings.Add(drawing);
plate.Parts.Add(new Part(drawing));
nest.Plates.Add(plate);
var nest = CreateNest("test-part", new Size(60, 120));
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }],
SheetSize = new Size(60, 120),
Parts = [new NestRequestPart { Id = "test-part", DxfPath = "test.dxf", Quantity = 5 }],
Plates = [new NestRequestPlate { Id = "sheet", Size = new Size(60, 120), Quantity = 1, PartSpacing = 0.1 }],
Material = "Steel",
Thickness = 0.125,
Spacing = 0.1
};
var original = new NestResponse
{
SheetCount = 1,
Utilization = 0.75,
CutTime = TimeSpan.FromMinutes(12.5),
Elapsed = TimeSpan.FromSeconds(3.2),
Status = NestJobStatus.Complete,
StopReason = NestJobStopReason.Completed,
Fulfillment = [new NestPartFulfillment("test-part", 5, 5, 0)],
StockUsage = [new NestStockUsage("sheet", 1, 0)],
PlateStockMappings = [new NestPlateStockMapping(0, "sheet")],
Nest = nest,
Request = request
};
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
try
@@ -44,16 +44,24 @@ public class NestResponsePersistenceTests
await original.SaveAsync(path);
var loaded = await NestResponse.LoadAsync(path);
Assert.Equal(NestResponse.CurrentSchemaVersion, loaded.SchemaVersion);
Assert.Equal(original.SheetCount, loaded.SheetCount);
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
Assert.Equal(original.CutTime, loaded.CutTime);
Assert.Equal(original.Elapsed, loaded.Elapsed);
Assert.Equal(NestJobStatus.Complete, loaded.Status);
Assert.Equal(NestJobStopReason.Completed, loaded.StopReason);
Assert.Equal(original.Fulfillment, loaded.Fulfillment);
Assert.Equal(original.StockUsage, loaded.StockUsage);
Assert.Equal(original.PlateStockMappings, loaded.PlateStockMappings);
Assert.Equal(original.Request.Material, loaded.Request.Material);
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count);
Assert.Equal("test-part", loaded.Request.Parts[0].Id);
Assert.Equal(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath);
Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity);
Assert.Equal("sheet", Assert.Single(loaded.Request.Plates!).Id);
Assert.NotNull(loaded.Nest);
Assert.Single(loaded.Nest.Plates);
@@ -63,4 +71,111 @@ public class NestResponsePersistenceTests
File.Delete(path);
}
}
[Fact]
public async Task LoadAsync_LegacyArchiveWithoutFulfillment_LeavesStatusUnspecified()
{
var path = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid()}.nestquote");
try
{
await WriteLegacyArchiveAsync(path, CreateNest("legacy-part", new Size(60, 120)));
var loaded = await NestResponse.LoadAsync(path);
Assert.Equal(0, loaded.SchemaVersion);
Assert.Null(loaded.Status);
Assert.Null(loaded.StopReason);
Assert.Empty(loaded.Fulfillment);
Assert.Empty(loaded.StockUsage);
Assert.Empty(loaded.PlateStockMappings);
Assert.Equal(1, loaded.SheetCount);
Assert.Equal(0.75, loaded.Utilization, precision: 4);
}
finally
{
File.Delete(path);
}
}
[Fact]
public async Task SaveAsync_LoadAsync_IncompleteResponsePreservesIdsAndUnplacedWithoutDxf()
{
var dxfPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.dxf");
var path = Path.Combine(Path.GetTempPath(), $"incomplete-{Guid.NewGuid()}.nestquote");
Assert.False(File.Exists(dxfPath));
var original = new NestResponse
{
SheetCount = 1,
Utilization = 0.4,
CutTime = TimeSpan.FromMinutes(2),
Elapsed = TimeSpan.FromMilliseconds(500),
Status = NestJobStatus.Incomplete,
StopReason = NestJobStopReason.StockExhausted,
Fulfillment = [new NestPartFulfillment("custom-id", 3, 1, 2)],
StockUsage = [new NestStockUsage("finite-stock", 1, 0)],
PlateStockMappings = [new NestPlateStockMapping(0, "finite-stock")],
Nest = CreateNest("custom-id", new Size(10, 10)),
Request = new NestRequest
{
Parts = [new NestRequestPart { Id = "custom-id", DxfPath = dxfPath, Quantity = 3 }],
Plates = [new NestRequestPlate { Id = "finite-stock", Size = new Size(10, 10), Quantity = 1 }]
}
};
try
{
await original.SaveAsync(path);
var loaded = await NestResponse.LoadAsync(path);
Assert.False(File.Exists(dxfPath));
Assert.Equal(NestJobStatus.Incomplete, loaded.Status);
Assert.Equal(NestJobStopReason.StockExhausted, loaded.StopReason);
Assert.Equal(new NestPartFulfillment("custom-id", 3, 1, 2), Assert.Single(loaded.Fulfillment));
Assert.Equal(new NestStockUsage("finite-stock", 1, 0), Assert.Single(loaded.StockUsage));
Assert.Equal(new NestPlateStockMapping(0, "finite-stock"), Assert.Single(loaded.PlateStockMappings));
Assert.Equal("custom-id", Assert.Single(loaded.Request.Parts).Id);
Assert.Equal("finite-stock", Assert.Single(loaded.Request.Plates!).Id);
Assert.Single(loaded.Nest.Drawings);
}
finally
{
File.Delete(path);
}
}
private static Nest CreateNest(string drawingName, Size size)
{
var nest = new Nest("test-nest");
var plate = new Plate(size);
var drawing = new Drawing(drawingName);
nest.Drawings.Add(drawing);
plate.Parts.Add(new Part(drawing));
nest.Plates.Add(plate);
return nest;
}
private static async Task WriteLegacyArchiveAsync(string path, Nest nest)
{
using var fs = new FileStream(path, FileMode.Create);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
await WriteEntryAsync(zip, "request.json", """
{"parts":[{"dxfPath":"legacy-missing.dxf","quantity":2}],"sheetSize":{"width":60,"length":120},"material":"Steel","thickness":0.06,"spacing":0.1,"strategy":0}
""");
await WriteEntryAsync(zip, "response.json", """
{"sheetCount":1,"utilization":0.75,"cutTimeTicks":120000,"elapsedTicks":340000}
""");
var nestEntry = zip.CreateEntry("nest.nest");
await using var stream = nestEntry.Open();
new NestWriter(nest).Write(stream);
}
private static async Task WriteEntryAsync(ZipArchive zip, string name, string contents)
{
var entry = zip.CreateEntry(name);
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(contents);
}
}
+163 -9
View File
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Converters;
@@ -11,7 +12,7 @@ namespace OpenNest.Tests.Api;
public class NestRunnerTests
{
[Fact]
public async Task RunAsync_SinglePart_ProducesResponse()
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{
var dxfPath = CreateTempSquareDxf(2, 2);
@@ -26,9 +27,17 @@ public class NestRunnerTests
var response = await NestRunner.RunAsync(request);
Assert.NotNull(response);
Assert.Equal(NestJobStatus.Complete, response.Status);
Assert.Equal(NestJobStopReason.Completed, response.StopReason);
Assert.Equal("part-0", Assert.Single(response.Fulfillment).PartId);
Assert.Equal(4, response.Fulfillment[0].Placed);
var stock = Assert.Single(response.StockUsage);
Assert.Equal("legacy-sheet", stock.StockId);
Assert.Null(stock.Remaining);
Assert.All(response.PlateStockMappings, mapping => Assert.Equal("legacy-sheet", mapping.StockId));
Assert.Equal(response.SheetCount, response.PlateStockMappings.Count);
Assert.NotNull(response.Nest);
Assert.True(response.SheetCount >= 1);
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "part-0");
Assert.True(response.Utilization > 0);
Assert.Equal(request, response.Request);
}
@@ -38,6 +47,155 @@ public class NestRunnerTests
}
}
[Fact]
public async Task RunAsync_ExplicitMixedFinitePlates_UsesPhysicalStockEntries()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }],
Plates =
[
new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 },
new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 }
]
});
Assert.Equal(NestJobStatus.Complete, response.Status);
Assert.Equal(2, response.SheetCount);
Assert.Equal(5, Assert.Single(response.Fulfillment).Placed);
Assert.Equal(0, response.Fulfillment[0].Unplaced);
Assert.Equal(new[] { "large", "small" }, response.PlateStockMappings.Select(mapping => mapping.StockId).Order());
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "small").Used);
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "large").Used);
Assert.All(response.StockUsage, usage => Assert.Equal(0, usage.Remaining));
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_ExplicitEmptyPlates_ReportsStockExhausted()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 1 }],
Plates = []
});
Assert.Equal(NestJobStatus.Incomplete, response.Status);
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
Assert.Equal(0, response.SheetCount);
Assert.Empty(response.StockUsage);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal(0, fulfillment.Placed);
Assert.Equal(1, fulfillment.Unplaced);
Assert.Empty(response.Nest.Plates);
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "square");
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart
{
Id = "locked-square",
DxfPath = dxfPath,
Quantity = 2,
AllowRotation = false
}],
Plates = [new NestRequestPlate { Id = "only-sheet", Size = new Size(5, 5), Quantity = 1 }]
});
Assert.Equal(NestJobStatus.Incomplete, response.Status);
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal(2, fulfillment.Requested);
Assert.Equal(1, fulfillment.Placed);
Assert.Equal(1, fulfillment.Unplaced);
var stock = Assert.Single(response.StockUsage);
Assert.Equal(1, stock.Used);
Assert.Equal(0, stock.Remaining);
var drawing = Assert.Single(response.Nest.Drawings);
Assert.Equal("locked-square", drawing.Name);
Assert.Equal(OpenNest.Math.Angle.TwoPI, drawing.Constraints.StepAngle);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_MixedPhysicalSheets_CalculatesWeightedUtilization()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }],
Plates =
[
new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 },
new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 }
]
});
Assert.Equal(2, response.SheetCount);
Assert.Equal(80d / 106d, response.Utilization, precision: 6);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_DuplicatePartIds_ThrowsBeforeNesting()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var request = new NestRequest
{
Parts =
[
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath },
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath }
]
};
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_BadDxfPath_Throws()
{
@@ -46,8 +204,7 @@ public class NestRunnerTests
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
};
await Assert.ThrowsAsync<FileNotFoundException>(
() => NestRunner.RunAsync(request));
await Assert.ThrowsAsync<FileNotFoundException>(() => NestRunner.RunAsync(request));
}
[Fact]
@@ -55,8 +212,7 @@ public class NestRunnerTests
{
var request = new NestRequest { Parts = [] };
await Assert.ThrowsAsync<ArgumentException>(
() => NestRunner.RunAsync(request));
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
}
private static string CreateTempSquareDxf(double width, double height)
@@ -69,9 +225,7 @@ public class NestRunnerTests
var pgm = ConvertGeometry.ToProgram(shape);
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
Dxf.ExportProgram(pgm, path);
return path;
}
}