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>
218 lines
7.7 KiB
C#
218 lines
7.7 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using Xunit;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<MergeResultDto> 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<string>(), null);
|
|
|
|
/// Result for the autostash retry.
|
|
public MergeResultDto StashedResult { get; set; } = new("merged", new List<string>(), null);
|
|
|
|
public override Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) =>
|
|
Task.FromResult(Targets);
|
|
|
|
public override Task<MergeResultDto> 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<string>(), 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<string>(), "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<string>(), 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<string>(), 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<string>(), "uncommitted changes"),
|
|
StashedResult = new("conflict", new List<string> { "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);
|
|
}
|
|
}
|