using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Localization; using ClaudeDo.Ui.Localization; 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 DetailsIslandRoadblockReplyTests : IDisposable { private readonly string _dbPath; public DetailsIslandRoadblockReplyTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_roadblock_reply_test_{Guid.NewGuid():N}.db"); using var ctx = NewContext(); ctx.Database.EnsureCreated(); // Loc is a process-wide ambient singleton other tests also mutate — pin it to // the real locale data so this test's assertions don't depend on run order. var dir = AppContext.BaseDirectory; while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales"))) dir = Path.GetDirectoryName(dir); Loc.Current = new Localizer( LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en"); } 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 NullServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi { public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); public Task AddAsync(DateOnly day, string text) => Task.FromResult(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 TaskId, string Prompt)? LastContinue; public Exception? ThrowOnContinue; public override Task ContinueTaskAsync(string taskId, string followUpPrompt) { if (ThrowOnContinue is not null) throw ThrowOnContinue; LastContinue = (taskId, followUpPrompt); 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()); } private static DetailsIslandViewModel PrepareDoneTaskWithRoadblock(DetailsIslandViewModel vm, string taskId = "task-rb-1") { vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Done }); vm.Monitor.ApplyState(TaskStatus.Done); vm.Roadblocks = "needs go-ahead to merge branches"; return vm; } [Fact] public void CanReplyToRoadblock_IsFalse_WithoutSessionId() { var vm = BuildVm(new RecordingWorkerClient()); PrepareDoneTaskWithRoadblock(vm); Assert.True(vm.ShowRoadblockCard); Assert.Null(vm.LatestRunSessionId); Assert.False(vm.CanReplyToRoadblock); Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null)); } [Fact] public void CanReplyToRoadblock_BecomesTrue_OnceSessionIdIsPresent() { var vm = BuildVm(new RecordingWorkerClient()); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; Assert.True(vm.CanReplyToRoadblock); } [Fact] public void SendRoadblockReplyCommand_IsDisabled_WhenDraftIsEmptyOrWhitespace() { var vm = BuildVm(new RecordingWorkerClient()); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null)); vm.RoadblockReplyDraft = " "; Assert.False(vm.SendRoadblockReplyCommand.CanExecute(null)); vm.RoadblockReplyDraft = "go ahead, merge it"; Assert.True(vm.SendRoadblockReplyCommand.CanExecute(null)); } [Fact] public async Task SendRoadblockReply_PassesTypedText_NotTheHardcodedContinueConstant() { var worker = new RecordingWorkerClient(); var vm = BuildVm(worker); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; vm.RoadblockReplyDraft = "yes, go ahead and merge"; await vm.SendRoadblockReplyCommand.ExecuteAsync(null); Assert.Equal(("task-rb-1", "yes, go ahead and merge"), worker.LastContinue); Assert.NotEqual("Continue working on this task.", worker.LastContinue?.Prompt); } [Fact] public async Task SendRoadblockReply_ClearsDraft_OnSuccess() { var worker = new RecordingWorkerClient(); var vm = BuildVm(worker); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; vm.RoadblockReplyDraft = "go ahead"; await vm.SendRoadblockReplyCommand.ExecuteAsync(null); Assert.Equal(string.Empty, vm.RoadblockReplyDraft); } [Fact] public async Task SendRoadblockReply_WhenWorkerThrows_ReportsErrorAndKeepsDraft() { var worker = new RecordingWorkerClient { ThrowOnContinue = new Exception("override slot busy") }; var vm = BuildVm(worker); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; vm.RoadblockReplyDraft = "go ahead"; string? reportedError = null; vm.ErrorReported += msg => reportedError = msg; await vm.SendRoadblockReplyCommand.ExecuteAsync(null); Assert.NotNull(reportedError); Assert.Contains("override slot busy", reportedError); Assert.Equal("go ahead", vm.RoadblockReplyDraft); } [Fact] public void Bind_ClearsRoadblockReplyDraft_ForNewTask() { var vm = BuildVm(new RecordingWorkerClient()); PrepareDoneTaskWithRoadblock(vm); vm.LatestRunSessionId = "session-abc"; vm.RoadblockReplyDraft = "leftover text"; vm.Bind(new TaskRowViewModel { Id = "task-rb-2", Status = TaskStatus.Idle }); Assert.Equal(string.Empty, vm.RoadblockReplyDraft); } [Fact] public void ShowContinueAndCanContinue_AreUnaffected_ByRoadblockReplyAddition() { // Regression: the existing Continue button (Failed/Cancelled only) must keep // working exactly as before — the roadblock reply field is an additional path, // not a replacement. var vm = BuildVm(new RecordingWorkerClient()); vm.Bind(new TaskRowViewModel { Id = "task-rb-3", Status = TaskStatus.Failed }); vm.Monitor.ApplyState(TaskStatus.Failed); Assert.True(vm.ShowContinue); Assert.False(vm.ContinueCommand.CanExecute(null)); // no session id yet vm.LatestRunSessionId = "session-xyz"; Assert.True(vm.ContinueCommand.CanExecute(null)); vm.Monitor.ApplyState(TaskStatus.Done); Assert.False(vm.ShowContinue); } [Theory] [InlineData(TaskStatus.WaitingForReview, true)] [InlineData(TaskStatus.Done, true)] [InlineData(TaskStatus.Failed, true)] [InlineData(TaskStatus.Cancelled, true)] [InlineData(TaskStatus.Running, false)] [InlineData(TaskStatus.Queued, false)] [InlineData(TaskStatus.Idle, false)] [InlineData(TaskStatus.WaitingForChildren, false)] public void ShowRoadblockCard_MatchesStatusMatrix_WhenARoadblockWasReported(TaskStatus status, bool expectedVisible) { var vm = BuildVm(new RecordingWorkerClient()); vm.Bind(new TaskRowViewModel { Id = "task-rb-matrix", Status = status }); vm.Monitor.ApplyState(status); vm.Roadblocks = "needs a go-ahead"; Assert.Equal(expectedVisible, vm.ShowRoadblockCard); } [Fact] public void ShowRoadblockCard_IsFalse_WithoutARoadblock_RegardlessOfStatus() { var vm = BuildVm(new RecordingWorkerClient()); vm.Bind(new TaskRowViewModel { Id = "task-rb-none", Status = TaskStatus.Done }); vm.Monitor.ApplyState(TaskStatus.Done); Assert.False(vm.ShowRoadblockCard); } }