The 4s reconcile tick called Apply(), which did Rows.Clear() + refill on every tick -- resetting the user's scroll position and selection while they're mid-read, exactly what the overlay exists to avoid. Reconcile granularly instead: find the overlap between the previous and latest snapshot (the ring buffer is append-mostly, evicting from the front as entries age out of the 30-min window), trim rows for evicted entries off the tail, and insert new entries at the head. A tick with no change now leaves Rows untouched. The WarnErrorOnly toggle still goes through the full Apply() rebuild, unchanged.
240 lines
8.5 KiB
C#
240 lines
8.5 KiB
C#
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
public class LogVisualizerViewModelTests
|
|
{
|
|
private sealed class FakeClient : StubWorkerClient
|
|
{
|
|
private readonly IReadOnlyList<WorkerLogEntry> _logs;
|
|
public FakeClient(IReadOnlyList<WorkerLogEntry> logs) => _logs = logs;
|
|
public override Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult(_logs);
|
|
}
|
|
|
|
// Simulates the worker's append-mostly LogRingBuffer across successive ticks: each
|
|
// RefreshAsync call returns whatever `Logs` currently holds.
|
|
private sealed class MutableFakeClient : StubWorkerClient
|
|
{
|
|
public IReadOnlyList<WorkerLogEntry> Logs { get; set; } = Array.Empty<WorkerLogEntry>();
|
|
public override Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult(Logs);
|
|
}
|
|
|
|
private static WorkerLogEntry E(WorkerLogLevel lvl, string msg)
|
|
=> new(msg, lvl, new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc));
|
|
|
|
private static WorkerLogEntry E(WorkerLogLevel lvl, string msg, DateTime timestampUtc)
|
|
=> new(msg, lvl, timestampUtc);
|
|
|
|
[Fact]
|
|
public async Task Refresh_populates_rows_from_worker_newest_first()
|
|
{
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a"), E(WorkerLogLevel.Error, "b") }));
|
|
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WarnErrorOnly_filters_out_info()
|
|
{
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[]
|
|
{ E(WorkerLogLevel.Info, "a"), E(WorkerLogLevel.Warn, "w"), E(WorkerLogLevel.Error, "e") }));
|
|
await vm.RefreshAsync();
|
|
|
|
vm.WarnErrorOnly = true;
|
|
|
|
Assert.Equal(new[] { "e", "w" }, vm.Rows.Select(r => r.Message));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_logs_yield_no_rows_and_a_status()
|
|
{
|
|
var vm = new LogVisualizerViewModel(new FakeClient(Array.Empty<WorkerLogEntry>()));
|
|
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.Empty(vm.Rows);
|
|
Assert.False(string.IsNullOrEmpty(vm.StatusText));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_orders_newest_entry_first()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[]
|
|
{
|
|
E(WorkerLogLevel.Info, "oldest", t0),
|
|
E(WorkerLogLevel.Info, "middle", t0.AddMinutes(1)),
|
|
E(WorkerLogLevel.Info, "newest", t0.AddMinutes(2)),
|
|
}));
|
|
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.Equal(new[] { "newest", "middle", "oldest" }, vm.Rows.Select(r => r.Message));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Refresh_orders_newest_entry_first_with_warn_error_only()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[]
|
|
{
|
|
E(WorkerLogLevel.Warn, "oldest", t0),
|
|
E(WorkerLogLevel.Info, "skipped", t0.AddMinutes(1)),
|
|
E(WorkerLogLevel.Error, "middle", t0.AddMinutes(2)),
|
|
E(WorkerLogLevel.Warn, "newest", t0.AddMinutes(3)),
|
|
}));
|
|
await vm.RefreshAsync();
|
|
|
|
vm.WarnErrorOnly = true;
|
|
|
|
Assert.Equal(new[] { "newest", "middle", "oldest" }, vm.Rows.Select(r => r.Message));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLast_orders_chronologically_oldest_first()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[]
|
|
{
|
|
E(WorkerLogLevel.Info, "oldest", t0),
|
|
E(WorkerLogLevel.Warn, "middle", t0.AddMinutes(1)),
|
|
E(WorkerLogLevel.Error, "newest", t0.AddMinutes(2)),
|
|
}));
|
|
await vm.RefreshAsync();
|
|
|
|
var text = vm.BuildCopyText();
|
|
|
|
var lines = text.Split('\n');
|
|
Assert.Equal(3, lines.Length);
|
|
Assert.EndsWith("INFO oldest", lines[0]);
|
|
Assert.EndsWith("WARN middle", lines[1]);
|
|
Assert.EndsWith("ERROR newest", lines[2]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLast_caps_at_40_most_recent_entries()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var entries = Enumerable.Range(0, 50)
|
|
.Select(i => E(WorkerLogLevel.Info, $"line{i}", t0.AddMinutes(i)))
|
|
.ToArray();
|
|
var vm = new LogVisualizerViewModel(new FakeClient(entries));
|
|
await vm.RefreshAsync();
|
|
|
|
var lines = vm.BuildCopyText().Split('\n');
|
|
|
|
Assert.Equal(40, lines.Length);
|
|
Assert.EndsWith("line10", lines[0]);
|
|
Assert.EndsWith("line49", lines[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLast_respects_active_warn_error_filter()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[]
|
|
{
|
|
E(WorkerLogLevel.Info, "skipped", t0),
|
|
E(WorkerLogLevel.Warn, "kept", t0.AddMinutes(1)),
|
|
}));
|
|
await vm.RefreshAsync();
|
|
vm.WarnErrorOnly = true;
|
|
|
|
var lines = vm.BuildCopyText().Split('\n');
|
|
|
|
Assert.Single(lines);
|
|
Assert.EndsWith("WARN kept", lines[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLastCommand_disabled_when_no_rows()
|
|
{
|
|
var vm = new LogVisualizerViewModel(new FakeClient(Array.Empty<WorkerLogEntry>()));
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.False(vm.CopyLastCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLastCommand_copies_and_reports_count_on_success()
|
|
{
|
|
string? copied = null;
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }),
|
|
text => { copied = text; return Task.FromResult(true); });
|
|
await vm.RefreshAsync();
|
|
|
|
await vm.CopyLastCommand.ExecuteAsync(null);
|
|
|
|
Assert.EndsWith("INFO a", copied);
|
|
Assert.Contains("1", vm.StatusText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CopyLastCommand_reports_failure_when_clipboard_unavailable()
|
|
{
|
|
var vm = new LogVisualizerViewModel(new FakeClient(new[] { E(WorkerLogLevel.Info, "a") }),
|
|
_ => Task.FromResult(false));
|
|
await vm.RefreshAsync();
|
|
|
|
await vm.CopyLastCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(string.IsNullOrEmpty(vm.StatusText));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tick_with_no_new_data_does_not_touch_rows()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var client = new MutableFakeClient { Logs = new[] { E(WorkerLogLevel.Info, "a", t0) } };
|
|
var vm = new LogVisualizerViewModel(client);
|
|
await vm.RefreshAsync();
|
|
var row = vm.Rows.Single();
|
|
|
|
var changeCount = 0;
|
|
vm.Rows.CollectionChanged += (_, _) => changeCount++;
|
|
await vm.RefreshAsync(); // same snapshot on the next tick
|
|
|
|
Assert.Equal(0, changeCount);
|
|
Assert.Same(row, vm.Rows.Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tick_appends_a_new_entry_without_clearing_existing_rows()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var client = new MutableFakeClient { Logs = new[] { E(WorkerLogLevel.Info, "a", t0) } };
|
|
var vm = new LogVisualizerViewModel(client);
|
|
await vm.RefreshAsync();
|
|
var existingRow = vm.Rows.Single();
|
|
|
|
client.Logs = new[] { E(WorkerLogLevel.Info, "a", t0), E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)) };
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message));
|
|
Assert.Same(existingRow, vm.Rows[1]); // the pre-existing row instance survived the tick
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tick_drops_the_row_for_an_entry_that_aged_out_of_the_window()
|
|
{
|
|
var t0 = new DateTime(2026, 6, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
var client = new MutableFakeClient
|
|
{
|
|
Logs = new[] { E(WorkerLogLevel.Info, "a", t0), E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)) },
|
|
};
|
|
var vm = new LogVisualizerViewModel(client);
|
|
await vm.RefreshAsync();
|
|
Assert.Equal(new[] { "b", "a" }, vm.Rows.Select(r => r.Message));
|
|
|
|
// "a" fell out of the ring buffer's 30-min window; "c" is a new entry.
|
|
client.Logs = new[] { E(WorkerLogLevel.Info, "b", t0.AddMinutes(1)), E(WorkerLogLevel.Info, "c", t0.AddMinutes(2)) };
|
|
await vm.RefreshAsync();
|
|
|
|
Assert.Equal(new[] { "c", "b" }, vm.Rows.Select(r => r.Message));
|
|
}
|
|
}
|