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.
This commit is contained in:
@@ -179,6 +179,30 @@ public class OperationStatusTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -35,6 +35,60 @@ public class OperationTimingTests
|
||||
Assert.Equal("TasksIsland.LoadForList", root2.GetProperty("op").GetString());
|
||||
Assert.Equal(7, root2.GetProperty("ms").GetInt64());
|
||||
Assert.False(root2.GetProperty("ok").GetBoolean());
|
||||
|
||||
// Runs of the app interleave in one file — without the pid a restart is
|
||||
// indistinguishable from concurrent work inside one instance.
|
||||
Assert.Equal(Environment.ProcessId, root1.GetProperty("pid").GetInt32());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_DropsFastSuccessesButKeepsEveryFailure()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
|
||||
var path = Path.Combine(dir, "operation-timing.ndjson");
|
||||
var sink = new OperationTiming(path, minMs: 25);
|
||||
|
||||
try
|
||||
{
|
||||
sink.Record("db", "Fast", TimeSpan.FromMilliseconds(3), ok: true); // noise
|
||||
sink.Record("db", "Cancelled", TimeSpan.FromMilliseconds(1), ok: false); // churn signal
|
||||
sink.Record("db", "Slow", TimeSpan.FromMilliseconds(25), ok: true); // boundary: kept
|
||||
|
||||
var ops = File.ReadAllLines(path)
|
||||
.Select(l => JsonDocument.Parse(l).RootElement.GetProperty("op").GetString())
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(new[] { "Cancelled", "Slow" }, ops);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_RollsTheFileOverOnceItPassesTheSizeCap()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
|
||||
var path = Path.Combine(dir, "operation-timing.ndjson");
|
||||
var sink = new OperationTiming(path, maxBytes: 200);
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 12; i++)
|
||||
sink.Record("db", "Op" + i, TimeSpan.FromMilliseconds(100), ok: true);
|
||||
|
||||
Assert.True(File.Exists(path + ".1"), "expected one rolled-over file");
|
||||
Assert.True(new FileInfo(path).Length < 200 + 200, "live file should have been truncated by the roll");
|
||||
|
||||
// The newest line survives the roll — rotation must never eat the current write.
|
||||
var last = File.ReadAllLines(path)[^1];
|
||||
Assert.Equal("Op11", JsonDocument.Parse(last).RootElement.GetProperty("op").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user