refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff

This commit is contained in:
Mika Kuns
2026-06-23 09:30:37 +02:00
parent 4022bd7197
commit 167d2fec6a
28 changed files with 923 additions and 1120 deletions

View File

@@ -1,54 +0,0 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DiffModalViewModelTests
{
public DiffModalViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
[Fact]
public async Task LoadAsync_CommitRange_NullHeadCommit_ShowsUnavailableState()
{
var vm = new DiffModalViewModel(null!)
{
WorktreePath = "/some/repo",
BaseRef = "abc123",
HeadCommit = null,
FromCommitRange = true,
};
await vm.LoadAsync();
Assert.Empty(vm.Files);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LoadAsync_CommitRange_NullBaseRef_ShowsUnavailableState()
{
var vm = new DiffModalViewModel(null!)
{
WorktreePath = "/some/repo",
BaseRef = null,
HeadCommit = "def456",
FromCommitRange = true,
};
await vm.LoadAsync();
Assert.Empty(vm.Files);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,196 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DiffViewerViewModelTests
{
public DiffViewerViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult);
}
// ── Files mode: commit-range guards (ported from DiffModal) ──
[Fact]
public async Task CommitRange_NullHeadCommit_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
vm.ConfigureCommitRange("/some/repo", "abc123", null);
await vm.LoadAsync();
Assert.Empty(vm.FileTree);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CommitRange_NullBaseRef_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
vm.ConfigureCommitRange("/some/repo", null, "def456");
await vm.LoadAsync();
Assert.Empty(vm.FileTree);
Assert.NotNull(vm.StatusMessage);
Assert.Contains("no longer available", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
// ── File tree building ──
[Fact]
public void DiffTree_Build_GroupsByFolder_AndCarriesFileLeaves()
{
var files = new List<DiffFileViewModel>
{
new() { Path = "src/a/x.cs", Additions = 3, Deletions = 1 },
new() { Path = "src/a/y.cs" },
new() { Path = "readme.md", Status = DiffFileStatus.Added },
};
var roots = DiffTree.Build(files);
Assert.Equal(2, roots.Count); // "src" dir + "readme.md" leaf
var src = roots.First(n => n is { IsDirectory: true, Name: "src" });
var a = Assert.Single(src.Children);
Assert.True(a is { IsDirectory: true, Name: "a" });
Assert.Equal(2, a.Children.Count);
var x = a.Children.First(n => n.Name == "x.cs");
Assert.False(x.IsDirectory);
Assert.NotNull(x.File);
Assert.Equal(3, x.Additions);
var readme = roots.First(n => n is { IsDirectory: false, Name: "readme.md" });
Assert.Equal("A", readme.StatusCode);
// First leaf walks depth-first in insertion order → src/a/x.cs.
Assert.Equal(x, DiffTree.FirstLeaf(roots));
}
// ── Planning mode (ported from PlanningDiff) ──
[Fact]
public async Task Planning_Load_PopulatesSubtasks_SelectsFirst()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
Assert.Equal(2, vm.Subtasks.Count);
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
}
[Fact]
public async Task Planning_SelectSubtask_SetsDisplayedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.SelectedSubtask = vm.Subtasks[1];
Assert.Equal("DIFF-B", vm.DisplayedDiff);
}
[Fact]
public async Task Planning_ToggleCombined_Success_DisplaysUnifiedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
Assert.Null(vm.CombinedWarning);
}
[Fact]
public async Task Planning_ToggleCombined_Conflict_ShowsWarning()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("subtask-42", vm.CombinedWarning);
Assert.Contains("2 files", vm.CombinedWarning);
}
[Fact]
public async Task Planning_ToggleCombined_HubReturnsNull_ShowsError()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = null,
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!);
}
}

View File

@@ -1,143 +0,0 @@
using System.IO;
using ClaudeDo.Data.Models;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Planning;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PlanningDiffViewModelTests
{
public PlanningDiffViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult);
}
[Fact]
public async Task InitializeAsync_PopulatesSubtasks()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "branch-1", "base1", "head1", "+1 -0", "diff1"),
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
}
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
Assert.Equal(2, vm.Subtasks.Count);
Assert.Equal(vm.Subtasks[0], vm.SelectedSubtask);
}
[Fact]
public async Task SelectingSubtask_InGroupedMode_SetsDisplayedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
}
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.SelectedSubtask = vm.Subtasks[1];
Assert.Equal("DIFF-B", vm.DisplayedDiff);
}
[Fact]
public async Task ToggleCombined_Success_DisplaysUnifiedDiff()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
// Wait for the async toggle command to complete
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.Equal("COMBINED-DIFF", vm.DisplayedDiff);
Assert.Null(vm.CombinedWarning);
}
[Fact]
public async Task ToggleCombined_Conflict_ShowsWarning()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("subtask-42", vm.CombinedWarning);
Assert.Contains("2 files", vm.CombinedWarning);
}
[Fact]
public async Task ToggleCombined_HubReturnsNull_ShowsError()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A"),
},
CombinedResult = null,
};
var vm = new PlanningDiffViewModel(fake, "plan-1", "main");
await vm.InitializeAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined)
await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!);
}
}