Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/Services/OperationStatusTests.cs
T
mika kuns 589b9e75f3 fix(ui): unfreeze the operation spinner and attribute rebind churn
Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
2026-08-12 13:50:05 +02:00

218 lines
7.6 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 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<Action>();
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);
}
}