using System.Collections.Concurrent; using System.Reflection; using System.Runtime.ExceptionServices; using System.Windows.Forms; using OpenNest.CNC; using OpenNest.Collections; using OpenNest.Controls; using OpenNest.Engine.BestFit; using OpenNest.Forms; using OpenNest.Geometry; namespace OpenNest.WinForms.Tests.Forms; [Collection("Fill operation lifetime")] public class BestFitViewerOperationLifetimeTests { [Fact] public void DispatcherObservesAsyncVoidFailureAfterOperationCompletes() => RunSta(() => { var priorContext = SynchronizationContext.Current; var dispatcher = new Dispatcher(); SynchronizationContext.SetSynchronizationContext(dispatcher); try { async void FailAfterAwait() { await Task.Yield(); throw new InvalidOperationException("Escaped async-void failure"); } FailAfterAwait(); var error = Assert.Throws(() => dispatcher.RunUntil(() => dispatcher.Operations == 0)); Assert.Equal("Escaped async-void failure", error.Message); } finally { SynchronizationContext.SetSynchronizationContext(priorContext); } }); [Fact] public void DispatcherDrainsPendingCallbacksWhenCompletionAlreadyHolds() => RunSta(() => { var dispatcher = new Dispatcher(); var callbackCount = 0; dispatcher.Post(_ => callbackCount++, null); dispatcher.RunUntil(() => true); Assert.Equal(1, callbackCount); }); [Fact] public void ShownAndSelectionPublishCompleteResultsWithPagingAndSourceSelection() => RunSta(() => { using var run = new ViewerRun(); var first = run.Queue(run.Drawings.ElementAt(0), 16); run.Form.Show(); Application.DoEvents(); // Deliver WinForms' posted Shown event before blocking for the worker. first.WaitForEntry(); run.FinishEvents(first); Assert.Equal(15, run.Grid.Controls.Count); Assert.Contains("16 candidates (16 kept)", run.Form.Text); Assert.Equal("1", run.Control("txtPage").Text); Invoke(run.Form, "NavigatePage", 1); Assert.Single(run.Grid.Controls.Cast()); Assert.Equal("2", run.Control("txtPage").Text); var second = run.Queue(run.Drawings.ElementAt(1), 1); ((DrawingListBox)run.Control("drawingListBox")).SelectedIndex = 1; second.WaitForEntry(); run.FinishEvents(second); Assert.Same(run.Drawings.ElementAt(1), second.Input.Drawing); Assert.Equal((120d, 36d, 0.5d), (second.Input.Length, second.Input.Width, second.Input.Spacing)); Assert.Equal("1", run.Control("txtPage").Text); var cell = Assert.Single(run.Grid.Controls.OfType()); typeof(Control).GetMethod("OnDoubleClick", BindingFlags.Instance | BindingFlags.NonPublic)! .Invoke(cell, [EventArgs.Empty]); Assert.Same(second.Result.Results[0], run.Form.SelectedResult); Assert.Same(run.Drawings.ElementAt(1), run.Form.SelectedDrawing); Assert.Equal(2, run.Form.SelectedParts.Count); Assert.All(run.Form.SelectedParts, part => Assert.Same(run.Drawings.ElementAt(1), part.BaseDrawing)); Assert.Equal(DialogResult.OK, run.Form.DialogResult); Assert.Empty(run.Form.Notices); }); [Theory] [InlineData(false)] [InlineData(true)] public void CurrentComputeOrRenderFailureReportsOnceAndClearsPartialAndPreviousResults(bool renderFailure) => RunSta(() => { using var run = new ViewerRun(); var previous = run.Queue(run.Drawings.ElementAt(0), 2); run.Finish(run.Start(previous), previous); Assert.Equal(2, run.Grid.Controls.Count); var work = run.Queue(run.Drawings.ElementAt(0), 1); var error = new InvalidOperationException("Injected Best-Fit failure"); if (renderFailure) run.Form.RenderFailure = error; else work.Error = error; var task = run.Start(work); var source = run.Source; run.Finish(task, work); var notice = Assert.Single(run.Form.Notices); Assert.Same(error, notice.Error); Assert.Same(run.Drawings.ElementAt(0), notice.Drawing); Assert.Empty(run.Grid.Controls.Cast()); Assert.Equal(0, Field(run.Form, "pageCount")); Assert.Null(Field?>(run.Form, "results")); Assert.True(run.Control("btnPrev").Enabled); Assert.True(run.Control("btnNext").Enabled); Assert.True(run.Control("txtPage").Enabled); Assert.Equal(Cursors.Default, run.Form.Cursor); Assert.Contains("Could not load results", run.Form.Text); Assert.Null(run.Source); AssertDisposed(source); }); [Theory] [InlineData(false)] [InlineData(true)] public void OnlyRequestedCancellationIsExpectedAndNonmodal(bool cancel) => RunSta(() => { using var run = new ViewerRun(); var work = run.Queue(run.Drawings.ElementAt(0), 1); work.Error = new OperationCanceledException("Injected cancellation"); var task = run.Start(work); var source = run.Source!; if (cancel) source.Cancel(); run.Finish(task, work); Assert.Equal(cancel ? 0 : 1, run.Form.Notices.Count); Assert.True(run.Control("txtPage").Enabled); AssertDisposed(source); }); [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] [InlineData(true, true)] public void OldCompletionCannotPublishReportOrResetNewerOperation(bool failOld, bool oldCompletesFirst) => RunSta(() => { using var run = new ViewerRun(); var old = run.Queue(run.Drawings.ElementAt(0), 2); if (failOld) old.Error = new InvalidOperationException("Stale failure"); var oldTask = run.Start(old); var oldSource = run.Source!; var newer = run.Queue(run.Drawings.ElementAt(0), 1); var newTask = run.Start(newer); var newSource = run.Source!; Assert.True(oldSource.IsCancellationRequested); Assert.True(oldSource.Token.IsCancellationRequested); // Not disposed while its worker still runs. Assert.False(newSource.IsCancellationRequested); if (oldCompletesFirst) { run.Finish(oldTask, old); Assert.Same(newSource, run.Source); Assert.False(run.Control("txtPage").Enabled); Assert.Equal(Cursors.WaitCursor, run.Form.Cursor); Assert.Contains("Computing", run.Form.Text); Assert.Empty(run.Grid.Controls.OfType()); AssertDisposed(oldSource); run.Finish(newTask, newer); } else { run.Finish(newTask, newer); var title = run.Form.Text; var loadingChanges = run.Form.LoadingChanges.Count; run.Finish(oldTask, old); Assert.Equal(title, run.Form.Text); Assert.Equal(loadingChanges, run.Form.LoadingChanges.Count); } Assert.Same(newer.Result.Results, Field>(run.Form, "results")); Assert.Single(run.Grid.Controls.OfType()); Assert.Empty(run.Form.Notices); Assert.Null(run.Source); AssertDisposed(oldSource); AssertDisposed(newSource); }); [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] [InlineData(true, true)] public void CloseOrDisposalRejectsLateResultsAndErrorsWithoutTouchingControls(bool dispose, bool fail) => RunSta(() => { using var run = new ViewerRun(); var work = run.Queue(run.Drawings.ElementAt(0), 1); if (fail) work.Error = new InvalidOperationException("Late failure"); run.Form.Show(); Application.DoEvents(); work.WaitForEntry(); var source = run.Source!; if (dispose) run.Form.Dispose(); else run.Form.Close(); if (!dispose) { Assert.True(source.IsCancellationRequested); Assert.Null(run.Source); } _ = source.Token; // Closing must not dispose a source still owned by its worker. var changes = run.Form.LoadingChanges.Count; run.FinishEvents(work); Assert.Empty(run.Form.Notices); Assert.Equal(changes, run.Form.LoadingChanges.Count); Assert.Equal(0, run.Form.LateUiCalls); AssertDisposed(source); Assert.Null(run.Source); Assert.True(run.Form.LoadResultsAsync().IsCompletedSuccessfully); Assert.Null(run.Source); }); private sealed class ViewerRun : IDisposable { private readonly SynchronizationContext? priorContext = SynchronizationContext.Current; private readonly Dispatcher dispatcher = new(); private readonly List works = []; private readonly List tasks = []; public DrawingCollection Drawings { get; } = new(); public TestViewer Form { get; } public TableLayoutPanel Grid => (TableLayoutPanel)Control("gridPanel"); public CancellationTokenSource? Source => Field(Form, "computeCts"); public ViewerRun() { foreach (var name in new[] { "first", "second" }) { 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)); Drawings.Add(new Drawing(name, program)); } Form = new TestViewer(Drawings, new Plate(36, 120) { PartSpacing = 0.5 }); SynchronizationContext.SetSynchronizationContext(dispatcher); } public Control Control(string name) => Form.Controls.Find(name, true).Single(); public Work Queue(Drawing drawing, int count) { var work = new Work { Result = new BestFitViewerForm.ComputeResult { Results = Enumerable.Range(0, count).Select(_ => new BestFitResult { Candidate = new PairCandidate { Drawing = drawing, Part2Offset = new Vector(11, 0) }, BoundingWidth = 21, BoundingHeight = 5, Keep = true, }).ToList(), TotalResults = count, KeptCount = count, ComputeSeconds = 0.1, TotalSeconds = 0.2, }, }; works.Add(work); Form.Workers.Add(work, CancellationToken.None); return work; } public Task Start(Work work) { var task = Form.LoadResultsAsync(); tasks.Add(task); work.WaitForEntry(); Assert.False(Control("txtPage").Enabled); return task; } public void Finish(Task task, Work work) { work.Release.Set(); dispatcher.RunUntil(() => task.IsCompleted); task.GetAwaiter().GetResult(); } public void FinishEvents(Work work) { work.Release.Set(); dispatcher.RunUntil(() => dispatcher.Operations == 0); } public void Dispose() { try { foreach (var work in works) work.Release.Set(); dispatcher.RunUntil(() => tasks.All(task => task.IsCompleted) && dispatcher.Operations == 0); } finally { try { Form.Dispose(); } finally { SynchronizationContext.SetSynchronizationContext(priorContext); foreach (var work in works) work.Dispose(); } } } } private sealed class Work : IDisposable { public ManualResetEventSlim Entered { get; } = new(); public ManualResetEventSlim Release { get; } = new(); public BestFitViewerForm.ComputeResult Result; public Exception? Error; public (Drawing Drawing, double Length, double Width, double Spacing) Input; public void WaitForEntry() => Assert.True(Entered.Wait(TimeSpan.FromSeconds(15)), "Worker did not start."); public void Dispose() { Entered.Dispose(); Release.Dispose(); } } private sealed class TestViewer(DrawingCollection drawings, Plate plate) : BestFitViewerForm(drawings, plate) { public BlockingCollection Workers { get; } = new(); public List<(Drawing Drawing, Exception Error)> Notices { get; } = []; public List LoadingChanges { get; } = []; public Exception? RenderFailure { get; set; } public int LateUiCalls { get; private set; } private bool closed; internal override ComputeResult ComputeResults(Drawing drawing, double length, double width, double spacing) { var work = Workers.Take(); work.Input = (drawing, length, width, spacing); work.Entered.Set(); if (!work.Release.Wait(TimeSpan.FromSeconds(15))) throw new TimeoutException("Worker was not released."); if (work.Error != null) throw work.Error; return work.Result; } internal override void ReportLoadFailure(Drawing drawing, Exception error) { CheckLifetime(); Notices.Add((drawing, error)); } internal override void SetLoading(bool loading) { CheckLifetime(); LoadingChanges.Add(loading); base.SetLoading(loading); } internal override void ShowPage(int page) { CheckLifetime(); base.ShowPage(page); if (RenderFailure != null) throw RenderFailure; // A failure after cells were added must not leave a partial display. } protected override void OnFormClosed(FormClosedEventArgs e) { closed = true; base.OnFormClosed(e); } private void CheckLifetime() { if (closed || IsDisposed || Disposing) LateUiCalls++; } } private sealed class Dispatcher : SynchronizationContext { private readonly BlockingCollection callbacks = new(); public int Operations { get; private set; } public override void OperationStarted() => Operations++; public override void OperationCompleted() => Operations--; public override void Post(SendOrPostCallback d, object? state) => callbacks.Add(() => d(state)); public void RunUntil(Func done) { // Async-void faults are posted before OperationCompleted: drain those too. while (!done() || callbacks.Count > 0) { Assert.True(callbacks.TryTake(out var callback, TimeSpan.FromSeconds(15)), "UI continuation did not complete."); callback(); } } } private static void AssertDisposed(CancellationTokenSource? source) => Assert.Throws(() => source!.Token); private static T Field(BestFitViewerForm form, string name) => (T)typeof(BestFitViewerForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(form)!; private static void Invoke(BestFitViewerForm form, string name, params object[] args) => typeof(BestFitViewerForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args); 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)), "STA test did not complete."); if (failure != null) ExceptionDispatchInfo.Capture(failure).Throw(); } }