using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.State; file sealed class FakeActiveMergeState : IActiveMergeState { public HashSet ActiveTaskIds { get; } = new(); public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId); } public sealed class TaskStateServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly TestDbContextFactory _factory; private readonly TaskStateServiceBuilder.Built _built; private readonly ITaskStateService _sut; private readonly string _listId; private readonly List _repos = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public TaskStateServiceTests() { _factory = _db.CreateFactory(); _built = TaskStateServiceBuilder.Build(_factory); _sut = _built.State; _listId = Guid.NewGuid().ToString(); using var ctx = _factory.CreateDbContext(); ctx.Lists.Add(new ListEntity { Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow, DefaultCommitType = "chore", }); ctx.SaveChanges(); } public void Dispose() { foreach (var r in _repos) r.Dispose(); _db.Dispose(); } private async Task SetListWorkingDirAsync(string workingDir) { await using var ctx = _factory.CreateDbContext(); var list = await ctx.Lists.FirstAsync(l => l.Id == _listId); list.WorkingDir = workingDir; await ctx.SaveChangesAsync(); } private async Task SeedTaskAsync( TaskStatus status, string? parentId = null, int sortOrder = 0, string? blockedBy = null, PlanningPhase phase = PlanningPhase.None, bool isManual = false) { var id = Guid.NewGuid().ToString(); await using var ctx = _factory.CreateDbContext(); ctx.Tasks.Add(new TaskEntity { Id = id, ListId = _listId, Title = "task", Status = status, PlanningPhase = phase, CreatedAt = DateTime.UtcNow, ParentTaskId = parentId, SortOrder = sortOrder, BlockedByTaskId = blockedBy, IsManual = isManual, }); await ctx.SaveChangesAsync(); return id; } private async Task GetStatusAsync(string id) { await using var ctx = _factory.CreateDbContext(); return await ctx.Tasks.Where(t => t.Id == id).Select(t => t.Status).FirstAsync(); } private async Task GetTaskAsync(string id) { await using var ctx = _factory.CreateDbContext(); return await new TaskRepository(ctx).GetByIdAsync(id) ?? throw new InvalidOperationException($"task {id} not found"); } // ─── EnqueueAsync ───────────────────────────────────────────────────── [Fact] public async Task EnqueueAsync_FromIdle_TransitionsToQueued_AndWakesQueue() { var id = await SeedTaskAsync(TaskStatus.Idle); var wakesBefore = _built.WakeCount(); var result = await _sut.EnqueueAsync(id, default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id)); Assert.True(_built.WakeCount() > wakesBefore); Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated"); } [Fact] public async Task EnqueueAsync_NoWorkingDir_QueuesNormally_NoBaseDirtyWarning() { // The list created in the constructor has no WorkingDir -- covers the // "no valid repo" acceptance case: queuing must not throw or block. var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.EnqueueAsync(id, default); Assert.True(result.Ok); Assert.Null(result.BaseDirty); Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id)); } [Fact] public async Task EnqueueAsync_CleanBaseRepo_NoBaseDirtyWarning() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); await SetListWorkingDirAsync(repo.RepoDir); var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.EnqueueAsync(id, default); Assert.True(result.Ok); Assert.Null(result.BaseDirty); } [Fact] public async Task EnqueueAsync_UntrackedFileInBaseRepo_ReturnsBaseDirtyWarning() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); File.WriteAllText(Path.Combine(repo.RepoDir, "new-file.txt"), "not committed"); await SetListWorkingDirAsync(repo.RepoDir); var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.EnqueueAsync(id, default); Assert.True(result.Ok); Assert.NotNull(result.BaseDirty); Assert.Equal(0, result.BaseDirty!.ModifiedCount); Assert.Equal(1, result.BaseDirty!.UntrackedCount); } [Fact] public async Task EnqueueAsync_ModifiedFileInBaseRepo_ReturnsBaseDirtyWarning() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = new GitRepoFixture(); _repos.Add(repo); File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "changed on disk"); await SetListWorkingDirAsync(repo.RepoDir); var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.EnqueueAsync(id, default); Assert.True(result.Ok); Assert.NotNull(result.BaseDirty); Assert.Equal(1, result.BaseDirty!.ModifiedCount); Assert.Equal(0, result.BaseDirty!.UntrackedCount); } [Fact] public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle() { var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true); var result = await _sut.EnqueueAsync(id, default); Assert.False(result.Ok); Assert.Contains("Manual task", result.Reason); Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id)); } [Fact] public async Task StartRunningAsync_ManualTask_Rejected_AndStaysIdle() { var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true); var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default); Assert.False(result.Ok); Assert.Contains("Manual task", result.Reason); Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id)); } [Fact] public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.EnqueueAsync(id, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); } [Fact] public async Task EnqueueAsync_DraftChild_Rejected_WhenParentNotFinalized() { var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active); var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); var result = await _sut.EnqueueAsync(child, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child)); } [Fact] public async Task EnqueueAsync_PlannedChild_Succeeds_WhenParentFinalized() { var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); var result = await _sut.EnqueueAsync(child, default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Queued, await GetStatusAsync(child)); } // ─── StartRunningAsync ──────────────────────────────────────────────── [Fact] public async Task StartRunningAsync_FromQueued_TransitionsToRunning_AndStampsStartedAt() { var id = await SeedTaskAsync(TaskStatus.Queued); var startedAt = new DateTime(2026, 4, 27, 10, 0, 0, DateTimeKind.Utc); var result = await _sut.StartRunningAsync(id, startedAt, default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Running, t.Status); Assert.Equal(startedAt, t.StartedAt); } [Fact] public async Task StartRunningAsync_FromRunning_Rejects() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default); Assert.False(result.Ok); } [Fact] public async Task StartRunningAsync_TwoParallelClaims_ExactlyOneWins() { var id = await SeedTaskAsync(TaskStatus.Queued); var startedAt = DateTime.UtcNow; // Two concurrent calls: only one ExecuteUpdate should affect a row. var t1 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default)); var t2 = Task.Run(() => _sut.StartRunningAsync(id, startedAt, default)); var results = await Task.WhenAll(t1, t2); var winners = results.Count(r => r.Ok); Assert.Equal(1, winners); Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); } [Fact] public async Task StartRunningAsync_DraftChild_Rejected_WhenParentNotFinalized() { var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Active); var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Idle, await GetStatusAsync(child)); } [Fact] public async Task StartRunningAsync_PlannedChild_Succeeds_WhenParentFinalized() { var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); var child = await SeedTaskAsync(TaskStatus.Idle, parentId: parent); var result = await _sut.StartRunningAsync(child, DateTime.UtcNow, default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Running, await GetStatusAsync(child)); } // ─── CompleteAsync ──────────────────────────────────────────────────── [Fact] public async Task CompleteAsync_FromRunning_TransitionsToDone() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Done, t.Status); Assert.Equal("ok", t.Result); Assert.NotNull(t.FinishedAt); } [Fact] public async Task CompleteAsync_FromQueued_Rejects() { var id = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.CompleteAsync(id, DateTime.UtcNow, "ok", default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Queued, await GetStatusAsync(id)); } // ─── FailAsync ──────────────────────────────────────────────────────── [Fact] public async Task FailAsync_FromRunning_TransitionsToFailed() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Failed, t.Status); Assert.Equal("boom", t.Result); } [Fact] public async Task FailAsync_FromQueued_TransitionsToFailed() { // OverrideSlotService can call MarkFailed before StartRunningAsync when a // preflight step (list lookup, worktree setup) fails — the task is still Queued. var id = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.FailAsync(id, DateTime.UtcNow, "list not found", default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Failed, t.Status); Assert.Equal("list not found", t.Result); } [Fact] public async Task FailAsync_FromDone_Rejects() { var id = await SeedTaskAsync(TaskStatus.Done); var result = await _sut.FailAsync(id, DateTime.UtcNow, "boom", default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Done, await GetStatusAsync(id)); } [Fact] public async Task FailAsync_FromWaitingForReview_IsNoOp() { var id = await SeedTaskAsync(TaskStatus.WaitingForReview); var result = await _sut.FailAsync(id, DateTime.UtcNow, "oops", default); Assert.False(result.Ok); Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(id)); } // ─── CancelAsync ────────────────────────────────────────────────────── [Fact] public async Task CancelAsync_FromRunning_TransitionsToCancelled() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.CancelAsync(id, DateTime.UtcNow, default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id)); } [Fact] public async Task CancelAsync_FromDone_Rejects() { var id = await SeedTaskAsync(TaskStatus.Done); var result = await _sut.CancelAsync(id, DateTime.UtcNow, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Done, await GetStatusAsync(id)); } [Fact] public async Task CancelAsync_WhileUnitMergeDraining_Rejects_AndDoesNotMutate() { var mergeState = new FakeActiveMergeState(); var built = TaskStateServiceBuilder.Build(_factory, () => mergeState); var id = await SeedTaskAsync(TaskStatus.WaitingForReview); mergeState.ActiveTaskIds.Add(id); var result = await built.State.CancelAsync(id, DateTime.UtcNow, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(id)); } [Fact] public async Task CancelAsync_AfterUnitMergeDrainCompletes_Succeeds() { var mergeState = new FakeActiveMergeState(); var built = TaskStateServiceBuilder.Build(_factory, () => mergeState); var id = await SeedTaskAsync(TaskStatus.WaitingForReview); // Merge drain finished (or never started) for this task — cancel behaves as before. var result = await built.State.CancelAsync(id, DateTime.UtcNow, default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(id)); } // ─── ResetToIdleAsync ───────────────────────────────────────────────── [Fact] public async Task ResetToIdleAsync_FromFailed_ClearsTimestamps() { var id = await SeedTaskAsync(TaskStatus.Failed); await using (var ctx = _factory.CreateDbContext()) { await ctx.Tasks.Where(t => t.Id == id) .ExecuteUpdateAsync(s => s .SetProperty(t => t.StartedAt, DateTime.UtcNow.AddMinutes(-5)) .SetProperty(t => t.FinishedAt, DateTime.UtcNow.AddMinutes(-1)) .SetProperty(t => t.Result, "old")); } var result = await _sut.ResetToIdleAsync(id, default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Idle, t.Status); Assert.Null(t.StartedAt); Assert.Null(t.FinishedAt); Assert.Null(t.Result); } [Fact] public async Task ResetToIdleAsync_FromRunning_Rejects() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.ResetToIdleAsync(id, default); Assert.False(result.Ok); Assert.Equal(TaskStatus.Running, await GetStatusAsync(id)); } // ─── StartPlanningAsync ─────────────────────────────────────────────── [Fact] public async Task StartPlanningAsync_FromIdle_SetsPlanningPhase() { var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.StartPlanningAsync(id, default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(TaskStatus.Idle, t.Status); Assert.Equal(PlanningPhase.Active, t.PlanningPhase); } [Fact] public async Task StartPlanningAsync_FromRunning_Rejects() { var id = await SeedTaskAsync(TaskStatus.Running); var result = await _sut.StartPlanningAsync(id, default); Assert.False(result.Ok); } // ─── FinalizePlanningAsync ──────────────────────────────────────────── [Fact] public async Task FinalizePlanningAsync_OnActivePhase_TransitionsToFinalized() { var id = await SeedTaskAsync(TaskStatus.Idle); await _sut.StartPlanningAsync(id, default); var result = await _sut.FinalizePlanningAsync(id, default); Assert.True(result.Ok); var t = await GetTaskAsync(id); Assert.Equal(PlanningPhase.Finalized, t.PlanningPhase); Assert.NotNull(t.PlanningFinalizedAt); } [Fact] public async Task FinalizePlanningAsync_OnNonePhase_Rejects() { var id = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.FinalizePlanningAsync(id, default); Assert.False(result.Ok); } // ─── BlockOnAsync / UnblockAsync ───────────────────────────────────── [Fact] public async Task BlockOnAsync_SetsBlockedByTaskId() { var pred = await SeedTaskAsync(TaskStatus.Queued); var task = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.BlockOnAsync(task, pred, default); Assert.True(result.Ok); var t = await GetTaskAsync(task); Assert.Equal(pred, t.BlockedByTaskId); } [Fact] public async Task UnblockAsync_ClearsBlockedByTaskId_AndWakesQueue() { var pred = await SeedTaskAsync(TaskStatus.Queued); var task = await SeedTaskAsync(TaskStatus.Queued); await _sut.BlockOnAsync(task, pred, default); var wakesBefore = _built.WakeCount(); var result = await _sut.UnblockAsync(task, default); Assert.True(result.Ok); var t = await GetTaskAsync(task); Assert.Null(t.BlockedByTaskId); Assert.True(_built.WakeCount() > wakesBefore); } // ─── SetDependsOnAsync ──────────────────────────────────────────────── [Fact] public async Task SetDependsOnAsync_SetsDependsOnTaskId() { var predecessor = await SeedTaskAsync(TaskStatus.Idle); var task = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.SetDependsOnAsync(task, predecessor, default); Assert.True(result.Ok); var t = await GetTaskAsync(task); Assert.Equal(predecessor, t.DependsOnTaskId); } [Fact] public async Task SetDependsOnAsync_Null_ClearsDependsOnTaskId_AndWakesQueue() { var predecessor = await SeedTaskAsync(TaskStatus.Idle); var task = await SeedTaskAsync(TaskStatus.Queued); await _sut.SetDependsOnAsync(task, predecessor, default); var wakesBefore = _built.WakeCount(); var result = await _sut.SetDependsOnAsync(task, null, default); Assert.True(result.Ok); var t = await GetTaskAsync(task); Assert.Null(t.DependsOnTaskId); Assert.True(_built.WakeCount() > wakesBefore); } [Fact] public async Task SetDependsOnAsync_SelfReference_Rejected() { var task = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.SetDependsOnAsync(task, task, default); Assert.False(result.Ok); var t = await GetTaskAsync(task); Assert.Null(t.DependsOnTaskId); } [Fact] public async Task SetDependsOnAsync_UnknownDependency_Rejected() { var task = await SeedTaskAsync(TaskStatus.Queued); var result = await _sut.SetDependsOnAsync(task, "does-not-exist", default); Assert.False(result.Ok); } [Fact] public async Task SetDependsOnAsync_NotFound_Rejected() { var predecessor = await SeedTaskAsync(TaskStatus.Idle); var result = await _sut.SetDependsOnAsync("does-not-exist", predecessor, default); Assert.False(result.Ok); } [Fact] public async Task SetDependsOnAsync_DirectCycle_Rejected() { var a = await SeedTaskAsync(TaskStatus.Queued); var b = await SeedTaskAsync(TaskStatus.Queued); var setup = await _sut.SetDependsOnAsync(b, a, default); Assert.True(setup.Ok); // a -> b would close the loop a -> b -> a. var result = await _sut.SetDependsOnAsync(a, b, default); Assert.False(result.Ok); var t = await GetTaskAsync(a); Assert.Null(t.DependsOnTaskId); } [Fact] public async Task SetDependsOnAsync_TransitiveCycle_Rejected() { var a = await SeedTaskAsync(TaskStatus.Queued); var b = await SeedTaskAsync(TaskStatus.Queued); var c = await SeedTaskAsync(TaskStatus.Queued); Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok); Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok); // a -> c would close the loop a -> c -> b -> a. var result = await _sut.SetDependsOnAsync(a, c, default); Assert.False(result.Ok); } // ─── RecoverStaleRunningAsync ───────────────────────────────────────── [Fact] public async Task RecoverStaleRunningAsync_FlipsAllRunningToFailed_ReturnsCount() { var r1 = await SeedTaskAsync(TaskStatus.Running); var r2 = await SeedTaskAsync(TaskStatus.Running); var q = await SeedTaskAsync(TaskStatus.Queued); var count = await _sut.RecoverStaleRunningAsync("worker restart", default); Assert.Equal(2, count); Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r1)); Assert.Equal(TaskStatus.Failed, await GetStatusAsync(r2)); Assert.Equal(TaskStatus.Queued, await GetStatusAsync(q)); var t = await GetTaskAsync(r1); Assert.StartsWith("[stale] ", t.Result); } [Fact] public async Task RecoverStaleRunningAsync_ResolvesBlockedSuccessor_AndAdvancesParent() { var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized); var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0); var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0); var count = await _sut.RecoverStaleRunningAsync("worker restart", default); Assert.Equal(1, count); Assert.Equal(TaskStatus.Failed, await GetStatusAsync(c0)); // c1 was blocked on the crashed task; the chain coordinator resolves it (cancels it, // since the predecessor didn't finish successfully) instead of leaving it Queued with // a dangling BlockedByTaskId that the picker would skip forever. Assert.Equal(TaskStatus.Cancelled, await GetStatusAsync(c1)); // Both children are now terminal, so the WaitingForChildren parent advances. Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent)); } // ─── Child terminal → chain advance ─────────────────────────────────── [Fact] public async Task CompleteAsync_OnChild_AdvancesNextBlockedSibling() { var parent = await SeedTaskAsync(TaskStatus.Idle, phase: PlanningPhase.Finalized); var c0 = await SeedTaskAsync(TaskStatus.Running, parentId: parent, sortOrder: 0); var c1 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 1, blockedBy: c0); var c2 = await SeedTaskAsync(TaskStatus.Queued, parentId: parent, sortOrder: 2, blockedBy: c1); var result = await _sut.CompleteAsync(c0, DateTime.UtcNow, "ok", default); Assert.True(result.Ok); Assert.Equal(TaskStatus.Done, await GetStatusAsync(c0)); // c1 was BlockedBy=c0 → chain coordinator unblocks → BlockedByTaskId cleared, still Queued. var t1 = await GetTaskAsync(c1); Assert.Equal(TaskStatus.Queued, t1.Status); Assert.Null(t1.BlockedByTaskId); // c2 still blocked on c1. var t2 = await GetTaskAsync(c2); Assert.Equal(TaskStatus.Queued, t2.Status); Assert.Equal(c1, t2.BlockedByTaskId); } // ─── TryAdvanceParentAsync ──────────────────────────────────────────── [Fact] public async Task TryAdvanceParentAsync_ZeroChildren_AdvancesToWaitingForReview() { // Mirrors deleting the last remaining child: no terminal transition fires for it, // so the caller invokes TryAdvanceParentAsync directly with zero children left. var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized); await _sut.TryAdvanceParentAsync(parent); Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent)); } [Fact] public async Task TryAdvanceParentAsync_AllChildrenTerminal_AdvancesToWaitingForReview() { var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized); await SeedTaskAsync(TaskStatus.Done, parentId: parent); await SeedTaskAsync(TaskStatus.Done, parentId: parent); await _sut.TryAdvanceParentAsync(parent); Assert.Equal(TaskStatus.WaitingForReview, await GetStatusAsync(parent)); } [Fact] public async Task TryAdvanceParentAsync_SomeChildrenStillRunning_DoesNotAdvance() { var parent = await SeedTaskAsync(TaskStatus.WaitingForChildren, phase: PlanningPhase.Finalized); await SeedTaskAsync(TaskStatus.Done, parentId: parent); await SeedTaskAsync(TaskStatus.Running, parentId: parent); await _sut.TryAdvanceParentAsync(parent); Assert.Equal(TaskStatus.WaitingForChildren, await GetStatusAsync(parent)); } }