using Bunit; using ScadaLink.CentralUI.Components.Shared; namespace ScadaLink.CentralUI.Tests.Shared; /// /// Regression tests for CentralUI-010. ToastNotification.AddToast /// scheduled Task.Delay(dismissMs).ContinueWith(...) with the result /// discarded; the continuation called InvokeAsync(StateHasChanged). When /// the host page is disposed before the delay elapses, the continuation ran /// against a disposed component and threw ObjectDisposedException on a /// thread-pool thread with no catch (an unobserved task exception). The fix /// holds a CancellationTokenSource cancelled in Dispose(). /// public class ToastNotificationTests : BunitContext { [Fact] public async Task ShowToast_AfterDisposal_IsNoOp_AndSchedulesNothing() { // Regression: the pre-fix AddToast always added the toast and scheduled // a Task.Delay continuation, even after Dispose() — the continuation // then ran InvokeAsync(StateHasChanged) against the disposed component. // The fix short-circuits AddToast once the disposal token is cancelled. var cut = Render(); await cut.InvokeAsync(() => cut.Instance.Dispose()); await cut.InvokeAsync(() => cut.Instance.ShowError("after dispose", autoDismissMs: 20)); Assert.Equal(0, cut.Instance.ToastCount); } [Fact] public async Task AutoDismiss_AfterDisposal_DoesNotThrowUnobservedException() { var unobserved = new List(); void Handler(object? s, UnobservedTaskExceptionEventArgs e) { unobserved.Add(e.Exception); e.SetObserved(); } TaskScheduler.UnobservedTaskException += Handler; try { var cut = Render(); // Auto-dismiss after a very short delay so the continuation is // guaranteed to fire well after we dispose the component. await cut.InvokeAsync(() => cut.Instance.ShowSuccess("hello", autoDismissMs: 20)); // Dispose the component while the auto-dismiss is still pending. await cut.InvokeAsync(() => cut.Instance.Dispose()); // Give the (now-cancelled) auto-dismiss well past its delay. await Task.Delay(250); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } finally { TaskScheduler.UnobservedTaskException -= Handler; } Assert.Empty(unobserved); } [Fact] public async Task AutoDismiss_BeforeDisposal_StillRemovesToast() { var cut = Render(); await cut.InvokeAsync(() => cut.Instance.ShowInfo("transient", autoDismissMs: 20)); // The toast is visible immediately. Assert.Contains("transient", cut.Markup); // After the dismiss delay it is removed (auto-dismiss still works). cut.WaitForAssertion( () => Assert.DoesNotContain("transient", cut.Markup), timeout: TimeSpan.FromSeconds(2)); } }