using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Tests; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Worker.Tests.Infrastructure; using CommunityToolkit.Mvvm.Input; using Microsoft.EntityFrameworkCore; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.UiVm; // ── Fake worker client ──────────────────────────────────────────────────────── sealed class FakeWorkerClient : StubWorkerClient; // ── Helper to build VM with pre-seeded Items ────────────────────────────────── file static class VmFactory { // Minimal SQLite :memory: factory — never actually called in these tests // (we seed Items directly), but required by the VM constructor. private static IDbContextFactory NullDbFactory() { var opts = new DbContextOptionsBuilder() .UseSqlite("DataSource=:memory:") .Options; return new NullDbContextFactory(opts); } private sealed class NullDbContextFactory(DbContextOptions opts) : IDbContextFactory { public ClaudeDoDbContext CreateDbContext() => new(opts); } public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create( IEnumerable rows) { var worker = new FakeWorkerClient(); var vm = new TasksIslandViewModel(NullDbFactory(), worker); foreach (var r in rows) vm.Items.Add(r); vm.Regroup(); return (vm, worker); } } // ── Tests ───────────────────────────────────────────────────────────────────── public class TasksIslandViewModelPlanningTests { private static TaskRowViewModel MakeRow( string id, TaskStatus status, string? parentId = null, int sortOrder = 0, 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() { var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active); var child1 = MakeRow("c1", TaskStatus.Idle, "p1"); var child2 = MakeRow("c2", TaskStatus.Idle, "p1"); var (vm, _) = VmFactory.Create([parent, child1, child2]); // Collapsed by default — children hidden, parent still present 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.True(IsVisibleInOpen(vm, child1)); Assert.True(IsVisibleInOpen(vm, child2)); } [Fact] public void OpenPlanningSession_IgnoresNonIdleRow() { var row = MakeRow("t1", TaskStatus.Queued); var (vm, _) = VmFactory.Create([row]); (string TaskId, bool Resume)? raised = null; vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume); vm.OpenPlanningSessionCommand.Execute(row); Assert.Null(raised); } [Fact] public void OpenPlanningSession_RequestsConPtyPaneForIdleRow() { var row = MakeRow("t1", TaskStatus.Idle); var (vm, _) = VmFactory.Create([row]); (string TaskId, bool Resume)? raised = null; vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume); vm.OpenPlanningSessionCommand.Execute(row); Assert.Equal(("t1", false), raised); } [Fact] public void OperationProgress_CreatingWorktreePhase_SetsOnlyMatchingRow() { var row1 = MakeRow("t1", TaskStatus.Running); var row2 = MakeRow("t2", TaskStatus.Running); var (_, worker) = VmFactory.Create([row1, row2]); worker.RaiseOperationProgress("t1", "creating_worktree", 0, 0); Assert.True(row1.HasCreationPhase); Assert.False(row2.HasCreationPhase); } [Fact] public void OperationProgress_UnrelatedPhase_DoesNotSetCreationPhase() { // Same channel carries TaskMergeService's merge phases (opKey is also the task id) -- // the row must only react to its own creation-phase token, not any phase for its id. var row = MakeRow("t1", TaskStatus.Running); var (_, worker) = VmFactory.Create([row]); worker.RaiseOperationProgress("t1", "merging", 0, 0); Assert.False(row.HasCreationPhase); } [Fact] public void ToggleExpand_TogglesParentExpansion() { var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized); var child = MakeRow("c1", TaskStatus.Idle, "p1"); var (vm, _) = VmFactory.Create([parent, child]); // Collapsed by default Assert.False(IsVisibleInOpen(vm, child)); // Expand vm.ToggleExpandCommand.Execute(parent); Assert.True(IsVisibleInOpen(vm, child)); // Collapse again vm.ToggleExpandCommand.Execute(parent); Assert.False(IsVisibleInOpen(vm, child)); } [Fact] public void Regroup_ChildWithoutParentInView_ReadsAsTopLevelOrphan() { // Parent not in the view: the child must be flagged orphan so it renders flat, and it // surfaces as a normal top-level row rather than an indented Draft. var orphan = MakeRow("c1", TaskStatus.Idle, parentId: "missing-parent"); var (vm, _) = VmFactory.Create([orphan]); Assert.False(orphan.ParentInView); Assert.False(orphan.ShowAsChild); Assert.False(orphan.IsDraft); Assert.True(IsVisibleInOpen(vm, orphan)); } [Fact] public void Regroup_ChildWithParentPresent_KeepsParentInView() { var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized); var child = MakeRow("c1", TaskStatus.Idle, "p1"); var (_, _) = VmFactory.Create([parent, child]); Assert.True(child.ParentInView); Assert.True(child.ShowAsChild); } [Fact] public async Task SendToQueueAsync_RoutesThroughWorkerHub_NotRawEf() { var row = MakeRow("t1", TaskStatus.Idle); var (vm, worker) = VmFactory.Create([row]); await ((IAsyncRelayCommand)vm.SendToQueueCommand).ExecuteAsync(row); Assert.Equal(("t1", TaskStatus.Queued), Assert.Single(worker.SetTaskStatusCalls)); } [Fact] public async Task SendToQueueAsync_TaskHasOpenInteractiveSession_DoesNotQueue() { var row = MakeRow("t1", TaskStatus.Idle); row.HasInteractiveSession = true; var (vm, worker) = VmFactory.Create([row]); await ((IAsyncRelayCommand)vm.SendToQueueCommand).ExecuteAsync(row); Assert.Empty(worker.SetTaskStatusCalls); } } // ── My Day add / remove (real DB) ───────────────────────────────────────────── public sealed class TasksIslandViewModelMyDayTests : IDisposable { private readonly DbFixture _db = new(); private int _numberSeed; public void Dispose() => _db.Dispose(); [Fact] public async Task AddToMyDay_SetsIsMyDay() { var listId = Guid.NewGuid().ToString("N"); var taskId = Guid.NewGuid().ToString("N"); await using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow, Number = ++_numberSeed, Status = TaskStatus.Idle, IsMyDay = false, }); await ctx.SaveChangesAsync(); } var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient()); var row = new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle, IsMyDay = false }; await ((IAsyncRelayCommand)vm.AddToMyDayCommand).ExecuteAsync(row); await using var verify = _db.CreateContext(); var loaded = await verify.Tasks.FirstAsync(t => t.Id == taskId); Assert.True(loaded.IsMyDay); Assert.True(row.IsMyDay); } [Fact] public async Task RemoveFromMyDay_OnParent_CascadesToEveryChild() { var listId = Guid.NewGuid().ToString("N"); var parentId = Guid.NewGuid().ToString("N"); var child1 = Guid.NewGuid().ToString("N"); var child2 = Guid.NewGuid().ToString("N"); await using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "P", CreatedAt = DateTime.UtcNow, Number = ++_numberSeed, Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, IsMyDay = true, }); ctx.Tasks.Add(new TaskEntity { Id = child1, ListId = listId, Title = "C1", CreatedAt = DateTime.UtcNow, Number = ++_numberSeed, Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true, }); ctx.Tasks.Add(new TaskEntity { Id = child2, ListId = listId, Title = "C2", CreatedAt = DateTime.UtcNow, Number = ++_numberSeed, Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true, }); await ctx.SaveChangesAsync(); } var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient()); var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.WaitingForChildren, IsMyDay = true }; await ((IAsyncRelayCommand)vm.RemoveFromMyDayCommand).ExecuteAsync(parentRow); await using var verify = _db.CreateContext(); Assert.False(await verify.Tasks.AnyAsync(t => t.IsMyDay), "removing the parent from My Day must clear IsMyDay on the parent and all its children"); } }