Files
OpenNest/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs
T
aj 565dc90762 refactor(ui): rephrase negative-tone disclaimers as affirmative guidance
Report, dialog and diagnostic text across post verification, cutoff
planning, alignment and cutting-plan review repeatedly told the user
what checks weren't (safety certifications, proof, guarantees) instead
of what to do. Reworded these to state the same caveats affirmatively
(inspect X, confirm Y, review Z) without changing the underlying
safety guidance, and updated the tests/docs that pinned the old wording.
2026-10-12 07:45:23 -04:00

269 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// The bounded, lazy selection over ranked candidates and the S07 verdict: cap first, side
/// coverage as a corrective scan, honest shortfall metadata — and the untouched tail is
/// never evaluated. Counts, not elapsed time.
/// </summary>
public class ContourEntrySelectionTests
{
private static readonly PreparedContours Owner =
PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters());
private static ContourEntryCandidate Cand(double x, double y, AutomaticEntryKind kind = AutomaticEntryKind.ConvexCorner) =>
new(new ContourChoice(0, 0, new Vector(x, y)) { Owner = Owner }, kind);
private static Func<ContourEntryCandidate, ContourFeasibilityVerdict> Feasible(params (double X, double Y)[] clearAt)
{
var set = new HashSet<(long, long)>(clearAt.Select(p => Cand(p.X, p.Y).GeometryKey));
return c => set.Contains(c.GeometryKey)
? new(ContourFeasibilityStatus.Clear, null)
: new(ContourFeasibilityStatus.Blocked, "test block");
}
private static Func<ContourEntryCandidate, ContourFeasibilityVerdict> AllClear() =>
_ => new(ContourFeasibilityStatus.Clear, null);
private static bool Selected(ContourSelectionResult result, double x, double y) =>
result.Choices.Any(c => c.Point.DistanceTo(new Vector(x, y)) < 1e-9);
[Fact]
public void LaterSafeCandidateSurvivesTwentyRejectedRivals()
{
// 18 blocked facing candidates cannot crowd out the one safe point at the tail.
var candidates = new List<ContourEntryCandidate>();
for (var i = 0; i < 18; i++)
candidates.Add(Cand(i * 0.001, 0));
candidates.Add(Cand(100, 100));
var result = ContourEntrySelection.Select(candidates, Feasible((100, 100)));
Assert.Single(result.Choices);
Assert.True(Selected(result, 100, 100));
Assert.Equal(19, result.EvaluatedCount);
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
}
[Fact]
public void EveryFeasibleSideIsRepresented()
{
// Corners on left/bottom and right/bottom, plus one point each on top-left and
// top-right; a wide tail of blocked points so coverage must chase the sides lazily.
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(10, 0), // bottom corners
Cand(0, 10, AutomaticEntryKind.StraightMidpoint), // top-left
Cand(10, 10, AutomaticEntryKind.TangentJoint), // top-right
};
for (var i = 1; i < 30; i++)
candidates.Add(Cand(i * 0.3, 0, AutomaticEntryKind.NearCorner)); // blocked bottom tail
var result = ContourEntrySelection.Select(candidates,
Feasible((0, 0), (10, 0), (0, 10), (10, 10)), maxEntries: 6);
Assert.Equal(4, result.Choices.Count);
Assert.True(Selected(result, 0, 10));
Assert.True(Selected(result, 10, 10));
// The blocked tail was chased to its end exactly once per missing side scan.
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
}
[Fact]
public void BlockedSideIsOmittedWithoutManufacturingPoints()
{
// No feasible candidate touches the top side.
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
};
var result = ContourEntrySelection.Select(candidates, Feasible((0, 0), (10, 0)));
Assert.Equal(2, result.Choices.Count);
Assert.False(Selected(result, 0, 10));
Assert.False(Selected(result, 10, 10));
// Every selected choice was feasible: nothing was manufactured.
Assert.All(result.Choices, c => Assert.InRange(c.Point.Y, 0, 0));
}
[Fact]
public void CapIsNeverExceededAndRankingComesFirst()
{
var candidates = new List<ContourEntryCandidate>();
for (var i = 0; i < 10; i++)
candidates.Add(Cand(10 - i * 0.01, 0)); // first-ranked points on the right/bottom
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
Assert.Equal(3, result.Choices.Count);
Assert.Equal(3, result.EvaluatedCount); // stopped the moment the cap filled
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
Assert.Equal(10.0, result.Choices[0].Point.X, 6); // rank-first point kept
}
[Fact]
public void NormalCaseNeverEvaluatesTheUntouchedTail()
{
// Four corners first (all sides covered), then a long clear tail. The cap fills at
// 16 with full coverage, so the chase runs empty and the tail stays untouched.
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
};
for (var i = 0; i < 36; i++)
candidates.Add(Cand(1 + i * 0.1, 1));
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 16);
Assert.Equal(16, result.Choices.Count);
Assert.Equal(16, result.EvaluatedCount);
Assert.DoesNotContain(candidates[16].GeometryKey, result.EvaluatedKeys);
Assert.DoesNotContain(candidates[^1].GeometryKey, result.EvaluatedKeys);
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
}
[Fact]
public void AllBlockedIsExhaustedGeometricImpossibility()
{
var candidates = new[] { Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10) };
var result = ContourEntrySelection.Select(candidates,
_ => new(ContourFeasibilityStatus.Blocked, "test block"));
Assert.Empty(result.Choices);
Assert.Equal(4, result.EvaluatedCount);
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
Assert.Contains("No tested lead-in fits", result.Reason);
}
[Fact]
public void IncompleteCheckIsNeverGeometricImpossibility()
{
var candidates = new[] { Cand(0, 0), Cand(10, 0) };
var first = true;
var result = ContourEntrySelection.Select(candidates, _ =>
{
ContourFeasibilityVerdict verdict = first
? new(ContourFeasibilityStatus.Incomplete, "incomplete material")
: new(ContourFeasibilityStatus.Clear, null);
first = false;
return verdict;
});
Assert.Equal(ContourSelectionShortfall.Incomplete, result.Shortfall);
Assert.DoesNotContain("No tested lead-in fits", result.Reason ?? string.Empty);
Assert.Contains("undetermined rather than a geometric verdict", result.Reason);
}
[Fact]
public void SmallCapsObeyTheCapNotCoverage()
{
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
};
var cap1 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 1);
var cap3 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
Assert.Single(cap1.Choices);
Assert.Equal(3, cap3.Choices.Count);
Assert.Equal(1, cap1.EvaluatedCount); // cap fills before any coverage scan
Assert.Equal(3, cap3.EvaluatedCount);
}
[Fact]
public void InvalidCapStaysInvalid()
{
Assert.Throws<ArgumentException>(() =>
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, 0));
Assert.Throws<ArgumentException>(() =>
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, -1));
}
[Fact]
public void DuplicatePointsNeverConsumeTwoSlots()
{
var candidates = new[] { Cand(5, 5), Cand(5, 5), Cand(5, 5) };
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 2);
Assert.Single(result.Choices);
Assert.Equal(1, result.EvaluatedCount); // deduplicated before evaluation
}
[Fact]
public void EmptyCatalogueIsExhaustedWithoutEvaluation()
{
var result = ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!);
Assert.Empty(result.Choices);
Assert.Equal(0, result.EvaluatedCount);
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
}
[Fact]
public void CancellationStopsBeforeAnyEvaluation()
{
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
var candidates = new[] { Cand(0, 0) };
Assert.ThrowsAny<OperationCanceledException>(() =>
ContourEntrySelection.Select(candidates, _ => new(ContourFeasibilityStatus.Clear, null),
token: cancelled.Token));
}
[Fact]
public void CoverageReplacementDropsTheWorstAndResorts()
{
// Cap 5 fills on points covering left/bottom/top; the right side is chased to (6,6),
// which must displace the worst selected point whose removal preserves the others
// ((3,4), the top-only cover is re-covered by (6,6)) and settle at its true rank slot.
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(0, 4), Cand(3, 4), Cand(6, 6),
};
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 5);
Assert.Equal(5, result.Choices.Count);
Assert.True(Selected(result, 6, 6));
Assert.False(Selected(result, 3, 4));
Assert.Equal(6, result.EvaluatedCount);
// Evaluation order IS global rank order; the late chaser sorts to the end.
Assert.Equal(6, result.Choices[^1].Point.X);
Assert.Equal(0, result.Choices[0].Point.X);
Assert.Equal(0, result.Choices[0].Point.Y);
}
[Fact]
public void CoverageNeverDestroysTheOnlyCoverOfAnotherSide()
{
// (2,0) is the ONLY cover of bottom; chasing the right side must not remove it even
// though it outranks nothing. The chaser replaces (2,4) (top, re-covered by (6,6)).
var candidates = new List<ContourEntryCandidate>
{
Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(2, 4), Cand(6, 6),
};
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 4);
// Cap 4: phase 1 keeps the first four (right not covered: (2,4) left+top only).
Assert.True(Selected(result, 2, 0)); // sole bottom cover survives
Assert.True(Selected(result, 6, 6)); // right-side chaser placed
Assert.False(Selected(result, 2, 4)); // safely displaced (top re-covered)
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
}
}