* TaskRepository.UpdateAsync defensively detaches any locally tracked entity with the same Id before attaching the patched copy, preventing EF identity conflicts when callers load via AsNoTracking and write back through the same DbContext (surfaced by ExternalMcpService UpdateTask integration tests). * TasksIslandViewModel auto-collapse now only fires for Finalized planning parents that are not yet Done. Active-phase parents stay expanded while the user is editing the plan, and Done parents stay expanded so all completed children land in CompletedItems alongside the parent. * Update three Ui.Tests fakes (ConflictResolution, PlanningDiff, DetailsIslandPlanning) to implement the two new IWorkerClient members (OpenInteractiveTerminalAsync, QueuePlanningSubtasksAsync). * Rewrite StreamLineFormatterTests to exercise the current assistant/user/result/system message format instead of the legacy stream_event parsing that was removed in the formatter rewrite. * Align AppSettingsRepository seed-default assertion with the permission-mode default that flipped from bypassPermissions to auto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
6.4 KiB
C#
142 lines
6.4 KiB
C#
using System.ComponentModel;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Planning;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
public class ConflictResolutionViewModelTests
|
|
{
|
|
// ------------------------------------------------------------------ fake
|
|
private sealed class FakeWorker : IWorkerClient
|
|
{
|
|
public bool IsConnected => false;
|
|
|
|
public string? ContinueCalledWith { get; private set; }
|
|
public string? AbortCalledWith { get; private set; }
|
|
public Exception? ContinueThrows { get; set; }
|
|
public Exception? AbortThrows { get; set; }
|
|
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
|
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
|
public event Action<string>? TaskUpdatedEvent;
|
|
public event Action<string>? WorktreeUpdatedEvent;
|
|
public event Action<string, string>? TaskMessageEvent;
|
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
|
public event Action<string>? PlanningMergeAbortedEvent;
|
|
public event Action<string>? PlanningCompletedEvent;
|
|
|
|
public Task WakeQueueAsync() => Task.CompletedTask;
|
|
public Task RunNowAsync(string taskId) => Task.CompletedTask;
|
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
|
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task<List<AgentInfo>> GetAgentsAsync() => Task.FromResult(new List<AgentInfo>());
|
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task DiscardPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
|
|
public Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
|
Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
|
Task.FromResult<CombinedDiffResultDto?>(null);
|
|
public Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
|
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
|
|
public Task ContinuePlanningMergeAsync(string planningTaskId)
|
|
{
|
|
ContinueCalledWith = planningTaskId;
|
|
if (ContinueThrows is not null) throw ContinueThrows;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task AbortPlanningMergeAsync(string planningTaskId)
|
|
{
|
|
AbortCalledWith = planningTaskId;
|
|
if (AbortThrows is not null) throw AbortThrows;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private static ConflictResolutionViewModel BuildVm(FakeWorker worker, string planningTaskId = "plan-1") =>
|
|
new ConflictResolutionViewModel(
|
|
worker,
|
|
planningTaskId,
|
|
subtaskTitle: "My subtask",
|
|
targetBranch: "main",
|
|
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
|
worktreePath: "C:/worktrees/plan-1");
|
|
|
|
// ------------------------------------------------------------------ tests
|
|
|
|
[Fact]
|
|
public async Task ContinueAsync_CallsHub_AndClosesOnSuccess()
|
|
{
|
|
var worker = new FakeWorker();
|
|
var vm = BuildVm(worker, "plan-42");
|
|
bool closeCalled = false;
|
|
vm.CloseRequested = () => closeCalled = true;
|
|
|
|
await vm.ContinueCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("plan-42", worker.ContinueCalledWith);
|
|
Assert.True(closeCalled);
|
|
Assert.Null(vm.ActionError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ContinueAsync_HubThrows_ShowsActionErrorAndStaysOpen()
|
|
{
|
|
var worker = new FakeWorker { ContinueThrows = new InvalidOperationException("hub down") };
|
|
var vm = BuildVm(worker);
|
|
bool closeCalled = false;
|
|
vm.CloseRequested = () => closeCalled = true;
|
|
|
|
await vm.ContinueCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(closeCalled);
|
|
Assert.Equal("hub down", vm.ActionError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortAsync_CallsHub_AndClosesOnSuccess()
|
|
{
|
|
var worker = new FakeWorker();
|
|
var vm = BuildVm(worker, "plan-99");
|
|
bool closeCalled = false;
|
|
vm.CloseRequested = () => closeCalled = true;
|
|
|
|
await vm.AbortCommand.ExecuteAsync(null);
|
|
|
|
Assert.Equal("plan-99", worker.AbortCalledWith);
|
|
Assert.True(closeCalled);
|
|
Assert.Null(vm.ActionError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AbortAsync_HubThrows_ShowsActionError()
|
|
{
|
|
var worker = new FakeWorker { AbortThrows = new InvalidOperationException("abort failed") };
|
|
var vm = BuildVm(worker);
|
|
bool closeCalled = false;
|
|
vm.CloseRequested = () => closeCalled = true;
|
|
|
|
await vm.AbortCommand.ExecuteAsync(null);
|
|
|
|
Assert.False(closeCalled);
|
|
Assert.Equal("abort failed", vm.ActionError);
|
|
}
|
|
|
|
// OpenInVsCode is not unit-tested here because abstracting Process.Start
|
|
// would require an indirection layer that isn't part of the approved design.
|
|
// The error path is covered by the VsCodeError property being set on catch.
|
|
}
|