feat(reporting): view nest reports in a window to save or print

Export Nest Report only wrote a file, so checking or printing a report meant
finding it and opening another program. View Nest Report renders the same
snapshot to a private temp folder and opens it in a WebView2 viewer with
Save As and Print; closing the window deletes the temp copy. Export and View
share the capture and write guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-04 10:02:42 -05:00
1 parent 54cea0c325
commit 9d4d024d60
6 files changed
+279 -22

No files matched your search

@@ -48,16 +48,20 @@ public class NestReportExportTests
using var host = new TestMainForm();
host.Show();
var item = Menu(host, "mnuFileExportNestReport");
var view = Menu(host, "mnuFileViewNestReport");
Assert.False(item.Enabled);
Assert.False(view.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);
Assert.True(view.Enabled);
form.Close();
host.Activate();
Assert.False(item.Enabled);
Assert.False(view.Enabled);
});
[Fact]
@@ -232,6 +236,55 @@ public class NestReportExportTests
}
});
[Fact]
public void ViewRendersATemporaryPdfForTheViewer() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
var nest = CreateJob("view job");
using var form = new EditNestForm(nest) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
Menu(host, "mnuFileViewNestReport").PerformClick();
Assert.Empty(host.DialogRequests);
Assert.Empty(host.Failures);
var path = Assert.Single(host.Viewed);
try
{
Assert.Equal("view job.report.pdf", Path.GetFileName(path));
var bytes = File.ReadAllBytes(path);
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(bytes, 0, 5));
Assert.Equal(2, nest.Drawings.Count);
}
finally
{
NestReportViewerForm.DeleteReportFolder(path);
}
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
});
[Fact]
public void ViewRenderFailureOpensNoViewerAndLeavesNoTempFolder() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
var nest = CreateJob("view failure");
nest.Notes = "em—dash"; // Fails font validation before layout.
using var form = new EditNestForm(nest) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
var reports = Path.Combine(Path.GetTempPath(), "OpenNest", "Reports");
var before = Directory.Exists(reports) ? Directory.GetDirectories(reports).Length : 0;
Menu(host, "mnuFileViewNestReport").PerformClick();
Assert.Empty(host.Viewed);
Assert.Contains("view failure", Assert.Single(host.Failures));
Assert.Equal(before, Directory.Exists(reports) ? Directory.GetDirectories(reports).Length : 0);
});
[Fact]
public void BusyAfterDialogRejectsWriteWithoutTouchingDestination() => RunSta(() =>
{
@@ -295,6 +348,7 @@ public class NestReportExportTests
public DialogResult NextDialogResult { get; set; } = DialogResult.OK;
public List<string> Failures { get; } = [];
public List<string> Successes { get; } = [];
public List<string> Viewed { get; } = [];
// No startup migrations/automatic new document, and no settings save at close.
protected override void OnLoad(EventArgs e) { }
@@ -310,6 +364,9 @@ public class NestReportExportTests
Failures.Add($"{targets?.Nest.Name}|{error.Message}");
internal override void ReportSuccess(string destination) => Successes.Add(destination);
internal override void ShowReportViewer(string pdfPath, EditNestForm.NestReportTargets targets) =>
Viewed.Add(pdfPath);
}
private static void RunSta(System.Action action) =>
+11 -1
View File
@@ -50,6 +50,7 @@ namespace OpenNest.Forms
toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator();
mnuFileExport = new System.Windows.Forms.ToolStripMenuItem();
mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem();
mnuFileViewNestReport = new System.Windows.Forms.ToolStripMenuItem();
mnuFileExportNestReport = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator();
mnuFileExit = new System.Windows.Forms.ToolStripMenuItem();
@@ -217,7 +218,7 @@ namespace OpenNest.Forms
//
// mnuFile
//
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingLibrary, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit });
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, mnuFileDrawingLibrary, mnuFileDrawingSets, mnuFileReleases, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileViewNestReport, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit });
mnuFile.Name = "mnuFile";
mnuFile.DropDownOpening += FileMenu_Opening;
mnuFile.Size = new System.Drawing.Size(37, 20);
@@ -312,6 +313,14 @@ namespace OpenNest.Forms
mnuFileExportAll.Text = "Export All";
mnuFileExportAll.Click += ExportAll_Click;
//
// mnuFileViewNestReport
//
mnuFileViewNestReport.Enabled = false;
mnuFileViewNestReport.Name = "mnuFileViewNestReport";
mnuFileViewNestReport.Size = new System.Drawing.Size(146, 22);
mnuFileViewNestReport.Text = "View Nest Report...";
mnuFileViewNestReport.Click += ViewNestReport_Click;
//
// mnuFileExportNestReport
//
mnuFileExportNestReport.Enabled = false;
@@ -1500,6 +1509,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.ToolStripMenuItem mnuCloseAll;
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
private System.Windows.Forms.ToolStripMenuItem mnuFileViewNestReport;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportNestReport;
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
+63 -19
View File
@@ -315,30 +315,69 @@ namespace OpenNest.Forms
private void ExportNestReport_Click(object sender, EventArgs e)
{
var form = activeForm;
if (form == null)
if (form == null || !TryCaptureReportTargets(form, out var targets))
return;
// The report never depends on a selected post or NC output; it only needs a
// quiescent nest so the snapshot is coherent.
Func<bool> 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.
if (!TryWriteNestReport(form, targets, destination))
return;
statusLabel1.Text = $"Saved nest report to {destination}";
ReportSuccess(destination);
}
/// <summary>Renders the report to a private temporary file and opens it in a viewer to save or print.</summary>
private void ViewNestReport_Click(object sender, EventArgs e)
{
var form = activeForm;
if (form == null || !TryCaptureReportTargets(form, out var targets))
return;
var path = Path.Combine(Path.GetTempPath(), "OpenNest", "Reports",
Guid.NewGuid().ToString("N"), targets.SuggestedFileName);
try
{
form.WriteNestReport(targets, destination, isJobBusy);
Directory.CreateDirectory(Path.GetDirectoryName(path));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ReportFailure(targets, ex);
return;
}
if (!TryWriteNestReport(form, targets, path))
{
NestReportViewerForm.DeleteReportFolder(path);
return;
}
ShowReportViewer(path, targets);
}
// The report never depends on a selected post or NC output; it only needs a
// quiescent nest so the snapshot is coherent.
private bool TryCaptureReportTargets(EditNestForm form, out EditNestForm.NestReportTargets targets)
{
try
{
targets = form.CaptureReportTargets(IsNestJobBusy);
return true;
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
{
ReportFailure(null, ex);
targets = null;
return false;
}
}
private bool TryWriteNestReport(EditNestForm form, EditNestForm.NestReportTargets targets, string destination)
{
try
{
form.WriteNestReport(targets, destination, IsNestJobBusy);
return true;
}
catch (Exception ex)
when (ex is InvalidOperationException
@@ -350,10 +389,15 @@ namespace OpenNest.Forms
{
// Failures describe the captured target; an active-document switch is irrelevant.
ReportFailure(targets, ex);
return;
return false;
}
statusLabel1.Text = $"Saved nest report to {destination}";
ReportSuccess(destination);
}
/// <summary>Opens the rendered report; internal so tests observe it without a WebView2 window.</summary>
internal virtual void ShowReportViewer(string pdfPath, EditNestForm.NestReportTargets targets)
{
var viewer = new NestReportViewerForm(pdfPath, targets.Nest.Name, targets.SuggestedFileName);
viewer.Show(this);
}
/// <summary>Returns the chosen path, or null on cancel. Overwrite consent is the dialog prompt.</summary>
+1
View File
@@ -23,6 +23,7 @@ namespace OpenNest.Forms
mnuFileExport.Enabled = hasValue;
mnuFileExportAll.Enabled = hasValue;
mnuFileExportNestReport.Enabled = hasValue && !storage.IsSaving;
mnuFileViewNestReport.Enabled = mnuFileExportNestReport.Enabled;
mnuEdit.Visible = hasValue;
mnuView.Visible = hasValue;
mnuNest.Visible = hasValue;
+138
View File
@@ -0,0 +1,138 @@
using System;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace OpenNest.Forms
{
/// <summary>
/// Shows a rendered nest report PDF in WebView2 with Save As and Print. The PDF is a private
/// temporary copy owned by this window: saving copies it, and closing deletes it.
/// </summary>
public sealed class NestReportViewerForm : Form
{
private readonly string pdfPath;
private readonly string suggestedFileName;
private readonly WebView2 browser = new() { Dock = DockStyle.Fill };
private readonly ToolStripButton printButton;
public NestReportViewerForm(string pdfPath, string nestName, string suggestedFileName)
{
this.pdfPath = pdfPath ?? throw new ArgumentNullException(nameof(pdfPath));
this.suggestedFileName = suggestedFileName;
Text = $"Nest Report - {nestName}";
Icon = Properties.Resources.app_icon;
StartPosition = FormStartPosition.CenterParent;
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
Size = new Size(System.Math.Min(1000, area.Width), System.Math.Min(900, area.Height));
var saveButton = new ToolStripButton("Save As...") { Name = "btnSaveAs" };
saveButton.Click += (_, _) => SaveAs();
printButton = new ToolStripButton("Print...") { Name = "btnPrint", Enabled = false };
printButton.Click += (_, _) => Print();
var toolbar = new ToolStrip { GripStyle = ToolStripGripStyle.Hidden };
toolbar.Items.AddRange(new ToolStripItem[] { saveButton, printButton });
Controls.Add(browser);
Controls.Add(toolbar);
}
/// <summary>The report file this window shows and deletes on close.</summary>
public string PdfPath => pdfPath;
protected override async void OnLoad(EventArgs e)
{
base.OnLoad(e);
try
{
var dataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OpenNest", "WebView2");
var environment = await CoreWebView2Environment.CreateAsync(null, dataFolder);
await browser.EnsureCoreWebView2Async(environment);
}
catch (Exception ex) when (ex is WebView2RuntimeNotFoundException or COMException or IOException
or UnauthorizedAccessException)
{
// Saving needs no viewer; only the preview and Print are unavailable.
MessageBox.Show(this,
"The report preview needs the Microsoft Edge WebView2 Runtime, which could not be started. "
+ "Use Save As to keep the report.\n\n" + ex.Message,
Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
if (IsDisposed)
return;
var core = browser.CoreWebView2;
core.Settings.AreDevToolsEnabled = false;
core.Settings.IsStatusBarEnabled = false;
// The report is the only page this window shows.
core.NewWindowRequested += (_, args) => args.Handled = true;
core.Navigate(new Uri(pdfPath).AbsoluteUri);
printButton.Enabled = true;
}
private void SaveAs()
{
using var dlg = new SaveFileDialog
{
Filter = "PDF report (*.pdf)|*.pdf",
FileName = suggestedFileName,
AddExtension = true,
DefaultExt = ".pdf",
OverwritePrompt = true,
};
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
try
{
// Overwrite consent is the dialog prompt.
File.Copy(pdfPath, dlg.FileName, true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
{
MessageBox.Show(this, $"Could not save the nest report: {ex.Message}", Text,
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void Print()
{
try
{
browser.CoreWebView2?.ShowPrintUI(CoreWebView2PrintDialogKind.Browser);
}
catch (Exception ex) when (ex is COMException or InvalidOperationException)
{
MessageBox.Show(this, $"Could not print the nest report: {ex.Message}", Text,
MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
// Release WebView2's hold on the file before deleting its private folder.
browser.Dispose();
base.OnFormClosed(e);
DeleteReportFolder(pdfPath);
}
/// <summary>Best effort: a leftover temp report is harmless.</summary>
internal static void DeleteReportFolder(string pdfPath)
{
try
{
var folder = Path.GetDirectoryName(pdfPath);
if (folder != null && Directory.Exists(folder))
Directory.Delete(folder, true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
}
}
}
+9 -2
View File
@@ -9,8 +9,15 @@ 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.
CNC output. There are no persisted report settings or templates.
**File -> View Nest Report...** runs the same guards and capture without a dialog,
renders to a private folder under `%TEMP%\OpenNest\Reports\` and opens a modeless
viewer window (WebView2's PDF viewer, sharing Help's WebView2 data folder) with
**Save As...**, which copies the rendered file to a chosen path, and **Print...**,
the WebView2 print dialog. Closing the window deletes its temporary folder; a
render failure opens no viewer and leaves no folder. Without the WebView2 Runtime
the window still offers Save As.
The same two calls back the command and any future integration: