1290 lines
50 KiB
C#
1290 lines
50 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 FakeWorker : StubWorkerClient { }
|
|
|
|
private MissionControlViewModel BuildVm(StubWorkerClient worker)
|
|
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
|
|
|
|
[Fact]
|
|
public void RecentSessions_NestHandoffs_PutsIndirectHandoffsUnderTheirOrigin()
|
|
{
|
|
var t0 = new DateTime(2026, 9, 28, 8, 0, 0, DateTimeKind.Utc);
|
|
MissionControlSessionEntity S(string id, int minutes, string? from = null)
|
|
=> new() { SessionId = id, Cwd = "c", Title = id, StartedAt = t0.AddMinutes(minutes), PredecessorSessionId = from };
|
|
// Newest first, as ListRecentAsync returns them. "orphan" names a predecessor outside the window.
|
|
var newestFirst = new[] { S("c", 40, "b"), S("other", 30), S("b", 20, "a"), S("orphan", 15, "gone"), S("a", 10) };
|
|
|
|
var rows = MissionControlViewModel.NestHandoffs(newestFirst)
|
|
.Select(r => (r.Session.SessionId, r.IsHandoff)).ToList();
|
|
|
|
Assert.Equal(
|
|
new[] { ("a", false), ("b", true), ("c", true), ("other", false), ("orphan", false) },
|
|
rows);
|
|
}
|
|
|
|
// 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<ActiveTaskDto> GetActiveTasks()
|
|
=> new[] { new ActiveTaskDto("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());
|
|
}
|
|
|
|
// ── activity strip: attention rows come first and carry a tint ─────────────
|
|
|
|
[Fact]
|
|
public async Task RefreshQueueAsync_IncludesAttentionRows_AttentionFirst_WithTint()
|
|
{
|
|
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 = "queued", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "run1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
|
|
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rev1", ListId = "L1", Title = "review", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
|
|
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rb1", ListId = "L1", Title = "roadblocked", Status = TaskStatus.WaitingForReview, RoadblockCount = 2, CreatedAt = DateTime.UtcNow, SortOrder = 3 });
|
|
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "f1", ListId = "L1", Title = "failed", Status = TaskStatus.Failed, CreatedAt = DateTime.UtcNow, SortOrder = 4 });
|
|
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "done1", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 5 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using var vm = BuildVm(new FakeWorker());
|
|
await vm.RefreshQueueAsync();
|
|
|
|
// Done is not activity; the other five are, ordered failed → review → running → queued.
|
|
Assert.Equal(new[] { "f1", "rev1", "rb1", "run1", "q1" }, vm.Queued.Select(q => q.Id).ToArray());
|
|
|
|
Assert.Equal("ErrorTint", vm.Queued[0].TintKey);
|
|
Assert.Equal("DoneTint", vm.Queued[1].TintKey);
|
|
Assert.Equal("RoadblockTint", vm.Queued[2].TintKey); // roadblocks outrank plain review
|
|
Assert.Equal("RunningTint", vm.Queued[3].TintKey);
|
|
Assert.Null(vm.Queued[4].TintKey);
|
|
Assert.Equal("RunningTintBorder", vm.Queued[3].TintBorderKey);
|
|
|
|
// A running task's worktree belongs to the autonomous run — no interactive session on it.
|
|
Assert.False(vm.Queued[3].CanOpenSession);
|
|
Assert.True(vm.Queued[4].CanOpenSession);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow, SortOrder = 1 });
|
|
db.Lists.Add(new ListEntity { Id = "L2", Name = "Beta", WorkingDir = @"C:\repos\beta", CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoDir", CreatedAt = DateTime.UtcNow, SortOrder = 2 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
using var vm = BuildVm(new FakeWorker());
|
|
var browsed = false;
|
|
vm.BrowseForSessionRequested = () => browsed = true;
|
|
|
|
await vm.RefreshSessionTargetsAsync();
|
|
|
|
// Home + browse first (they survive a failing list query), then the lists in sort order.
|
|
Assert.Equal(4, vm.SessionTargets.Count);
|
|
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray());
|
|
|
|
vm.SessionTargets[1].StartCommand.Execute(null);
|
|
Assert.True(browsed);
|
|
}
|
|
|
|
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 SwapPanes_SwapsSlots_InBothCollections()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
await vm.OpenConPtySessionAsync("t2");
|
|
await vm.OpenConPtySessionAsync("t3");
|
|
var (a, b, c) = (vm.Panes[0], vm.Panes[1], vm.Panes[2]);
|
|
|
|
vm.SwapPanes(c, a);
|
|
|
|
Assert.Equal(new[] { c, b, a }, vm.Panes);
|
|
Assert.Equal(new[] { c, b, a }, vm.ConPtySessions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"claudedo_mc_cfg_{Guid.NewGuid():N}.json");
|
|
try
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker,
|
|
new UsagePillViewModel(worker), new AppSettings { ConfigPath = path });
|
|
for (var i = 1; i <= 4; i++) await vm.OpenConPtySessionAsync($"t{i}");
|
|
Assert.Equal(2, vm.ColumnCount); // auto: 4 panes -> 2x2
|
|
|
|
vm.SetColumnsCommand.Execute("3");
|
|
Assert.Equal(3, vm.ColumnCount);
|
|
|
|
vm.SetColumnsCommand.Execute("4");
|
|
vm.Panes[0].CloseCommand.Execute(null);
|
|
Assert.Equal(3, vm.ColumnCount); // never more columns than panes
|
|
|
|
Assert.Equal(4, AppSettings.Load(path).MissionControlColumns);
|
|
|
|
vm.SetColumnsCommand.Execute("0");
|
|
Assert.Equal(2, vm.ColumnCount);
|
|
}
|
|
finally { try { File.Delete(path); } catch { } }
|
|
}
|
|
|
|
[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, string paneId, 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, string? model = null, 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);
|
|
}
|
|
|
|
private sealed class RecordingReviewMergeWorker : StubWorkerClient
|
|
{
|
|
public string TaskId { get; } = "review-handler-task";
|
|
public readonly TaskCompletionSource<(string TaskId, IReadOnlyList<string> Ids, string Phase)> HandoffCall = new();
|
|
public override Task<string> CreateMergeHelperTaskAsync(
|
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
|
=> Task.FromResult(TaskId);
|
|
public override Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
|
|
{
|
|
HandoffCall.TrySetResult((taskId, survivingTaskIds, nextPhase));
|
|
return base.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase, ct);
|
|
}
|
|
}
|
|
|
|
// The review-list "merge the reviews" entry: same host-task shape as the full list handler,
|
|
// but the pane's launch spec must come from the handoff path with phase "merge" and the
|
|
// freshly created handler task id — no triage phase first.
|
|
[Fact]
|
|
public async Task OpenMergeReviewConPtySessionAsync_AddsPane_LaunchingMergePhaseForHandlerTask()
|
|
{
|
|
var worker = new RecordingReviewMergeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenMergeReviewConPtySessionAsync("L1", new[] { "r1", "r2" });
|
|
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
Assert.Equal(worker.TaskId, pane.TaskId);
|
|
Assert.True(pane.IsTaskBased);
|
|
|
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(worker.TaskId, call.TaskId);
|
|
Assert.Equal(new[] { "r1", "r2" }, call.Ids);
|
|
Assert.Equal("merge", call.Phase);
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
// The handoff replaces a tile in place -- appending it would push it to the end of the grid and
|
|
// reshuffle every other tile on every phase change.
|
|
[Fact]
|
|
public async Task OpenMergeHelperHandoffConPtySessionAsync_KeepsPanePosition()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
var dir = Path.Combine("C:", "Some", "Path");
|
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
|
var taskId = vm.ConPtySessions[1].TaskId!;
|
|
var color = vm.ConPtySessions[1].IdentityColorKey;
|
|
|
|
await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
|
|
|
|
Assert.Equal(3, vm.ConPtySessions.Count);
|
|
Assert.Equal(taskId, vm.ConPtySessions[1].TaskId);
|
|
Assert.Equal(taskId, vm.Panes[1].TaskId);
|
|
// Same task, new pane -- the identity colour has to survive the handoff, or the tile the
|
|
// user was tracking changes colour mid-run.
|
|
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
|
|
}
|
|
|
|
private sealed class RecordingSessionHandoffWorker : StubWorkerClient
|
|
{
|
|
public string? AdHocPaneId;
|
|
public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new();
|
|
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
|
{
|
|
AdHocPaneId ??= paneId;
|
|
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
|
|
}
|
|
public override Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
|
{
|
|
HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId));
|
|
return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct);
|
|
}
|
|
}
|
|
|
|
// handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the
|
|
// outgoing pane is really closed rather than left running beside the new one.
|
|
[Fact]
|
|
public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd()
|
|
{
|
|
var worker = new RecordingSessionHandoffWorker();
|
|
using var vm = BuildVm(worker);
|
|
var dir = Path.Combine("C:", "Some", "Path");
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
|
await vm.OpenConPtySessionAsync("t2");
|
|
var outgoing = vm.ConPtySessions[1];
|
|
Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId);
|
|
var color = outgoing.IdentityColorKey;
|
|
var opened = 0;
|
|
vm.OpenRequested += () => opened++;
|
|
|
|
worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md");
|
|
|
|
Assert.Equal(3, vm.ConPtySessions.Count);
|
|
var incoming = vm.ConPtySessions[1];
|
|
Assert.NotSame(outgoing, incoming);
|
|
Assert.Null(incoming.TaskId);
|
|
Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey);
|
|
Assert.Equal(color, incoming.IdentityColorKey);
|
|
Assert.Same(incoming, vm.Panes[1]);
|
|
Assert.Equal(1, opened);
|
|
Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested"));
|
|
|
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SessionHandoffRequested_TaskPane_KeepsTaskId()
|
|
{
|
|
var worker = new RecordingSessionHandoffWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
|
|
worker.RaiseSessionHandoffRequested("t1", "brief.md");
|
|
|
|
Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId);
|
|
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("t1", call.TaskId);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionHandoffRequested_UnknownPane_DoesNothing()
|
|
{
|
|
var worker = new RecordingSessionHandoffWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
worker.RaiseSessionHandoffRequested("nope", "brief.md");
|
|
|
|
Assert.Empty(vm.ConPtySessions);
|
|
Assert.False(worker.HandoffCall.Task.IsCompleted);
|
|
}
|
|
|
|
[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"));
|
|
}
|
|
|
|
// ── usage-limit auto-restart: the exit code has to reach the worker ─────────────────────────
|
|
|
|
private sealed class RecordingExitWorker : StubWorkerClient
|
|
{
|
|
public readonly List<(string TaskId, int ExitCode)> Reported = new();
|
|
public override Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
|
{
|
|
Reported.Add((taskId, exitCode));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
// A handler tile that ran for a while and then died non-zero used to be a silent dead pane.
|
|
// Whether it was the usage limit (and worth reopening) is the worker's call, so the UI's only
|
|
// job is reporting the exit -- hence no filtering here, every task pane reports.
|
|
[Fact]
|
|
public async Task PaneSessionDiesAfterRunning_ReportsExitToWorker()
|
|
{
|
|
var worker = new RecordingExitWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
|
|
DieAfterRunning(pane, exitCode: 1);
|
|
|
|
Assert.Equal(new[] { ("handler-1", 1) }, worker.Reported);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClosedPane_DoesNotReportItsSessionExit()
|
|
{
|
|
var worker = new RecordingExitWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
DieAfterRunning(pane, exitCode: 1);
|
|
|
|
Assert.Empty(worker.Reported);
|
|
}
|
|
|
|
// Drives a pane's terminal past its startup grace window and then kills it. IsRunning stamps the
|
|
// launch time off the terminal's own clock, so a sleep-free "ran for 90 minutes" needs a fresh
|
|
// terminal with a stubbed clock -- the pane re-wires its handlers on assignment.
|
|
private static void DieAfterRunning(ConPtyPaneViewModel pane, int exitCode)
|
|
{
|
|
var launchedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var callCount = 0;
|
|
pane.Terminal = new InteractiveTerminalViewModel(
|
|
() => callCount++ == 0 ? launchedAt : launchedAt.AddMinutes(90));
|
|
pane.Terminal.IsRunning = true;
|
|
pane.Terminal.OnSessionProcessExited(null, exitCode);
|
|
}
|
|
|
|
// The worker's pre-usage-limit warning is typed into the pane for that handler task. There is no
|
|
// ConPTY process in a headless test, so SendLineAsync reports "not delivered" -- what this pins
|
|
// is the routing: the right pane is picked, and a warning for a task with no pane open is a
|
|
// silent no-op rather than an error in the footer.
|
|
[Fact]
|
|
public async Task HandlerUsageWarning_WithNoPaneForTheTask_IsSilentlyIgnored()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
string? error = null;
|
|
vm.ErrorReported += msg => error = msg;
|
|
|
|
await vm.WarnHandlerSessionAsync("handler-nobody-opened", "USAGE LIMIT IMMINENT");
|
|
|
|
Assert.Null(error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HandlerUsageWarning_UndeliverableToItsPane_SurfacesAnError()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperHandoffConPtySessionAsync("handler-1", new[] { "t1" }, "merge");
|
|
Assert.Single(vm.ConPtySessions);
|
|
string? error = null;
|
|
vm.ErrorReported += msg => error = msg;
|
|
|
|
await vm.WarnHandlerSessionAsync("handler-1", "USAGE LIMIT IMMINENT");
|
|
|
|
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);
|
|
}
|
|
|
|
// ── Abandon run: single-action "stop session + cancel its still-open tasks" ────────────────
|
|
|
|
private sealed class RecordingAbandonWorker : StubWorkerClient
|
|
{
|
|
public IReadOnlyList<HandlerOpenTaskDto> OpenTasks { get; set; } = Array.Empty<HandlerOpenTaskDto>();
|
|
public readonly List<string> Cancelled = new();
|
|
|
|
public override Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
|
|
=> Task.FromResult(OpenTasks);
|
|
|
|
public override Task CancelTaskAsync(string taskId)
|
|
{
|
|
Cancelled.Add(taskId);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
// A plain OpenConPtySessionAsync pane (not a list handler) never shows/allows the action --
|
|
// acceptance criterion 5 ("only at handler panes").
|
|
[Fact]
|
|
public async Task AbandonRun_OnNonHandlerPane_IsNotAvailable()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
|
|
Assert.False(pane.IsListHandler);
|
|
Assert.False(pane.AbandonRunCommand.CanExecute(null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbandonRun_ListHandlerPane_IsListHandlerTrue_CommandAvailable()
|
|
{
|
|
var worker = new RecordingAbandonWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
|
|
Assert.True(pane.IsListHandler);
|
|
Assert.True(pane.AbandonRunCommand.CanExecute(null));
|
|
}
|
|
|
|
// Acceptance criterion 4: no open tasks (or no round entry, which the fake models the same
|
|
// way) closes the pane straight away -- no confirmation dialog.
|
|
[Fact]
|
|
public async Task AbandonRun_NoOpenTasks_ClosesPaneWithoutConfirming()
|
|
{
|
|
var worker = new RecordingAbandonWorker { OpenTasks = Array.Empty<HandlerOpenTaskDto>() };
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
var confirmCalled = false;
|
|
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
|
|
|
|
pane.AbandonRunCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.False(confirmCalled);
|
|
Assert.Empty(vm.ConPtySessions);
|
|
}
|
|
|
|
// Acceptance criterion 3 (decline branch): declining leaves the session and its tasks
|
|
// untouched.
|
|
[Fact]
|
|
public async Task AbandonRun_OpenTasks_ConfirmDeclined_LeavesSessionAndTasksUntouched()
|
|
{
|
|
var worker = new RecordingAbandonWorker
|
|
{
|
|
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
|
|
};
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
string? confirmMessage = null;
|
|
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
|
|
|
|
pane.AbandonRunCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.NotNull(confirmMessage);
|
|
Assert.Empty(worker.Cancelled);
|
|
Assert.Single(vm.ConPtySessions);
|
|
}
|
|
|
|
// Acceptance criteria 1-3 (accept branch): the count is confirmed, every open task is
|
|
// cancelled, then the pane closes -- one action instead of two.
|
|
[Fact]
|
|
public async Task AbandonRun_OpenTasks_ConfirmAccepted_CancelsEachThenClosesPane()
|
|
{
|
|
var worker = new RecordingAbandonWorker
|
|
{
|
|
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
|
|
};
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
vm.ConfirmAsync = _ => Task.FromResult(true);
|
|
|
|
pane.AbandonRunCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
|
|
Assert.Empty(vm.ConPtySessions);
|
|
}
|
|
|
|
// The tile's own X must not silently kill a live session -- a reflex click on a running pane
|
|
// has to go through the same confirm mechanism as Abandon Run.
|
|
[Fact]
|
|
public async Task Close_RunningSession_ConfirmDeclined_LeavesSessionOpen()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
pane.Terminal.IsRunning = true;
|
|
string? confirmMessage = null;
|
|
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.NotNull(confirmMessage);
|
|
Assert.Single(vm.ConPtySessions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Close_RunningSession_ConfirmAccepted_ClosesPane()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
pane.Terminal.IsRunning = true;
|
|
vm.ConfirmAsync = _ => Task.FromResult(true);
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.Empty(vm.ConPtySessions);
|
|
}
|
|
|
|
// A dead session (start failure or exited-after-startup) is pure cleanup -- no confirm needed.
|
|
[Fact]
|
|
public async Task Close_DeadSession_ClosesWithoutConfirming()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
pane.Terminal.ReportStartFailure("boom");
|
|
var confirmCalled = false;
|
|
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.False(confirmCalled);
|
|
Assert.Empty(vm.ConPtySessions);
|
|
}
|
|
}
|