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 Docs = new[] { OneConflictFile() }; public bool StartCalled; public string? ContinuedPlanningParent; public string? AbortedPlanningParent; public override Task 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 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 ContinueConflictMergeAsync(string taskId) { Continued = true; return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty(), 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()) }, }; 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\no\nz\n", file.ResultText); // unresolved seeds Ours // 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("ours-b\n", vm.ActiveResultText); // unresolved seeds Ours // 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 conflicts · 0 resolved", vm.PositionText); vm.Current!.AcceptOursCommand.Execute(null); // 1 conflict, 1 resolved Assert.Equal("1 conflicts · 1 resolved", vm.PositionText); } }