Queuing (update_task_status, batch_update_task_status) and run_task_now now surface a non-blocking baseDirty warning (separate modified/untracked counts) when the list's working dir has uncommitted changes at enqueue time, since a new worktree forks from the commit tip and silently misses them. BaseDirtyChecker caches per working dir for a few seconds so a batch queue over many tasks in one list only shells out to git once. The UI surfaces the same warning via the footer error strip on queue actions.
720 lines
26 KiB
C#
720 lines
26 KiB
C#
using System.Linq;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels;
|
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
public class MissionControlViewModelTests : IDisposable
|
|
{
|
|
private readonly string _dbPath;
|
|
|
|
public MissionControlViewModelTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mc_test_{Guid.NewGuid():N}.db");
|
|
using var ctx = NewContext();
|
|
ctx.Database.EnsureCreated();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { File.Delete(_dbPath); } catch { }
|
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
|
}
|
|
|
|
private ClaudeDoDbContext NewContext()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
|
}
|
|
|
|
private sealed class FakeWorker : StubWorkerClient { }
|
|
|
|
private MissionControlViewModel BuildVm(StubWorkerClient worker)
|
|
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
|
|
|
|
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
|
|
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
|
|
// to completion before either resumes past the DB await. This reproduces the open-path races
|
|
// deterministically: a real async gap (e.g. Task.Yield) resumes its continuation on a
|
|
// thread-pool thread with no synchronization context to serialize it back onto the caller,
|
|
// which turns the repro into a genuine (flaky) data race instead of the intended
|
|
// same-thread double-invocation the bug describes.
|
|
private sealed class GatedDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
private readonly TaskCompletionSource _gate = new();
|
|
public GatedDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
|
public async Task<ClaudeDoDbContext> CreateDbContextAsync(CancellationToken ct = default)
|
|
{
|
|
await _gate.Task;
|
|
return _create();
|
|
}
|
|
public void Release() => _gate.TrySetResult();
|
|
}
|
|
|
|
private MissionControlViewModel BuildGatedVm(StubWorkerClient worker, out GatedDbFactory factory)
|
|
{
|
|
factory = new GatedDbFactory(NewContext);
|
|
return new MissionControlViewModel(factory, worker, new UsagePillViewModel(worker));
|
|
}
|
|
|
|
// ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────
|
|
|
|
[Fact]
|
|
public void TaskStarted_DoesNotAddPane()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
|
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
|
|
|
|
Assert.Empty(vm.Panes);
|
|
}
|
|
|
|
[Fact]
|
|
public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks()
|
|
{
|
|
var worker = new SeededFakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
Assert.Empty(vm.Panes);
|
|
}
|
|
|
|
private sealed class SeededFakeWorker : StubWorkerClient
|
|
{
|
|
public override IReadOnlyList<ActiveTask> GetActiveTasks()
|
|
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
|
|
}
|
|
|
|
// ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─
|
|
|
|
[Fact]
|
|
public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
// Running task has higher SortOrder — must still appear first
|
|
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
|
|
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.RefreshQueueAsync();
|
|
|
|
Assert.True(vm.HasQueued);
|
|
Assert.Equal(2, vm.Queued.Count);
|
|
|
|
Assert.Equal("r1", vm.Queued[0].Id);
|
|
Assert.True(vm.Queued[0].IsRunning);
|
|
|
|
Assert.Equal("q1", vm.Queued[1].Id);
|
|
Assert.False(vm.Queued[1].IsRunning);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HasQueued_TrueWhenOnlyRunningTasksExist()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.RefreshQueueAsync();
|
|
|
|
Assert.True(vm.HasQueued);
|
|
}
|
|
|
|
// ── acceptance criterion (c): row click invokes OpenInApp ──────────────────
|
|
|
|
[Fact]
|
|
public async Task Row_Click_InvokesOpenInApp()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
string? opened = null;
|
|
vm.OpenInApp = id => opened = id;
|
|
|
|
await vm.RefreshQueueAsync();
|
|
vm.Queued[0].OpenInAppCommand!.Execute(null);
|
|
|
|
Assert.Equal("q1", opened);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Row_Click_RunningTask_InvokesOpenInApp()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
string? opened = null;
|
|
vm.OpenInApp = id => opened = id;
|
|
|
|
await vm.RefreshQueueAsync();
|
|
vm.Queued[0].OpenInAppCommand!.Execute(null);
|
|
|
|
Assert.Equal("r1", opened);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Queue_ReflectsQueuedTasks_InSortOrder()
|
|
{
|
|
await SeedQueueAsync();
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.RefreshQueueAsync();
|
|
|
|
Assert.True(vm.HasQueued);
|
|
Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
|
|
}
|
|
|
|
private async Task SeedQueueAsync()
|
|
{
|
|
await using var db = NewContext();
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
db.Tasks.Add(new TaskEntity { Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
|
|
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
db.Tasks.Add(new TaskEntity { Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
// Mirrors TaskStateService.EnqueueAsync writing to the same DB so the test can verify the
|
|
// resulting task state without the real worker process — EnqueueTaskAsync now delegates the
|
|
// actual status write to the worker hub instead of doing a raw EF write itself.
|
|
private sealed class QueueingWorkerClient : StubWorkerClient
|
|
{
|
|
private readonly Func<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 { Id = "idleTask", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new QueueingWorkerClient(NewContext);
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.EnqueueTaskAsync("idleTask");
|
|
|
|
Assert.Equal(new[] { "idleTask" }, worker.QueuedTaskIds);
|
|
Assert.True(vm.HasQueued);
|
|
Assert.Contains(vm.Queued, q => q.Id == "idleTask");
|
|
|
|
await using var verify = NewContext();
|
|
var entity = await verify.Tasks.FirstAsync(t => t.Id == "idleTask");
|
|
Assert.Equal(TaskStatus.Queued, entity.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnqueueTaskAsync_TaskHasOpenConPtySession_DoesNotQueue()
|
|
{
|
|
await using (var db = NewContext())
|
|
{
|
|
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
|
db.Tasks.Add(new TaskEntity { Id = "t1", ListId = "L1", Title = "Do the thing", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
var worker = new QueueingWorkerClient(NewContext);
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
|
|
await vm.EnqueueTaskAsync("t1");
|
|
|
|
Assert.Empty(worker.QueuedTaskIds);
|
|
await using var verify = NewContext();
|
|
var entity = await verify.Tasks.FirstAsync(t => t.Id == "t1");
|
|
Assert.Equal(TaskStatus.Idle, entity.Status);
|
|
}
|
|
|
|
private sealed class ThrowingLaunchSpecWorker : StubWorkerClient
|
|
{
|
|
public override Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
|
=> throw new InvalidOperationException("no worktree");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
|
|
Assert.Single(vm.Panes);
|
|
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
|
Assert.True(vm.HasPanes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenConPtySessionAsync_SameTaskTwice_DoesNotDuplicate()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenConPtySessionAsync_OverlappingCallsSameTask_ProducesSinglePane()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildGatedVm(worker, out var factory);
|
|
|
|
var t1 = vm.OpenConPtySessionAsync("t1");
|
|
var t2 = vm.OpenConPtySessionAsync("t1");
|
|
factory.Release();
|
|
await System.Threading.Tasks.Task.WhenAll(t1, t2);
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
Assert.Single(vm.Panes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningConPtySessionAsync_AddsPane_ToConPtySessionsAndPanes()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenPlanningConPtySessionAsync("t1", resume: false);
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
Assert.Equal("t1", vm.ConPtySessions[0].TaskId);
|
|
Assert.Single(vm.Panes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningConPtySessionAsync_OverlappingCallsSameTask_ProducesSinglePane()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildGatedVm(worker, out var factory);
|
|
|
|
var t1 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
|
|
var t2 = vm.OpenPlanningConPtySessionAsync("t1", resume: false);
|
|
factory.Release();
|
|
await System.Threading.Tasks.Task.WhenAll(t1, t2);
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
Assert.Single(vm.Panes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
|
{
|
|
var worker = new ThrowingLaunchSpecWorker();
|
|
using var vm = BuildVm(worker);
|
|
string? error = null;
|
|
vm.ErrorReported += msg => error = msg;
|
|
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
|
|
// The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
Assert.NotNull(pane.Terminal.StartError);
|
|
Assert.False(pane.Terminal.IsStarting);
|
|
Assert.NotNull(error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Panes_MirrorsConPtySessions_InOrder()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
await vm.OpenConPtySessionAsync("t2");
|
|
|
|
Assert.Equal(2, vm.Panes.Count);
|
|
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
|
Assert.Same(vm.ConPtySessions[1], vm.Panes[1]);
|
|
Assert.Equal(2, vm.ColumnCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
|
|
Assert.Empty(vm.ConPtySessions);
|
|
Assert.Empty(vm.Panes);
|
|
}
|
|
|
|
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
|
|
{
|
|
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
|
=> throw new InvalidOperationException("bad directory");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject"));
|
|
|
|
Assert.Single(vm.ConPtySessions);
|
|
Assert.Null(vm.ConPtySessions[0].TaskId);
|
|
Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle);
|
|
Assert.Single(vm.Panes);
|
|
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
var dir = Path.Combine("C:", "Some", "Path");
|
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
|
await vm.OpenAdHocConPtySessionAsync(dir);
|
|
|
|
Assert.Equal(2, vm.ConPtySessions.Count);
|
|
Assert.Equal(2, vm.Panes.Count);
|
|
Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
|
{
|
|
var worker = new ThrowingAdHocLaunchSpecWorker();
|
|
using var vm = BuildVm(worker);
|
|
string? error = null;
|
|
vm.ErrorReported += msg => error = msg;
|
|
|
|
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
|
|
|
var pane = Assert.Single(vm.ConPtySessions);
|
|
Assert.NotNull(pane.Terminal.StartError);
|
|
Assert.False(pane.Terminal.IsStarting);
|
|
Assert.NotNull(error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
|
var pane = vm.ConPtySessions[0];
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
|
|
Assert.Empty(vm.ConPtySessions);
|
|
Assert.Empty(vm.Panes);
|
|
}
|
|
|
|
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
|
|
{
|
|
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, 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 BlockingSubmitWorker : StubWorkerClient
|
|
{
|
|
public int CallCount { get; private set; }
|
|
public readonly TaskCompletionSource<object?> Gate = new();
|
|
|
|
public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
return Gate.Task;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitForReview_RapidDoubleClick_OnlyCallsWorkerOnce()
|
|
{
|
|
var worker = new BlockingSubmitWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
pane.Terminal.IsRunning = true; // simulate a live hand-driven session
|
|
|
|
// Bypass CanExecute entirely -- Execute(null) is what a genuinely simultaneous
|
|
// double-click would still reach even if the button briefly disables itself.
|
|
pane.SubmitForReviewCommand.Execute(null);
|
|
pane.SubmitForReviewCommand.Execute(null);
|
|
|
|
Assert.Equal(1, worker.CallCount);
|
|
Assert.True(pane.IsSubmitPending);
|
|
|
|
worker.Gate.SetResult(null);
|
|
await Task.Delay(20);
|
|
|
|
Assert.Empty(vm.ConPtySessions);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubmitForReview_Failure_ClearsIsSubmitPending_AllowingRetry()
|
|
{
|
|
var worker = new ThrowingSubmitWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
pane.Terminal.IsRunning = true;
|
|
string? error = null;
|
|
vm.ErrorReported += msg => error = msg;
|
|
|
|
pane.SubmitForReviewCommand.Execute(null);
|
|
|
|
Assert.NotNull(error);
|
|
Assert.False(pane.IsSubmitPending);
|
|
Assert.True(pane.SubmitForReviewCommand.CanExecute(null));
|
|
}
|
|
|
|
private sealed class ThrowingSubmitWorker : StubWorkerClient
|
|
{
|
|
public override Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
|
|
=> throw new InvalidOperationException("worker unreachable");
|
|
}
|
|
|
|
private static int SubscriberCount(ConPtyPaneViewModel pane, string eventFieldName)
|
|
{
|
|
var field = typeof(ConPtyPaneViewModel).GetField(eventFieldName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
var del = (Delegate?)field!.GetValue(pane);
|
|
return del?.GetInvocationList().Length ?? 0;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CloseConPtySession_UnsubscribesSubmitForReviewRequested()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
|
|
Assert.Equal(1, SubscriberCount(pane, "SubmitForReviewRequested"));
|
|
|
|
pane.CloseCommand.Execute(null);
|
|
|
|
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
|
|
Assert.Equal(0, SubscriberCount(pane, "ErrorReported"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_UnsubscribesSubmitForReviewRequested()
|
|
{
|
|
var worker = new FakeWorker();
|
|
var vm = BuildVm(worker);
|
|
await vm.OpenConPtySessionAsync("t1");
|
|
var pane = vm.ConPtySessions[0];
|
|
|
|
vm.Dispose();
|
|
|
|
Assert.Equal(0, SubscriberCount(pane, "SubmitForReviewRequested"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleLayoutCommand_FlipsIsFocusMode()
|
|
{
|
|
var worker = new FakeWorker();
|
|
using var vm = BuildVm(worker);
|
|
|
|
Assert.False(vm.IsFocusMode);
|
|
vm.ToggleLayoutCommand.Execute(null);
|
|
Assert.True(vm.IsFocusMode);
|
|
vm.ToggleLayoutCommand.Execute(null);
|
|
Assert.False(vm.IsFocusMode);
|
|
}
|
|
}
|