* 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>
219 lines
9.7 KiB
C#
219 lines
9.7 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
public class DetailsIslandPlanningTests : IDisposable
|
|
{
|
|
private readonly string _dbPath;
|
|
|
|
public DetailsIslandPlanningTests()
|
|
{
|
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_test_{Guid.NewGuid():N}.db");
|
|
using var ctx = NewContext();
|
|
ctx.Database.EnsureCreated();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { File.Delete(_dbPath); } catch { }
|
|
try { File.Delete(_dbPath + "-wal"); } catch { }
|
|
try { File.Delete(_dbPath + "-shm"); } catch { }
|
|
}
|
|
|
|
private ClaudeDoDbContext NewContext()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite($"Data Source={_dbPath}")
|
|
.Options;
|
|
return new ClaudeDoDbContext(opts);
|
|
}
|
|
|
|
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
private readonly Func<ClaudeDoDbContext> _create;
|
|
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
|
public ClaudeDoDbContext CreateDbContext() => _create();
|
|
}
|
|
|
|
private sealed class FakeWorkerClient : IWorkerClient
|
|
{
|
|
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 bool IsConnected => false;
|
|
public MergeTargetsDto? MergeTargetsResult { get; set; }
|
|
|
|
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(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 Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
}
|
|
|
|
private sealed class NullServiceProvider : IServiceProvider
|
|
{
|
|
public object? GetService(Type serviceType) => null;
|
|
}
|
|
|
|
private DetailsIslandViewModel BuildVm(FakeWorkerClient worker)
|
|
{
|
|
var factory = new TestDbFactory(NewContext);
|
|
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider());
|
|
}
|
|
|
|
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
|
|
new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt };
|
|
|
|
// ── CanMergeAll tests exercising the real VM ─────────────────────────────
|
|
|
|
[Fact]
|
|
public void CanMergeAll_AllChildrenDoneActiveWorktrees_True()
|
|
{
|
|
var vm = BuildVm(new FakeWorkerClient());
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
|
|
vm.RecomputeCanMergeAll();
|
|
|
|
Assert.True(vm.CanMergeAll);
|
|
Assert.Null(vm.MergeAllDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanMergeAll_AnyChildNotDone_FalseWithReason()
|
|
{
|
|
var vm = BuildVm(new FakeWorkerClient());
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Running, WorktreeState.Active));
|
|
|
|
vm.RecomputeCanMergeAll();
|
|
|
|
Assert.False(vm.CanMergeAll);
|
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
Assert.Contains("1 subtask", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("not done", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanMergeAll_AnyChildDiscarded_FalseWithReason()
|
|
{
|
|
var vm = BuildVm(new FakeWorkerClient());
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Discarded));
|
|
|
|
vm.RecomputeCanMergeAll();
|
|
|
|
Assert.False(vm.CanMergeAll);
|
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
Assert.True(
|
|
vm.MergeAllDisabledReason!.Contains("discarded", StringComparison.OrdinalIgnoreCase) ||
|
|
vm.MergeAllDisabledReason.Contains("kept", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanMergeAll_AnyChildKept_FalseWithReason()
|
|
{
|
|
var vm = BuildVm(new FakeWorkerClient());
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Kept));
|
|
|
|
vm.RecomputeCanMergeAll();
|
|
|
|
Assert.False(vm.CanMergeAll);
|
|
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
Assert.True(
|
|
vm.MergeAllDisabledReason!.Contains("kept", StringComparison.OrdinalIgnoreCase) ||
|
|
vm.MergeAllDisabledReason.Contains("discarded", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// ── Branch-load test exercising the VM via Bind ──────────────────────────
|
|
|
|
[Fact]
|
|
public async Task MergeTargetBranches_LoadedFromWorkerOnPlanningParent()
|
|
{
|
|
// Seed a Planning parent with one child that has a worktree
|
|
const string parentId = "parent-1";
|
|
const string childId = "child-1";
|
|
const string listId = "list-1";
|
|
|
|
await using (var ctx = NewContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = parentId, ListId = listId, Title = "Parent",
|
|
Status = TaskStatus.Idle, PlanningPhase = PlanningPhase.Active,
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = childId, ListId = listId, Title = "Child",
|
|
Status = TaskStatus.Done, ParentTaskId = parentId, CreatedAt = DateTime.UtcNow,
|
|
});
|
|
ctx.Set<WorktreeEntity>().Add(new WorktreeEntity
|
|
{
|
|
TaskId = childId, Path = "/tmp/wt", BranchName = "branch",
|
|
BaseCommit = "abc", CreatedAt = DateTime.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var fake = new FakeWorkerClient
|
|
{
|
|
MergeTargetsResult = new MergeTargetsDto("main", new[] { "main", "dev" }),
|
|
};
|
|
|
|
var vm = BuildVm(fake);
|
|
|
|
// Bind triggers BindAsync → LoadPlanningChildrenAsync → GetMergeTargetsAsync
|
|
var parentRow = new TaskRowViewModel { Id = parentId };
|
|
parentRow.Status = TaskStatus.Idle;
|
|
parentRow.PlanningPhase = PlanningPhase.Active;
|
|
vm.Bind(parentRow);
|
|
|
|
// Wait for the background load to settle
|
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
|
while (DateTime.UtcNow < deadline && vm.MergeTargetBranches.Count == 0)
|
|
await Task.Delay(20);
|
|
|
|
Assert.Contains("main", vm.MergeTargetBranches);
|
|
Assert.Contains("dev", vm.MergeTargetBranches);
|
|
Assert.Equal("main", vm.SelectedMergeTarget);
|
|
}
|
|
}
|