Files
OpenNest/OpenNest.Tests/Reporting/NestPdfWriterTests.cs
T
aj 565dc90762 refactor(ui): rephrase negative-tone disclaimers as affirmative guidance
Report, dialog and diagnostic text across post verification, cutoff
planning, alignment and cutting-plan review repeatedly told the user
what checks weren't (safety certifications, proof, guarantees) instead
of what to do. Reworded these to state the same caveats affirmatively
(inspect X, confirm Y, review Z) without changing the underlying
safety guidance, and updated the tests/docs that pinned the old wording.
2026-10-12 07:45:23 -04:00

239 lines
11 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+24 x 48 in\s+2\s+4\s+\d+\.\d%", summary);
Assert.Matches(@"1\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
Assert.Matches(@"2\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
Assert.Matches(@"3\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
Assert.Matches(@"4\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
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.Contains("Verify geometry and CNC output separately", 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 };
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);
Assert.Matches(@"1\s+Alpha\s+2\s+0\s+2\s+0\s+-", 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);
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);
}
}
}