Files
aj 2b0b962c8f fix(engine): enforce whole-job safety invariants
Task 5 of the whole-job engine API: add a geometry safety gate that
validates every candidate trial before the runner commits accounting.

- NestJobPlacementValidator: closed-contour validity, rotation-policy
  compliance, work-area containment per quadrant, hole-aware material
  overlap, and required part spacing. Overlap is interior-only, so
  zero-clearance edge/corner contact remains a valid placement.
- NestJobValidator: route candidate validation through the geometry
  gate; reject unusable/unclosed/degenerate contours up front.
- NestJobRunner: wrap candidate evaluation in a progress bridge that
  tags legacy engine detail with the current candidate context.
- LegacyPlateNesterAdapter: forward IProgress to the legacy engine so
  its progress surfaces under the active candidate.
- Tests: geometry (quadrants, rotations, touching, containment, holes,
  empty stock, real Default/Strip smoke), validation, and cancellation
  suites; repaired test fakes that emitted out-of-bounds or overlapping
  placements the gate now correctly rejects.

Engine.Tests: 70 passed, 0 failed, 0 skipped in Debug and Release.
Windows-only OpenNest.Tests not run on Linux.
2026-09-18 05:56:37 -04:00

160 lines
7.5 KiB
C#

using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestJobGeometryTests
{
[Fact]
public void CandidateOutsideUsableWorkAreaFailsWithoutMutatingInput()
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 1);
var sourceGeometry = part.Geometry.Motions.ToArray();
var stock = new NestPlateStock("stock", new Size(20, 30), 1,
edgeSpacing: new Spacing(2, 1, 3, 4));
var job = new NestJob(new[] { part }, new[] { stock });
var runner = new NestJobRunner(_ => new CandidateNester(new[] { new NestJobPlacement("part", 0, 26, 1, 0) }));
Assert.Throws<InvalidOperationException>(() => runner.Solve(job));
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
Assert.Equal(1, job.Parts[0].Quantity);
Assert.Equal(1, job.Plates[0].Quantity);
}
[Theory]
[InlineData(1, 0, 0)]
[InlineData(2, -11, 0)]
[InlineData(3, -11, -7)]
[InlineData(4, 0, -7)]
public void UnequalRectanglesFitAtEachQuadrantsUsableOrigin(int quadrant, double x, double y)
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 3)), 1);
var stock = new NestPlateStock("stock", new Size(7, 11), 1, quadrant: quadrant);
var job = new NestJob(new[] { part }, new[] { stock });
var result = Solve(job, new NestJobPlacement("part", 0, x, y, 0));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new NestJobPlacement("part", 0, x, y, 0), Assert.Single(result.Plates[0].Placements));
}
[Fact]
public void FixedAndBoundedRotationPoliciesRejectDisallowedAngles()
{
var fixedPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)), 1,
rotation: RotationPolicy.Fixed(System.Math.PI / 2));
var fixedJob = new NestJob(new[] { fixedPart }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
Assert.Throws<InvalidOperationException>(() => Solve(fixedJob, new NestJobPlacement("part", 0, 0, 0, 0)));
var fixedResult = Solve(fixedJob, new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 2));
Assert.Equal(NestJobStatus.Complete, fixedResult.Status);
var boundedPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)), 1,
rotation: RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4));
var boundedJob = new NestJob(new[] { boundedPart }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
Assert.Throws<InvalidOperationException>(() => Solve(boundedJob,
new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 3)));
var boundedResult = Solve(boundedJob, new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 4));
Assert.Equal(NestJobStatus.Complete, boundedResult.Status);
}
[Fact]
public void EdgeTouchingIsAllowedAtZeroSpacingAndRejectedAtPositiveSpacing()
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 2);
var touching = new[]
{
new NestJobPlacement("part", 0, 0, 0, 0),
new NestJobPlacement("part", 1, 2, 0, 0)
};
var zeroSpacing = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
var positiveSpacing = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(10, 10), 1, 0.1) });
Assert.Equal(NestJobStatus.Complete, Solve(zeroSpacing, touching).Status);
Assert.Throws<InvalidOperationException>(() => Solve(positiveSpacing, touching));
}
[Fact]
public void OverlapAndContainmentAreRejected()
{
var outer = new NestJobPart("outer", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 6)), 1);
var inner = new NestJobPart("inner", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
var job = new NestJob(new[] { outer, inner }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
Assert.Throws<InvalidOperationException>(() => Solve(job,
new NestJobPlacement("outer", 0, 0, 0, 0),
new NestJobPlacement("inner", 0, 2, 2, 0)));
}
[Fact]
public void EmptyStockStopsWithoutCallingCandidateNester()
{
var nester = new CountingNester();
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
var job = new NestJob(new[] { part }, Array.Empty<NestPlateStock>());
var result = new NestJobRunner(_ => nester).Solve(job);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(0, nester.Calls);
}
[Theory]
[InlineData("Default")]
[InlineData("Strip")]
public void RealEngineSmokeCasesPreserveInputAndProduceSafeAccounting(string strategy)
{
var drawing = new Drawing("generated rectangle", TestDrawingFactory.Rectangle(6, 4));
var part = DrawingJobMapper.FromDrawing("part", drawing, 3);
var sourceGeometry = part.Geometry.Motions.ToArray();
var stock = new NestPlateStock("stock", new Size(30, 50), 1, 1, new Spacing(1, 1, 1, 1));
var job = new NestJob(new[] { part }, new[] { stock }, new NestJobOptions(strategy));
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
var materialized = NestResultMaterializer.Materialize(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.All(result.Fulfillment, fulfillment => Assert.Equal(fulfillment.Requested,
fulfillment.Placed + fulfillment.Unplaced));
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
Assert.Equal(3, job.Parts[0].Quantity);
Assert.Equal(1, job.Plates[0].Quantity);
Assert.All(materialized.Nest.Plates, plate =>
{
var workArea = plate.WorkArea();
Assert.All(plate.Parts, placed =>
{
Assert.True(placed.BoundingBox.Left >= workArea.Left - 1e-6);
Assert.True(placed.BoundingBox.Right <= workArea.Right + 1e-6);
Assert.True(placed.BoundingBox.Bottom >= workArea.Bottom - 1e-6);
Assert.True(placed.BoundingBox.Top <= workArea.Top + 1e-6);
});
for (var left = 0; left < plate.Parts.Count; left++)
for (var right = left + 1; right < plate.Parts.Count; right++)
Assert.False(plate.Parts[left].Intersects(plate.Parts[right], out _));
});
}
private static NestJobResult Solve(NestJob job, params NestJobPlacement[] placements) =>
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
{
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => new(placements);
}
private sealed class CountingNester : IPlateNester
{
public int Calls { get; private set; }
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
Calls++;
return new PlateCandidate(Array.Empty<NestJobPlacement>());
}
}
}