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:
134
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
Normal file
134
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user