Files
OpenNest/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.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

81 lines
3.3 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using OpenNest.Reporting;
using PdfSharp.Pdf.IO;
namespace OpenNest.Tests.Reporting;
public sealed class NestPdfWriterConcurrencyTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-concurrency-" + Guid.NewGuid().ToString("N"));
public NestPdfWriterConcurrencyTests() => Directory.CreateDirectory(directory);
public void Dispose() => Directory.Delete(directory, true);
[Fact]
public void Write_ConcurrentMultiPageAndDetailViewExportsMatchSequentialExports()
{
var snapshots = new[] { NestReportTestData.CreateMultiPlateNest(), NestReportTestData.CreateDenseNest() }
.Select(nest => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt)).ToArray();
var expected = snapshots.Select((snapshot, index) =>
{
var path = Path.Combine(directory, $"reference-{index}.pdf");
NestPdfWriter.Write(snapshot, path);
return PageContent(path);
}).ToArray();
var mismatches = new ConcurrentBag<int>();
Parallel.For(0, 32, new ParallelOptions { MaxDegreeOfParallelism = System.Math.Max(8, Environment.ProcessorCount) }, index =>
{
var path = Path.Combine(directory, $"multi-{index}.pdf");
NestPdfWriter.Write(snapshots[index % 2], path);
if (PageContent(path) != expected[index % 2])
mismatches.Add(index);
});
Assert.Empty(mismatches);
}
[Fact]
public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport()
{
// PDFsharp/MigraDoc layout shares process-wide font state; unsynchronized concurrent
// exports produced shifted/merged words (for example "Total physicalsheets").
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
var reference = Path.Combine(directory, "reference.pdf");
NestPdfWriter.Write(snapshot, reference);
var expected = PageContent(reference);
var mismatches = new ConcurrentBag<int>();
Parallel.For(0, 128, new ParallelOptions { MaxDegreeOfParallelism = System.Math.Max(8, Environment.ProcessorCount) }, index =>
{
var path = Path.Combine(directory, $"export-{index}.pdf");
NestPdfWriter.Write(snapshot, path);
if (PageContent(path) != expected)
mismatches.Add(index);
});
Assert.Empty(mismatches);
}
/// <summary>Page content streams only: document IDs and font-subset tags vary per export.</summary>
private static string PageContent(string path)
{
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
var text = new StringBuilder();
foreach (var page in pdf.Pages)
{
foreach (var item in page.Contents.Elements)
{
var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfSharp.Pdf.PdfDictionary
?? item as PdfSharp.Pdf.PdfDictionary;
if (dictionary?.Stream != null)
text.Append(Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue));
}
text.Append('\f');
}
return text.ToString();
}
}