## Ziel Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den die abgebende Session selbst geschrieben hat. Motivation: eine lange Session mit vollem/verrottetem Kontex ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
1230 lines
48 KiB
C#
1230 lines
48 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));
|
|
|
|
// 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 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);
|
|
}
|
|
}
|