using System.Linq; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using Microsoft.EntityFrameworkCore; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Ui.Tests.ViewModels; public class MissionControlViewModelTests : IDisposable { private readonly string _dbPath; public MissionControlViewModelTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mc_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() .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 MissionControlViewModel BuildVm(StubWorkerClient worker) => new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker)); // ── acceptance criterion (a): TaskStarted must NOT add a pane ────────────── [Fact] public void TaskStarted_DoesNotAddPane() { var worker = new FakeWorker(); using var vm = BuildVm(worker); worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); Assert.Empty(vm.Panes); } [Fact] public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks() { var worker = new SeededFakeWorker(); using var vm = BuildVm(worker); Assert.Empty(vm.Panes); } private sealed class SeededFakeWorker : StubWorkerClient { public override IReadOnlyList GetActiveTasks() => new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) }; } // ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─ [Fact] public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); // Running task has higher SortOrder — must still appear first db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); Assert.Equal(2, vm.Queued.Count); Assert.Equal("r1", vm.Queued[0].Id); Assert.True(vm.Queued[0].IsRunning); Assert.Equal("q1", vm.Queued[1].Id); Assert.False(vm.Queued[1].IsRunning); } [Fact] public async Task HasQueued_TrueWhenOnlyRunningTasksExist() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); } // ── acceptance criterion (c): row click invokes OpenInApp ────────────────── [Fact] public async Task Row_Click_InvokesOpenInApp() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); string? opened = null; vm.OpenInApp = id => opened = id; await vm.RefreshQueueAsync(); vm.Queued[0].OpenInAppCommand!.Execute(null); Assert.Equal("q1", opened); } [Fact] public async Task Row_Click_RunningTask_InvokesOpenInApp() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); string? opened = null; vm.OpenInApp = id => opened = id; await vm.RefreshQueueAsync(); vm.Queued[0].OpenInAppCommand!.Execute(null); Assert.Equal("r1", opened); } [Fact] public async Task Queue_ReflectsQueuedTasks_InSortOrder() { await SeedQueueAsync(); var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray()); } private async Task SeedQueueAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 }); await db.SaveChangesAsync(); } // Mirrors TaskStateService.EnqueueAsync writing to the same DB so the test can verify the // resulting task state without the real worker process — EnqueueTaskAsync now delegates the // actual status write to the worker hub instead of doing a raw EF write itself. private sealed class QueueingWorkerClient : StubWorkerClient { private readonly Func _newContext; public List QueuedTaskIds { get; } = new(); public QueueingWorkerClient(Func newContext) => _newContext = newContext; public override async Task SetTaskStatusAsync(string taskId, TaskStatus status) { QueuedTaskIds.Add(taskId); await using var db = _newContext(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId); if (entity is null) return; entity.Status = status; await db.SaveChangesAsync(); } } [Fact] public async Task EnqueueTaskAsync_SetsTaskQueued_AndShowsInStrip() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "idleTask", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new QueueingWorkerClient(NewContext); using var vm = BuildVm(worker); await vm.EnqueueTaskAsync("idleTask"); Assert.Equal(new[] { "idleTask" }, worker.QueuedTaskIds); Assert.True(vm.HasQueued); Assert.Contains(vm.Queued, q => q.Id == "idleTask"); await using var verify = NewContext(); var entity = await verify.Tasks.FirstAsync(t => t.Id == "idleTask"); Assert.Equal(TaskStatus.Queued, entity.Status); } [Fact] public async Task EnqueueTaskAsync_TaskHasOpenConPtySession_DoesNotQueue() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "t1", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new QueueingWorkerClient(NewContext); using var vm = BuildVm(worker); await vm.OpenConPtySessionAsync("t1"); await vm.EnqueueTaskAsync("t1"); Assert.Empty(worker.QueuedTaskIds); await using var verify = NewContext(); var entity = await verify.Tasks.FirstAsync(t => t.Id == "t1"); Assert.Equal(TaskStatus.Idle, entity.Status); } private sealed class ThrowingLaunchSpecWorker : StubWorkerClient { public override Task GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) => throw new InvalidOperationException("no worktree"); } [Fact] public async Task OpenConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenConPtySessionAsync("t1"); Assert.Single(vm.ConPtySessions); Assert.Equal("t1", vm.ConPtySessions[0].TaskId); Assert.Single(vm.Panes); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); Assert.True(vm.HasPanes); } [Fact] public async Task OpenConPtySessionAsync_SameTaskTwice_DoesNotDuplicate() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenConPtySessionAsync("t1"); await vm.OpenConPtySessionAsync("t1"); Assert.Single(vm.ConPtySessions); } [Fact] public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure() { var worker = new ThrowingLaunchSpecWorker(); using var vm = BuildVm(worker); string? error = null; vm.ErrorReported += msg => error = msg; await vm.OpenConPtySessionAsync("t1"); // The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing. var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.Terminal.StartError); Assert.False(pane.Terminal.IsStarting); Assert.NotNull(error); } [Fact] public async Task Panes_MirrorsConPtySessions_InOrder() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenConPtySessionAsync("t1"); await vm.OpenConPtySessionAsync("t2"); Assert.Equal(2, vm.Panes.Count); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); Assert.Same(vm.ConPtySessions[1], vm.Panes[1]); Assert.Equal(2, vm.ColumnCount); } [Fact] public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenConPtySessionAsync("t1"); var pane = vm.ConPtySessions[0]; pane.CloseCommand.Execute(null); Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.Panes); } private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient { public override Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => throw new InvalidOperationException("bad directory"); } [Fact] public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject")); Assert.Single(vm.ConPtySessions); Assert.Null(vm.ConPtySessions[0].TaskId); Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle); Assert.Single(vm.Panes); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); } [Fact] public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); var dir = Path.Combine("C:", "Some", "Path"); await vm.OpenAdHocConPtySessionAsync(dir); await vm.OpenAdHocConPtySessionAsync(dir); Assert.Equal(2, vm.ConPtySessions.Count); Assert.Equal(2, vm.Panes.Count); Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId)); } [Fact] public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure() { var worker = new ThrowingAdHocLaunchSpecWorker(); using var vm = BuildVm(worker); string? error = null; vm.ErrorReported += msg => error = msg; await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path")); var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.Terminal.StartError); Assert.False(pane.Terminal.IsStarting); Assert.NotNull(error); } [Fact] public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path")); var pane = vm.ConPtySessions[0]; pane.CloseCommand.Execute(null); Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.Panes); } private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient { public override Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, CancellationToken ct = default) => throw new InvalidOperationException("spec failed"); } private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient { public override Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => throw new InvalidOperationException("create failed"); } private sealed class FixedTaskIdMergeHelperWorker : StubWorkerClient { public string TaskId { get; } = "fixed-handler-task"; public int CreateCallCount { get; private set; } public override Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) { CreateCallCount++; return Task.FromResult(TaskId); } } [Fact] public async Task OpenMergeHelperConPtySessionAsync_AddsTaskBasedPane() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" }); var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.TaskId); Assert.True(pane.IsTaskBased); Assert.Single(vm.Panes); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_EachGetsItsOwnTaskAndPane() { // Each run creates a brand-new handler task (the stub returns a fresh Guid per call), // so two runs still yield two distinct, non-deduped panes -- but for a different reason // than the old ad-hoc pane (never deduped by construction): dedup is now by TaskId, and // there simply is no shared TaskId across separate runs. var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); Assert.Equal(2, vm.ConPtySessions.Count); Assert.NotEqual(vm.ConPtySessions[0].TaskId, vm.ConPtySessions[1].TaskId); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_SameHandlerTaskId_FocusesExistingPane_NoDuplicate() { var worker = new FixedTaskIdMergeHelperWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); var firstPane = Assert.Single(vm.ConPtySessions); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); Assert.Single(vm.ConPtySessions); Assert.Same(firstPane, vm.FocusedPane); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_EmptySelection_NoPane() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", Array.Empty()); Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.Panes); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure() { var worker = new ThrowingMergeHelperLaunchSpecWorker(); using var vm = BuildVm(worker); string? error = null; vm.ErrorReported += msg => error = msg; await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.Terminal.StartError); Assert.False(pane.Terminal.IsStarting); Assert.NotNull(error); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_TaskCreationThrows_RaisesErrorReported_NoPaneCreated() { var worker = new ThrowingCreateMergeHelperTaskWorker(); using var vm = BuildVm(worker); string? error = null; vm.ErrorReported += msg => error = msg; await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.Panes); Assert.NotNull(error); } [Fact] public void ToggleLayoutCommand_FlipsIsFocusMode() { var worker = new FakeWorker(); using var vm = BuildVm(worker); Assert.False(vm.IsFocusMode); vm.ToggleLayoutCommand.Execute(null); Assert.True(vm.IsFocusMode); vm.ToggleLayoutCommand.Execute(null); Assert.False(vm.IsFocusMode); } }