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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user