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.
194 lines
6.3 KiB
C#
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);
|
|
}
|
|
}
|