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 _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((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 Ticks_that_drain_after_the_operation_ended_do_not_resurrect_the_indicator() { // The frozen-spinner bug, measured in the running app: the UI thread is blocked for ~2.4s // while the main window is built. The tick timer fires on a threadpool thread meanwhile and // only *posts* its UI work, so three ticks queued up, End ran first — and the queued ticks // then set ShowIndicator back to true. The timer was already disposed, so nothing ever // cleared it again. A poster that queues instead of running stands in for that blocked // dispatcher. var time = new FakeTimeProvider(DateTimeOffset.UtcNow); var queued = new List(); var status = new OperationStatus(time, queued.Add); var op = status.Begin("Checking for updates…"); time.Advance(TimeSpan.FromSeconds(3)); // ticks fire, their UI work piles up unprocessed op.Dispose(); // the operation finishes before the queue drains Assert.False(status.ShowIndicator); foreach (var action in queued.ToArray()) action(); // dispatcher catches up Assert.False(status.ShowIndicator); Assert.False(status.IsRunning); } [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); } }