feat(ui): add merge-target dropdown and merge-all controls to planning detail

- Add SubtaskDiffDto and CombinedDiffResultDto to PlanningDtos.cs
- Extend IWorkerClient with 5 planning merge methods and 5 events
- Implement methods and hub subscriptions on WorkerClient
- Add Status and WorktreeState to SubtaskRowViewModel
- Add MergeTargetBranches, SelectedMergeTarget, CanMergeAll,
  MergeAllDisabledReason, MergeAllError, RecomputeCanMergeAll,
  MergeAllCommand, ReviewCombinedDiffCommand (Task 14 TODO)
  to DetailsIslandViewModel
- Add planning merge section to DetailsIslandView.axaml
  (merge target ComboBox + buttons + error label), gated on
  Task.IsPlanningParent
- Add 4 xUnit tests covering CanMergeAll logic and DTO shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 16:22:27 +02:00
parent 2cab33d708
commit 4c6fd9f024
6 changed files with 409 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandPlanningTests
{
// Minimal shim exercising RecomputeCanMergeAll logic without needing WorkerClient
private sealed class PlanningVmShim
{
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
public bool CanMergeAll { get; private set; }
public string? MergeAllDisabledReason { get; private set; }
public void RecomputeCanMergeAll()
{
var notDone = Subtasks.Count(c => c.Status != TaskStatus.Done);
if (notDone > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
return;
}
var badWt = Subtasks.FirstOrDefault(c =>
c.WorktreeState == WorktreeState.Discarded ||
c.WorktreeState == WorktreeState.Kept);
if (badWt is not null)
{
CanMergeAll = false;
MergeAllDisabledReason = "at least one worktree was discarded/kept";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
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 MergeTargetsDto? MergeTargetsResult { get; set; }
public Task WakeQueueAsync() => 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(MergeTargetsResult);
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 ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
public void FireTaskUpdated(string id) => TaskUpdatedEvent?.Invoke(id);
public void FireWorktreeUpdated(string id) => WorktreeUpdatedEvent?.Invoke(id);
}
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt };
[Fact]
public void CanMergeAll_AllChildrenDoneActiveWorktrees_True()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.RecomputeCanMergeAll();
Assert.True(shim.CanMergeAll);
Assert.Null(shim.MergeAllDisabledReason);
}
[Fact]
public void CanMergeAll_AnyChildNotDone_FalseWithReason()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Running, WorktreeState.Active));
shim.RecomputeCanMergeAll();
Assert.False(shim.CanMergeAll);
Assert.NotNull(shim.MergeAllDisabledReason);
Assert.Contains("1 subtask", shim.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
Assert.Contains("not done", shim.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CanMergeAll_AnyChildDiscarded_FalseWithReason()
{
var shim = new PlanningVmShim();
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
shim.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Discarded));
shim.RecomputeCanMergeAll();
Assert.False(shim.CanMergeAll);
Assert.NotNull(shim.MergeAllDisabledReason);
Assert.True(
shim.MergeAllDisabledReason!.Contains("discarded", StringComparison.OrdinalIgnoreCase) ||
shim.MergeAllDisabledReason.Contains("kept", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void MergeTargetBranches_LoadedFromWorkerOnPlanningParent()
{
var fake = new FakeWorkerClient();
fake.MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" });
var result = fake.GetMergeTargetsAsync("any-task-id").GetAwaiter().GetResult();
Assert.NotNull(result);
Assert.Equal("main", result!.DefaultBranch);
Assert.Contains("main", result.LocalBranches);
Assert.Contains("dev", result.LocalBranches);
}
}