Files
OpenNest/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
T
ajandClaude Opus 5.5 d2da7396ce feat(reports): move generated time to the footer and name standard thicknesses
The generated date sat among the summary's report details and printed a UTC
offset nobody on the shop floor reads. It now goes in every page's footer,
left of "Page X of Y", in the computer's own time zone without the offset.

The thickness now leads with its gauge or plate fraction ("16 ga - 0.0598 in")
when it matches one, as the thickness box lists it. Reporting references Core
only, so the desktop looks the name up in StandardThicknesses and passes it on
the snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:40 -05:00

290 lines
14 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);
// The generation time is in every page's footer, in this computer's time zone, no offset.
var generated = "Generated " + NestReportTestData.GeneratedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
Assert.Contains(generated, summary);
Assert.Contains(generated, plate);
Assert.DoesNotContain("Generated:", summary);
Assert.DoesNotContain("+00:00", summary + plate);
Assert.Matches(@"Distinct layouts:\s+1", summary);
Assert.Matches(@"Total physical sheets:\s+2", summary);
Assert.Matches(@"1\s+24 x 48 in\s+2\s+4\s+\d+\.\d%", summary);
Assert.Contains("40.8 lb each", summary);
Assert.Contains("Sheet weight", summary);
Assert.Contains("81.7 lb total", summary);
Assert.Equal(new[]
{
"1 SHORT 1\nBracket\nSize: 10 x 10 in\nWeight: 3.43 lb ea\nTotal weight: 13.7 lb\nRequired 5, nested 4\nPlates: 1",
"2 EXTRA 1\nRotated\nSize: 2 x 4 in\nWeight: 0.28 lb ea\nTotal weight: 0.57 lb\nRequired 1, nested 2\nPlates: 1",
"3 SHORT 5\nBracket\nSize: 2 x 3 in\nWeight: 0.21 lb ea\nTotal weight: 0.43 lb\nRequired 7, nested 2\nPlates: 1",
"4 SHORT 3\nUnplaced\nSize: 1 x 2 in\nWeight: 0.07 lb ea\nRequired 3, nested 0\nPlates: -",
}, ReportPdf.PartCards(path));
Assert.Contains("Page 1 of 2", summary);
Assert.Matches(@"Stock size\s+Quantity\s+Parts per plate", summary);
Assert.DoesNotMatch(@"\bR\d+\b", summary + plate);
Assert.DoesNotContain("Copies", summary + plate);
Assert.DoesNotContain("Parts/sheet", summary + plate);
Assert.Contains("Plate 1", plate);
Assert.Contains("Report test job", plate);
Assert.Matches(@"Quantity:\s+2", plate);
Assert.Matches(@"1\s+Bracket\s+2\s+4", plate);
Assert.Matches(@"2\s+Rotated\s+1\s+2", plate);
Assert.Matches(@"3\s+Bracket\s+1\s+2", plate);
Assert.DoesNotMatch(@"(?m)^4\s+Unplaced", plate);
Assert.DoesNotContain("Cutoff test", plate + summary);
Assert.DoesNotContain("Verify geometry and CNC output separately", plate);
Assert.DoesNotContain("Filled outlines are closed parts", plate);
Assert.DoesNotContain("Part IDs are shown where space permits", 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_NamedThicknessLeadsTheDecimalOnSummaryAndPlatePages()
{
var path = Path.Combine(directory, "named-thickness.pdf");
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt)
with
{ ThicknessName = "1/8\"" };
NestPdfWriter.Write(snapshot, path);
Assert.Contains("1/8\" - 0.125 in", ReportPdf.Text(path, 1));
Assert.Contains("1/8\" - 0.125 in", ReportPdf.Text(path, 2));
}
[SkippableFact]
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
{
var nest = new Nest("Empty test") { Units = Units.Millimeters };
nest.Plates.Add(new Plate(24, 48) { Quantity = 1 });
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);
// No thickness or density: the card shows no weight and the note says what is missing.
Assert.Equal(new[] { "1 SHORT 2\nAlpha\nSize: 10 x 20 mm\nWeight: -\nRequired 2, nested 0\nPlates: -" },
ReportPdf.PartCards(Path.Combine(directory, "demand.pdf")));
Assert.Contains("Weights need the nest thickness and a material density", demand);
Assert.Contains("Page 1 of 1", demand);
}
[SkippableFact]
public void Write_TrailingEmptyStockDoesNotAddPagesOrCountTowardTotals()
{
var nest = NestReportTestData.CreateMultiPlateNest();
var expected = ReportPdf.Pages(WriteReport(nest, "without-empty-stock.pdf"));
nest.Plates.Add(new Plate(24, 48) { Quantity = 5 });
nest.Plates.Add(new Plate(30, 60) { Quantity = 2 });
var before = NestReportBuilderTests.Fingerprint(nest);
var actual = ReportPdf.Pages(WriteReport(nest));
Assert.Equal(expected, actual);
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
}
[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));
}
[SkippableFact]
public void Write_UnlabelableTinyPartKeepsItsTableRowWithoutAddingPages()
{
var nest = NestReportTestData.CreateMultiPlateNest();
nest.Plates[3].Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.01, 0.01), new Vector(44, 20)));
var pages = ReportPdf.Pages(WriteReport(nest));
Assert.Equal(5, pages.Length);
Assert.Matches(@"4\s+Tiny\s+1\s+1", pages[4]);
Assert.DoesNotContain("detail", string.Join("\n", pages));
}
[Theory]
[InlineData("cell-overflow", "Drawing 2")]
[InlineData("header-overflow", "Page header")]
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario, string identified)
{
var nest = NestReportTestData.CreateMultiPlateNest();
switch (scenario)
{
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("2", 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);
[SkippableFact]
public void Write_CutTimeEstimateAppearsOnSummaryAndPlatePages()
{
var path = Path.Combine(directory, "timed.report.pdf");
var parameters = new CutParameters { Feedrate = 100, RapidTravelRate = 600, Units = Units.Inches };
NestPdfWriter.Write(NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt,
parameters), path);
var summary = ReportPdf.Text(path, 1);
var plate = ReportPdf.Text(path, 2);
Assert.Contains("Estimated cut time:", summary);
Assert.DoesNotContain("basis", summary, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Est. cut time", summary);
// A plate with copies puts its total on a second line, not after a comma.
Assert.Matches(@"\d+s each", summary);
Assert.Matches(@"\d+s total", summary);
Assert.DoesNotContain("each,", summary);
Assert.Contains("per plate", plate);
Assert.Contains("Total cut time:", plate);
}
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);
}
}
}