Files
OpenNest/OpenNest.WinForms.Tests/Forms/PlateViewFillLifetimeTests.cs
T
ajandClaude Opus 5.5 0c7ad5aefb refactor(engine): list the built-in fill strategies instead of scanning
FillStrategyRegistry now holds a fixed, ordered list of the seven
built-in strategies (Pairs, Row, Column, RectBestFit, Extents, Linear,
TiltedRemnant, in their Order sequence) instead of reflecting over the
Engine assembly. LoadFrom and LoadPlugins are gone; nothing outside
tests called them, so outside IFillStrategy plug-ins were never loaded
by a shipped host and none are loaded now. IFillStrategy stays public.

The disabled set stays process-wide and keeps its Disable, Enable,
SetEnabled, Strategies, AllStrategies and DisabledNames behavior for the
Options form. DefaultPlateFiller still reads Strategies, so interactive
Fill, the fixed-strategy engines, Irregular's block catalog and private
plate fill, the cutout lattice fill and the brute-force runner all keep
honoring it.

PlateViewFillLifetimeTests drives fills through PlateView, which builds
its own filler, so a filler-level seam can't reach it. The registry gets
an internal UseForTesting hook that swaps the list until disposed, and
the WinForms test project can see Engine internals for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:50 -04:00

322 lines
12 KiB
C#

using System.Collections.Concurrent;
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Engine;
using OpenNest.Engine.Strategies;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
[CollectionDefinition("Fill operation lifetime", DisableParallelization = true)]
public class FillOperationLifetimeCollection;
[Collection("Fill operation lifetime")]
public class PlateViewFillLifetimeTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ClosedProgressWindowStaysBusyThroughCommitAndCleanup(bool accept)
{
RunSta(() =>
{
using var run = new FillRun();
run.Start();
var progressForm = Assert.Single(Application.OpenForms.OfType<NestProgressForm>());
if (accept)
Invoke(progressForm, "AcceptButton_Click", null, EventArgs.Empty);
progressForm.Close();
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
Assert.True(run.View.IsFillInProgress);
Assert.True(run.Context.Token.IsCancellationRequested);
// Closing the modeless window must neither release the guard nor allow another fill.
run.View.FillWithProgress(run.Group, run.WorkArea);
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
Assert.Equal(1, run.Calls);
using (var dialog = new AutomaticCutOffForm(run.View, Units.Inches))
{
Invoke(dialog, "PreviewButton_Click", null, EventArgs.Empty);
Assert.Empty(PreviewParts(run.View));
Assert.False(dialog.Controls.Find("applyButton", true).Single().Enabled);
Invoke(dialog, "ApplyButton_Click", null, EventArgs.Empty);
Assert.NotEqual(DialogResult.OK, dialog.DialogResult);
Assert.Empty(run.View.Plate.CutOffs);
}
var busyDuringCommit = false;
run.View.Plate.PartAdded += (_, _) => busyDuringCommit = run.View.IsFillInProgress;
run.Complete();
Assert.Equal(accept, busyDuringCommit);
Assert.Equal(accept ? 2 : 1, run.View.Plate.Parts.Count);
Assert.False(run.View.IsFillInProgress);
Assert.Null(run.View.ActiveWorkArea);
Assert.Empty(PreviewParts(run.View));
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
});
}
[Fact]
public void StoppedFillStillRefusesPlateEditsUntilItReturns()
{
RunSta(() =>
{
using var run = new FillRun();
var existing = run.View.Plate.Parts[0];
var location = existing.Location;
run.View.SetAction(typeof(ActionClone), run.Group[0].BaseDrawing);
run.Start();
// Stopping only requests cancellation; the worker reads Plate.Parts until it returns.
Assert.Single(Application.OpenForms.OfType<NestProgressForm>()).Close();
var click = new MouseEventArgs(MouseButtons.Left, 1, 10, 10, 0);
Raise(run.View, "OnMouseDown", click);
Raise(run.View, "OnKeyDown", new KeyEventArgs(Keys.Enter));
run.View.SetAction(typeof(ActionSelect));
run.View.LayoutParts.Single(p => p.BasePart == existing).IsSelected = true;
run.View.SelectedParts.Add(run.View.LayoutParts.Single(p => p.BasePart == existing));
Raise(run.View, "OnKeyDown", new KeyEventArgs(Keys.Delete));
Raise(run.View, "ProcessDialogKey", Keys.Left);
Assert.Same(existing, Assert.Single(run.View.Plate.Parts));
Assert.Equal(location, existing.Location);
run.Complete();
Assert.False(run.View.IsFillInProgress);
run.View.SetAction(typeof(ActionClone), run.Group[0].BaseDrawing);
Raise(run.View, "OnMouseDown", click);
Assert.Equal(2, run.View.Plate.Parts.Count);
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void QueuedProgressCannotOverwriteNewPreviewEvenDuringAnotherFill(bool startAnotherFill)
{
RunSta(() =>
{
using var run = new FillRun();
run.Start();
run.Complete();
var lateCallbacks = run.Dispatcher.TakeProgress();
Assert.NotEmpty(lateCallbacks);
using var dialog = new AutomaticCutOffForm(run.View, Units.Inches);
Invoke(dialog, "PreviewButton_Click", null, EventArgs.Empty);
var preview = PreviewParts(run.View).ToArray();
Assert.NotEmpty(preview);
if (startAnotherFill)
run.Start();
foreach (var callback in lateCallbacks)
callback();
Assert.Equal(preview, PreviewParts(run.View).ToArray());
Assert.Null(run.View.ActiveWorkArea);
Assert.Equal(startAnotherFill, run.View.IsFillInProgress);
});
}
[Fact]
public void SetupFailureReleasesGuardAndClosesProgressWindow()
{
RunSta(() =>
{
using var run = new FillRun();
var plateField = typeof(PlateView).GetField("plate", BindingFlags.Instance | BindingFlags.NonPublic)!;
var plate = run.View.Plate;
// Inject an unavailable input without invoking SetPlate's non-null contract.
plateField.SetValue(run.View, null);
try
{
run.View.FillWithProgress(run.Group, run.WorkArea);
Assert.False(run.View.IsFillInProgress);
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
}
finally
{
plateField.SetValue(run.View, plate);
}
run.Start();
run.Complete();
Assert.False(run.View.IsFillInProgress);
});
}
[Fact]
public void WorkerFailureReleasesGuardAndDiscardsPreview()
{
RunSta(() =>
{
using var run = new FillRun { Fail = true };
run.Start();
run.Complete();
Assert.False(run.View.IsFillInProgress);
Assert.Empty(PreviewParts(run.View));
Assert.Single(run.View.Plate.Parts);
Assert.Empty(Application.OpenForms.OfType<NestProgressForm>());
});
}
// PlateView builds its own filler, so the registry's list is swapped for the test's strategy.
private sealed class BlockingFillStrategy : IFillStrategy
{
public string Name => "WinForms lifetime regression";
public NestPhase Phase => NestPhase.Linear;
public int Order => 0;
public Func<FillContext, List<Part>>? Run { get; set; }
public List<Part> Fill(FillContext context) => Run!(context);
}
private sealed class FillRun : IDisposable
{
private readonly ManualResetEventSlim entered = new();
private readonly ManualResetEventSlim release = new();
private readonly BlockingFillStrategy strategy;
private readonly IDisposable strategies;
private readonly string priorEngine;
private readonly SynchronizationContext? priorContext;
public PlateView View { get; }
public List<Part> Group { get; }
public Box WorkArea { get; }
public ControlledDispatcher Dispatcher { get; } = new();
public FillContext Context { get; private set; } = null!;
public bool Fail { get; init; }
public int Calls { get; private set; }
public FillRun()
{
priorEngine = EngineSelection.EngineName;
strategy = new BlockingFillStrategy { Run = Fill };
strategies = FillStrategyRegistry.UseForTesting([strategy]);
EngineSelection.EngineName = EngineSelection.DefaultEngineName;
var program = new CNC.Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(10, 0));
program.Codes.Add(new LinearMove(10, 5));
program.Codes.Add(new LinearMove(0, 5));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("fill lifetime rectangle", program);
var plate = new Plate(36, 120) { PartSpacing = 0.5 };
plate.Parts.Add(new Part(drawing, new Vector(70, 10)));
View = new PlateView { Plate = plate };
Group = [new Part(drawing)];
WorkArea = new Box(0, 0, 60, 36);
priorContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(Dispatcher);
}
private List<Part> Fill(FillContext context)
{
Calls++;
Context = context;
var parts = new List<Part> { new(context.Item.Drawing, new Vector(1, 1)) };
context.ReportProgress(parts, "Blocked until the test releases the worker");
entered.Set();
if (!release.Wait(TimeSpan.FromSeconds(15)))
throw new TimeoutException("The test did not release the fill worker.");
if (Fail)
throw new InvalidOperationException("Injected worker failure");
return parts;
}
public void Start()
{
entered.Reset();
release.Reset();
View.FillWithProgress(Group, WorkArea);
Assert.True(entered.Wait(TimeSpan.FromSeconds(15)), "The fill strategy was not entered.");
Assert.True(View.IsFillInProgress);
}
public void Complete()
{
release.Set();
Dispatcher.CompleteOperations();
}
public void Dispose()
{
try
{
Complete();
}
finally
{
strategy.Run = null;
strategies.Dispose();
EngineSelection.EngineName = priorEngine;
SynchronizationContext.SetSynchronizationContext(priorContext);
View.Dispose();
entered.Dispose();
release.Dispose();
}
}
}
// Hold Progress<T> deliveries but execute await continuations on the STA. No sleeps,
// message-loop polling, or dependence on which thread wins the completion/progress race.
private sealed class ControlledDispatcher : SynchronizationContext
{
private readonly BlockingCollection<System.Action> continuations = new();
private readonly ConcurrentQueue<System.Action> progress = new();
private int operations;
public override void OperationStarted() => operations++;
public override void OperationCompleted() => operations--;
public override void Post(SendOrPostCallback callback, object? state)
{
if (state is NestProgress)
progress.Enqueue(() => callback(state));
else
continuations.Add(() => callback(state));
}
public System.Action[] TakeProgress()
{
var callbacks = new List<System.Action>();
while (progress.TryDequeue(out var callback))
callbacks.Add(callback);
return callbacks.ToArray();
}
public void CompleteOperations()
{
while (operations > 0)
{
Assert.True(continuations.TryTake(out var callback, TimeSpan.FromSeconds(15)),
"The fill did not complete.");
callback!();
}
}
}
private static IReadOnlyList<LayoutPart> PreviewParts(PlateView view) =>
(IReadOnlyList<LayoutPart>)typeof(PlateView)
.GetProperty("PreviewParts", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view)!;
private static void Raise(PlateView view, string method, object argument) =>
typeof(PlateView).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic,
[argument.GetType()])!.Invoke(view, [argument]);
private static void Invoke(Form form, string method, params object?[] arguments) =>
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
}