using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.MissionControl; using Xunit; namespace ClaudeDo.Ui.Tests.ViewModels.MissionControl; public class ConPtyPaneViewModelTests { private static ConPtyPaneViewModel NewTaskPane( Func> descriptorFactory, string taskId = "t1") => new(taskId, "Some Task", descriptorFactory); private static Task NeverCompletes() => new TaskCompletionSource().Task; private static Task Failing(string message = "boom") => Task.FromException(new InvalidOperationException(message)); // ── CanSubmitForReview gating (fix: don't offer Submit for Review on a dead/starting pane) ── [Fact] public void CanSubmitForReview_False_WhileStarting() { using var pane = NewTaskPane(NeverCompletes); pane.Start(); Assert.True(pane.Terminal.IsStarting); Assert.False(pane.SubmitForReviewCommand.CanExecute(null)); } [Fact] public void CanSubmitForReview_False_AfterLaunchFailure() { using var pane = NewTaskPane(() => Failing()); pane.Start(); Assert.NotNull(pane.Terminal.StartError); Assert.True(pane.Terminal.HasExited); Assert.False(pane.SubmitForReviewCommand.CanExecute(null)); } [Fact] public void CanSubmitForReview_True_WhenRunning() { using var pane = NewTaskPane(NeverCompletes); SetRunning(pane); Assert.True(pane.SubmitForReviewCommand.CanExecute(null)); } [Fact] public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning() { using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes); SetRunning(pane); Assert.False(pane.IsTaskBased); Assert.False(pane.SubmitForReviewCommand.CanExecute(null)); } [Fact] public void CanSubmitForReview_False_WhileSubmitPending() { using var pane = NewTaskPane(NeverCompletes); SetRunning(pane); Assert.True(pane.SubmitForReviewCommand.CanExecute(null)); pane.IsSubmitPending = true; Assert.False(pane.SubmitForReviewCommand.CanExecute(null)); pane.IsSubmitPending = false; Assert.True(pane.SubmitForReviewCommand.CanExecute(null)); } // ── Retry (fix: a failed launch used to permanently occupy the TaskId dedupe slot) ────────── [Fact] public void RetryCommand_Disabled_BeforeAndWhileStarting() { using var pane = NewTaskPane(NeverCompletes); Assert.False(pane.RetryCommand.CanExecute(null)); pane.Start(); Assert.False(pane.RetryCommand.CanExecute(null)); // still starting, no failure yet } [Fact] public void RetryCommand_Enabled_AfterLaunchFailure() { using var pane = NewTaskPane(() => Failing()); pane.Start(); Assert.True(pane.RetryCommand.CanExecute(null)); } [Fact] public void Retry_ReplacesTerminal_AndRefetchesDescriptor() { var callCount = 0; Func> factory = () => { callCount++; return callCount == 1 ? Failing() : NeverCompletes(); }; using var pane = NewTaskPane(factory); pane.Start(); Assert.True(pane.RetryCommand.CanExecute(null)); var terminalBeforeRetry = pane.Terminal; pane.RetryCommand.Execute(null); Assert.Equal(2, callCount); Assert.NotSame(terminalBeforeRetry, pane.Terminal); Assert.Null(pane.Terminal.StartError); Assert.False(pane.Terminal.HasExited); Assert.False(pane.RetryCommand.CanExecute(null)); } [Fact] public void Retry_FailsAgain_StillOffersRetry() { using var pane = NewTaskPane(() => Failing()); pane.Start(); Assert.True(pane.RetryCommand.CanExecute(null)); pane.RetryCommand.Execute(null); Assert.NotNull(pane.Terminal.StartError); Assert.True(pane.RetryCommand.CanExecute(null)); } // ── Died-at-startup (fix: an immediate ConPTY exit used to leave StartError null, so Retry // never became reachable and the pane was stuck reusing the same persisted session id) ── [Fact] public void RetryCommand_Enabled_AfterProcessExitsNonzeroRightAfterLaunch() { using var pane = NewTaskPane(NeverCompletes); SetRunning(pane); Assert.False(pane.RetryCommand.CanExecute(null)); pane.Terminal.OnSessionProcessExited(null, 1); Assert.NotNull(pane.Terminal.StartError); Assert.True(pane.RetryCommand.CanExecute(null)); } // ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no // StartError, no signal at all) ───────────────────────────────────────────────────────── [Fact] public void RetryCommand_Enabled_AfterDeathAfterStartup() { using var pane = NewTaskPane(NeverCompletes); DieAfterRunning(pane, exitCode: 1); Assert.True(pane.Terminal.HasExitedAfterStartup); Assert.True(pane.RetryCommand.CanExecute(null)); } [Fact] public void ExitedBannerText_IncludesExitCode() { using var pane = NewTaskPane(NeverCompletes); DieAfterRunning(pane, exitCode: 42); Assert.Contains("42", pane.ExitedBannerText); } // ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ──────────────────── [Fact] public void SessionDiedAfterStartup_TaskPane_RaisesWithTaskIdAndExitCode() { using var pane = NewTaskPane(NeverCompletes, taskId: "handler-1"); (string TaskId, int ExitCode)? reported = null; pane.SessionDiedAfterStartup += (id, code) => reported = (id, code); DieAfterRunning(pane, exitCode: 1); Assert.Equal(("handler-1", 1), reported); } [Fact] public void SessionDiedAfterStartup_AdHocPane_NeverRaises() { using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes); var raised = false; pane.SessionDiedAfterStartup += (_, _) => raised = true; DieAfterRunning(pane, exitCode: 1); Assert.False(raised); } // Retry swaps in a fresh terminal -- the death of the RETRIED session must still be reported. [Fact] public void SessionDiedAfterStartup_StillWiredAfterRetry() { var callCount = 0; using var pane = NewTaskPane( () => callCount++ == 0 ? Failing() : NeverCompletes(), taskId: "handler-1"); pane.Start(); pane.RetryCommand.Execute(null); var raised = false; pane.SessionDiedAfterStartup += (_, _) => raised = true; DieAfterRunning(pane, exitCode: 1); Assert.True(raised); } // ── Terminal.ErrorReported forwarding (fix: a failed paste/drop had no path to the footer // error strip) ──────────────────────────────────────────────────────────────────────────── [Fact] public void TerminalErrorReported_Forwards_ToPaneErrorReported() { using var pane = NewTaskPane(NeverCompletes); string? error = null; pane.ErrorReported += msg => error = msg; pane.Terminal.ReportSendFailed(); Assert.NotNull(error); } [Fact] public void TerminalErrorReported_StillWiredAfterRetry() { using var pane = NewTaskPane(() => Failing()); pane.Start(); pane.RetryCommand.Execute(null); string? error = null; pane.ErrorReported += msg => error = msg; pane.Terminal.ReportSendFailed(); Assert.NotNull(error); } private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true; // Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch // time off the real clock, so a sleep-free "ran for a while" needs the exit routed through a // terminal whose clock has already moved on. 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); } }