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