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() .UseSqlite($"Data Source={_dbPath}") .Options; return new ClaudeDoDbContext(opts); } private sealed class TestDbFactory : IDbContextFactory { private readonly Func _create; public TestDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); } private sealed class FakeWorker : StubWorkerClient { } private MissionControlViewModel BuildVm(StubWorkerClient worker) => new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker)); // 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 { private readonly Func _create; private readonly TaskCompletionSource _gate = new(); public GatedDbFactory(Func create) => _create = create; public ClaudeDoDbContext CreateDbContext() => _create(); public async Task 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 GetActiveTasks() => new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) }; } // ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─ [Fact] public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); // Running task has higher SortOrder — must still appear first db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); Assert.Equal(2, vm.Queued.Count); Assert.Equal("r1", vm.Queued[0].Id); Assert.True(vm.Queued[0].IsRunning); Assert.Equal("q1", vm.Queued[1].Id); Assert.False(vm.Queued[1].IsRunning); } [Fact] public async Task HasQueued_TrueWhenOnlyRunningTasksExist() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); } // ── acceptance criterion (c): row click invokes OpenInApp ────────────────── [Fact] public async Task Row_Click_InvokesOpenInApp() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); string? opened = null; vm.OpenInApp = id => opened = id; await vm.RefreshQueueAsync(); vm.Queued[0].OpenInAppCommand!.Execute(null); Assert.Equal("q1", opened); } [Fact] public async Task Row_Click_RunningTask_InvokesOpenInApp() { await using (var db = NewContext()) { db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); await db.SaveChangesAsync(); } var worker = new FakeWorker(); using var vm = BuildVm(worker); string? opened = null; vm.OpenInApp = id => opened = id; await vm.RefreshQueueAsync(); vm.Queued[0].OpenInAppCommand!.Execute(null); Assert.Equal("r1", opened); } [Fact] public async Task Queue_ReflectsQueuedTasks_InSortOrder() { await SeedQueueAsync(); var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.RefreshQueueAsync(); Assert.True(vm.HasQueued); Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray()); } private async Task SeedQueueAsync() { await using var db = NewContext(); db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); db.Tasks.Add(new TaskEntity { Id = "q2", ListId = "L1", Title = "second", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 1 }); db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "first", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 }); db.Tasks.Add(new TaskEntity { Id = "idle1", ListId = "L1", Title = "idle", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, SortOrder = 2 }); await db.SaveChangesAsync(); } // Mirrors TaskStateService.EnqueueAsync writing to the same DB so the test can verify the // resulting task state without the real worker process — EnqueueTaskAsync now delegates the // actual status write to the worker hub instead of doing a raw EF write itself. private sealed class QueueingWorkerClient : StubWorkerClient { private readonly Func _newContext; public List QueuedTaskIds { get; } = new(); public QueueingWorkerClient(Func newContext) => _newContext = newContext; public override async Task SetTaskStatusAsync(string taskId, TaskStatus status) { QueuedTaskIds.Add(taskId); await using var db = _newContext(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId); if (entity is null) return 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 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 GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => throw new InvalidOperationException("bad directory"); } [Fact] public async Task OpenAdHocConPtySessionAsync_AddsPane_TitleFromDirectoryLeaf() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path", "MyProject")); Assert.Single(vm.ConPtySessions); Assert.Null(vm.ConPtySessions[0].TaskId); Assert.Equal("MyProject", vm.ConPtySessions[0].DisplayTitle); Assert.Single(vm.Panes); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); } [Fact] public async Task OpenAdHocConPtySessionAsync_TwoOpens_NeverDeduped_YieldsTwoPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); var dir = Path.Combine("C:", "Some", "Path"); await vm.OpenAdHocConPtySessionAsync(dir); await vm.OpenAdHocConPtySessionAsync(dir); Assert.Equal(2, vm.ConPtySessions.Count); Assert.Equal(2, vm.Panes.Count); Assert.All(vm.ConPtySessions, s => Assert.Null(s.TaskId)); } [Fact] public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure() { var worker = new ThrowingAdHocLaunchSpecWorker(); using var vm = BuildVm(worker); string? error = null; vm.ErrorReported += msg => error = msg; await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path")); var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.Terminal.StartError); Assert.False(pane.Terminal.IsStarting); Assert.NotNull(error); } [Fact] public async Task CloseConPtySession_RemovesAdHocPane_FromConPtySessionsAndPanes() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path")); var pane = vm.ConPtySessions[0]; pane.CloseCommand.Execute(null); Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.Panes); } private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient { public override Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, CancellationToken ct = default) => throw new InvalidOperationException("spec failed"); } private sealed class ThrowingCreateMergeHelperTaskWorker : StubWorkerClient { public override Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => throw new InvalidOperationException("create failed"); } private sealed class FixedTaskIdMergeHelperWorker : StubWorkerClient { public string TaskId { get; } = "fixed-handler-task"; public int CreateCallCount { get; private set; } public override Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) { CreateCallCount++; return Task.FromResult(TaskId); } } [Fact] public async Task OpenMergeHelperConPtySessionAsync_AddsTaskBasedPane() { var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" }); var pane = Assert.Single(vm.ConPtySessions); Assert.NotNull(pane.TaskId); Assert.True(pane.IsTaskBased); Assert.Single(vm.Panes); Assert.Same(vm.ConPtySessions[0], vm.Panes[0]); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_EachGetsItsOwnTaskAndPane() { // Each run creates a brand-new handler task (the stub returns a fresh Guid per call), // so two runs still yield two distinct, non-deduped panes -- but for a different reason // than the old ad-hoc pane (never deduped by construction): dedup is now by TaskId, and // there simply is no shared TaskId across separate runs. var worker = new FakeWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); Assert.Equal(2, vm.ConPtySessions.Count); Assert.NotEqual(vm.ConPtySessions[0].TaskId, vm.ConPtySessions[1].TaskId); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_SameHandlerTaskId_FocusesExistingPane_NoDuplicate() { var worker = new FixedTaskIdMergeHelperWorker(); using var vm = BuildVm(worker); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); var firstPane = Assert.Single(vm.ConPtySessions); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" }); Assert.Single(vm.ConPtySessions); Assert.Same(firstPane, vm.FocusedPane); } [Fact] public async Task OpenMergeHelperConPtySessionAsync_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()); 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 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); } }