feat(merge): unify planning conflicts onto the resolver + 3-pane VM foundation
Route planning unit-merge conflicts through ConflictResolverViewModel (OpenForPlanningAsync) and delete the old ConflictResolutionViewModel dialog. Add active-file 3-pane reconstruction (MergeFile OursText/TheirsText/ResultText, ActiveFile, SelectFileCommand, active-file readout) as the VM foundation for the Rider-style editor. Seam preserved; Ui.Tests 128/128.
This commit is contained in:
@@ -19,22 +19,39 @@ public class ConflictResolverViewModelTests
|
||||
|
||||
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)
|
||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||
{
|
||||
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)
|
||||
{
|
||||
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||
WrittenTaskId = taskId; WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||
@@ -160,4 +177,137 @@ public class ConflictResolverViewModelTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user