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; // OnWorkerTaskUpdated falls back to a full LoadForList reload for a planning parent (or any task // with children) because the delta path can't recompute the child-derived flags on its own. That // reload used to hand back brand new TaskRowViewModel instances, orphaning SelectedTask (and the // DetailsIslandViewModel bound to it) on a live status change. See TaskUpdated_FullReload_* below. public class TasksIslandFullReloadSelectionTests : IDisposable { private readonly string _dbPath; public TasksIslandFullReloadSelectionTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_fullreload_{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 TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class FakeWorker : StubWorkerClient { } private async Task SeedPlanningParentWithChildAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "P1", ListId = "L1", Title = "Parent", Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, CreatedAt = DateTime.UtcNow, SortOrder = 0, }); db.Tasks.Add(new TaskEntity { Id = "C1", ListId = "L1", ParentTaskId = "P1", Title = "Child", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1, }); await db.SaveChangesAsync(); } [Fact] public async Task TaskUpdated_FullReload_KeepsSameSelectedInstance() { await SeedPlanningParentWithChildAsync(); var worker = new FakeWorker(); var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker); vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User }); if (vm.LoadTask is { } lt) await lt; Assert.True(await vm.SelectByIdAsync("P1")); var selectedBefore = vm.SelectedTask; Assert.NotNull(selectedBefore); // Child finishes, which flips the parent's status — the broadcast lands for the parent's // id and (being a planning parent) takes the full-reload branch of OnWorkerTaskUpdated. await using (var db = NewContext()) { (await db.Tasks.FirstAsync(t => t.Id == "C1")).Status = TaskStatus.Done; (await db.Tasks.FirstAsync(t => t.Id == "P1")).Status = TaskStatus.WaitingForReview; await db.SaveChangesAsync(); } worker.RaiseTaskUpdated("P1"); var deadline = DateTime.UtcNow.AddSeconds(3); while (DateTime.UtcNow < deadline && vm.SelectedTask?.Status != TaskStatus.WaitingForReview) await Task.Delay(25); // The reload must not have replaced the row: SelectedTask still points at the same // instance, it just got mutated in place — so the detail pane stays bound and current. Assert.Same(selectedBefore, vm.SelectedTask); Assert.Equal(TaskStatus.WaitingForReview, vm.SelectedTask!.Status); Assert.Same(vm.SelectedTask, vm.Items.First(r => r.Id == "P1")); } }