using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; // PlanningOperation backs the shared feedback surface for the two planning actions triggered // from the task-row context menu (which closes the instant a click lands, so there is nothing // per-row left to anchor an indicator to) — see docs/superpowers/plans/2026-08-11-operation-feedback.md, A4. public class TasksIslandPlanningOperationTests : IDisposable { private readonly string _dbPath; public TasksIslandPlanningOperationTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_planning_op_{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() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class GatedWorkerClient : StubWorkerClient { public override bool IsConnected => true; public TaskCompletionSource QueueGate { get; } = new(); public TaskCompletionSource FinalizeGate { get; } = new(); public bool ThrowOnQueue { get; set; } public bool ThrowOnFinalize { get; set; } public override async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) { if (ThrowOnQueue) throw new InvalidOperationException("boom"); await QueueGate.Task; } public override async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { if (ThrowOnFinalize) throw new InvalidOperationException("boom"); await FinalizeGate.Task; } } private TasksIslandViewModel BuildViewModel(GatedWorkerClient worker) => new(new TestDbFactory(NewContext), worker); private static TaskRowViewModel Row(string id) => new() { Id = id, Status = TaskStatus.Idle }; [Fact] public async Task QueuePlanningSubtasks_sets_PlanningOperation_IsRunning_while_pending() { var worker = new GatedWorkerClient(); var vm = BuildViewModel(worker); var row = Row("parent-1"); var run = vm.QueuePlanningSubtasksCommand.ExecuteAsync(row); Assert.True(vm.PlanningOperation.IsRunning); worker.QueueGate.SetResult(null); await run; Assert.False(vm.PlanningOperation.IsRunning); } [Fact] public async Task FinalizePlanningSession_sets_PlanningOperation_IsRunning_while_pending() { var worker = new GatedWorkerClient(); var vm = BuildViewModel(worker); var row = Row("parent-2"); var run = vm.FinalizePlanningSessionCommand.ExecuteAsync(row); Assert.True(vm.PlanningOperation.IsRunning); worker.FinalizeGate.SetResult(null); await run; Assert.False(vm.PlanningOperation.IsRunning); } [Fact] public async Task PlanningCommands_CanExecute_is_locked_while_either_operation_runs() { var worker = new GatedWorkerClient(); var vm = BuildViewModel(worker); var row = Row("parent-3"); var run = vm.QueuePlanningSubtasksCommand.ExecuteAsync(row); Assert.False(vm.QueuePlanningSubtasksCommand.CanExecute(row)); Assert.False(vm.FinalizePlanningSessionCommand.CanExecute(row)); worker.QueueGate.SetResult(null); await run; Assert.True(vm.QueuePlanningSubtasksCommand.CanExecute(row)); Assert.True(vm.FinalizePlanningSessionCommand.CanExecute(row)); } [Fact] public async Task QueuePlanningSubtasks_ends_the_operation_when_the_worker_call_throws() { var worker = new GatedWorkerClient { ThrowOnQueue = true }; var vm = BuildViewModel(worker); var row = Row("parent-4"); await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row); Assert.False(vm.PlanningOperation.IsRunning); Assert.True(vm.QueuePlanningSubtasksCommand.CanExecute(row)); } }