Merge branch 'claudedo/75514ee68371402b814638290af47cca'
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||
@@ -53,6 +53,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question);
|
||||
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
|
||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds);
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||
public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId);
|
||||
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
|
||||
|
||||
public void RaisePrepStarted() => PrepStartedEvent?.Invoke();
|
||||
public void RaisePrepLine(string line) => PrepLineEvent?.Invoke(line);
|
||||
@@ -129,6 +133,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult<CombinedDiffResultDto?>(null);
|
||||
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public virtual Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
|
||||
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||
public virtual Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult<string?>(null);
|
||||
public virtual Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult("");
|
||||
|
||||
@@ -245,6 +245,98 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
||||
Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─── externallyDriven (MCP-driven merges must not auto-open the UI resolver) ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_ExternallyDriven_ConflictBroadcastFlagsExternallyDriven()
|
||||
{
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||
|
||||
var (orch, spy) = BuildOrchestrator(db);
|
||||
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
|
||||
|
||||
var conflictCall = Assert.Single(spy, c => c.Method == "PlanningMergeConflict");
|
||||
Assert.True((bool)conflictCall.Args[3]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DefaultNotExternallyDriven_ConflictBroadcastFlagsFalse()
|
||||
{
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||
|
||||
var (orch, spy) = BuildOrchestrator(db);
|
||||
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||
|
||||
var conflictCall = Assert.Single(spy, c => c.Method == "PlanningMergeConflict");
|
||||
Assert.False((bool)conflictCall.Args[3]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveExternalConflictsAsync_ExternallyDrivenAndMidMerge_ReturnsEntry()
|
||||
{
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var (parentId, _, subB, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||
|
||||
var (orch, _) = BuildOrchestrator(db);
|
||||
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
|
||||
|
||||
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
|
||||
var entry = Assert.Single(active);
|
||||
Assert.Equal(parentId, entry.PlanningTaskId);
|
||||
Assert.Equal(subB, entry.SubtaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveExternalConflictsAsync_UiDriven_ReturnsEmpty()
|
||||
{
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||
|
||||
var (orch, _) = BuildOrchestrator(db);
|
||||
await orch.StartAsync(parentId, "main", CancellationToken.None); // externallyDriven defaults false
|
||||
|
||||
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
|
||||
Assert.Empty(active);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The in-memory ExternallyDriven flag alone must never be trusted: if the repo was already
|
||||
/// taken out of mid-merge through some other path (e.g. the driving Claude session died and
|
||||
/// something else ran `git merge --abort` directly, without going through AbortAsync), the
|
||||
/// query must not report a phantom external conflict. Proves the MERGE_HEAD coupling.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetActiveExternalConflictsAsync_StaleStateRepoNoLongerMidMerge_SelfHeals()
|
||||
{
|
||||
var db = NewDb();
|
||||
var repo = NewRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||
|
||||
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
|
||||
|
||||
var (orch, _) = BuildOrchestrator(db);
|
||||
await orch.StartAsync(parentId, "main", CancellationToken.None, externallyDriven: true);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||
|
||||
var active = await orch.GetActiveExternalConflictsAsync(CancellationToken.None);
|
||||
Assert.Empty(active);
|
||||
}
|
||||
|
||||
// ─── Stateless abort (post-restart recovery) ───────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -109,7 +109,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
|
||||
public event Action<string>? PlanningMergeAbortedEvent;
|
||||
public event Action<string>? PlanningCompletedEvent;
|
||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||
@@ -121,6 +121,8 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => Task.FromResult<CombinedDiffResultDto?>(null);
|
||||
public Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||
public Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
||||
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
|
||||
|
||||
public Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult<AppSettingsDto?>(null);
|
||||
public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user