Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
Mika Kuns 23a93ce0bb
All checks were successful
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
fix(merge): unresolved conflicts compose to empty, not Ours (+ review nits)
Code-review follow-ups before push:
- MergeFile.ResultText/Compose() fell back to Ours for unresolved conflicts while
  the editor seeds them empty — align both on empty so the public model matches the
  pane and Continue can't silently auto-accept Ours.
- Bound the gutter re-layout retry (was an unbounded Background re-post when the
  editor isn't laid out, e.g. minimized).
- Pluralize the readout ('1 conflict' not '1 conflicts'). Tests updated. Ui 128 green.
2026-06-19 13:14:51 +02:00

314 lines
11 KiB
C#

using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Conflicts;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
// A file split as: stable "a\n", one conflict, stable "z\n".
private static ConflictDocumentDto OneConflictFile(string path = "README.md") =>
new(path, false, new[]
{
new MergeSegmentDto(false, "a\n", "", null, ""),
new MergeSegmentDto(true, "", "ours\n", "base\n", "theirs\n"),
new MergeSegmentDto(false, "z\n", "", null, ""),
});
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenTaskId;
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public IReadOnlyList<ConflictDocumentDto> Docs = new[] { OneConflictFile() };
public bool StartCalled;
public string? ContinuedPlanningParent;
public string? AbortedPlanningParent;
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
{
StartCalled = true;
return Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
}
public override Task ContinuePlanningMergeAsync(string planningTaskId)
{
ContinuedPlanningParent = planningTaskId; return Task.CompletedTask;
}
public override Task AbortPlanningMergeAsync(string planningTaskId)
{
AbortedPlanningParent = planningTaskId; return Task.CompletedTask;
}
public override Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> Task.FromResult(new MergeConflictDocumentsDto(taskId, Docs));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
WrittenTaskId = taskId; WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
{
Continued = true;
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
}
public override Task AbortConflictMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
}
[Fact]
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.Equal(1, vm.TotalConflicts);
Assert.NotNull(vm.Current);
Assert.False(vm.CanContinue);
vm.Current!.AcceptTheirsCommand.Execute(null);
Assert.True(vm.CanContinue);
Assert.Equal(1, vm.ResolvedCount);
}
[Fact]
public async Task Continue_WritesComposedFile_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Current!.AcceptOursCommand.Execute(null); // choose "ours\n" for the single conflict
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
// stable "a\n" + chosen "ours\n" + stable "z\n"
Assert.Equal("a\nours\nz\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
[Fact]
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
{
var worker = new FakeWorker { ContinueStatus = "conflict" };
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Current!.AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Navigation_MovesBetweenConflicts_AcrossTheFlattenedList()
{
var worker = new FakeWorker
{
Docs = new[]
{
new ConflictDocumentDto("a.cs", false, new[]
{
new MergeSegmentDto(true, "", "o1\n", null, "t1\n"),
new MergeSegmentDto(false, "mid\n", "", null, ""),
new MergeSegmentDto(true, "", "o2\n", null, "t2\n"),
}),
},
};
var vm = new ConflictResolverViewModel(worker, "task-1");
await vm.OpenAsync("main");
Assert.Equal(2, vm.TotalConflicts);
Assert.Equal("o1\n", vm.Current!.Ours);
Assert.False(vm.PreviousCommand.CanExecute(null));
Assert.True(vm.NextCommand.CanExecute(null));
vm.NextCommand.Execute(null);
Assert.Equal("o2\n", vm.Current!.Ours);
Assert.False(vm.NextCommand.CanExecute(null));
Assert.True(vm.PreviousCommand.CanExecute(null));
}
[Fact]
public async Task BinaryConflict_BlocksContinue()
{
var worker = new FakeWorker
{
Docs = new[] { new ConflictDocumentDto("logo.png", true, System.Array.Empty<MergeSegmentDto>()) },
};
var vm = new ConflictResolverViewModel(worker, "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts); // file is shown
Assert.True(vm.HasBinaryFiles);
Assert.Equal(0, vm.TotalConflicts); // nothing to resolve in-app
Assert.False(vm.CanContinue); // binary blocks continue
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.AbortCommand.ExecuteAsync(null);
Assert.True(worker.Aborted);
Assert.True(closed);
}
[Fact]
public async Task OpenForPlanning_LoadsConflicts_WithoutStartingAMerge()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "parent-1");
var hasConflicts = await vm.OpenForPlanningAsync("parent-1", "subtask-7");
Assert.True(hasConflicts);
Assert.False(worker.StartCalled); // the orchestrator already started the merge
Assert.Equal(1, vm.TotalConflicts);
}
[Fact]
public async Task PlanningContinue_HandsBackToOrchestrator_AndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "parent-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenForPlanningAsync("parent-1", "subtask-7");
vm.Current!.AcceptOursCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("subtask-7", worker.WrittenTaskId); // resolution written against the subtask's working tree
Assert.Equal("parent-1", worker.ContinuedPlanningParent);
Assert.False(worker.Continued); // single-task continue must NOT be used in planning mode
Assert.True(closed);
}
[Fact]
public async Task PlanningAbort_AbortsThePlanningMerge()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "parent-1");
await vm.OpenForPlanningAsync("parent-1", "subtask-7");
await vm.AbortCommand.ExecuteAsync(null);
Assert.Equal("parent-1", worker.AbortedPlanningParent);
Assert.False(worker.Aborted); // single-task abort must NOT be used in planning mode
}
// ── Task 1 new tests ─────────────────────────────────────────────────────
[Fact]
public void OursTheirsResult_Reconstruction()
{
// stable "a\n" + conflict(ours "o\n", base null, theirs "t\n") + stable "z\n"
var segments = new[]
{
MergeFileSegment.Stable("a\n"),
MergeFileSegment.FromConflict(new MergeConflictBlock("o\n", null, "t\n")),
MergeFileSegment.Stable("z\n"),
};
var file = new MergeFile("f.cs", false, segments);
Assert.Equal("a\no\nz\n", file.OursText);
Assert.Equal("a\nt\nz\n", file.TheirsText);
Assert.Equal("a\nz\n", file.ResultText); // unresolved conflicts start empty
// After resolving: ResultText reflects the resolution
file.Conflicts[0].Resolution = "r\n";
Assert.Equal("a\nr\nz\n", file.ResultText);
}
[Fact]
public async Task ActiveFile_DefaultsToFirstFile_AfterOpen()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
await vm.OpenAsync("main");
Assert.NotNull(vm.ActiveFile);
Assert.Equal("README.md", vm.ActiveFile!.Path);
}
[Fact]
public async Task SelectFile_PreservesResolution_AndUpdatesPassThroughs()
{
// Two-file setup
var worker = new FakeWorker
{
Docs = new[]
{
new ConflictDocumentDto("a.cs", false, new[]
{
new MergeSegmentDto(false, "a\n", "", null, ""),
new MergeSegmentDto(true, "", "ours-a\n", null, "theirs-a\n"),
new MergeSegmentDto(false, "z\n", "", null, ""),
}),
new ConflictDocumentDto("b.cs", false, new[]
{
new MergeSegmentDto(true, "", "ours-b\n", null, "theirs-b\n"),
}),
},
};
var vm = new ConflictResolverViewModel(worker, "task-1");
await vm.OpenAsync("main");
// Resolve the block in file A
var fileA = vm.Files[0];
fileA.Conflicts[0].Resolution = "resolved-a\n";
// Switch to file B
vm.SelectFileCommand.Execute(vm.Files[1]);
Assert.Equal("b.cs", vm.ActiveFile!.Path);
Assert.Equal("", vm.ActiveResultText); // unresolved conflicts start empty
// Switch back to file A
vm.SelectFileCommand.Execute(vm.Files[0]);
Assert.Equal("a.cs", vm.ActiveFile!.Path);
// Resolution survived the round-trip
Assert.Equal("a\nresolved-a\nz\n", vm.ActiveResultText);
Assert.Equal("a\nours-a\nz\n", vm.ActiveOursText);
}
[Fact]
public async Task PositionText_ReadsActiveFileConflicts()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
await vm.OpenAsync("main");
// 1 conflict, 0 resolved
Assert.Equal("1 conflict · 0 resolved", vm.PositionText);
vm.Current!.AcceptOursCommand.Execute(null);
// 1 conflict, 1 resolved
Assert.Equal("1 conflict · 1 resolved", vm.PositionText);
}
}