Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandReviewActionsTests.cs
T
mika kuns 79f90a9a8e fix(worker,ui): block cancelling a task while its unit merge is draining
TaskStateService.CancelAsync allowed cancelling a WaitingForReview task
even while PlanningMergeOrchestrator was mid-drain on it: ApproveReview
awaits the whole multi-subtask merge synchronously, so a concurrent
CancelReview (UI or MCP) could flip the parent to Cancelled while the
orchestrator kept merging children onto the target branch, then
FinalizeParentDoneAsync would find the parent no longer WaitingForReview
and give up - leaving the merged diffs stranded with no rollback.

CancelAsync now rejects with a clear reason when HasActiveMerge(taskId)
is true. TaskStateService can't take PlanningMergeOrchestrator as a
direct constructor dependency (circular back to ITaskStateService), so
it takes a lazily-resolved Func<IActiveMergeState> instead, mirroring
the existing Func<ITaskStateService> cycle-break already used for
PlanningChainCoordinator.

UI polish: DetailsIslandViewModel.IsMergeDraining gates
CancelReviewCommand's CanExecute (same shape as
WorktreesOverviewModalViewModel.IsMerging), and the command's catch now
raises ErrorReported instead of swallowing the rejection silently.
2026-08-06 13:38:02 +02:00

230 lines
8.4 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandReviewActionsTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandReviewActionsTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_review_actions_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class RecordingWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string? ParkedTaskId;
public override Task RejectReviewToIdleAsync(string taskId)
{
ParkedTaskId = taskId;
return Task.CompletedTask;
}
}
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
public async Task ParkReview_CallsRejectReviewToIdle_ForTheBoundTask()
{
var worker = new RecordingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-park-1", Status = TaskStatus.WaitingForReview });
await vm.ParkReviewCommand.ExecuteAsync(null);
Assert.Equal("task-park-1", worker.ParkedTaskId);
}
[Fact]
public void SendBack_IsDisabledUntilFeedbackIsEntered()
{
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-fb-1", Status = TaskStatus.WaitingForReview });
Assert.False(vm.RejectReviewCommand.CanExecute(null));
vm.ReviewFeedback = "tighten the error handling";
Assert.True(vm.RejectReviewCommand.CanExecute(null));
vm.ReviewFeedback = " ";
Assert.False(vm.RejectReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsEnabled_WhenThereIsNothingToReview()
{
// A childless sandbox run has no worktree diff to inspect, so the gate must
// not block it — it approves straight through.
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-nodiff-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.False(vm.Merge.HasReviewableDiff);
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
}
[Fact]
public void Approve_IsGatedUntilDiffOpened_AndReLocksOnNewRun()
{
var vm = BuildVm(new RecordingWorkerClient());
vm.Bind(new TaskRowViewModel { Id = "task-diff-1", Status = TaskStatus.WaitingForReview });
vm.Merge.SyncWorktree("/tmp/wt", null, null, "Active", null);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
// There is a diff to read, but it has not been opened → merge is blocked.
Assert.True(vm.Merge.HasReviewableDiff);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
// Opening the diff records the inspection and unlocks the merge.
vm.Merge.DiffViewed?.Invoke();
Assert.False(vm.ShowReviewDiffHint);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
// A new run means a fresh diff — the gate re-engages.
vm.Monitor.ApplyState(TaskStatus.Running);
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null));
}
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task ApproveReview_WhenWorkerThrows_CallsShowErrorAsync()
{
var worker = new ThrowingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
}
[Fact]
public void CancelReview_IsDisabled_WhileUnitMergeIsDraining()
{
var worker = new RecordingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-drain-1", Status = TaskStatus.WaitingForReview });
Assert.True(vm.CancelReviewCommand.CanExecute(null));
worker.RaisePlanningMergeStarted("task-drain-1", "main");
Assert.False(vm.CancelReviewCommand.CanExecute(null));
worker.RaisePlanningCompleted("task-drain-1");
Assert.True(vm.CancelReviewCommand.CanExecute(null));
}
[Fact]
public void CancelReview_ReDisables_UntilMergeAborted()
{
var worker = new RecordingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-drain-2", Status = TaskStatus.WaitingForReview });
worker.RaisePlanningMergeStarted("task-drain-2", "main");
Assert.False(vm.CancelReviewCommand.CanExecute(null));
worker.RaisePlanningMergeAborted("task-drain-2");
Assert.True(vm.CancelReviewCommand.CanExecute(null));
}
[Fact]
public void CancelReview_UnaffectedByMergeEvents_ForADifferentTask()
{
var worker = new RecordingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-drain-3", Status = TaskStatus.WaitingForReview });
worker.RaisePlanningMergeStarted("some-other-parent", "main");
Assert.True(vm.CancelReviewCommand.CanExecute(null));
}
private sealed class ThrowingCancelWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string ExceptionMessage { get; init; } =
"A merge is in progress for this task; wait for it to finish before cancelling.";
public override Task CancelReviewAsync(string taskId) => throw new Exception(ExceptionMessage);
}
[Fact]
public async Task CancelReview_WhenWorkerThrows_RaisesErrorReported()
{
var worker = new ThrowingCancelWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-cancel-err-1", Status = TaskStatus.WaitingForReview });
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
await vm.CancelReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
}
}