using System.Collections.Generic; using System.Threading.Tasks; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Modals; using Xunit; namespace ClaudeDo.Ui.Tests.ViewModels; /// /// Covers the two things that made a working merge look broken: the prefilled commit message /// (must be the worker's conventional default, not a hand-rolled "Merge task: …") and the /// progress feedback while the post-merge verify gate holds the call open for minutes. /// public class MergeModalViewModelTests { private sealed class Worker : StubWorkerClient { public MergeTargetsDto? Targets { get; set; } = new("main", new[] { "main", "dev" }, "fix(my-list): merge do the thing"); public TaskCompletionSource MergeGate { get; } = new(); public bool BlockMerge { get; set; } public string? CapturedCommitMessage { get; private set; } public bool CapturedAutostash { get; private set; } /// Result for a call with autostash=false; the autostash retry always merges. public MergeResultDto CleanResult { get; set; } = new("merged", new List(), null); /// Result for the autostash retry. public MergeResultDto StashedResult { get; set; } = new("merged", new List(), null); public override Task GetMergeTargetsAsync(string taskId) => Task.FromResult(Targets); public override Task MergeTaskAsync( string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) { CapturedCommitMessage = commitMessage; CapturedAutostash = autostash; return BlockMerge ? MergeGate.Task : Task.FromResult(autostash ? StashedResult : CleanResult); } } private static (MergeModalViewModel vm, Worker worker) Build() { var worker = new Worker(); return (new MergeModalViewModel(worker, new MergeCoordinator()), worker); } [Fact] public async Task Initialize_prefills_the_workers_default_commit_message() { var (vm, _) = Build(); await vm.InitializeAsync("task-1", "do the thing"); Assert.Equal("fix(my-list): merge do the thing", vm.CommitMessage); Assert.Equal("main", vm.SelectedBranch); } [Fact] public async Task Initialize_keeps_local_fallback_when_worker_sends_no_default() { var (vm, worker) = Build(); worker.Targets = new MergeTargetsDto("main", new[] { "main" }, ""); await vm.InitializeAsync("task-1", "do the thing"); Assert.False(string.IsNullOrWhiteSpace(vm.CommitMessage)); Assert.NotEqual("", vm.CommitMessage); } [Fact] public async Task Initialize_flags_a_dirty_target_up_front() { var (vm, worker) = Build(); worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: true); await vm.InitializeAsync("task-1", "do the thing"); Assert.True(vm.ShowStashRetry); Assert.False(string.IsNullOrWhiteSpace(vm.WarningMessage)); } [Fact] public async Task Initialize_does_not_flag_a_clean_target() { var (vm, worker) = Build(); worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: false); await vm.InitializeAsync("task-1", "do the thing"); Assert.False(vm.ShowStashRetry); Assert.Null(vm.WarningMessage); } [Fact] public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after() { var (vm, worker) = Build(); await vm.InitializeAsync("task-1", "do the thing"); worker.BlockMerge = true; var submit = vm.SubmitCommand.ExecuteAsync(null); Assert.True(vm.IsBusy); var merging = vm.ProgressMessage; Assert.False(string.IsNullOrWhiteSpace(merging)); // The verify gate starts — a distinct phase, so the user sees why this is taking minutes. // (The elapsed time rides in a format argument, which the test localizer drops.) worker.RaiseOperationProgress("task-1", "verifying", 90, 0); Assert.NotEqual(merging, vm.ProgressMessage); worker.MergeGate.SetResult(new MergeResultDto("merged", new List(), null)); await submit; Assert.Null(vm.ProgressMessage); Assert.False(vm.IsBusy); Assert.True(vm.Merged); } [Fact] public async Task Dirty_target_offers_the_stash_retry_and_merges_with_autostash() { var (vm, worker) = Build(); await vm.InitializeAsync("task-1", "do the thing"); worker.CleanResult = new MergeResultDto("dirty_target", new List(), "target working tree has uncommitted changes"); await vm.SubmitCommand.ExecuteAsync(null); Assert.True(vm.ShowStashRetry); Assert.False(vm.Merged); Assert.False(worker.CapturedAutostash); await vm.StashAndMergeCommand.ExecuteAsync(null); Assert.True(worker.CapturedAutostash); Assert.True(vm.Merged); Assert.False(vm.ShowStashRetry); } [Fact] public async Task Progress_for_another_task_is_ignored() { var (vm, worker) = Build(); await vm.InitializeAsync("task-1", "do the thing"); worker.BlockMerge = true; var submit = vm.SubmitCommand.ExecuteAsync(null); var merging = vm.ProgressMessage; worker.RaiseOperationProgress("some-other-task", "verifying", 42, 0); Assert.Equal(merging, vm.ProgressMessage); worker.MergeGate.SetResult(new MergeResultDto("merged", new List(), null)); await submit; } [Fact] public async Task Submit_unsubscribes_so_a_later_broadcast_cannot_revive_the_progress_line() { var (vm, worker) = Build(); await vm.InitializeAsync("task-1", "do the thing"); await vm.SubmitCommand.ExecuteAsync(null); worker.RaiseOperationProgress("task-1", "verifying", 10, 0); Assert.Null(vm.ProgressMessage); } [Fact] public async Task Submit_shows_the_rebase_phase_with_branch_count_instead_of_stalled_merging() { var (vm, worker) = Build(); await vm.InitializeAsync("task-1", "do the thing"); worker.BlockMerge = true; var submit = vm.SubmitCommand.ExecuteAsync(null); var merging = vm.ProgressMessage; // Needs the branch total, which only OperationProgress carries. worker.RaiseOperationProgress("task-1", "rebasing", 1, 2); Assert.NotEqual(merging, vm.ProgressMessage); Assert.Contains("(1/2)", vm.ProgressMessage); worker.MergeGate.SetResult(new MergeResultDto("merged", new List(), null)); await submit; Assert.Null(vm.ProgressMessage); } [Fact] public async Task Stash_retry_ending_in_conflict_hands_the_stash_consent_to_the_resolver() { // Regression: the resolver restarts the merge itself and needs the same consent, or the // dirty target refuses it and the resolver never opens. var worker = new Worker { CleanResult = new("dirty_target", new List(), "uncommitted changes"), StashedResult = new("conflict", new List { "a.cs" }, null), }; bool? capturedAutostash = null; var vm = new MergeModalViewModel(worker, new MergeCoordinator { Handler = (_, _, autostash) => { capturedAutostash = autostash; return Task.CompletedTask; }, }); await vm.InitializeAsync("task-1", "do the thing"); await vm.StashAndMergeCommand.ExecuteAsync(null); Assert.True(capturedAutostash); } }