Files
OpenNest/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
T
aj b15c4cf6c6 feat(reporting): add multi-plate pagination and dense-label fallback
Slice 2 of the nest report plan: pagination, overflow and dense-label
coverage on top of the Slice 1 one-plate library.

- NestPdfWriter: general multi-plate pagination. Summary/plate tables
  continue across pages with repeated (HeadingFormat) header rows; notes
  flow as an ordinary paragraph instead of a bounded cell; page headers
  are wrapped and sized into the top margin; drawing areas are located
  per page via DocumentRenderer.GetRenderInfoFromPage.
- ReportText: lossless pre-wrapping (MigraDoc clips an over-tall row and
  lets an unbroken token overflow a narrow cell without warning), capped
  table-cell line counts, plate-range compression ("1-2, 4"), and
  A/B/.../AA map-grid row names.
- NestReportDiagram: label placement centers each part ID on its
  PolyLabel pole (matching PlateView's LayoutPart), computed on a
  placement-independent quantized copy so identical parts share one
  label position. Parts whose ID cannot fit legibly at overview scale
  get a lettered/numbered map grid and a zoomed, framed detail page per
  crowded cell; the writer fails with plate/part/ID when even that
  cannot place a label, or a plate would need more than 24 detail views.
- Tests: NestPdfLayoutTests (multi-plate totals/ranges/same-named
  references, table continuation with no lost rows, long name/notes
  wrapping without column overflow, mm units in all four quadrants,
  dense-label detail views with hole avoidance, save/reload of the
  tabbed/lead-in fixture with stale tab flags, invalid later-plate data,
  late write failures against an existing destination, source
  unchanged on success/failure), ReportPdf test helper (page/word/
  content-stream extraction).
- docs/nest-reports.md: replace the Slice 1 one-plate limits section
  with the general pagination/dense-label contract and the process-wide
  PDFsharp/MigraDoc render lock.

Verification: Reporting filter 70/70; full OpenNest.Tests 2524
passed/21 skipped/0 failed; Engine 351/351; IO 77/77;
EnableWindowsTargeting=true full-solution build 0 errors; scoped
dotnet format --verify-no-changes exit 0. Preview PDFs rendered and
visually inspected (evidence: /home/aj/extracted/2026-09-29/opennest-report-slice2/).
Windows runtime, the desktop adapter and packaged-app font deployment
remain unverified (Slice 3).
2026-09-29 15:43:00 -04:00

214 lines
9.8 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
using OpenNest.Geometry;
using OpenNest.Reporting;
using PdfSharp.Pdf.IO;
namespace OpenNest.Tests.Reporting;
public sealed class NestPdfWriterTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-" + Guid.NewGuid().ToString("N"));
public NestPdfWriterTests() => Directory.CreateDirectory(directory);
[Fact]
public void Write_OnePlateJobProducesPortraitSummaryAndLandscapeVectorPlatePage()
{
var path = WriteReport(NestReportTestData.CreateNest());
using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import))
{
Assert.Equal(2, pdf.PageCount);
Assert.Equal((612, 792), ReportPdf.EffectiveSize(pdf.Pages[0]));
Assert.Equal((792, 612), ReportPdf.EffectiveSize(pdf.Pages[1]));
}
var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path));
// Subset-embedded faces; PDF names escape the space as #20.
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans(?![,\w])", bytes);
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans,Bold\b", bytes);
Assert.Contains("/FontFile2", bytes);
// Thumbnails and diagrams are vectors, never raster screenshots.
Assert.DoesNotMatch(@"/Subtype\s*/Image", bytes);
Assert.Matches(@"/Subtype\s*/Form", bytes);
}
[SkippableFact]
public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers()
{
var path = WriteReport(NestReportTestData.CreateNest());
var summary = ReportPdf.Text(path, 1);
var plate = ReportPdf.Text(path, 2);
Assert.Contains("Report test job", summary);
Assert.Contains("Test customer", summary);
Assert.Contains("Steel A36", summary);
Assert.Contains("0.125 in", summary);
Assert.Contains("2026-09-29 12:00 +00:00", summary);
Assert.Matches(@"Distinct layouts:\s+1", summary);
Assert.Matches(@"Total physical sheets:\s+2", summary);
Assert.Matches(@"1\s+48 x 24 in\s+2\s+4\s+\d+\.\d%", summary);
Assert.Matches(@"R001\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
Assert.Matches(@"R002\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
Assert.Matches(@"R003\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
Assert.Contains("Page 1 of 2", summary);
Assert.Contains("Plate 1", plate);
Assert.Contains("Report test job", plate);
Assert.Matches(@"Copies:\s+2", plate);
Assert.Matches(@"R001\s+Bracket\s+2\s+4", plate);
Assert.Matches(@"R002\s+Rotated\s+1\s+2", plate);
Assert.Matches(@"R003\s+Bracket\s+1\s+2", plate);
Assert.DoesNotContain("R004", plate);
Assert.DoesNotContain("Cutoff test", plate + summary);
Assert.Contains("not a dimensioned cutting drawing", plate);
Assert.Contains("not a geometry or CNC approval", plate);
Assert.Contains("Page 2 of 2", plate);
// Repeated export of the same snapshot is textually identical.
Assert.Equal(summary + plate, ReportPdf.Text(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
+ ReportPdf.Text(Path.Combine(directory, "again.pdf"), 2));
}
[SkippableFact]
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
{
var nest = new Nest("Empty test") { Units = Units.Millimeters };
var empty = ReportPdf.Text(WriteReport(nest, "empty.pdf"), 1);
Assert.Contains("No plates in this job.", empty);
Assert.Contains("No parts in this job.", empty);
Assert.Contains("Page 1 of 1", empty);
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2));
var demand = ReportPdf.Text(WriteReport(nest, "demand.pdf"), 1);
Assert.Contains("No plates in this job.", demand);
Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
Assert.Contains("Page 1 of 1", demand);
}
[Fact]
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
{
var nest = NestReportTestData.CreateTabbedNest();
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
Assert.True(geometry.StrokeOnly);
var path = WriteReport(nest);
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
Assert.Equal(2, pdf.PageCount);
// The vector diagram is the last content stream on the plate page. Each stroke-only
// contour must begin its own subpath; a missing move-to draws a false connecting line.
// The drawing area's clip path ("W* n") precedes the geometry.
var diagram = ReportPdf.ContentStreams(pdf.Pages[1]).Last();
diagram = diagram[(diagram.IndexOf("W* n", StringComparison.Ordinal) + 4)..];
Assert.Equal(geometry.Contours.Length, Regex.Matches(diagram, @"(?m)^[-\d. ]+ m$").Count);
}
[Fact]
public void Write_LateFailureLeavesExistingReportUnchangedAndNoTemporaryFile()
{
var path = Path.Combine(directory, "existing.report.pdf");
var original = "%PDF previous report"u8.ToArray();
File.WriteAllBytes(path, original);
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
Assert.Throws<IOException>(() => NestPdfWriter.Write(snapshot, path, stream => new FailingStream(stream, 512)));
Assert.Equal(original, File.ReadAllBytes(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Fact]
public void Write_SuccessfulExportReplacesExistingReport()
{
var path = Path.Combine(directory, "existing.report.pdf");
File.WriteAllText(path, "old");
WriteReport(NestReportTestData.CreateNest(), "existing.report.pdf");
Assert.StartsWith("%PDF-", Encoding.ASCII.GetString(File.ReadAllBytes(path), 0, 5));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Theory]
[InlineData("illegible-label", "Plate 4, part 2 (R004)")]
[InlineData("cell-overflow", "Drawing R002")]
[InlineData("header-overflow", "Page header")]
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
{
var nest = NestReportTestData.CreateMultiPlateNest();
switch (scenario)
{
case "illegible-label":
// Too small for a legible label even in a detail view, on the LAST plate.
nest.Plates[3].Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.01, 0.01), new Vector(44, 20)));
break;
case "cell-overflow":
// A row taller than a page would be silently clipped by MigraDoc.
nest.Plates[0].Parts[2].BaseDrawing.Name = string.Concat(Enumerable.Repeat("0123456789", 60));
break;
case "header-overflow":
nest.Name = string.Join(" ", Enumerable.Repeat("Very long nest name", 40));
break;
}
var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep");
var before = NestReportBuilderTests.Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws<NotSupportedException>(() => NestPdfWriter.Write(snapshot, path));
Assert.Contains(identified, error.Message);
Assert.Contains("support", error.Message);
Assert.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
}
[Fact]
public void Write_UnsupportedTextIsIdentifiedBeforeReplacingDestination()
{
var nest = NestReportTestData.CreateNest();
nest.Plates[0].Parts[2].BaseDrawing.Name = "Rotated 中";
var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep");
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws<InvalidOperationException>(() => NestPdfWriter.Write(snapshot, path));
Assert.Contains("R002", error.Message);
Assert.Contains("U+4E2D", error.Message);
Assert.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
public void Dispose() => Directory.Delete(directory, true);
private string WriteReport(Nest nest, string name = "test.report.pdf")
{
var path = Path.Combine(directory, name);
NestPdfWriter.Write(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt), path);
return path;
}
internal sealed class FailingStream(Stream inner, long limit) : Stream
{
private long written;
public override bool CanRead => false;
public override bool CanSeek => inner.CanSeek;
public override bool CanWrite => true;
public override long Length => inner.Length;
public override long Position { get => inner.Position; set => inner.Position = value; }
public override void Flush() => inner.Flush();
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
public override void SetLength(long value) => inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
{
if (written + count > limit)
throw new IOException("injected late report write failure");
written += count;
inner.Write(buffer, offset, count);
}
}
}