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.
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" />
|
||||
<ProjectReference Include="..\Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using OpenNest.Reporting;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public sealed class AtomicReportFileTests : IDisposable
|
||||
{
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-report-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public AtomicReportFileTests() => Directory.CreateDirectory(directory);
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulRenderReplacesExistingFileOnlyAfterRendering()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.pdf");
|
||||
File.WriteAllText(path, "original");
|
||||
AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
Assert.Equal("original", File.ReadAllText(path));
|
||||
stream.Write("replacement"u8);
|
||||
});
|
||||
Assert.Equal("replacement", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateRenderFailurePreservesExistingFileAndRemovesTemporarySibling()
|
||||
{
|
||||
var path = Path.Combine(directory, "existing.pdf");
|
||||
File.WriteAllText(path, "original");
|
||||
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
stream.Write("partial PDF"u8);
|
||||
Assert.Equal(2, Directory.GetFiles(directory).Length);
|
||||
throw new IOException("injected late render failure");
|
||||
}));
|
||||
Assert.Equal("original", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedNewExportLeavesNoOutput()
|
||||
{
|
||||
var path = Path.Combine(directory, "new.pdf");
|
||||
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
|
||||
{
|
||||
stream.Write("partial"u8);
|
||||
throw new IOException("injected write failure");
|
||||
}));
|
||||
Assert.Empty(Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedReplacementPreservesDirectoryTargetAndCleansTemporaryFile()
|
||||
{
|
||||
var path = Path.Combine(directory, "not-a-file.pdf");
|
||||
Directory.CreateDirectory(path);
|
||||
Assert.ThrowsAny<IOException>(() => AtomicReportFile.Write(path, stream => stream.Write("PDF"u8)));
|
||||
Assert.True(Directory.Exists(path));
|
||||
Assert.Empty(Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(directory, true);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics;
|
||||
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), EffectiveSize(pdf.Pages[0]));
|
||||
Assert.Equal((792, 612), 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 = ExtractText(path, 1);
|
||||
var plate = ExtractText(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, ExtractText(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
|
||||
+ ExtractText(Path.Combine(directory, "again.pdf"), 2));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
|
||||
{
|
||||
var nest = new Nest("Empty test") { Units = Units.Millimeters };
|
||||
var empty = ExtractText(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 = ExtractText(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.
|
||||
var diagram = ContentStreams(pdf.Pages[1]).Last();
|
||||
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("second-plate")]
|
||||
[InlineData("summary-overflow")]
|
||||
[InlineData("plate-table-overflow")]
|
||||
[InlineData("illegible-label")]
|
||||
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var plate = nest.Plates[0];
|
||||
switch (scenario)
|
||||
{
|
||||
case "second-plate":
|
||||
nest.Plates.Add(new Plate(24, 48) { Quantity = 1 });
|
||||
nest.Plates[1].Parts.Add(new Part(plate.Parts[0].BaseDrawing));
|
||||
break;
|
||||
case "summary-overflow":
|
||||
for (var i = 0; i < 40; i++)
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle($"Demand {i:D2}", 1, 1));
|
||||
break;
|
||||
case "plate-table-overflow":
|
||||
for (var i = 0; i < 12; i++)
|
||||
// Large enough for readable labels, so only the table can overflow.
|
||||
plate.Parts.Add(new Part(NestReportTestData.Rectangle($"Small {i:D2}", 2, 2), new Vector(2 + i * 3, 16)));
|
||||
break;
|
||||
case "illegible-label":
|
||||
plate.Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.05, 0.05), new Vector(44, 20)));
|
||||
break;
|
||||
}
|
||||
var path = Path.Combine(directory, "keep.report.pdf");
|
||||
File.WriteAllText(path, "keep");
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
var error = Assert.Throws<NotSupportedException>(() => NestPdfWriter.Write(snapshot, path));
|
||||
|
||||
Assert.Contains("not supported", error.Message);
|
||||
Assert.Equal("keep", File.ReadAllText(path));
|
||||
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
private static (int, int) EffectiveSize(PdfSharp.Pdf.PdfPage page)
|
||||
{
|
||||
var box = page.MediaBox;
|
||||
var width = (int)System.Math.Round(box.Width);
|
||||
var height = (int)System.Math.Round(box.Height);
|
||||
return page.Rotate % 180 == 0 ? (width, height) : (height, width);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ContentStreams(PdfSharp.Pdf.PdfPage page)
|
||||
{
|
||||
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)
|
||||
yield return Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Poppler text extraction; skipped where poppler-utils is not installed.</summary>
|
||||
private static string ExtractText(string path, int page)
|
||||
{
|
||||
var info = new ProcessStartInfo("pdftotext")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (var argument in new[] { "-layout", "-f", page.ToString(), "-l", page.ToString(), path, "-" })
|
||||
info.ArgumentList.Add(argument);
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(info)!;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Skip.If(true, "pdftotext (poppler-utils) is not installed.");
|
||||
throw;
|
||||
}
|
||||
using (process)
|
||||
{
|
||||
var text = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
Assert.Equal(0, process.ExitCode);
|
||||
return Regex.Replace(text, "[ \t]+\n", "\n");
|
||||
}
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Reporting;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public class NestReportBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Capture_ReconcilesOrderedReferenceUnionWithoutCountingCutoffs()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
Assert.Equal("Report test job", snapshot.Name);
|
||||
Assert.Equal("Test customer", snapshot.Customer);
|
||||
Assert.Equal("Synthetic report test data", snapshot.Notes);
|
||||
Assert.Equal("Steel", snapshot.Material);
|
||||
Assert.Equal("A36", snapshot.Grade);
|
||||
Assert.Equal(0.125, snapshot.Thickness);
|
||||
Assert.Equal("in", snapshot.Units);
|
||||
Assert.Equal(NestReportTestData.GeneratedAt, snapshot.GeneratedAt);
|
||||
Assert.Equal(new[] { "R001", "R002", "R003", "R004" }, snapshot.Drawings.Select(d => d.Id));
|
||||
Assert.Equal(new[] { "Bracket", "Rotated", "Bracket", "Unplaced" }, snapshot.Drawings.Select(d => d.Name));
|
||||
Assert.Equal(new long[] { 5, 1, 7, 3 }, snapshot.Drawings.Select(d => d.Required));
|
||||
Assert.Equal(new long[] { 4, 2, 2, 0 }, snapshot.Drawings.Select(d => d.Nested));
|
||||
Assert.Equal(new long[] { 1, 0, 5, 3 }, snapshot.Drawings.Select(d => d.Shortage));
|
||||
Assert.Equal(new long[] { 0, 1, 0, 0 }, snapshot.Drawings.Select(d => d.Extra));
|
||||
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
|
||||
Assert.Empty(snapshot.Drawings[3].Plates);
|
||||
var plate = Assert.Single(snapshot.Plates);
|
||||
Assert.Equal(new[] { "R001", "R001", "R002", "R003" }, plate.Parts.Select(p => p.ReportId));
|
||||
Assert.Single(plate.Cutoffs);
|
||||
Assert.Equal(2, snapshot.TotalSheets);
|
||||
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
|
||||
Assert.Equal(0.125, plate.PartSpacing);
|
||||
Assert.Equal(nest.Plates[0].Utilization(), plate.Utilization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RecountsAcrossPlateCopiesWithWideIntegersAndUniquePlateNumbers()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var drawing = nest.Plates[0].Parts[0].BaseDrawing;
|
||||
// Change copies after adding: the legacy cached Nested count stays stale.
|
||||
nest.Plates[0].Quantity = int.MaxValue;
|
||||
var second = new Plate(24, 48) { Quantity = 3 };
|
||||
second.Parts.Add(new Part(drawing));
|
||||
second.Parts.Add(new Part(drawing));
|
||||
nest.Plates.Add(second);
|
||||
var cached = drawing.Quantity.Nested;
|
||||
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
|
||||
Assert.Equal(checked(2L * int.MaxValue + 6), snapshot.Drawings[0].Nested);
|
||||
Assert.Equal(new[] { 1, 2 }, snapshot.Drawings[0].Plates);
|
||||
Assert.Equal(checked((long)int.MaxValue + 3), snapshot.TotalSheets);
|
||||
Assert.Equal(cached, drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_IsRepeatableDoesNotMutateSourcesAndRetainsNoMutableReferences()
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
var part = nest.Plates[0].Parts[0];
|
||||
var drawing = part.BaseDrawing;
|
||||
var source = Fingerprint(nest);
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
var serialized = JsonSerializer.Serialize(snapshot);
|
||||
|
||||
Assert.Equal(source, Fingerprint(nest));
|
||||
Assert.Equal(serialized, JsonSerializer.Serialize(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt)));
|
||||
Assert.NotEmpty(snapshot.Plates);
|
||||
nest.Name = "Edited";
|
||||
nest.Material.Name = "Edited material";
|
||||
nest.Plates[0].Size = new Size(100, 200);
|
||||
nest.Plates[0].Quantity = 42;
|
||||
part.Location = new Vector(500, 600);
|
||||
((LinearMove)part.Program.Codes[1]).EndPoint = new Vector(99, 99);
|
||||
((LinearMove)drawing.Program.Codes[1]).EndPoint = new Vector(88, 88);
|
||||
drawing.Name = "Edited drawing";
|
||||
drawing.Quantity.Required = 123;
|
||||
nest.Drawings.Clear();
|
||||
nest.Plates.Clear();
|
||||
Assert.Equal(serialized, JsonSerializer.Serialize(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_EmptyAndDemandOnlyJobsKeepExplicitEmptyCollectionsAndOrdinalOrder()
|
||||
{
|
||||
var nest = new Nest("Empty test");
|
||||
var empty = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal("Empty test", empty.Name);
|
||||
Assert.Empty(empty.Drawings);
|
||||
Assert.Empty(empty.Plates);
|
||||
Assert.Equal(0, empty.TotalSheets);
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle("zeta", 1, 1, 5));
|
||||
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 1, 1, 2));
|
||||
var demand = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal(new[] { "Alpha", "zeta" }, demand.Drawings.Select(d => d.Name));
|
||||
Assert.All(demand.Drawings, d => { Assert.Equal(0, d.Nested); Assert.Equal(d.Required, d.Shortage); });
|
||||
Assert.Empty(demand.Plates);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 48, 1)]
|
||||
[InlineData(24, -1, 1)]
|
||||
[InlineData(double.NaN, 48, 1)]
|
||||
[InlineData(24, double.PositiveInfinity, 1)]
|
||||
[InlineData(24, 48, 0)]
|
||||
[InlineData(24, 48, -1)]
|
||||
public void Capture_RejectsInvalidPlateBeforeProducingSnapshot(double width, double length, int copies)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
nest.Plates[0].Size = new Size(width, length);
|
||||
nest.Plates[0].Quantity = copies;
|
||||
var before = Fingerprint(nest);
|
||||
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
|
||||
Assert.Contains("Plate 1", error.Message);
|
||||
Assert.Equal(before, Fingerprint(nest));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 0, 0, 48, 24)]
|
||||
[InlineData(2, -48, 0, 0, 24)]
|
||||
[InlineData(3, -48, -24, 0, 0)]
|
||||
[InlineData(4, 0, -24, 48, 0)]
|
||||
public void Capture_UsesQuadrantAwareStockBounds(int quadrant, double left, double bottom, double right, double top)
|
||||
{
|
||||
var nest = NestReportTestData.CreateNest();
|
||||
nest.Plates[0].Quadrant = quadrant;
|
||||
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
|
||||
Assert.Equal(new ReportBounds(left, bottom, right, top), Assert.Single(snapshot.Plates).Bounds);
|
||||
}
|
||||
|
||||
internal static string Fingerprint(Nest nest) => JsonSerializer.Serialize(new
|
||||
{
|
||||
nest.Name,
|
||||
nest.Customer,
|
||||
nest.Notes,
|
||||
nest.Material,
|
||||
nest.Thickness,
|
||||
nest.Units,
|
||||
nest.DateCreated,
|
||||
nest.DateLastModified,
|
||||
Drawings = nest.Drawings.Select(d => new { d.Id, d.Name, d.Quantity, Main = NestWriter.GetProgramText(d.Program), Subs = NestWriter.GetSubProgramsText(d.Program) }),
|
||||
Plates = nest.Plates.Select(p => new
|
||||
{
|
||||
p.Size,
|
||||
p.Quantity,
|
||||
p.PartSpacing,
|
||||
p.Quadrant,
|
||||
Parts = p.Parts.Select(part => new
|
||||
{
|
||||
part.BaseDrawing.Id,
|
||||
part.Location,
|
||||
part.Rotation,
|
||||
part.HasManualLeadIns,
|
||||
part.LeadInsLocked,
|
||||
Main = NestWriter.GetProgramText(part.Program),
|
||||
Subs = NestWriter.GetSubProgramsText(part.Program),
|
||||
}),
|
||||
}),
|
||||
}, new JsonSerializerOptions { IncludeFields = true, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals });
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
/// <summary>Small, synthetic report examples; never production job data.</summary>
|
||||
public static class NestReportTestData
|
||||
{
|
||||
public static DateTimeOffset GeneratedAt { get; } = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public static Nest CreateNest()
|
||||
{
|
||||
var nest = new Nest("Report test job")
|
||||
{
|
||||
Customer = "Test customer",
|
||||
Notes = "Synthetic report test data",
|
||||
Material = new Material("Steel", "A36"),
|
||||
Thickness = 0.125,
|
||||
Units = Units.Inches,
|
||||
DateCreated = new DateTime(2026, 9, 1),
|
||||
DateLastModified = new DateTime(2026, 9, 28),
|
||||
};
|
||||
var holed = CreateHoledDrawing("Bracket", 5);
|
||||
var rotated = Rectangle("Rotated", 4, 2, 1);
|
||||
var sameName = Rectangle("Bracket", 3, 2, 7);
|
||||
var unplaced = Rectangle("Unplaced", 2, 1, 3);
|
||||
nest.Drawings.Add(unplaced);
|
||||
nest.Drawings.Add(rotated);
|
||||
nest.Drawings.Add(holed);
|
||||
// DrawingCollection is name-keyed; this reference deliberately exists only in placements.
|
||||
var plate = new Plate(24, 48) { Quantity = 2, PartSpacing = 0.125 };
|
||||
plate.Parts.Add(new Part(holed, new Vector(2, 2)));
|
||||
plate.Parts.Add(new Part(holed, new Vector(14, 2)));
|
||||
var turned = new Part(rotated);
|
||||
turned.Rotate(System.Math.PI / 2);
|
||||
turned.Location = new Vector(30, 2);
|
||||
plate.Parts.Add(turned);
|
||||
plate.Parts.Add(new Part(sameName, new Vector(32, 2)));
|
||||
var cutoffProgram = new Program();
|
||||
cutoffProgram.Codes.Add(new RapidMove(40, 0));
|
||||
cutoffProgram.Codes.Add(new LinearMove(40, 24));
|
||||
var cutoff = new Drawing("Cutoff test", cutoffProgram) { IsCutOff = true };
|
||||
cutoff.Quantity.Required = 99;
|
||||
plate.Parts.Add(new Part(cutoff));
|
||||
nest.Drawings.Add(cutoff);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
public static Nest CreateTabbedNest()
|
||||
{
|
||||
var drawing = CreateHoledDrawing("Tabbed with hole", 1);
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(System.Math.PI / 2);
|
||||
part.Offset(20, 5);
|
||||
part.ApplyLeadIns(new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
|
||||
ExternalLeadOut = new LineLeadOut { Length = 0.25 },
|
||||
ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
|
||||
TabsEnabled = true,
|
||||
TabConfig = new NormalTab { Size = 0.15 },
|
||||
}, new Vector(-5, -5));
|
||||
part.LeadInsLocked = true;
|
||||
var nest = new Nest("Tabbed report test") { Units = Units.Inches };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(24, 48) { Quantity = 1 };
|
||||
plate.Parts.Add(part);
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
public static Drawing CreateHoledDrawing(string name, int required)
|
||||
{
|
||||
var program = RectangleProgram(10, 10);
|
||||
program.Codes.Add(new RapidMove(6, 5));
|
||||
program.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
|
||||
var drawing = new Drawing(name, program);
|
||||
drawing.Quantity.Required = required;
|
||||
return drawing;
|
||||
}
|
||||
|
||||
public static Drawing Rectangle(string name, double length, double width, int required = 1)
|
||||
{
|
||||
var drawing = new Drawing(name, RectangleProgram(length, width));
|
||||
drawing.Quantity.Required = required;
|
||||
return drawing;
|
||||
}
|
||||
|
||||
private static Program RectangleProgram(double length, double width)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(0, 0));
|
||||
program.Codes.Add(new LinearMove(0, width));
|
||||
program.Codes.Add(new LinearMove(length, width));
|
||||
program.Codes.Add(new LinearMove(length, 0));
|
||||
program.Codes.Add(new LinearMove(0, 0));
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using OpenNest.Reporting;
|
||||
using PdfSharp.Drawing;
|
||||
using PdfSharp.Fonts;
|
||||
using PdfSharp.Pdf;
|
||||
|
||||
namespace OpenNest.Tests.Reporting;
|
||||
|
||||
public class ReportFontsTests
|
||||
{
|
||||
[Fact]
|
||||
public void BundledFontsEmbedWithoutSystemFontResolutionAcrossRepeatedExports()
|
||||
{
|
||||
ReportFonts.Initialize();
|
||||
var resolver = GlobalFontSettings.FontResolver;
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
ReportFonts.Initialize();
|
||||
Assert.Same(resolver, GlobalFontSettings.FontResolver);
|
||||
using var doc = new PdfDocument();
|
||||
var page = doc.AddPage();
|
||||
using (var gfx = XGraphics.FromPdfPage(page))
|
||||
{
|
||||
gfx.DrawString("Résumé Ø TEST DATA", new XFont(ReportFonts.Family, 10), XBrushes.Black, 20, 20);
|
||||
gfx.DrawString("Bold", new XFont(ReportFonts.Family, 10, XFontStyleEx.Bold), XBrushes.Black, 20, 40);
|
||||
}
|
||||
using var stream = new MemoryStream();
|
||||
doc.Save(stream, false);
|
||||
Assert.True(stream.Length > 1000);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ASCII, 0.25 in; R001\nRésumé Ø Æ")]
|
||||
[InlineData("")]
|
||||
public void SupportedTextPasses(string text) => ReportFonts.ValidateText(text, "part R001");
|
||||
|
||||
[Theory]
|
||||
[InlineData("中文")]
|
||||
[InlineData("bad\u0000text")]
|
||||
[InlineData("hidden\u00ADhyphen")]
|
||||
public void UnsupportedTextHasAnIdentifiedErrorInsteadOfMissingGlyphs(string text)
|
||||
{
|
||||
var error = Assert.Throws<InvalidOperationException>(() => ReportFonts.ValidateText(text, "part R001"));
|
||||
Assert.Contains("part R001", error.Message);
|
||||
Assert.Contains("U+", error.Message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user