Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
T
Mika Kuns 9f90e75a85 refactor(tests): share StubWorkerClient between Ui.Tests and Worker.Tests
Worker.Tests carried its own hand-rolled FakeWorkerClient implementing the
whole IWorkerClient surface, duplicating StubWorkerClient in Ui.Tests. Every
hub-signature change had to be applied twice. Link StubWorkerClient.cs into
Worker.Tests via a csproj Compile Include and subclass it there; add
SetTaskStatusCalls tracking to the shared stub (the one behavior Worker.Tests
needed that Ui.Tests didn't have).
2026-09-02 12:17:00 +02:00

299 lines
11 KiB
C#

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<ClaudeDoDbContext> NullDbFactory()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("DataSource=:memory:")
.Options;
return new NullDbContextFactory(opts);
}
private sealed class NullDbContextFactory(DbContextOptions<ClaudeDoDbContext> opts)
: IDbContextFactory<ClaudeDoDbContext>
{
public ClaudeDoDbContext CreateDbContext() => new(opts);
}
public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create(
IEnumerable<TaskRowViewModel> 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<TaskRowViewModel?>)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<TaskRowViewModel?>)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<TaskRowViewModel?>)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<TaskRowViewModel?>)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");
}
}