From 314ca2f2a6b888658254f81fb8595c5873b5171b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 29 Sep 2026 18:29:05 -0400 Subject: [PATCH] feat(desktop): add File -> Export Nest Report command Capture the report target and reject whole-job nesting, open progress windows, interactive fill and busy plate actions across every view sharing the nest; revalidate after the save dialog, capture the snapshot synchronously on the UI thread and render it through OpenNest.Reporting. Windows adapter tests cover enablement, guards, cancel, success and a write failure against an existing destination (compile-only on Linux; Windows runtime acceptance still owed). --- .../Forms/NestReportExportTests.cs | 321 ++++++++++++++++++ OpenNest/Forms/EditNestForm.cs | 74 ++++ OpenNest/Forms/MainForm.Designer.cs | 12 +- OpenNest/Forms/MainForm.cs | 82 +++++ OpenNest/OpenNest.csproj | 1 + docs/nest-reports.md | 29 +- 6 files changed, 511 insertions(+), 8 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs diff --git a/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs b/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs new file mode 100644 index 0000000..c8e4e75 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs @@ -0,0 +1,321 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Forms; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Forms; + +// Reads Application.OpenForms for the busy guards, so it must not overlap fill-lifetime tests +// that transiently open NestProgressForm. +[Collection("Fill operation lifetime")] +public class NestReportExportTests +{ + [Fact] + public void MenuFollowsActiveDocument() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + var item = Menu(host, "mnuFileExportNestReport"); + Assert.False(item.Enabled); + + using var form = new EditNestForm(new Nest("enabled job")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + Assert.True(item.Enabled); + + form.Close(); + host.Activate(); + Assert.False(item.Enabled); + }); + + [Fact] + public void JobBusyRejectsBeforeAnyDialog() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + using var form = new EditNestForm(new Nest("busy job")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + SetField(host, "nestingInProgress", true); + try + { + Menu(host, "mnuFileExportNestReport").PerformClick(); + } + finally + { + SetField(host, "nestingInProgress", false); + } + + Assert.Empty(host.DialogRequests); + var failure = Assert.Single(host.Failures); + Assert.Contains("busy job", failure); + Assert.Contains("Finish or cancel", failure); + }); + + [Fact] + public void InteractiveFillOnAnyViewOfTheNestRejects() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + using var form = new EditNestForm(new Nest("filling job")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + SetBackingField(form.PlateView, "IsFillInProgress", true); + try + { + Menu(host, "mnuFileExportNestReport").PerformClick(); + Assert.Empty(host.DialogRequests); + Assert.Contains("Finish or cancel", Assert.Single(host.Failures)); + } + finally + { + SetBackingField(form.PlateView, "IsFillInProgress", false); + } + + // Cleared guard: the command now reaches the (test-intercepted) dialog. + Menu(host, "mnuFileExportNestReport").PerformClick(); + Assert.Single(host.DialogRequests); + }); + + [Fact] + public void BusyPlateActionOnAnyViewOfTheNestRejects() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + using var form = new EditNestForm(CreateJob("action job")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + // The default ActionSelect reports busy while parts are selected. + form.PlateView.SelectAll(); + Assert.True(form.PlateView.Actions.CurrentAction!.IsBusy()); + try + { + Menu(host, "mnuFileExportNestReport").PerformClick(); + Assert.Empty(host.DialogRequests); + Assert.Contains("Finish or cancel", Assert.Single(host.Failures)); + } + finally + { + form.PlateView.DeselectAll(); + } + Assert.False(form.PlateView.Actions.CurrentAction!.IsBusy()); + }); + + [Fact] + public void InvalidNestNameRejectsBeforeAnyDialog() => RunSta(() => + { + using var host = new TestMainForm(); + host.Show(); + using var form = new EditNestForm(new Nest("bad/name")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + + Menu(host, "mnuFileExportNestReport").PerformClick(); + + Assert.Empty(host.DialogRequests); + Assert.Contains("bad/name", Assert.Single(host.Failures)); + }); + + [Fact] + public void CancelWritesNothingAndNotifiesNothing() => RunSta(() => + { + using var host = new TestMainForm { NextDialogPath = null }; + host.Show(); + using var form = new EditNestForm(CreateJob("cancel job")) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + var path = Path.Combine(Path.GetTempPath(), $"opennest-report-cancel-{Guid.NewGuid():N}.pdf"); + + host.NextDialogPath = path; + host.NextDialogResult = DialogResult.Cancel; + Menu(host, "mnuFileExportNestReport").PerformClick(); + + Assert.False(File.Exists(path)); + Assert.Empty(host.Failures); + Assert.Empty(host.Successes); + }); + + [Fact] + public void SuccessfulExportWritesPdfAndLeavesJobStateUntouched() => RunSta(() => + { + using var host = new TestMainForm { NextDialogResult = DialogResult.OK }; + host.Show(); + var nest = CreateJob("export job"); + using var form = new EditNestForm(nest) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + form.PlateManager.LoadAt(1); + var path = Path.Combine(Path.GetTempPath(), $"opennest-report-ok-{Guid.NewGuid():N}.pdf"); + try + { + host.NextDialogPath = path; + Menu(host, "mnuFileExportNestReport").PerformClick(); + + Assert.Equal(path, Assert.Single(host.Successes)); + var bytes = File.ReadAllBytes(path); + Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(bytes, 0, 5)); + Assert.True(bytes.Length > 1000); + // Selected plate and job contents are untouched by reporting. + Assert.Equal(1, form.PlateManager.CurrentIndex); + Assert.Equal(2, nest.Plates.Count); + Assert.Single(nest.Plates[0].Parts); + Assert.Equal(2, nest.Drawings.Count); + } + finally + { + File.Delete(path); + } + }); + + [Fact] + public void RenderFailureLeavesExistingDestinationUnchanged() => RunSta(() => + { + using var host = new TestMainForm { NextDialogResult = DialogResult.OK }; + host.Show(); + var nest = CreateJob("failure job"); + // Outside the report's supported text contract: fails font validation before layout. + nest.Notes = "em\u2014dash"; + using var form = new EditNestForm(nest) { MdiParent = host }; + form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here. + form.Show(); + var path = Path.Combine(Path.GetTempPath(), $"opennest-report-fail-{Guid.NewGuid():N}.pdf"); + var original = new byte[] { 1, 2, 3, 4 }; + File.WriteAllBytes(path, original); + try + { + host.NextDialogPath = path; + Menu(host, "mnuFileExportNestReport").PerformClick(); + + Assert.Empty(host.Successes); + var failure = Assert.Single(host.Failures); + Assert.Contains("failure job", failure); + Assert.Equal(original, File.ReadAllBytes(path)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, ".*report-fail*.tmp")); + } + finally + { + File.Delete(path); + } + }); + + [Fact] + public void BusyAfterDialogRejectsWriteWithoutTouchingDestination() => RunSta(() => + { + using var form = new EditNestForm(CreateJob("late busy")); + form.PlateView.SetOverlapAutoCheck(null); + var targets = form.CaptureReportTargets(() => false); + var path = Path.Combine(Path.GetTempPath(), $"opennest-report-late-{Guid.NewGuid():N}.pdf"); + try + { + var error = Assert.Throws( + () => form.WriteNestReport(targets, path, () => true)); + Assert.Contains("started while saving", error.Message); + Assert.False(File.Exists(path)); + } + finally + { + File.Delete(path); + } + }); + + [Fact] + public void WriteRejectsAStaleTargetFromAnotherDocument() => RunSta(() => + { + using var form = new EditNestForm(CreateJob("current job")); + form.PlateView.SetOverlapAutoCheck(null); + var stale = new EditNestForm.NestReportTargets(new Nest("other job"), "other.report.pdf"); + + var error = Assert.Throws( + () => form.WriteNestReport(stale, "ignored.pdf", () => false)); + + Assert.Contains("active document changed", error.Message); + }); + + private static Nest CreateJob(string name) + { + var nest = new Nest(name) { Units = Units.Inches }; + var drawing = new Drawing("rect", Rectangle()); + drawing.Quantity.Required = 2; + nest.Drawings.Add(drawing); + nest.Drawings.Add(new Drawing("spare", Rectangle())); + var first = new Plate(24, 48) { Quantity = 1 }; + first.Parts.Add(new Part(drawing, new Vector(2, 2))); + var second = new Plate(24, 48) { Quantity = 2 }; + second.Parts.Add(new Part(drawing, new Vector(4, 4))); + nest.Plates.Add(first); + nest.Plates.Add(second); + return nest; + } + + private static Program Rectangle() + { + var program = new Program(); + program.Codes.Add(new RapidMove(0, 0)); + program.Codes.Add(new LinearMove(4, 0)); + program.Codes.Add(new LinearMove(4, 2)); + program.Codes.Add(new LinearMove(0, 2)); + program.Codes.Add(new LinearMove(0, 0)); + return program; + } + + private static ToolStripMenuItem Menu(MainForm host, string name) => + (ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single(); + + private static void SetField(object target, string name, object value) => + typeof(MainForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(target, value); + + // Same reflection precedent PlateViewFillLifetimeTests uses for fill-lifetime state. + private static void SetBackingField(object target, string property, object value) => + target.GetType() + .GetField($"<{property}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(target, value); + + private sealed class TestMainForm : MainForm + { + public List DialogRequests { get; } = []; + public string? NextDialogPath { get; set; } + public DialogResult NextDialogResult { get; set; } = DialogResult.OK; + public List Failures { get; } = []; + public List Successes { get; } = []; + + // No startup migrations/automatic new document, and no settings save at close. + protected override void OnLoad(EventArgs e) { } + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { } + + internal override string? ShowReportSaveDialog(string suggestedFileName) + { + DialogRequests.Add(suggestedFileName); + return NextDialogResult == DialogResult.OK ? NextDialogPath : null; + } + + internal override void ReportFailure(EditNestForm.NestReportTargets? targets, Exception error) => + Failures.Add($"{targets?.Nest.Name}|{error.Message}"); + + internal override void ReportSuccess(string destination) => Successes.Add(destination); + } + + private static void RunSta(System.Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + action(); + } + catch (Exception ex) + { + failure = ex; + } + }) + { IsBackground = true }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete."); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +} diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index d34cab5..b84abbb 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -16,6 +16,7 @@ using OpenNest.Engine.Sequencing; using OpenNest.IO; using OpenNest.Math; using OpenNest.Properties; +using OpenNest.Reporting; using OpenNest.Shapes; using Timer = System.Timers.Timer; @@ -478,6 +479,79 @@ namespace OpenNest.Forms } while (PlateManager.LoadNext()); } + /// The report target fixed before the save dialog opens. + internal sealed record NestReportTargets(Nest Nest, string SuggestedFileName); + + /// + /// Capture the report target before the owned save dialog opens. The exact nest + /// reference is fixed here; later failures are reported against it, not a newer + /// active document. Whole-job nesting, open progress windows (via ), + /// interactive fill and busy plate actions on every view sharing this nest are rejected. + /// + internal NestReportTargets CaptureReportTargets(Func isJobBusy) + { + ArgumentNullException.ThrowIfNull(isJobBusy); + var nest = Nest; + if (nest == null) + throw new InvalidOperationException("No nest is available to report."); + + // The nest name becomes the suggested file name; reject unusable names before the dialog. + if ( + string.IsNullOrWhiteSpace(nest.Name) + || nest.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + ) + throw new InvalidOperationException( + $"The nest name '{nest.Name}' cannot be used as a report file name. Rename the nest first." + ); + + if (isJobBusy() || IsReportTargetBusy(nest)) + throw new InvalidOperationException( + "Finish or cancel the current nesting or plate action before exporting the nest report." + ); + + return new NestReportTargets(nest, $"{nest.Name}.report.pdf"); + } + + /// + /// Revalidate the fixed target after the dialog closed, then synchronously capture the + /// snapshot on the UI thread and write it. The dialog pumps messages, so an async fill + /// could have committed or started meanwhile; once capture begins nothing yields. + /// + internal void WriteNestReport( + NestReportTargets targets, + string destination, + Func isJobBusy + ) + { + ArgumentNullException.ThrowIfNull(targets); + ArgumentException.ThrowIfNullOrWhiteSpace(destination); + ArgumentNullException.ThrowIfNull(isJobBusy); + + if (!ReferenceEquals(targets.Nest, Nest)) + throw new InvalidOperationException( + "The active document changed while saving. No report was written." + ); + if (isJobBusy() || IsReportTargetBusy(targets.Nest)) + throw new InvalidOperationException( + "A nesting or plate operation started while saving. No report was written." + ); + + // Reporting must have no accounting side effects: no quantity refresh, no + // selection or dirty-state change. Only the detached snapshot reaches the writer. + var snapshot = NestReportBuilder.Capture(targets.Nest, DateTimeOffset.Now); + NestPdfWriter.Write(snapshot, destination); + } + + private bool IsReportTargetBusy(Nest nest) => + Application + .OpenForms.OfType() + .Where(form => ReferenceEquals(form.Nest, nest)) + .Select(form => form.PlateView) + .Any(view => + !view.IsDisposed + && (view.IsFillInProgress || view.Actions.CurrentAction?.IsBusy() == true) + ); + public void RotateCw() { PlateView.Plate.Rotate90(RotationType.CW); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 5cc8940..ba6d61a 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -39,6 +39,7 @@ toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileExportNestReport = new System.Windows.Forms.ToolStripMenuItem(); toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); @@ -193,7 +194,7 @@ // // mnuFile // - mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit }); + mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit }); mnuFile.Name = "mnuFile"; mnuFile.Size = new System.Drawing.Size(37, 20); mnuFile.Text = "&File"; @@ -266,6 +267,14 @@ mnuFileExportAll.Text = "Export All"; mnuFileExportAll.Click += ExportAll_Click; // + // mnuFileExportNestReport + // + mnuFileExportNestReport.Enabled = false; + mnuFileExportNestReport.Name = "mnuFileExportNestReport"; + mnuFileExportNestReport.Size = new System.Drawing.Size(146, 22); + mnuFileExportNestReport.Text = "Export Nest Report..."; + mnuFileExportNestReport.Click += ExportNestReport_Click; + // // toolStripMenuItem3 // toolStripMenuItem3.Name = "toolStripMenuItem3"; @@ -1360,6 +1369,7 @@ private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel; private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel; private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll; + private System.Windows.Forms.ToolStripMenuItem mnuFileExportNestReport; private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem mnuViewZoomTo; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index c605d8b..3765f11 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -216,6 +216,7 @@ namespace OpenNest.Forms exportNestMenu.Enabled = hasValue; mnuFileExport.Enabled = hasValue; mnuFileExportAll.Enabled = hasValue; + mnuFileExportNestReport.Enabled = hasValue && !databaseSaveInProgress; btnZoomOut.Enabled = hasValue; btnZoomIn.Enabled = hasValue; mnuEdit.Visible = hasValue; @@ -742,6 +743,87 @@ namespace OpenNest.Forms activeForm.ExportAll(); } + private void ExportNestReport_Click(object sender, EventArgs e) + { + var form = activeForm; + if (form == null) + return; + + // The report never depends on a selected post or NC output; it only needs a + // quiescent nest so the snapshot is coherent. + Func isJobBusy = IsNestJobBusy; + EditNestForm.NestReportTargets targets; + try + { + targets = form.CaptureReportTargets(isJobBusy); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + ReportFailure(null, ex); + return; + } + + var destination = ShowReportSaveDialog(targets.SuggestedFileName); + if (destination == null) + return; // Cancel: nothing written, job untouched. + + try + { + form.WriteNestReport(targets, destination, isJobBusy); + } + catch (Exception ex) + when (ex is InvalidOperationException + or NotSupportedException + or InvalidDataException + or IOException + or UnauthorizedAccessException + or ArgumentException) + { + // Failures describe the captured target; an active-document switch is irrelevant. + ReportFailure(targets, ex); + return; + } + statusLabel1.Text = $"Saved nest report to {destination}"; + ReportSuccess(destination); + } + + /// + /// True while any operation could mutate nests or hold their plate views: whole-job + /// nesting, an open progress window (a closed one can still await its commit), or a + /// background database save serializing the live nest. + /// + private bool IsNestJobBusy() => + nestingInProgress + || Application.OpenForms.OfType().Any() + || databaseSaveInProgress; + + /// Returns the chosen path, or null on cancel. Overwrite consent is the dialog prompt. + internal virtual string ShowReportSaveDialog(string suggestedFileName) + { + using var dlg = new SaveFileDialog + { + Filter = "PDF report (*.pdf)|*.pdf", + FileName = suggestedFileName, + AddExtension = true, + DefaultExt = ".pdf", + OverwritePrompt = true, + }; + return dlg.ShowDialog(this) == DialogResult.OK ? dlg.FileName : null; + } + + /// Failure notice naming the fixed target; internal so tests observe it without dialogs. + internal virtual void ReportFailure( + EditNestForm.NestReportTargets targets, + Exception error) => + MessageBox.Show(this, + $"Could not export the nest report{(targets == null ? "" : $" for '{targets.Nest.Name}'")}: {error.Message}", + "Export Nest Report", MessageBoxButtons.OK, MessageBoxIcon.Warning); + + /// Success notice; internal so tests observe completion without dialogs. + internal virtual void ReportSuccess(string destination) => + MessageBox.Show(this, $"Nest report saved to:\n\n{destination}", "Export Nest Report", + MessageBoxButtons.OK, MessageBoxIcon.Information); + private void Exit_Click(object sender, EventArgs e) { Close(); diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index 18ceac0..c8b0f4b 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -27,6 +27,7 @@ + diff --git a/docs/nest-reports.md b/docs/nest-reports.md index 83fbbbb..5364af6 100644 --- a/docs/nest-reports.md +++ b/docs/nest-reports.md @@ -1,8 +1,18 @@ -# Nest report PDF library +# Nest report PDF export -The first delivery is a bounded, cross-platform library slice, not yet a desktop -menu command or a general report layout engine. Capture a stable `Nest` on its -owning thread, then render only the detached snapshot: +Desktop: **File -> Export Nest Report...** writes `.report.pdf` for the +active nest's whole job through an overwrite-confirming save dialog. The command +is disabled without an open document and during a background database save, and +it refuses to run while whole-job nesting, an open progress window, interactive +fill or a busy plate action holds the nest or any second window sharing it. After +the dialog closes, the target and those conditions are revalidated; the snapshot +is then captured synchronously on the UI thread and only that detached snapshot +reaches the renderer. Export never changes the selected plate, dirty state, +timestamps or quantities, and it never touches post selection, verification or +CNC output. Opening and printing use any normal PDF viewer; the report includes +no printer controls and there are no persisted report settings or templates. + +The same two calls back the command and any future integration: ```csharp var snapshot = NestReportBuilder.Capture(nest, DateTimeOffset.Now); @@ -129,6 +139,11 @@ vector curves, unfilled holes and visible tab gaps with no connecting stroke. Pixel inspection supplements geometry assertions; it is not a CNC-validation result. -Windows compilation is not runtime acceptance. The later desktop adapter must be -tested on Windows for busy-operation guards, cancellation, overwrite handling, -unchanged job state and PDF viewing/printing, including the packaged application. +Windows compilation is not runtime acceptance. `OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs` +covers enablement, busy/cancel/failure/success adapter behavior, but it only +compiles on Linux. Windows runtime acceptance still owes: exporting a real nest +(and the packaged application, so bundled fonts/notices are verified), checking +page sizes/labels/copies and embedded fonts with `pdffonts`, a tabbed part with a +hole, opening and printing the PDF, and exercising fill/nesting rejection, cancel, +overwrite and invalid-path behavior with job state and any existing destination +unchanged.