The DB's global UtcConverter only tags DateTimes as Utc on read (SpecifyKind), it never converts on write. SetScheduledForAsync persisted the ThemedDatePicker's Local/Unspecified wall-clock value verbatim, so QueuePicker's comparison against DateTime.UtcNow fired scheduled tasks late by the local UTC offset (e.g. 2h in CEST). Convert to UTC at the write boundary, and ToLocalTime() at the read/compare sites (overdue checks in TaskRowViewModel/TasksIslandViewModel, the date-picker's edit seed) so existing scheduled/overdue display doesn't shift. Existing DB rows hold local wall-clock values mistagged as Utc; no migration added (few rows, one-time 2h-class shift accepted per the originating audit finding).
370 lines
13 KiB
C#
370 lines
13 KiB
C#
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<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
|
|
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
|
|
{
|
|
Number = TestTaskNumbers.Next(),
|
|
Id = parentId,
|
|
ListId = list.Id,
|
|
Title = "Parent",
|
|
CreatedAt = DateTime.UtcNow,
|
|
Status = parentStatus,
|
|
PlanningPhase = parentPhase,
|
|
SortOrder = 0,
|
|
});
|
|
db.Tasks.Add(new TaskEntity
|
|
{
|
|
Number = TestTaskNumbers.Next(),
|
|
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<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
|
|
{
|
|
Number = TestTaskNumbers.Next(),
|
|
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<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));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetScheduledForAsync_LocalDateTime_PersistsAsUtc()
|
|
{
|
|
await SeedTasksAsync(("t1", TaskStatus.Idle, null, 0));
|
|
|
|
var vm = BuildViewModel();
|
|
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
|
|
var row = vm.Items.First(r => r.Id == "t1");
|
|
|
|
// ThemedDatePicker hands SetScheduledForAsync Local/Unspecified wall-clock values.
|
|
var localWhen = DateTime.SpecifyKind(new DateTime(2026, 8, 26, 15, 0, 0), DateTimeKind.Local);
|
|
|
|
await vm.SetScheduledForAsync(row, localWhen);
|
|
|
|
await using var db = NewContext();
|
|
var entity = await db.Tasks.FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal(DateTimeKind.Utc, entity.ScheduledFor!.Value.Kind);
|
|
Assert.Equal(localWhen.ToUniversalTime(), entity.ScheduledFor!.Value);
|
|
}
|
|
}
|