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).
This commit is contained in:
aj
2026-09-29 18:29:05 -04:00
parent 64045be968
commit 314ca2f2a6
6 changed files with 511 additions and 8 deletions
@@ -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<InvalidOperationException>(
() => 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<InvalidOperationException>(
() => 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<string> DialogRequests { get; } = [];
public string? NextDialogPath { get; set; }
public DialogResult NextDialogResult { get; set; } = DialogResult.OK;
public List<string> Failures { get; } = [];
public List<string> 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();
}
}