using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; namespace ClaudeDo.Worker.Tests; public sealed class WaitingForChildrenLifecycleTests : IDisposable { private readonly DbFixture _db = new(); private readonly TestDbContextFactory _factory; private readonly TaskStateServiceBuilder.Built _built; public WaitingForChildrenLifecycleTests() { _factory = _db.CreateFactory(); _built = TaskStateServiceBuilder.Build(_factory); } public void Dispose() => _db.Dispose(); private async Task SeedRunningStandaloneAsync() { using var ctx = _db.CreateContext(); ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); return "p1"; } [Fact] public async Task SubmitForChildren_moves_running_task_to_WaitingForChildren() { var id = await SeedRunningStandaloneAsync(); var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default); Assert.True(result.Ok); using var ctx = _db.CreateContext(); var t = await new TaskRepository(ctx).GetByIdAsync(id); Assert.Equal(TaskStatus.WaitingForChildren, t!.Status); Assert.Equal("ran ok", t.Result); Assert.NotNull(t.FinishedAt); } [Fact] public async Task SubmitForChildren_rejects_when_not_running() { var id = await SeedRunningStandaloneAsync(); await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default); var second = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default); Assert.False(second.Ok); } private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses) { using var ctx = _db.CreateContext(); if (!ctx.Lists.Any()) ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent", Status = Enum.Parse(parentStatus), Result = "parent ran", CreatedAt = DateTime.UtcNow }); int i = 0; foreach (var cs in childStatuses) ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child", Status = cs, ParentTaskId = "par", CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); } [Fact] public async Task LastChildDone_advances_WaitingForChildren_parent_to_WaitingForReview() { await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running); await _built.State.CompleteAsync("c1", DateTime.UtcNow, "child ok", default); using var ctx = _db.CreateContext(); var parent = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); } [Fact] public async Task NonLastChild_leaves_parent_in_WaitingForChildren() { await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Running, TaskStatus.Idle); await _built.State.CompleteAsync("c0", DateTime.UtcNow, "ok", default); using var ctx = _db.CreateContext(); var parent = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status); } [Fact] public async Task FailedChild_still_advances_parent_and_flags_failure() { await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running); await _built.State.FailAsync("c1", DateTime.UtcNow, "boom", default); using var ctx = _db.CreateContext(); var parent = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); Assert.Contains("1 failed", parent.Result!); } private async Task SeedParentChildPairAsync(PlanningPhase parentPhase) { using var ctx = _db.CreateContext(); if (!ctx.Lists.Any()) ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent", Status = TaskStatus.WaitingForChildren, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Child", Status = TaskStatus.Idle, ParentTaskId = "par", CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); } [Fact] public async Task ImprovementChild_can_be_enqueued() { await SeedParentChildPairAsync(PlanningPhase.None); var result = await _built.State.EnqueueAsync("kid", default); Assert.True(result.Ok); using var ctx = _db.CreateContext(); Assert.Equal(TaskStatus.Queued, (await new TaskRepository(ctx).GetByIdAsync("kid"))!.Status); } [Fact] public async Task PlanningDraftChild_cannot_be_enqueued() { await SeedParentChildPairAsync(PlanningPhase.Active); var result = await _built.State.EnqueueAsync("kid", default); Assert.False(result.Ok); } [Fact] public async Task SequentialChildren_parent_only_advances_after_both_are_terminal() { // Arrange: parent WaitingForChildren, child1 Done, child2 blocked by child1 (Running) using (var ctx = _db.CreateContext()) { ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent", Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "c1", ListId = "l1", Title = "Child1", Status = TaskStatus.Running, ParentTaskId = "par", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = "c2", ListId = "l1", Title = "Child2", Status = TaskStatus.Running, ParentTaskId = "par", BlockedByTaskId = "c1", CreatedAt = DateTime.UtcNow }); await ctx.SaveChangesAsync(); } // Act: complete child1 — parent must stay WaitingForChildren because child2 is still running await _built.State.CompleteAsync("c1", DateTime.UtcNow, "c1 ok", default); using (var ctx = _db.CreateContext()) { var par = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForChildren, par!.Status); } // Act: complete child2 — now all children are terminal; parent must advance await _built.State.CompleteAsync("c2", DateTime.UtcNow, "c2 ok", default); using (var ctx = _db.CreateContext()) { var par = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForReview, par!.Status); } } // ─── FinalizePlanningAsync ──────────────────────────────────────────── private async Task SeedActivePlanningParentAsync(string id = "par") { using var ctx = _db.CreateContext(); if (!ctx.Lists.Any()) ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = id, ListId = "l1", Title = "Parent", Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); return id; } private async Task SeedChildAsync(string parentId, string childId, TaskStatus status = TaskStatus.Idle) { using var ctx = _db.CreateContext(); ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = "l1", Title = "Child", Status = status, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow, }); await ctx.SaveChangesAsync(); } [Fact] public async Task FinalizePlanning_WithChildren_SetsWaitingForChildren_AndPlanningFinalized() { await SeedActivePlanningParentAsync(); await SeedChildAsync("par", "c1"); await SeedChildAsync("par", "c2"); var result = await _built.State.FinalizePlanningAsync("par", default); Assert.True(result.Ok); using var ctx = _db.CreateContext(); var parent = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status); Assert.Equal(PlanningPhase.Finalized, parent.PlanningPhase); } [Fact] public async Task FinalizePlanning_WithChildren_ThenAllChildrenTerminal_AdvancesToWaitingForReview() { await SeedActivePlanningParentAsync(); await SeedChildAsync("par", "c1"); await SeedChildAsync("par", "c2"); await _built.State.FinalizePlanningAsync("par", default); // Manually transition parent to WaitingForChildren so CompleteAsync works on the children. // Children are Idle — complete them by seeding them as Running first. using (var ctx = _db.CreateContext()) { await ctx.Tasks.Where(t => t.Id == "c1").ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); await ctx.Tasks.Where(t => t.Id == "c2").ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Running)); } await _built.State.CompleteAsync("c1", DateTime.UtcNow, "ok", default); using (var ctx = _db.CreateContext()) Assert.Equal(TaskStatus.WaitingForChildren, (await new TaskRepository(ctx).GetByIdAsync("par"))!.Status); await _built.State.CompleteAsync("c2", DateTime.UtcNow, "ok", default); using (var ctx = _db.CreateContext()) Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(ctx).GetByIdAsync("par"))!.Status); } [Fact] public async Task FinalizePlanning_WithNoChildren_SetsWaitingForReview_Directly() { await SeedActivePlanningParentAsync(); var result = await _built.State.FinalizePlanningAsync("par", default); Assert.True(result.Ok); using var ctx = _db.CreateContext(); var parent = await new TaskRepository(ctx).GetByIdAsync("par"); Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); Assert.Equal(PlanningPhase.Finalized, parent.PlanningPhase); } }