Files
OpenNest/OpenNest.Tests/Reporting/NestReportGeometryTests.cs
T
aj 74d2b6aa68 feat(reporting): add one-plate nest report PDF library slice
Add OpenNest.Reporting (net8.0, PDFsharp-MigraDoc 6.2.4) with a detached
report snapshot and a PDF writer for an empty/demand-only job or a single
plate layout: Letter portrait summary with vector part thumbnails and a
landscape plate page with a vector sheet diagram, both with Page X of Y.

- Snapshot uses reference identity for document-local R### IDs, recounts
  nested quantities from placements x copies with checked wide integers,
  excludes cutoffs from accounting, and retains no live domain objects.
- Geometry keeps Cut/Display material paths, drops rapid/scribe/lead paths,
  preserves native arcs, holes and intentional tab gaps, and rejects
  malformed programs with plate/part identification.
- Open contours and cutoffs are stroked per contour so PDFsharp cannot join
  a tab gap to the next contour.
- Bundled DejaVu Sans via a custom resolver (including MigraDoc's error
  font); unsupported text fails with the field and code point.
- Layouts beyond this slice (multiple plates, page overflow, labels that
  do not fit) fail with NotSupportedException before the destination is
  replaced; output is rendered to a temporary sibling and moved atomically.
2026-09-29 13:13:32 -04:00

163 lines
7.5 KiB
C#

using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Reporting;
namespace OpenNest.Tests.Reporting;
public class NestReportGeometryTests
{
[Fact]
public void Capture_PreservesNativeCircleHoleAndAppliesBakedRotationAndLocationOnce()
{
var nest = NestReportTestData.CreateNest();
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var holed = snapshot.Plates[0].Parts[0].Geometry;
Assert.False(holed.StrokeOnly);
Assert.Equal(2, holed.Contours.Length);
Assert.All(holed.Contours, c => Assert.True(c.Closed));
var circle = Assert.Single(holed.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
Assert.Equal(new ReportPoint(7, 7), circle.Center);
Assert.Equal(1, circle.Radius);
Assert.Equal(-2 * System.Math.PI, circle.SweepAngle);
Assert.Equal(new ReportBounds(2, 2, 12, 12), holed.Bounds);
var turned = snapshot.Plates[0].Parts[2].Geometry;
AssertBounds(new ReportBounds(28, 2, 30, 6), turned.Bounds);
AssertBounds(new ReportBounds(0, 0, 4, 2), snapshot.Drawings[1].Geometry.Bounds);
}
[Fact]
public void Capture_RealTabbedLeadInHoleRemainsOpenAndNeverMutatesPrograms()
{
var nest = NestReportTestData.CreateTabbedNest();
var part = nest.Plates[0].Parts[0];
var converted = ConvertProgram.ToGeometry(part.Program);
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadin);
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadout);
Assert.NotEmpty(part.Program.SubPrograms);
// Deliberately stale settings must not control closure detection.
part.CuttingParameters.TabsEnabled = false;
var before = NestReportBuilderTests.Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var geometry = Assert.Single(snapshot.Plates[0].Parts).Geometry;
Assert.True(geometry.StrokeOnly);
var open = Assert.Single(geometry.Contours.Where(c => !c.Closed));
Assert.NotEqual(open.Segments[0].Start, open.Segments[^1].End);
var gapX = open.Segments[^1].End.X - open.Segments[0].Start.X;
var gapY = open.Segments[^1].End.Y - open.Segments[0].Start.Y;
Assert.Equal(0.15, System.Math.Sqrt(gapX * gapX + gapY * gapY), 6);
Assert.Single(geometry.Contours.Where(c => c.Closed));
AssertBounds(new ReportBounds(10, 5, 20, 15), geometry.Bounds);
var hole = Assert.Single(geometry.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
Assert.Equal(15, hole.Center!.X, 6);
Assert.Equal(10, hole.Center.Y, 6);
var outlineCount = converted.Count(e => SpecialLayers.IsMaterial(e.Layer) && e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout);
Assert.Equal(outlineCount, geometry.Contours.Sum(c => c.Segments.Length));
Assert.False(snapshot.Drawings[0].Geometry.StrokeOnly);
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
}
[Fact]
public void Capture_PreservesNativeArcSweepAndTrueBounds()
{
var nest = NestReportTestData.CreateNest();
var program = nest.Plates[0].Parts[0].Program;
program.Codes.Clear();
program.Codes.Add(new RapidMove(2, 0));
program.Codes.Add(new ArcMove(0, 2, 0, 0, RotationType.CCW));
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
var arc = Assert.Single(Assert.Single(geometry.Contours).Segments);
Assert.Equal(new ReportPoint(2, 2), arc.Center);
Assert.Equal(2, arc.Radius);
Assert.Equal(0, arc.StartAngle);
Assert.Equal(System.Math.PI / 2, arc.SweepAngle);
AssertBounds(new ReportBounds(2, 2, 4, 4), geometry.Bounds);
Assert.True(geometry.StrokeOnly);
}
[Fact]
public void Capture_ExcludedLayersCannotConnectSeparateMaterialPaths()
{
var nest = NestReportTestData.CreateNest();
var codes = nest.Plates[0].Parts[0].Program.Codes;
codes.Clear();
codes.Add(new RapidMove(0, 0));
codes.Add(new LinearMove(1, 0) { Layer = LayerType.Display });
codes.Add(new LinearMove(1, 1) { Layer = LayerType.Scribe });
codes.Add(new LinearMove(2, 1));
codes.Add(new LinearMove(100, 100) { Layer = LayerType.Leadout });
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
Assert.Equal(2, geometry.Contours.Length);
Assert.All(geometry.Contours, c => { Assert.False(c.Closed); Assert.Single(c.Segments); });
Assert.Equal(new ReportBounds(2, 2, 4, 3), geometry.Bounds);
}
[Theory]
[InlineData("missing")]
[InlineData("nonfinite")]
[InlineData("arc-center")]
[InlineData("zero-radius")]
[InlineData("unequal-radii")]
[InlineData("rotation")]
[InlineData("unsupported")]
[InlineData("null-code")]
[InlineData("null-subprogram")]
[InlineData("cyclic-subprogram")]
[InlineData("nonfinite-offset")]
public void Capture_RejectsMalformedPlacedGeometryWithIdentification(string damage)
{
var nest = NestReportTestData.CreateNest();
var part = nest.Plates[0].Parts[0];
var codes = part.Program.Codes;
switch (damage)
{
case "missing": codes.Clear(); break;
case "nonfinite": ((LinearMove)codes[1]).EndPoint = new Vector(double.NaN, 1); break;
case "arc-center": codes.Add(new ArcMove(1, 0, double.PositiveInfinity, 0)); break;
case "zero-radius": codes.Add(new ArcMove(6, 5, 6, 5)); break;
case "unequal-radii": codes.Add(new ArcMove(10, 5, 5, 5)); break;
case "rotation": codes.Add(new ArcMove(6, 5, 5, 5, (RotationType)42)); break;
case "unsupported": codes.Add(new UnsupportedCode()); break;
case "null-code": codes.Add(null!); break;
case "null-subprogram": codes.Add(new SubProgramCall { Id = 42 }); break;
case "cyclic-subprogram": codes.Add(new SubProgramCall { Id = 42, Program = part.Program }); break;
case "nonfinite-offset": part.Location = new Vector(double.NaN, 0); break;
}
var count = codes.Count;
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
Assert.Contains("Plate 1", error.Message);
Assert.Contains("part 1", error.Message);
Assert.Contains("Bracket", error.Message);
Assert.Equal(count, codes.Count);
}
[Fact]
public void Capture_RejectsUnrenderableDemandOnlyDrawingByName()
{
var nest = new Nest();
var drawing = new Drawing("Missing demand geometry");
drawing.Quantity.Required = 3;
nest.Drawings.Add(drawing);
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
Assert.Contains("Missing demand geometry", error.Message);
}
private static void AssertBounds(ReportBounds expected, ReportBounds actual)
{
Assert.Equal(expected.Left, actual.Left, 6);
Assert.Equal(expected.Bottom, actual.Bottom, 6);
Assert.Equal(expected.Right, actual.Right, 6);
Assert.Equal(expected.Top, actual.Top, 6);
}
private sealed class UnsupportedCode : ICode
{
public CodeType Type => (CodeType)999;
public ICode Clone() => new UnsupportedCode();
}
}