Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/DiffViewerViewModelTests.cs
T
Mika Kuns f6994f32d5 fix(ui): show ParseOp indicator on the planning-mode diff parse too
ParseOp only covered LoadFilesAsync; OnDisplayedDiffChanged's parse (driven
by ToggleCombinedAsync and OnSelectedSubtaskChanged) had no visible
feedback even though the offload was already correct. Both call sites now
go through one ParseOffUiThreadAsync helper (Begin on the UI thread before
Task.Run, End via `using` regardless of a stale generation), so the
already-visible toolbar indicator covers the planning path without
duplicating the begin/offload/end ceremony.
2026-08-12 13:41:05 +02:00

339 lines
13 KiB
C#

using System.ComponentModel;
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");
}
// A throwaway config path — the view toggles call Save(), and a test must never
// overwrite the developer's real ~/.todo-app/ui.config.json.
private static AppSettings TestSettings() => new()
{
ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"),
};
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; }
public string? CombinedException { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
CombinedException is not null
? throw new Exception(CombinedException)
: 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(), TestSettings());
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(), TestSettings());
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, TestSettings());
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, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.SelectedSubtask = vm.Subtasks[1];
Assert.Equal("DIFF-B", vm.DisplayedDiff);
}
[Fact]
public async Task Planning_SelectSubtask_PopulatesPlanningFilesAsync()
{
const string diff = "diff --git a/foo.cs b/foo.cs\n" +
"--- a/foo.cs\n" +
"+++ b/foo.cs\n" +
"@@ -1,1 +1,1 @@\n" +
"-old\n" +
"+new\n";
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, diff) }
};
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
// OnDisplayedDiffChanged offloads the parse via Task.Run and writes PlanningFiles back
// fire-and-forget, so it may not have landed yet when LoadAsync returns.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.PlanningFiles.Count == 0) await Task.Delay(10);
var file = Assert.Single(vm.PlanningFiles);
Assert.Equal("foo.cs", file.Path);
}
private static string DiffFor(string path) =>
$"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1,1 +1,1 @@\n-old\n+new\n";
[Fact]
public async Task Planning_SelectSubtask_RunsAndEndsParseOp()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, DiffFor("a.cs")),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, DiffFor("b.cs")),
}
};
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
// Let the auto-selected first subtask's parse settle before starting the assertion.
var settleDeadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < settleDeadline && vm.ParseOp.IsRunning) await Task.Delay(10);
// ParseOp.Begin runs synchronously (before the offloaded parse's first await point can
// return), so this handler is guaranteed to observe IsRunning=true regardless of how
// fast the background parse itself completes — no race with the poll loop below.
var sawRunning = false;
vm.ParseOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning) && vm.ParseOp.IsRunning)
sawRunning = true;
};
vm.SelectedSubtask = vm.Subtasks[1];
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.ParseOp.IsRunning) await Task.Delay(10);
Assert.True(sawRunning);
Assert.False(vm.ParseOp.IsRunning);
var file = Assert.Single(vm.PlanningFiles);
Assert.Equal("b.cs", file.Path);
}
[Fact]
public async Task Planning_RapidSubtaskSwitches_ParseOpEndsAndOnlyLatestWins()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[]
{
new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, DiffFor("a.cs")),
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, DiffFor("b.cs")),
}
};
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
var settleDeadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < settleDeadline && vm.ParseOp.IsRunning) await Task.Delay(10);
var sawRunning = false;
vm.ParseOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning) && vm.ParseOp.IsRunning)
sawRunning = true;
};
// Two changes fired back-to-back without awaiting in between: the first parse is
// overtaken by the second before it can write PlanningFiles.
vm.SelectedSubtask = vm.Subtasks[1];
vm.SelectedSubtask = vm.Subtasks[0];
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.ParseOp.IsRunning) await Task.Delay(10);
Assert.True(sawRunning);
Assert.False(vm.ParseOp.IsRunning);
var file = Assert.Single(vm.PlanningFiles);
Assert.Equal("a.cs", file.Path);
}
[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, TestSettings());
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, TestSettings());
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, TestSettings());
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!);
}
[Fact]
public async Task Planning_ToggleCombined_WhenWorkerThrows_ShowsExceptionMessage()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedException = "planning task not found",
};
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
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("planning task not found", vm.CombinedWarning);
Assert.Equal("", vm.DisplayedDiff);
}
}