using System; using System.Threading.Tasks; using ClaudeDo.Data.Models; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using Xunit; namespace ClaudeDo.Ui.Tests; // Covers the MCP-driven-merge popup fix: a unit-merge conflict started by an MCP session // (review_task/continue_merge) must not auto-open the in-app resolver — two parties editing the // same shared checkout at once is how the original bug manifested. The UI-driven path (a human // clicking Approve) keeps opening the resolver as before. public class IslandsShellViewModelExternalMergeTests { [Fact] public void ExternallyDriven_ShowsBanner() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: true); Assert.True(vm.IsExternalMergeBannerVisible); } [Fact] public void UiDriven_DoesNotShowExternalBanner() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", new[] { "file.txt" }, externallyDriven: false); Assert.False(vm.IsExternalMergeBannerVisible); } [Fact] public void Aborted_ClearsBanner() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty(), externallyDriven: true); Assert.True(vm.IsExternalMergeBannerVisible); vm.OnPlanningMergeAborted("plan1"); Assert.False(vm.IsExternalMergeBannerVisible); } // Covers the unit-merge-error-propagation fix: a real merge failure (blocked/verify_failed/ // untracked_collision) reaches the UI as a reason on PlanningMergeAborted, and must surface // in the footer strip rather than silently vanishing once the banner is cleared. [Fact] public void Aborted_WithReason_FlashesFooterError() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeAborted("plan1", "child merge blocked: unrelated histories"); Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel); Assert.Contains("child merge blocked: unrelated histories", vm.WorkerLogText); Assert.True(vm.IsWorkerLogVisible); } [Fact] public void Aborted_WithoutReason_DoesNotFlashFooterError() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeAborted("plan1", null); Assert.False(vm.IsWorkerLogVisible); } [Fact] public void Completed_ClearsBanner() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty(), externallyDriven: true); vm.OnPlanningMergeCompleted("plan1"); Assert.False(vm.IsExternalMergeBannerVisible); } [Fact] public void UnrelatedPlanningTaskEnding_LeavesBannerVisible() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty(), externallyDriven: true); vm.OnPlanningMergeCompleted("some-other-plan"); Assert.True(vm.IsExternalMergeBannerVisible); } [Fact] public void LaterConflictOnSamePlanningTask_UpdatesInPlaceWithoutDuplicating() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "subA", Array.Empty(), externallyDriven: true); vm.OnPlanningMergeConflict("plan1", "subB", Array.Empty(), externallyDriven: true); Assert.True(vm.IsExternalMergeBannerVisible); // A single terminal event for that planning task clears it entirely — proves the second // conflict updated the same entry rather than piling up a second one. vm.OnPlanningMergeCompleted("plan1"); Assert.False(vm.IsExternalMergeBannerVisible); } [Fact] public async Task OpenExternalMergeConflictCommand_NoPendingConflict_DoesNotThrow() { var vm = new IslandsShellViewModel(); await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null); } [Fact] public async Task OpenExternalMergeConflictCommand_NoDialogsWired_DoesNotThrow() { var vm = new IslandsShellViewModel(); vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty(), externallyDriven: true); // Dialogs/ConflictResolverFactory aren't wired in the test-only ctor — a deliberate // click must degrade gracefully rather than throw, same as the existing UI-driven path. await vm.OpenExternalMergeConflictCommand.ExecuteAsync(null); } private sealed class DirtyTargetWorker : StubWorkerClient { public override Task StartConflictMergeAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult(new MergeResultDto("dirty_target", Array.Empty(), "target working tree has uncommitted changes")); } [Fact] public async Task RequestConflictResolution_ResolverRefused_ShowsWhyInTheFooter() { // Regression: a refusal (dirty target, ...) left the resolver closed with no explanation anywhere. var vm = new IslandsShellViewModel { Dialogs = new RecordingDialogService(), ConflictResolverFactory = id => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(new DirtyTargetWorker(), id), }; await vm.RequestConflictResolutionAsync("t1", "main"); Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel); Assert.Contains("uncommitted changes", vm.WorkerLogText); } }