using System.Collections.Specialized; 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; public class TasksIslandRegroupTests : IDisposable { private readonly string _dbPath; public TasksIslandRegroupTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_test_{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 TasksIslandViewModel BuildViewModel() { var factory = new TestDbFactory(NewContext); return new TasksIslandViewModel(factory, worker: null); } private async Task SeedPlanningWithChildAsync( TaskStatus parentStatus, TaskStatus childStatus, string parentId = "p1", string childId = "c1", PlanningPhase parentPhase = PlanningPhase.None) { await using var db = NewContext(); var list = new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow, }; db.Lists.Add(list); db.Tasks.Add(new TaskEntity { Id = parentId, ListId = list.Id, Title = "Parent", CreatedAt = DateTime.UtcNow, Status = parentStatus, PlanningPhase = parentPhase, SortOrder = 0, }); db.Tasks.Add(new TaskEntity { Id = childId, ListId = list.Id, Title = "Child", CreatedAt = DateTime.UtcNow, Status = childStatus, ParentTaskId = parentId, SortOrder = 1, }); await db.SaveChangesAsync(); } // Rows is the flat HeaderRow|TaskRowViewModel union — a row's section is read off the // nearest preceding HeaderRow, mirroring TasksIslandViewModel.SectionFor. HasAction is // unique to the Completed header (its Clear-completed button), so it disambiguates // Completed from the (also non-overdue) Open header without depending on locale text. private enum Section { None, Overdue, Open, Completed } private static Section SectionOf(TasksIslandViewModel vm, TaskRowViewModel row) { var idx = vm.Rows.IndexOf(row); if (idx < 0) return Section.None; for (int i = idx - 1; i >= 0; i--) if (vm.Rows[i] is HeaderRow h) return h.IsOverdue ? Section.Overdue : h.HasAction ? Section.Completed : Section.Open; return Section.Open; } private static bool IsInCompleted(TasksIslandViewModel vm, string taskId) => vm.Rows.OfType().Any(r => r.Id == taskId && SectionOf(vm, r) == Section.Completed); private static bool IsInOpen(TasksIslandViewModel vm, string taskId, bool requireChild = false) => vm.Rows.OfType().Any(r => r.Id == taskId && (!requireChild || r.IsChild) && SectionOf(vm, r) == Section.Open); private async Task SeedTasksAsync(params (string Id, TaskStatus Status, DateTime? ScheduledFor, int SortOrder)[] tasks) { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow }); foreach (var t in tasks) { db.Tasks.Add(new TaskEntity { Id = t.Id, ListId = "list1", Title = t.Id, CreatedAt = DateTime.UtcNow, Status = t.Status, ScheduledFor = t.ScheduledFor, SortOrder = t.SortOrder, }); } await db.SaveChangesAsync(); } private static ListNavItemViewModel VirtualList(string id, string name) => new() { Id = id, Kind = ListKind.Virtual, Name = name }; 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) { vm.LoadForList(list); // LoadForList fires a background Task; wait briefly until Items are populated // or until a timeout occurs (some tests may legitimately expect 0 items, so // we just wait a short deterministic period). var deadline = DateTime.UtcNow.AddSeconds(5); while (DateTime.UtcNow < deadline) { await Task.Delay(25); // Break out as soon as any Items present, or the background task has settled. if (vm.Items.Count > 0) break; } // One more tick for Regroup after load await Task.Delay(50); } [Fact] public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Queued, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, VirtualList("virtual:queued", "Queued")); Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); } [Fact] public async Task VirtualQueued_PlannedParentWithQueuedChild_ParentIsStandaloneRow_ChildIsNot() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Finalized, childStatus: TaskStatus.Queued, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, VirtualList("virtual:queued", "Queued")); Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); } [Fact] public async Task VirtualRunning_RunningChildOfPlanningParent_IsNotStandaloneRow() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Running, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, VirtualList("virtual:running", "Running")); Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); } [Fact] public async Task Done_ChildOfOpenPlanningParent_StaysNestedUnderParent() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Done, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); // Parents with children collapse by default; expand to surface the nested child. vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1")); // Child with Done status under an open Planning parent should NOT land in the Completed section Assert.False(IsInCompleted(vm, "c1")); // Child should appear nested (IsChild == true) in the Open section Assert.True(IsInOpen(vm, "c1", requireChild: true)); } [Fact] public async Task Done_ChildOfDonePlanningParent_MovesToCompleted() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Done, childStatus: TaskStatus.Done, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); Assert.True(IsInCompleted(vm, "p1")); Assert.True(IsInCompleted(vm, "c1")); } [Fact] public async Task Regroup_Rows_HeaderPositionsAndCountsMatchSections() { var yesterday = DateTime.Today.AddDays(-1); await SeedTasksAsync( ("overdue1", TaskStatus.Idle, yesterday, 0), ("open1", TaskStatus.Idle, null, 1), ("completed1", TaskStatus.Done, null, 2)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); Assert.Equal(6, vm.Rows.Count); var overdueHeader = Assert.IsType(vm.Rows[0]); Assert.True(overdueHeader.IsOverdue); Assert.Equal(1, overdueHeader.Count); Assert.Equal("overdue1", Assert.IsType(vm.Rows[1]).Id); // ShowOpenLabel is true here because Overdue is also present — the Open header IS emitted. var openHeader = Assert.IsType(vm.Rows[2]); Assert.False(openHeader.IsOverdue); Assert.False(openHeader.HasAction); Assert.Equal(1, openHeader.Count); Assert.Equal("open1", Assert.IsType(vm.Rows[3]).Id); var completedHeader = Assert.IsType(vm.Rows[4]); Assert.True(completedHeader.HasAction); Assert.Equal(1, completedHeader.Count); Assert.Equal("completed1", Assert.IsType(vm.Rows[5]).Id); } [Fact] public async Task Regroup_EmptySections_EmitNoHeader() { // Open-only: no Overdue means ShowOpenLabel is false too, so the Open header is never // emitted either — Rows should hold exactly the one task row, nothing else. await SeedTasksAsync(("open1", TaskStatus.Idle, null, 0)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); Assert.False(vm.HasOverdue); Assert.False(vm.HasCompleted); Assert.False(vm.ShowOpenLabel); var row = Assert.Single(vm.Rows); Assert.Equal("open1", Assert.IsType(row).Id); } [Fact] public async Task Regroup_CompletedSection_HiddenWhileIsShowingCompletedFalse() { await SeedTasksAsync( ("open1", TaskStatus.Idle, null, 0), ("completed1", TaskStatus.Done, null, 1)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); Assert.True(IsInCompleted(vm, "completed1")); vm.IsShowingCompleted = false; Assert.DoesNotContain(vm.Rows.OfType(), h => h.HasAction); Assert.DoesNotContain(vm.Rows.OfType(), r => r.Id == "completed1"); // HasCompleted reflects the underlying data, independent of the toggle. Assert.True(vm.HasCompleted); vm.IsShowingCompleted = true; Assert.True(IsInCompleted(vm, "completed1")); } [Fact] public async Task Rows_ParentExpandCollapse_ReconcilesGranularly_NoResetEvent() { await SeedPlanningWithChildAsync( parentStatus: TaskStatus.Idle, parentPhase: PlanningPhase.Active, childStatus: TaskStatus.Idle, parentId: "p1", childId: "c1"); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var actions = new List(); vm.Rows.CollectionChanged += (_, e) => actions.Add(e.Action); vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1")); vm.ToggleExpandCommand.Execute(vm.Items.First(r => r.Id == "p1")); Assert.NotEmpty(actions); Assert.DoesNotContain(NotifyCollectionChangedAction.Reset, actions); } [Fact] public async Task ReorderAsync_WithinOpenSection_MovesRowInItemsAndRows() { await SeedTasksAsync( ("t1", TaskStatus.Idle, null, 0), ("t2", TaskStatus.Idle, null, 1), ("t3", TaskStatus.Idle, null, 2)); var vm = BuildViewModel(); await LoadAndWaitAsync(vm, UserList("list1", "Default")); var t1 = vm.Items.First(r => r.Id == "t1"); var t3 = vm.Items.First(r => r.Id == "t3"); await vm.ReorderAsync(t1, t3, placeBelow: true); Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Items.Select(r => r.Id)); Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Rows.OfType().Select(r => r.Id)); } }