Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/Services/OperationStatusTests.cs
T
Mika Kuns ca65849280 feat(ui): add OperationStatus feedback primitive and OperationIndicator control
Foundation for long-running-operation feedback (P0-1). OperationStatus tracks
IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report)
via an injectable TimeProvider, no static timer. OperationIndicator binds a
status to the shared spinner style. Pre-provisions ops.* locale keys for
groups A and C; no ViewModel wired up yet.
2026-08-12 08:31:38 +02:00

194 lines
6.3 KiB
C#

using System.Threading;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests.Services;
public class OperationStatusTests
{
// Minimal hand-rolled fake: TimeProvider's default CreateTimer schedules against the real
// wall clock regardless of a GetUtcNow() override, so a usable fake must implement its own
// timer queue and fire callbacks synchronously from Advance().
private sealed class FakeTimeProvider : TimeProvider
{
private readonly List<FakeTimer> _timers = new();
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset start) => _utcNow = start;
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan by)
{
var target = _utcNow + by;
while (true)
{
FakeTimer? next = null;
foreach (var timer in _timers)
{
if (timer.NextDue is not { } due || due > target) continue;
if (next is null || due < next.NextDue) next = timer;
}
if (next is null) break;
_utcNow = next.NextDue!.Value;
next.Fire();
}
_utcNow = target;
}
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new FakeTimer(this, callback, state, dueTime, period);
_timers.Add(timer);
return timer;
}
private sealed class FakeTimer : ITimer
{
private readonly FakeTimeProvider _owner;
private readonly TimerCallback _callback;
private readonly object? _state;
private TimeSpan _period;
public DateTimeOffset? NextDue { get; private set; }
public FakeTimer(FakeTimeProvider owner, TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
_owner = owner;
_callback = callback;
_state = state;
_period = period;
NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : owner._utcNow + dueTime;
}
public void Fire()
{
_callback(_state);
NextDue = _period <= TimeSpan.Zero || _period == Timeout.InfiniteTimeSpan
? null
: NextDue + _period;
}
public bool Change(TimeSpan dueTime, TimeSpan period)
{
_period = period;
NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : _owner._utcNow + dueTime;
return true;
}
public void Dispose() => _owner._timers.Remove(this);
public ValueTask DisposeAsync()
{
Dispose();
return ValueTask.CompletedTask;
}
}
}
// Production posts the threadpool timer callback via Dispatcher.UIThread.Post; tests run
// without a bootstrapped Avalonia dispatcher, so the callback is invoked synchronously instead.
private static OperationStatus Create(FakeTimeProvider time) => new(time, action => action());
[Fact]
public void Begin_sets_IsRunning_immediately_and_ShowIndicator_after_grace_period()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
using var op = status.Begin("Working…");
Assert.True(status.IsRunning);
Assert.False(status.ShowIndicator);
time.Advance(TimeSpan.FromMilliseconds(300));
Assert.True(status.ShowIndicator);
}
[Fact]
public void ShowIndicator_stays_false_when_the_operation_ends_before_grace_period()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
var op = status.Begin("Working…");
time.Advance(TimeSpan.FromMilliseconds(200));
op.Dispose();
time.Advance(TimeSpan.FromMilliseconds(200)); // past the original 300ms grace mark
Assert.False(status.ShowIndicator);
Assert.False(status.IsRunning);
}
[Fact]
public void IsStalled_measures_from_the_last_Report_not_from_Begin()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
using var op = status.Begin("Working…");
time.Advance(TimeSpan.FromSeconds(50));
Assert.False(status.IsStalled);
status.Report("Still working…");
time.Advance(TimeSpan.FromSeconds(59)); // 109s total, only 59s since the Report
Assert.False(status.IsStalled);
time.Advance(TimeSpan.FromSeconds(2)); // 111s total, 61s since the Report
Assert.True(status.IsStalled);
}
[Fact]
public void No_report_at_all_stalls_60_seconds_after_Begin()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
using var op = status.Begin("Working…");
time.Advance(TimeSpan.FromSeconds(59));
Assert.False(status.IsStalled);
time.Advance(TimeSpan.FromSeconds(2));
Assert.True(status.IsStalled);
}
[Fact]
public void Elapsed_is_formatted_as_mm_ss()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
using var op = status.Begin("Working…");
time.Advance(TimeSpan.FromMilliseconds(74_300)); // lands exactly on a tick
Assert.Equal("01:14", status.Elapsed);
}
[Fact]
public void Dispose_ends_the_operation_even_when_the_using_block_throws()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
Assert.Throws<InvalidOperationException>((Action)(() =>
{
using var op = status.Begin("Working…");
time.Advance(TimeSpan.FromMilliseconds(300));
throw new InvalidOperationException("boom");
}));
Assert.False(status.IsRunning);
Assert.False(status.ShowIndicator);
}
[Fact]
public void Report_overwrites_the_label()
{
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var status = Create(time);
using var op = status.Begin("Merging…");
status.Report("Verifying…");
Assert.Equal("Verifying…", status.Label);
}
}