Merge claudedo/923d3b07183c4ca8bcd15aed12f9df39

This commit is contained in:
mika kuns
2026-08-10 16:21:01 +02:00
6 changed files with 369 additions and 139 deletions
@@ -1,3 +1,4 @@
using System.Collections.Specialized;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
@@ -84,6 +85,49 @@ public class TasksIslandRegroupTests : IDisposable
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<TaskRowViewModel>().Any(r => r.Id == taskId && SectionOf(vm, r) == Section.Completed);
private static bool IsInOpen(TasksIslandViewModel vm, string taskId, bool requireChild = false) =>
vm.Rows.OfType<TaskRowViewModel>().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 };
@@ -171,10 +215,10 @@ public class TasksIslandRegroupTests : IDisposable
// 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 go to CompletedItems
Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1");
// Child should appear nested (IsChild == true) in OpenItems
Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild);
// 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]
@@ -189,7 +233,120 @@ public class TasksIslandRegroupTests : IDisposable
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
Assert.Contains(vm.CompletedItems, r => r.Id == "p1");
Assert.Contains(vm.CompletedItems, r => r.Id == "c1");
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<HeaderRow>(vm.Rows[0]);
Assert.True(overdueHeader.IsOverdue);
Assert.Equal(1, overdueHeader.Count);
Assert.Equal("overdue1", Assert.IsType<TaskRowViewModel>(vm.Rows[1]).Id);
// ShowOpenLabel is true here because Overdue is also present — the Open header IS emitted.
var openHeader = Assert.IsType<HeaderRow>(vm.Rows[2]);
Assert.False(openHeader.IsOverdue);
Assert.False(openHeader.HasAction);
Assert.Equal(1, openHeader.Count);
Assert.Equal("open1", Assert.IsType<TaskRowViewModel>(vm.Rows[3]).Id);
var completedHeader = Assert.IsType<HeaderRow>(vm.Rows[4]);
Assert.True(completedHeader.HasAction);
Assert.Equal(1, completedHeader.Count);
Assert.Equal("completed1", Assert.IsType<TaskRowViewModel>(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<TaskRowViewModel>(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<HeaderRow>(), h => h.HasAction);
Assert.DoesNotContain(vm.Rows.OfType<TaskRowViewModel>(), 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<NotifyCollectionChangedAction>();
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<TaskRowViewModel>().Select(r => r.Id));
}
}
@@ -211,6 +211,19 @@ public class TasksIslandViewModelPlanningTests
PlanningPhase phase = PlanningPhase.None)
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
// vm.Rows is the flat HeaderRow|TaskRowViewModel union — a row is "in the Open section" when
// it's present in Rows and the nearest preceding HeaderRow (if any) isn't Overdue/Completed.
// HasAction uniquely identifies the Completed header (its Clear-completed button).
private static bool IsVisibleInOpen(TasksIslandViewModel vm, TaskRowViewModel row)
{
var idx = vm.Rows.IndexOf(row);
if (idx < 0) return false;
for (int i = idx - 1; i >= 0; i--)
if (vm.Rows[i] is HeaderRow h)
return !h.IsOverdue && !h.HasAction;
return true;
}
[Fact]
public void PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
{
@@ -221,16 +234,16 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child1, child2]);
// Collapsed by default — children hidden, parent still present
Assert.DoesNotContain(child1, vm.OpenItems);
Assert.DoesNotContain(child2, vm.OpenItems);
Assert.Contains(parent, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child1));
Assert.False(IsVisibleInOpen(vm, child2));
Assert.True(IsVisibleInOpen(vm, parent));
// Expand the parent
vm.ToggleExpandCommand.Execute(parent);
// Children now visible
Assert.Contains(child1, vm.OpenItems);
Assert.Contains(child2, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, child1));
Assert.True(IsVisibleInOpen(vm, child2));
}
[Fact]
@@ -268,15 +281,15 @@ public class TasksIslandViewModelPlanningTests
var (vm, _) = VmFactory.Create([parent, child]);
// Collapsed by default
Assert.DoesNotContain(child, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child));
// Expand
vm.ToggleExpandCommand.Execute(parent);
Assert.Contains(child, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, child));
// Collapse again
vm.ToggleExpandCommand.Execute(parent);
Assert.DoesNotContain(child, vm.OpenItems);
Assert.False(IsVisibleInOpen(vm, child));
}
[Fact]
@@ -291,7 +304,7 @@ public class TasksIslandViewModelPlanningTests
Assert.False(orphan.ParentInView);
Assert.False(orphan.ShowAsChild);
Assert.False(orphan.IsDraft);
Assert.Contains(orphan, vm.OpenItems);
Assert.True(IsVisibleInOpen(vm, orphan));
}
[Fact]