using System.Collections.Concurrent; using System.Reflection; using System.Runtime.ExceptionServices; using System.Windows.Forms; 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()); if (accept) Invoke(progressForm, "AcceptButton_Click", null, EventArgs.Empty); progressForm.Close(); Assert.Empty(Application.OpenForms.OfType()); 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()); 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()); }); } [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()); } 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()); }); } // Use the real strategy registry rather than a production-only injection hook. public sealed class BlockingFillStrategy : IFillStrategy { public string Name => "WinForms lifetime regression"; public NestPhase Phase => NestPhase.Linear; public int Order => 0; public Func>? Run { get; set; } public List 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 string[] priorStrategies; private readonly string priorEngine; private readonly SynchronizationContext? priorContext; public PlateView View { get; } public List 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() { priorStrategies = FillStrategyRegistry.Strategies.Select(s => s.Name).ToArray(); priorEngine = EngineSelection.EngineName; FillStrategyRegistry.LoadFrom(typeof(BlockingFillStrategy).Assembly); strategy = Assert.Single(FillStrategyRegistry.AllStrategies.OfType()); FillStrategyRegistry.Disable(strategy.Name); FillStrategyRegistry.SetEnabled(strategy.Name); EngineSelection.EngineName = EngineSelection.DefaultEngineName; strategy.Run = Fill; 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 Fill(FillContext context) { Calls++; Context = context; var parts = new List { 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; FillStrategyRegistry.SetEnabled(priorStrategies); EngineSelection.EngineName = priorEngine; SynchronizationContext.SetSynchronizationContext(priorContext); View.Dispose(); entered.Dispose(); release.Dispose(); } } } // Hold Progress 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 continuations = new(); private readonly ConcurrentQueue 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(); 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 PreviewParts(PlateView view) => (IReadOnlyList)typeof(PlateView) .GetProperty("PreviewParts", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view)!; 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) { 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(60)), "The STA test did not complete."); if (failure != null) ExceptionDispatchInfo.Capture(failure).Throw(); } }