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).
299 lines
11 KiB
C#
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");
|
|
}
|
|
}
|