Files
ajandClaude Opus 5.5 2b23150801 fix(reporting): open the report viewer centered on the main form
FormStartPosition.CenterParent is not applied to modeless windows, so the
viewer opened wherever Windows placed it. Center it on its owner in OnLoad,
shrunk to fit and kept inside that screen's working area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:58:36 -05:00

391 lines
15 KiB
C#

using System.Reflection;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Geometry;
using static OpenNest.WinForms.Tests.TestReflection;
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 CuttingToolbarExposesExistingActionsAndOnlyAvailablePosts() => RunSta(() =>
{
using var host = new TestMainForm();
var toolbar = (ToolStrip)typeof(MainForm)
.GetField("toolStrip1", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(host)!;
foreach (var name in new[] { "btnCutPlanner", "btnSequenceByPath", "btnPost" })
{
var item = Assert.Single(toolbar.Items.Find(name, false));
Assert.Equal(24, item.Image!.Width);
Assert.Equal(24, item.Image.Height);
}
var post = Assert.IsType<ToolStripDropDownButton>(toolbar.Items["btnPost"]);
var menu = Menu(host, "mnuNestPost");
Assert.Equal(menu.Visible, post.Visible);
Assert.Equal(menu.DropDownItems.Count, post.DropDownItems.Count);
// Without a document these toolbar shortcuts must be harmless.
((ToolStripButton)toolbar.Items["btnCutPlanner"]!).PerformClick();
((ToolStripButton)toolbar.Items["btnSequenceByPath"]!).PerformClick();
foreach (ToolStripMenuItem item in menu.DropDownItems)
{
var toolbarItem = Assert.Single(post.DropDownItems.OfType<ToolStripMenuItem>(),
candidate => candidate.Text == item.Text);
Assert.Same(item.Tag, toolbarItem.Tag);
}
});
[Fact]
public void MenuFollowsActiveDocument() => RunSta(() =>
{
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]
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);
// EnsureSentinel already appended a trailing empty plate behind the two loaded here.
var plateCountBeforeExport = nest.Plates.Count;
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(plateCountBeforeExport, 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 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 ViewerCentersOnItsOwnerInsideTheScreen()
{
var area = Screen.PrimaryScreen!.WorkingArea;
var owner = new System.Drawing.Rectangle(area.Left + 100, area.Top + 50, 600, 400);
var small = NestReportViewerForm.CenteredBounds(owner, new System.Drawing.Size(200, 100));
Assert.Equal(owner.Left + 200, small.Left);
Assert.Equal(owner.Top + 150, small.Top);
// Larger than the owner: still centered on it, then shrunk and kept on screen.
var large = NestReportViewerForm.CenteredBounds(owner,
new System.Drawing.Size(area.Width + 500, area.Height + 500));
Assert.Equal(area, large);
}
[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", TestPrograms.Rectangle(4, 2));
drawing.Quantity.Required = 2;
nest.Drawings.Add(drawing);
nest.Drawings.Add(new Drawing("spare", TestPrograms.Rectangle(4, 2)));
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 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 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; } = [];
public List<string> Viewed { 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);
internal override void ShowReportViewer(string pdfPath, EditNestForm.NestReportTargets targets) =>
Viewed.Add(pdfPath);
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(90), "The STA test did not complete.");
}