diff --git a/docs/plans/2026-03-09-xunit-test-suite.md b/docs/plans/2026-03-09-xunit-test-suite.md new file mode 100644 index 0000000..a8a2a16 --- /dev/null +++ b/docs/plans/2026-03-09-xunit-test-suite.md @@ -0,0 +1,417 @@ +# OpenNest xUnit Test Suite Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Convert the ad-hoc OpenNest.Test console harness into a proper xUnit test suite with test data hosted in a separate git repo. + +**Architecture:** Replace the console app with an xUnit test project. A `TestData` helper resolves the test data path from env var `OPENNEST_TEST_DATA` or fallback `../OpenNest.Test.Data/`. Tests skip with a message if data is missing. Test classes: `FillTests` (full plate fills), `RemnantFillTests` (filling remnant areas). All tests assert part count >= target and zero overlaps. + +**Tech Stack:** C# / .NET 8, xUnit, OpenNest.Core + Engine + IO + +--- + +### Task 1: Set up test data repo and push fixture files + +**Step 1: Clone the empty repo next to OpenNest** + +```bash +cd C:/Users/AJ/Desktop/Projects +git clone https://git.thecozycat.net/aj/OpenNest.Test.git OpenNest.Test.Data +``` + +**Step 2: Copy fixture files into the repo** + +```bash +cp ~/Desktop/"N0308-017.zip" OpenNest.Test.Data/ +cp ~/Desktop/"N0308-008.zip" OpenNest.Test.Data/ +cp ~/Desktop/"30pcs Fill.zip" OpenNest.Test.Data/ +``` + +**Step 3: Commit and push** + +```bash +cd OpenNest.Test.Data +git add . +git commit -m "feat: add initial test fixture nest files" +git push +``` + +--- + +### Task 2: Convert OpenNest.Test from console app to xUnit project + +**Files:** +- Modify: `OpenNest.Test/OpenNest.Test.csproj` +- Delete: `OpenNest.Test/Program.cs` +- Create: `OpenNest.Test/TestData.cs` + +**Step 1: Replace the csproj with xUnit configuration** + +Overwrite `OpenNest.Test/OpenNest.Test.csproj`: + +```xml + + + + net8.0-windows + enable + enable + false + true + + + + + + + + + + + + + + + +``` + +**Step 2: Delete Program.cs** + +```bash +rm OpenNest.Test/Program.cs +``` + +**Step 3: Create TestData helper** + +Create `OpenNest.Test/TestData.cs`: + +```csharp +using OpenNest.IO; + +namespace OpenNest.Test; + +public static class TestData +{ + private static readonly string? BasePath = ResolveBasePath(); + + public static bool IsAvailable => BasePath != null; + + public static string SkipReason => + "Test data not found. Set OPENNEST_TEST_DATA env var or clone " + + "https://git.thecozycat.net/aj/OpenNest.Test.git to ../OpenNest.Test.Data/"; + + public static string GetPath(string filename) + { + if (BasePath == null) + throw new InvalidOperationException(SkipReason); + + var path = Path.Combine(BasePath, filename); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Test fixture not found: {path}"); + + return path; + } + + public static Nest LoadNest(string filename) + { + var reader = new NestReader(GetPath(filename)); + return reader.Read(); + } + + public static Plate CleanPlateFrom(Plate reference) + { + var plate = new Plate(); + plate.Size = reference.Size; + plate.PartSpacing = reference.PartSpacing; + plate.EdgeSpacing = reference.EdgeSpacing; + plate.Quadrant = reference.Quadrant; + return plate; + } + + private static string? ResolveBasePath() + { + // 1. Environment variable + var envPath = Environment.GetEnvironmentVariable("OPENNEST_TEST_DATA"); + + if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) + return envPath; + + // 2. Sibling directory (../OpenNest.Test.Data/ relative to solution root) + var dir = AppContext.BaseDirectory; + + // Walk up from bin/Debug/net8.0-windows to find the solution root. + for (var i = 0; i < 6; i++) + { + var parent = Directory.GetParent(dir); + + if (parent == null) + break; + + dir = parent.FullName; + var candidate = Path.Combine(dir, "OpenNest.Test.Data"); + + if (Directory.Exists(candidate)) + return candidate; + } + + return null; + } +} +``` + +**Step 4: Build** + +Run: `dotnet build OpenNest.Test/OpenNest.Test.csproj` +Expected: Build succeeds. + +**Step 5: Commit** + +```bash +git add OpenNest.Test/ +git commit -m "refactor: convert OpenNest.Test to xUnit project with TestData helper" +``` + +--- + +### Task 3: Add FillTests + +**Files:** +- Create: `OpenNest.Test/FillTests.cs` + +**Step 1: Create FillTests.cs** + +```csharp +using OpenNest.Geometry; +using Xunit; +using Xunit.Abstractions; + +namespace OpenNest.Test; + +public class FillTests +{ + private readonly ITestOutputHelper _output; + + public FillTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Category", "Fill")] + public void N0308_008_HingePlate_FillsAtLeast75() + { + Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); + + var nest = TestData.LoadNest("N0308-008.zip"); + var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2")); + var plate = TestData.CleanPlateFrom(nest.Plates[0]); + + var engine = new NestEngine(plate); + var sw = System.Diagnostics.Stopwatch.StartNew(); + engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }); + sw.Stop(); + + _output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms"); + + Assert.True(plate.Parts.Count >= 75, + $"Expected >= 75 parts, got {plate.Parts.Count}"); + AssertNoOverlaps(plate.Parts.ToList()); + } + + [Fact] + [Trait("Category", "Fill")] + public void RemainderStripRefill_30pcs_FillsAtLeast32() + { + Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); + + var nest = TestData.LoadNest("30pcs Fill.zip"); + var drawing = nest.Drawings.First(); + var plate = TestData.CleanPlateFrom(nest.Plates[0]); + + var engine = new NestEngine(plate); + var sw = System.Diagnostics.Stopwatch.StartNew(); + engine.Fill(new NestItem { Drawing = drawing, Quantity = 0 }); + sw.Stop(); + + _output.WriteLine($"Parts: {plate.Parts.Count} | Time: {sw.ElapsedMilliseconds}ms"); + + Assert.True(plate.Parts.Count >= 32, + $"Expected >= 32 parts, got {plate.Parts.Count}"); + AssertNoOverlaps(plate.Parts.ToList()); + } + + private void AssertNoOverlaps(List parts) + { + for (var i = 0; i < parts.Count; i++) + { + for (var j = i + 1; j < parts.Count; j++) + { + if (parts[i].Intersects(parts[j], out _)) + Assert.Fail($"Overlap detected: part [{i}] vs [{j}]"); + } + } + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test OpenNest.Test/ -v normal` +Expected: 2 tests pass (or skip if data missing). + +**Step 3: Commit** + +```bash +git add OpenNest.Test/FillTests.cs +git commit -m "test: add FillTests for full plate fill and remainder strip refill" +``` + +--- + +### Task 4: Add RemnantFillTests + +**Files:** +- Create: `OpenNest.Test/RemnantFillTests.cs` + +**Step 1: Create RemnantFillTests.cs** + +```csharp +using OpenNest.Geometry; +using Xunit; +using Xunit.Abstractions; + +namespace OpenNest.Test; + +public class RemnantFillTests +{ + private readonly ITestOutputHelper _output; + + public RemnantFillTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + [Trait("Category", "Remnant")] + public void N0308_017_PT02_RemnantFillsAtLeast10() + { + Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); + + var nest = TestData.LoadNest("N0308-017.zip"); + var plate = nest.Plates[0]; + var pt02 = nest.Drawings.First(d => d.Name.Contains("PT02")); + var remnant = plate.GetRemnants()[0]; + + _output.WriteLine($"Remnant: ({remnant.X:F2},{remnant.Y:F2}) {remnant.Width:F2}x{remnant.Height:F2}"); + + var countBefore = plate.Parts.Count; + var engine = new NestEngine(plate); + var sw = System.Diagnostics.Stopwatch.StartNew(); + engine.Fill(new NestItem { Drawing = pt02, Quantity = 0 }, remnant); + sw.Stop(); + + var added = plate.Parts.Count - countBefore; + _output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms"); + + Assert.True(added >= 10, $"Expected >= 10 parts in remnant, got {added}"); + + var newParts = plate.Parts.Skip(countBefore).ToList(); + AssertNoOverlaps(newParts); + AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts); + } + + [Fact] + [Trait("Category", "Remnant")] + public void N0308_008_HingePlate_RemnantFillsAtLeast8() + { + Skip.IfNot(TestData.IsAvailable, TestData.SkipReason); + + var nest = TestData.LoadNest("N0308-008.zip"); + var plate = nest.Plates[0]; + var hinge = nest.Drawings.First(d => d.Name.Contains("HINGE PLATE #2")); + var remnants = plate.GetRemnants(); + + _output.WriteLine($"Remnant 0: ({remnants[0].X:F2},{remnants[0].Y:F2}) {remnants[0].Width:F2}x{remnants[0].Height:F2}"); + + var countBefore = plate.Parts.Count; + var engine = new NestEngine(plate); + var sw = System.Diagnostics.Stopwatch.StartNew(); + engine.Fill(new NestItem { Drawing = hinge, Quantity = 0 }, remnants[0]); + sw.Stop(); + + var added = plate.Parts.Count - countBefore; + _output.WriteLine($"Added: {added} parts | Time: {sw.ElapsedMilliseconds}ms"); + + Assert.True(added >= 8, $"Expected >= 8 parts in remnant, got {added}"); + + var newParts = plate.Parts.Skip(countBefore).ToList(); + AssertNoOverlaps(newParts); + AssertNoCrossOverlaps(plate.Parts.Take(countBefore).ToList(), newParts); + } + + private void AssertNoOverlaps(List parts) + { + for (var i = 0; i < parts.Count; i++) + { + for (var j = i + 1; j < parts.Count; j++) + { + if (parts[i].Intersects(parts[j], out _)) + Assert.Fail($"Overlap detected: part [{i}] vs [{j}]"); + } + } + } + + private void AssertNoCrossOverlaps(List existing, List added) + { + for (var i = 0; i < existing.Count; i++) + { + for (var j = 0; j < added.Count; j++) + { + if (existing[i].Intersects(added[j], out _)) + Assert.Fail($"Cross-overlap: existing [{i}] vs added [{j}]"); + } + } + } +} +``` + +**Step 2: Run all tests** + +Run: `dotnet test OpenNest.Test/ -v normal` +Expected: 4 tests pass. + +**Step 3: Commit** + +```bash +git add OpenNest.Test/RemnantFillTests.cs +git commit -m "test: add RemnantFillTests for remnant area filling" +``` + +--- + +### Task 5: Add test project to solution and final verification + +**Step 1: Add to solution** + +```bash +cd C:/Users/AJ/Desktop/Projects/OpenNest +dotnet sln OpenNest.sln add OpenNest.Test/OpenNest.Test.csproj +``` + +**Step 2: Build entire solution** + +Run: `dotnet build OpenNest.sln` +Expected: All projects build, 0 errors. + +**Step 3: Run all tests** + +Run: `dotnet test OpenNest.sln -v normal` +Expected: 4 tests pass, 0 failures. + +**Step 4: Commit** + +```bash +git add OpenNest.sln +git commit -m "chore: add OpenNest.Test to solution" +```