fix(central-ui): resolve CentralUI-007..014 — nav authz, UTC date filters, disposal guards, N+1 fix, async script analysis
This commit is contained in:
60
tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs
Normal file
60
tests/ScadaLink.CentralUI.Tests/Shared/DiffDialogTests.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using Bunit;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-011. <c>DiffDialog.OpenAsync</c> returns the
|
||||
/// <c>TaskCompletionSource</c>'s task, completed only by <c>Close()</c>. If the
|
||||
/// user navigated away while the dialog was open, <c>DisposeAsync</c> ran but
|
||||
/// never completed the TCS — the awaiting caller was suspended forever and any
|
||||
/// cleanup after the await was skipped. The fix completes the TCS in
|
||||
/// <c>DisposeAsync</c>.
|
||||
/// </summary>
|
||||
public class DiffDialogTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
|
||||
{
|
||||
var cut = Render<DiffDialog>();
|
||||
|
||||
// Open the dialog; the returned task represents the caller's await.
|
||||
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
|
||||
// await the dialog's own (deliberately long-lived) task.
|
||||
Task<bool> pending = null!;
|
||||
await cut.InvokeAsync(
|
||||
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
|
||||
|
||||
Assert.False(pending.IsCompleted, "Dialog task should be pending while open.");
|
||||
|
||||
// Simulate navigating away while the dialog is still open.
|
||||
await cut.InvokeAsync(async () => await cut.Instance.DisposeAsync());
|
||||
|
||||
// The awaiter must complete deterministically rather than hang forever.
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
Assert.True(pending.IsCompletedSuccessfully);
|
||||
var result = await pending;
|
||||
Assert.False(result, "Dismiss-on-dispose should resolve to false (not confirmed).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Close_CompletesPendingTaskWithTrue()
|
||||
{
|
||||
var cut = Render<DiffDialog>();
|
||||
|
||||
// Block-bodied lambda so InvokeAsync sees a void delegate — it must NOT
|
||||
// await the dialog's own (deliberately long-lived) task.
|
||||
Task<bool> pending = null!;
|
||||
await cut.InvokeAsync(
|
||||
() => { pending = cut.Instance.ShowAsync("Compare", "before", "after"); });
|
||||
|
||||
// Closing via the Close button completes the task with true.
|
||||
await cut.InvokeAsync(() => cut.Find("button.btn-secondary").Click());
|
||||
|
||||
var completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(pending, completed);
|
||||
var result = await pending;
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Bunit;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for CentralUI-010. <c>ToastNotification.AddToast</c>
|
||||
/// scheduled <c>Task.Delay(dismissMs).ContinueWith(...)</c> with the result
|
||||
/// discarded; the continuation called <c>InvokeAsync(StateHasChanged)</c>. When
|
||||
/// the host page is disposed before the delay elapses, the continuation ran
|
||||
/// against a disposed component and threw <c>ObjectDisposedException</c> on a
|
||||
/// thread-pool thread with no catch (an unobserved task exception). The fix
|
||||
/// holds a <c>CancellationTokenSource</c> cancelled in <c>Dispose()</c>.
|
||||
/// </summary>
|
||||
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<ToastNotification>();
|
||||
|
||||
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<Exception>();
|
||||
void Handler(object? s, UnobservedTaskExceptionEventArgs e)
|
||||
{
|
||||
unobserved.Add(e.Exception);
|
||||
e.SetObserved();
|
||||
}
|
||||
TaskScheduler.UnobservedTaskException += Handler;
|
||||
try
|
||||
{
|
||||
var cut = Render<ToastNotification>();
|
||||
// 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<ToastNotification>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user