Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs
T
mika kuns 9c4a63d2c5 fix(worker): thread handler task id into list-handler kickoff prompts
Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).

Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.

RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
2026-08-21 18:00:16 +02:00

793 lines
29 KiB
C#

using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.MissionControl;
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<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 FakeWorker : StubWorkerClient { }
private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
// to completion before either resumes past the DB await. This reproduces the open-path races
// deterministically: a real async gap (e.g. Task.Yield) resumes its continuation on a
// thread-pool thread with no synchronization context to serialize it back onto the caller,
// which turns the repro into a genuine (flaky) data race instead of the intended
// same-thread double-invocation the bug describes.
private sealed class GatedDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
private readonly TaskCompletionSource _gate = new();
public GatedDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
public async Task<ClaudeDoDbContext> CreateDbContextAsync(CancellationToken ct = default)
{
await _gate.Task;
return _create();
}
public void Release() => _gate.TrySetResult();
}
private MissionControlViewModel BuildGatedVm(StubWorkerClient worker, out GatedDbFactory factory)
{
factory = new GatedDbFactory(NewContext);
return new MissionControlViewModel(factory, 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<ActiveTask> 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 { Number = TestTaskNumbers.Next(), Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), 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 { Number = TestTaskNumbers.Next(), 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 { Number = TestTaskNumbers.Next(), 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 { Number = TestTaskNumbers.Next(), 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 { Number = TestTaskNumbers.Next(), Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), 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<ClaudeDoDbContext> _newContext;
public List<string> QueuedTaskIds { get; } = new();
public QueueingWorkerClient(Func<ClaudeDoDbContext> newContext) => _newContext = newContext;
public override async Task<BaseDirtyWarningDto?> 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 null;
entity.Status = status;
await db.SaveChangesAsync();
return null;
}
}
[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 { Number = TestTaskNumbers.Next(), 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 { Number = TestTaskNumbers.Next(), 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");
string? error = null;
vm.ErrorReported += msg => error = msg;
await vm.EnqueueTaskAsync("t1");
Assert.Empty(worker.QueuedTaskIds);
Assert.NotNull(error);
await using var verify = NewContext();
var entity = await verify.Tasks.FirstAsync(t => t.Id == "t1");
Assert.Equal(TaskStatus.Idle, entity.Status);
}
private sealed class ThrowingSetStatusWorkerClient : StubWorkerClient
{
public Exception Error { get; init; } = new Exception("enqueue offline");
public override Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status) =>
throw Error;
}
[Fact]
public async Task EnqueueTaskAsync_WhenWorkerThrows_RaisesErrorReported()
{
var worker = new ThrowingSetStatusWorkerClient();
using var vm = BuildVm(worker);
string? error = null;
vm.ErrorReported += msg => error = msg;
await vm.EnqueueTaskAsync("t1");
Assert.NotNull(error);
Assert.Contains("enqueue offline", error);
}
private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> 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_OverlappingCallsSameTask_ProducesSinglePane()
{
var worker = new FakeWorker();
using var vm = BuildGatedVm(worker, out var factory);
var t1 = vm.OpenConPtySessionAsync("t1");
var t2 = vm.OpenConPtySessionAsync("t1");
factory.Release();
await System.Threading.Tasks.Task.WhenAll(t1, t2);
Assert.Single(vm.ConPtySessions);
Assert.Single(vm.Panes);
}
[Fact]
public async Task OpenPlanningConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenPlanningConPtySessionAsync("t1", resume: false);
Assert.Single(vm.ConPtySessions);
Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
Assert.Single(vm.Panes);
}
[Fact]
public async Task OpenPlanningConPtySessionAsync_OverlappingCallsSameTask_ProducesSinglePane()
{
var worker = new FakeWorker();
using var vm = BuildGatedVm(worker, out var factory);
var t1 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
var t2 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
factory.Release();
await System.Threading.Tasks.Task.WhenAll(t1, t2);
Assert.Single(vm.ConPtySessions);
Assert.Single(vm.Panes);
}
[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<LaunchSpec> 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<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed");
}
private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient
{
public override Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> 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<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> 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_OverlappingCallsSameList_CreatesOnlyOneHostTask()
{
var worker = new FixedTaskIdMergeHelperWorker();
var factory = new GatedDbFactory(NewContext);
using var vm = new MissionControlViewModel(factory, worker, new UsagePillViewModel(worker));
// Both calls run their synchronous guard-check prefix before either resumes past the
// gated DB lookup — call 2 must lose the race and bail out immediately.
var t1 = vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var t2 = vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
factory.Release();
await System.Threading.Tasks.Task.WhenAll(t1, t2);
Assert.Equal(1, worker.CreateCallCount);
Assert.Single(vm.ConPtySessions);
Assert.Single(vm.Panes);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_EmptySelection_NoPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", Array.Empty<string>());
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 async Task OpenMergeHelperHandoffConPtySessionAsync_ExistingPaneForTaskId_ClosesOldPane_OpensNewFocusedPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var outgoingPane = Assert.Single(vm.ConPtySessions);
var taskId = outgoingPane.TaskId!;
await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
var survivingPane = Assert.Single(vm.ConPtySessions);
Assert.Equal(taskId, survivingPane.TaskId);
Assert.NotSame(outgoingPane, survivingPane);
Assert.Same(survivingPane, vm.FocusedPane);
Assert.Single(vm.Panes);
Assert.Same(survivingPane, vm.Panes[0]);
// The outgoing pane went through CloseConPtySession (not just spliced out of the
// collection) -- it unsubscribed its event handlers, same signal the
// CloseConPtySession_UnsubscribesSubmitForReviewRequested test relies on.
Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
}
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
{
var worker = new BlockingSubmitWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var outgoingPane = Assert.Single(vm.ConPtySessions);
var taskId = outgoingPane.TaskId!;
await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
var survivingPane = Assert.Single(vm.ConPtySessions);
survivingPane.Terminal.IsRunning = true; // simulate a live hand-driven session
survivingPane.SubmitForReviewCommand.Execute(null);
Assert.Equal(1, worker.CallCount);
Assert.True(survivingPane.IsSubmitPending);
worker.Gate.SetResult(null);
await Task.Delay(20);
Assert.Empty(vm.ConPtySessions);
}
private sealed class BlockingSubmitWorker : StubWorkerClient
{
public int CallCount { get; private set; }
public readonly TaskCompletionSource<object?> Gate = new();
public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
{
CallCount++;
return Gate.Task;
}
}
[Fact]
public async Task SubmitForReview_RapidDoubleClick_OnlyCallsWorkerOnce()
{
var worker = new BlockingSubmitWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
pane.Terminal.IsRunning = true; // simulate a live hand-driven session
// Bypass CanExecute entirely -- Execute(null) is what a genuinely simultaneous
// double-click would still reach even if the button briefly disables itself.
pane.SubmitForReviewCommand.Execute(null);
pane.SubmitForReviewCommand.Execute(null);
Assert.Equal(1, worker.CallCount);
Assert.True(pane.IsSubmitPending);
worker.Gate.SetResult(null);
await Task.Delay(20);
Assert.Empty(vm.ConPtySessions);
}
[Fact]
public async Task SubmitForReview_Failure_ClearsIsSubmitPending_AllowingRetry()
{
var worker = new ThrowingSubmitWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
pane.Terminal.IsRunning = true;
string? error = null;
vm.ErrorReported += msg => error = msg;
pane.SubmitForReviewCommand.Execute(null);
Assert.NotNull(error);
Assert.False(pane.IsSubmitPending);
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
}
private sealed class ThrowingSubmitWorker : StubWorkerClient
{
public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> throw new InvalidOperationException("worker unreachable");
}
private static int SubscriberCount(ConPtyPaneViewModel pane, string eventFieldName)
{
var field = typeof(ConPtyPaneViewModel).GetField(eventFieldName,
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var del = (Delegate?)field!.GetValue(pane);
return del?.GetInvocationList().Length ?? 0;
}
[Fact]
public async Task CloseConPtySession_UnsubscribesSubmitForReviewRequested()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
Assert.Equal(1, SubscriberCount(pane, "SubmitForReviewRequested"));
pane.CloseCommand.Execute(null);
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
Assert.Equal(0, SubscriberCount(pane, "ErrorReported"));
}
[Fact]
public async Task Dispose_UnsubscribesSubmitForReviewRequested()
{
var worker = new FakeWorker();
var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
vm.Dispose();
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
}
[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);
}
}