Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelExternalMergeTests.cs
T
mika kunsandClaude Sonnet 5.5 feb0afa33a fix(merge): pass the stash consent to the conflict resolver so it opens on a dirty target
The resolver restarts the merge via StartConflictMerge, which had no autostash.
After a stash-retry approve reported 'conflict', the restart hit the same dirty
target, got 'dirty_target' and the resolver silently never opened. Thread the
consent through the hub, client, coordinator and resolver; surface a refusal in
the footer instead of dropping it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:25:52 +02:00

151 lines
5.4 KiB
C#

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<string>(), 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<string>(), externallyDriven: true);
vm.OnPlanningMergeCompleted("plan1");
Assert.False(vm.IsExternalMergeBannerVisible);
}
[Fact]
public void UnrelatedPlanningTaskEnding_LeavesBannerVisible()
{
var vm = new IslandsShellViewModel();
vm.OnPlanningMergeConflict("plan1", "sub1", Array.Empty<string>(), 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<string>(), externallyDriven: true);
vm.OnPlanningMergeConflict("plan1", "subB", Array.Empty<string>(), 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<string>(), 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<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch, bool autostash = false) =>
Task.FromResult(new MergeResultDto("dirty_target", Array.Empty<string>(), "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);
}
}