using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; // Phase 3 reconcile tick: a periodic, awaitable-for-tests diff of the flat `Items` master // collection against SQLite that patches properties in place. It must never rebuild a row and // never fall back to LoadForList — see // docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md. // It DOES Regroup, but only when a patch changed a grouping input: healing a row's data while // leaving it in the wrong section would fix nothing the user can see. public class TasksIslandReconcileTickTests : IDisposable { private readonly string _dbPath; public TasksIslandReconcileTickTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_reconcile_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); } public void Dispose() { try { File.Delete(_dbPath); } catch { } try { File.Delete(_dbPath + "-wal"); } catch { } try { File.Delete(_dbPath + "-shm"); } catch { } } private ClaudeDoDbContext NewContext() { var opts = new DbContextOptionsBuilder() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class CountingDbFactory : IDbContextFactory { private readonly Func _create; public int CreateCalls { get; private set; } public CountingDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() { CreateCalls++; return _create(); } } private static ListNavItemViewModel UserList(string listEntityId, string name) => new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list, int expectedCount = 1) { vm.LoadForList(list); var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { await Task.Delay(25); if (vm.Items.Count >= expectedCount) break; } await Task.Delay(50); } private async Task SeedListAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } private async Task SeedTaskAsync(string id, string title, TaskStatus status, int sortOrder) { await using var db = NewContext(); db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = id, ListId = "L1", Title = title, Status = status, CreatedAt = DateTime.UtcNow, SortOrder = sortOrder, }); await db.SaveChangesAsync(); } [Fact] public async Task Tick_patches_a_diverging_property() { await SeedListAsync(); await SeedTaskAsync("T1", "Original title", TaskStatus.Idle, 0); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work")); Assert.Equal("Original title", vm.Items.Single(r => r.Id == "T1").Title); await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Title = "Renamed elsewhere"; await db.SaveChangesAsync(); } await vm.ReconcileTickAsync(); Assert.Equal("Renamed elsewhere", vm.Items.Single(r => r.Id == "T1").Title); } [Fact] public async Task Tick_never_creates_a_new_row_instance() { await SeedListAsync(); await SeedTaskAsync("T1", "Task one", TaskStatus.Idle, 0); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work")); var rowBefore = vm.Items.Single(r => r.Id == "T1"); await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Status = TaskStatus.Running; await db.SaveChangesAsync(); } await vm.ReconcileTickAsync(); var rowAfter = vm.Items.Single(r => r.Id == "T1"); Assert.True(ReferenceEquals(rowBefore, rowAfter)); Assert.Equal(TaskStatus.Running, rowAfter.Status); } [Fact] public async Task Tick_with_no_changes_raises_no_property_changed() { await SeedListAsync(); await SeedTaskAsync("T1", "Task one", TaskStatus.Idle, 0); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work")); var row = vm.Items.Single(r => r.Id == "T1"); var changedProperties = new List(); row.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName); await vm.ReconcileTickAsync(); Assert.Empty(changedProperties); } [Fact] public async Task Tick_discards_a_stale_result_when_a_newer_refresh_wins_the_race() { await SeedListAsync(); await SeedTaskAsync("T1", "Task one", TaskStatus.Queued, 0); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work")); Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status); var barrierReached = new TaskCompletionSource(); var proceed = new TaskCompletionSource(); vm.ReconcileTickTestBarrier = async () => { barrierReached.TrySetResult(); await proceed.Task; }; // Tick reads "Queued" (nothing has changed yet), then parks at the barrier before // applying its (about to become stale) result. var tickTask = vm.ReconcileTickAsync(); await barrierReached.Task; // A fresher broadcast lands and wins: the DB flips to Running and the delta path applies // it with a newer sequence number while the tick is still parked. await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Status = TaskStatus.Running; await db.SaveChangesAsync(); } await vm.RefreshTaskFromWorkerAsync("T1"); Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); // Let the tick's stale "Queued" apply attempt run — the sequence guard must discard it. proceed.TrySetResult(); await tickTask; Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); } [Fact] public async Task Tick_caps_reconciliation_at_the_row_limit() { await SeedListAsync(); var total = TasksIslandViewModel.ReconcileRowCap + 1; for (var i = 0; i < total; i++) await SeedTaskAsync($"T{i}", $"Task {i}", TaskStatus.Queued, i); var factory = new CountingDbFactory(NewContext); var vm = new TasksIslandViewModel(factory, worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work"), expectedCount: total); Assert.Equal(total, vm.Items.Count); await using (var db = NewContext()) { await db.Tasks.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); } var createCallsBefore = factory.CreateCalls; await vm.ReconcileTickAsync(); Assert.Equal(createCallsBefore + 1, factory.CreateCalls); // exactly one DB query for the whole tick var reconciled = vm.Items.Count(r => r.Status == TaskStatus.Running); Assert.Equal(TasksIslandViewModel.ReconcileRowCap, reconciled); // The row beyond the cap (last one loaded, per SortOrder) must not have been touched. var lastRow = vm.Items.Single(r => r.Id == $"T{total - 1}"); Assert.Equal(TaskStatus.Queued, lastRow.Status); } [Fact] public async Task Tick_moves_a_healed_done_task_into_the_completed_section() { await SeedListAsync(); await SeedTaskAsync("T1", "Task one", TaskStatus.Idle, 0); await SeedTaskAsync("T2", "Task two", TaskStatus.Idle, 1); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work"), expectedCount: 2); vm.IsShowingCompleted = true; Assert.False(vm.HasCompleted); // The TaskUpdated broadcast goes missing: SQLite says Done, the row still reads open. await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); t.Status = TaskStatus.Done; await db.SaveChangesAsync(); } await vm.ReconcileTickAsync(); Assert.True(vm.HasCompleted); // Rows is the rendered order, so the healed row has to sit *under* the completed header // (the only one carrying an action command), not still be interleaved with the open rows. var rows = vm.Rows.ToList(); var headerIndex = rows.FindIndex(r => r is HeaderRow { HasAction: true }); var healedIndex = rows.FindIndex(r => r is TaskRowViewModel { Id: "T1" }); Assert.True(headerIndex >= 0, "completed header was never emitted"); Assert.True(healedIndex > headerIndex, $"healed row at {healedIndex}, completed header at {headerIndex}"); } [Fact] public async Task Tick_reorders_the_chain_when_a_depends_on_link_is_healed() { await SeedListAsync(); await SeedTaskAsync("HEAD", "Chain head", TaskStatus.Idle, 0); await SeedTaskAsync("MID", "Unrelated", TaskStatus.Idle, 1); await SeedTaskAsync("DEP", "Dependent", TaskStatus.Idle, 2); var vm = new TasksIslandViewModel(new CountingDbFactory(NewContext), worker: null); await LoadAndWaitAsync(vm, UserList("L1", "Work"), expectedCount: 3); Assert.Equal(new[] { "HEAD", "MID", "DEP" }, vm.Rows.OfType().Select(r => r.Id)); await using (var db = NewContext()) { var t = await db.Tasks.FirstAsync(x => x.Id == "DEP"); t.DependsOnTaskId = "HEAD"; await db.SaveChangesAsync(); } await vm.ReconcileTickAsync(); // The dependent is pulled directly under its head and gets the rail, not just the data. Assert.Equal(new[] { "HEAD", "DEP", "MID" }, vm.Rows.OfType().Select(r => r.Id)); Assert.True(vm.Items.Single(r => r.Id == "DEP").ShowAsChainMember); Assert.Equal(1, vm.Items.Single(r => r.Id == "DEP").ChainStep); } }