The task-numbers slice added a UNIQUE index on tasks.number. Ui.Tests seed TaskEntity rows directly instead of going through TaskRepository, so every row kept the default Number = 0 and 31 tests failed with 'UNIQUE constraint failed: tasks.number'.
356 lines
13 KiB
C#
356 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 sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
public TestDbFactory(Func<ClaudeDoDbContext> 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
|
|
{
|
|
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));
|
|
}
|
|
}
|