using ClaudeDo.Data; using ClaudeDo.Ui.ViewModels.Islands; using Microsoft.EntityFrameworkCore; using Xunit; namespace ClaudeDo.Ui.Tests.ViewModels; public class TaskMonitorViewModelTests : IDisposable { private readonly string _dbPath; public TaskMonitorViewModelTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_monitor_{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 FakeWorker : StubWorkerClient { } private TaskMonitorViewModel Build(FakeWorker worker) => new TaskMonitorViewModel(new TestDbFactory(NewContext), worker); [Fact] public void Feeds_AccumulateLogLines_WithKinds() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskMessage("t1", "[sys] boot"); worker.RaiseTaskMessage("t1", "[tool] read file"); worker.RaiseTaskMessage("t1", "[claude] hello"); Assert.Equal(3, vm.Log.Count); Assert.Equal(LogKind.Sys, vm.Log[0].Kind); Assert.Equal(LogKind.Tool, vm.Log[1].Kind); Assert.Equal(LogKind.Claude, vm.Log[2].Kind); } [Fact] public void Feeds_ForOtherTask_AreIgnored() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskMessage("other", "[sys] not mine"); Assert.Empty(vm.Log); } [Fact] public void TaskFinished_FlipsState_AndAppendsDoneLine() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); Assert.True(vm.IsDone); Assert.Equal("done", vm.AgentState); Assert.Equal(LogKind.Done, vm.Log[^1].Kind); } [Fact] public void ApplyOutcome_SplitsRoadblockMarker() { var worker = new FakeWorker(); using var vm = Build(worker); vm.ApplyOutcome( "Summary text\n\nRoadblocks reported during the run:\n- something broke", errorFallback: null); Assert.Equal("Summary text", vm.SessionOutcome); Assert.Equal("- something broke", vm.Roadblocks); } [Fact] public void HasRoadblock_TrueAfterRoadblockOutcome() { var worker = new FakeWorker(); using var vm = Build(worker); vm.ApplyOutcome("Summary\n\nRoadblocks reported during the run:\n- broke", errorFallback: null); Assert.True(vm.HasRoadblock); } [Fact] public void Detach_WhenNotDetached_InvokesDetachRequested() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); TaskMonitorViewModel? requested = null; vm.DetachRequested = m => requested = m; vm.DetachCommand.Execute(null); Assert.Same(vm, requested); } [Fact] public void TaskQuestionAsked_SurfacesQuestion() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskQuestionAsked("t1", "q1", "DPAPI or plaintext?"); Assert.True(vm.HasPendingQuestion); Assert.Equal("DPAPI or plaintext?", vm.PendingQuestion); Assert.True(vm.SubmitAnswerCommand.CanExecute(null) is false); // no draft yet } [Fact] public void TaskQuestionAsked_ForOtherTask_IsIgnored() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskQuestionAsked("other", "q1", "not mine"); Assert.False(vm.HasPendingQuestion); } [Fact] public async Task SubmitAnswer_InvokesClient_AndClears() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskQuestionAsked("t1", "q1", "DPAPI or plaintext?"); vm.AnswerDraft = "DPAPI please"; Assert.True(vm.SubmitAnswerCommand.CanExecute(null)); await vm.SubmitAnswerCommand.ExecuteAsync(null); Assert.Equal(("t1", "q1", "DPAPI please"), worker.LastAnswer); Assert.False(vm.HasPendingQuestion); Assert.Equal(string.Empty, vm.AnswerDraft); } [Fact] public void TaskQuestionResolved_ClearsMatchingQuestion() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskQuestionAsked("t1", "q1", "?"); worker.RaiseTaskQuestionResolved("t1", "q1"); Assert.False(vm.HasPendingQuestion); } [Fact] public void TaskFinished_ClearsPendingQuestion() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseTaskQuestionAsked("t1", "q1", "?"); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); Assert.False(vm.HasPendingQuestion); } // ── Interactive composer ────────────────────────────────────────────────── [Fact] public void InteractiveStarted_ForSubscribedTask_SetsIsInteractiveLive() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); Assert.True(vm.IsInteractiveLive); Assert.Equal("running", vm.AgentState); } [Fact] public void InteractiveStarted_ForOtherTask_IsIgnored() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("other"); Assert.False(vm.IsInteractiveLive); } [Fact] public void InteractiveEnded_ForSubscribedTask_ClearsIsInteractiveLive() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); worker.RaiseInteractiveEnded("t1"); Assert.False(vm.IsInteractiveLive); Assert.Equal("done", vm.AgentState); } [Fact] public void InteractiveEnded_ForOtherTask_IsIgnored() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); worker.RaiseInteractiveEnded("other"); Assert.True(vm.IsInteractiveLive); // unchanged } [Fact] public void SubmitComposerCommand_CanExecute_FalseWhenNotLive() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); vm.ComposerDraft = "hello"; Assert.False(vm.SubmitComposerCommand.CanExecute(null)); } [Fact] public void SubmitComposerCommand_CanExecute_FalseWhenLiveButDraftWhitespace() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); vm.ComposerDraft = " "; Assert.False(vm.SubmitComposerCommand.CanExecute(null)); } [Fact] public void SubmitComposerCommand_CanExecute_TrueWhenLiveAndDraftSet() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); vm.ComposerDraft = "hello"; Assert.True(vm.SubmitComposerCommand.CanExecute(null)); } [Fact] public async Task SubmitComposer_AddsUserLogLine_ClearsDraft_CallsClient() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); vm.ComposerDraft = "do the thing"; await vm.SubmitComposerCommand.ExecuteAsync(null); Assert.Single(worker.SentInteractive); Assert.Equal(("t1", "do the thing"), worker.SentInteractive[0]); Assert.Equal(string.Empty, vm.ComposerDraft); Assert.Single(vm.Log); Assert.Equal(LogKind.User, vm.Log[0].Kind); Assert.Equal("do the thing", vm.Log[0].Text); } [Fact] public async Task InterruptInteractiveCommand_WhenLive_RecordsOneCall() { var worker = new FakeWorker(); using var vm = Build(worker); vm.SetTaskId("t1"); worker.RaiseInteractiveStarted("t1"); await vm.InterruptInteractiveCommand.ExecuteAsync(null); Assert.Single(worker.InterruptedInteractive); Assert.Equal("t1", worker.InterruptedInteractive[0]); } }