Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="MailKit" Version="4.17.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.*" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
Expand Down
36 changes: 29 additions & 7 deletions src/ControlMenu/Components/Shared/TransientNotice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ namespace ControlMenu.Components.Shared;
public sealed class TransientNotice : IDisposable
{
private readonly Func<Task> _onChange;
private readonly TimeProvider _timeProvider;
private CancellationTokenSource? _cts;

public TransientNotice(Func<Task> onChange) => _onChange = onChange;
/// <param name="onChange">Invoked after an auto-dismiss clears the message, so the owning
/// component can re-render.</param>
/// <param name="timeProvider">Clock backing the auto-dismiss delay. Defaults to
/// <see cref="TimeProvider.System"/>; tests pass a fake clock so they can land assertions
/// exactly on a dismiss deadline instead of racing real elapsed time.</param>
public TransientNotice(Func<Task> onChange, TimeProvider? timeProvider = null)
{
_onChange = onChange;
_timeProvider = timeProvider ?? TimeProvider.System;
}

public string? Message { get; private set; }
public string CssClass { get; private set; } = "";
Expand All @@ -31,13 +41,25 @@ public void Show(string message, string cssClass = "", string icon = "", int dis
_cts = new CancellationTokenSource();
var token = _cts.Token;

_ = Task.Delay(dismissMs, token).ContinueWith(async _ =>
_ = DismissAfterAsync(dismissMs, token);
}

private async Task DismissAfterAsync(int dismissMs, CancellationToken token)
{
try
{
Message = null;
CssClass = "";
Icon = "";
await _onChange();
}, token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
await Task.Delay(TimeSpan.FromMilliseconds(dismissMs), _timeProvider, token)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return; // a newer Show() or a Clear() superseded this timer
}

Message = null;
CssClass = "";
Icon = "";
await _onChange().ConfigureAwait(false);
}

/// <summary>Clears the message immediately and cancels/disposes any pending auto-dismiss.</summary>
Expand Down
69 changes: 56 additions & 13 deletions tests/ControlMenu.Tests/Components/TransientNoticeTests.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
using ControlMenu.Components.Shared;
using Microsoft.Extensions.Time.Testing;

namespace ControlMenu.Tests.Components;

public class TransientNoticeTests
{
/// <summary>
/// Builds a notice on a fake clock plus a signal that completes the first time the notice asks
/// its component to re-render. The auto-dismiss clears the message *before* invoking that
/// callback, so awaiting the signal is a deterministic "the dismiss has finished" handle — which
/// is what lets every test below drive time explicitly instead of racing real elapsed time.
/// </summary>
private static (TransientNotice Notice, Task Rerendered) NoticeOn(FakeTimeProvider time)
{
var rerendered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var notice = new TransientNotice(
() => { rerendered.TrySetResult(); return Task.CompletedTask; },
time);
return (notice, rerendered.Task);
}

/// <summary>
/// Asserts no auto-dismiss re-render arrives within a real grace window. A dismiss runs its
/// state change on a continuation, so a wrongly-surviving timer fires slightly *after* the
/// <see cref="FakeTimeProvider.Advance"/> that released it — asserting instantly would race
/// past the very bug these tests exist to catch. Waiting real time is safe here precisely
/// because the clock is fake: a correctly-cancelled timer never fires no matter how long we
/// wait, since fake time only moves when a test moves it.
/// </summary>
private static async Task AssertNoRerender(Task rerendered, string because)
{
var grace = Task.Delay(TimeSpan.FromMilliseconds(250));
Assert.False(await Task.WhenAny(rerendered, grace) == rerendered, because);
}

[Fact]
public void Show_SetsMessageClassIconAndVisible()
{
var notice = new TransientNotice(() => Task.CompletedTask);
var notice = new TransientNotice(() => Task.CompletedTask, new FakeTimeProvider());
notice.Show("hello", "status-success", "bi-check", dismissMs: 60_000);

Assert.Equal("hello", notice.Message);
Expand All @@ -19,47 +49,60 @@ public void Show_SetsMessageClassIconAndVisible()
[Fact]
public async Task AutoDismiss_ClearsMessageAndNotifies()
{
var changes = 0;
var notice = new TransientNotice(() => { Interlocked.Increment(ref changes); return Task.CompletedTask; });
notice.Show("bye", dismissMs: 80);
var time = new FakeTimeProvider();
var (notice, rerendered) = NoticeOn(time);

notice.Show("bye", dismissMs: 80);
Assert.True(notice.IsVisible);
await Task.Delay(250);

time.Advance(TimeSpan.FromMilliseconds(80));
await rerendered.WaitAsync(TimeSpan.FromSeconds(5));

Assert.Null(notice.Message);
Assert.False(notice.IsVisible);
Assert.True(changes >= 1, "auto-dismiss should notify the component to re-render");
}

[Fact]
public async Task Show_TwiceQuickly_LaterMessageSurvivesTheEarlierTimer()
{
// The bug this helper fixes: the settings pages started a Task.Delay timer with no CTS, so
// an earlier message's timer would fire and wipe a newer message. Show() must cancel the
// prior timer.
var notice = new TransientNotice(() => Task.CompletedTask);
// prior timer. The fake clock lands the assertions exactly on the two deadlines that matter,
// rather than sampling somewhere between them and hoping the scheduler cooperates.
var time = new FakeTimeProvider();
var (notice, rerendered) = NoticeOn(time);

notice.Show("first", dismissMs: 200);
await Task.Delay(100);
time.Advance(TimeSpan.FromMilliseconds(100));
notice.Show("second", dismissMs: 200); // restarts; the first 200ms timer must be cancelled

await Task.Delay(180); // ~280ms: past the first message's 200ms deadline, before the second's (~300ms)
// t=200ms — exactly when the *first* message's timer was due. It was cancelled, so the newer
// message must still be standing.
time.Advance(TimeSpan.FromMilliseconds(100));
await AssertNoRerender(rerendered, "the first message's timer was cancelled and must not fire");
Assert.Equal("second", notice.Message);

await Task.Delay(180); // ~460ms: past the second's deadline
// t=300ms — the second message's own deadline; now it clears.
time.Advance(TimeSpan.FromMilliseconds(100));
await rerendered.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Null(notice.Message);
}

[Fact]
public async Task Clear_RemovesMessageImmediatelyAndStopsTimer()
{
var notice = new TransientNotice(() => Task.CompletedTask);
var time = new FakeTimeProvider();
var (notice, rerendered) = NoticeOn(time);

notice.Show("x", dismissMs: 60_000);
notice.Clear();

Assert.Null(notice.Message);
Assert.False(notice.IsVisible);
await Task.Delay(50); // the long timer must not fire and clobber state later

// Advance far past the cancelled timer's deadline: it must never fire and clobber state.
time.Advance(TimeSpan.FromMinutes(5));
await AssertNoRerender(rerendered, "Clear() cancelled the timer, so it must never fire");
Assert.Null(notice.Message);
}
}
1 change: 1 addition & 0 deletions tests/ControlMenu.Tests/ControlMenu.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageReference Include="bunit" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="SkiaSharp" />
Expand Down
Loading