Replace the whole-file conflict model with line-level hunks, the foundation for the full in-app merge editor. - ConflictMarkerParser: parses git conflict markers (incl. diff3 base) into ordered stable/conflict MergeSegments; exact round-trip + Compose - GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the working tree carries the merge base in conflict markers - TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file, parses into segments, flags binary files - hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/ ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes - tests: 8 parser unit tests + a real-git integration test asserting line-level hunks with a diff3 base
330 lines
17 KiB
C#
330 lines
17 KiB
C#
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
|
|
|
// ── Fake worker client ────────────────────────────────────────────────────────
|
|
|
|
sealed class FakeWorkerClient : IWorkerClient
|
|
{
|
|
public int StartPlanningCalls { get; private set; }
|
|
public int ResumePlanningCalls { get; private set; }
|
|
public int DiscardPlanningCalls { get; private set; }
|
|
public int FinalizePlanningCalls { get; private set; }
|
|
public int WakeQueueCalls { get; private set; }
|
|
|
|
public bool IsConnected => false;
|
|
public bool IsReconnecting => false;
|
|
public string? LastApproveTarget => null;
|
|
#pragma warning disable CS0067 // events required by IWorkerClient but not exercised by this fake
|
|
public event System.ComponentModel.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? ConnectionRestoredEvent;
|
|
public event Action<string>? WorktreeUpdatedEvent;
|
|
public event Action<string>? ListUpdatedEvent;
|
|
public event Action<string, string>? TaskMessageEvent;
|
|
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
|
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
|
|
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
|
|
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
|
|
|
|
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 RefreshAgentsAsync() => Task.CompletedTask;
|
|
public Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
|
|
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
|
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
|
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
|
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
|
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId) => Task.FromResult(new MergeConflictDocumentsDto(taskId, System.Array.Empty<ConflictDocumentDto>()));
|
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
|
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
public Task AbortConflictMergeAsync(string taskId) => Task.CompletedTask;
|
|
public Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
|
public Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
|
public Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
|
public Task WakeQueueAsync() { WakeQueueCalls++; return Task.CompletedTask; }
|
|
public Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) { StartPlanningCalls++; return Task.CompletedTask; }
|
|
public Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) { ResumePlanningCalls++; return Task.CompletedTask; }
|
|
public Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
|
{
|
|
DiscardPlanningCalls++;
|
|
return Task.FromResult(new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, 0, 0));
|
|
}
|
|
public Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) { FinalizePlanningCalls++; return Task.CompletedTask; }
|
|
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
|
|
|
|
public event Action? PrepStartedEvent;
|
|
public event Action<string>? PrepLineEvent;
|
|
public event Action<bool>? PrepFinishedEvent;
|
|
public event Action<string>? RefineStartedEvent;
|
|
public event Action<string, bool, string?>? RefineFinishedEvent;
|
|
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 event Action<PrimeFiredEvent>? PrimeFired;
|
|
#pragma warning restore CS0067
|
|
|
|
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 ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
|
public Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
|
|
|
public Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult<AppSettingsDto?>(null);
|
|
public Task UpdateAppSettingsAsync(AppSettingsDto dto) => Task.CompletedTask;
|
|
public Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync() => Task.FromResult(new List<PrimeScheduleDto>());
|
|
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto) => Task.FromResult<PrimeScheduleDto?>(null);
|
|
public Task DeletePrimeScheduleAsync(Guid id) => Task.CompletedTask;
|
|
public Task UpdateListAsync(UpdateListDto dto) => Task.CompletedTask;
|
|
public Task UpdateListConfigAsync(UpdateListConfigDto dto) => Task.CompletedTask;
|
|
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) => Task.FromResult<WorktreeCleanupDto?>(null);
|
|
public Task<WorktreeResetDto?> ResetAllWorktreesAsync() => Task.FromResult<WorktreeResetDto?>(null);
|
|
public Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) => Task.FromResult(new List<WorktreeOverviewDto>());
|
|
public Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) => Task.FromResult((true, (string?)null));
|
|
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) => Task.FromResult<ForceRemoveResultDto?>(null);
|
|
public Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult<string?>(null);
|
|
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end) => Task.FromResult("");
|
|
public Task<bool> RunDailyPrepNowAsync() => Task.FromResult(false);
|
|
public Task ClearMyDayAsync() => Task.CompletedTask;
|
|
public Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
|
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
|
|
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
|
|
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
|
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
|
|
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync() => Task.FromResult<OnlineInboxStateDto?>(null);
|
|
public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask;
|
|
public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask;
|
|
public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask;
|
|
}
|
|
|
|
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
|
|
|
file static class VmFactory
|
|
{
|
|
// Minimal SQLite :memory: factory — never actually called in these tests
|
|
// (we seed Items directly), but required by the VM constructor.
|
|
private static IDbContextFactory<ClaudeDoDbContext> NullDbFactory()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.Options;
|
|
return new NullDbContextFactory(opts);
|
|
}
|
|
|
|
private sealed class NullDbContextFactory(DbContextOptions<ClaudeDoDbContext> opts)
|
|
: IDbContextFactory<ClaudeDoDbContext>
|
|
{
|
|
public ClaudeDoDbContext CreateDbContext() => new(opts);
|
|
}
|
|
|
|
public static (TasksIslandViewModel vm, FakeWorkerClient worker) Create(
|
|
IEnumerable<TaskRowViewModel> rows)
|
|
{
|
|
var worker = new FakeWorkerClient();
|
|
var vm = new TasksIslandViewModel(NullDbFactory(), worker);
|
|
foreach (var r in rows)
|
|
vm.Items.Add(r);
|
|
vm.Regroup();
|
|
return (vm, worker);
|
|
}
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
public class TasksIslandViewModelPlanningTests
|
|
{
|
|
private static TaskRowViewModel MakeRow(
|
|
string id,
|
|
TaskStatus status,
|
|
string? parentId = null,
|
|
int sortOrder = 0,
|
|
PlanningPhase phase = PlanningPhase.None)
|
|
=> new TaskRowViewModel { Id = id, Status = status, ParentTaskId = parentId, PlanningPhase = phase };
|
|
|
|
[Fact]
|
|
public void ToggleExpand_CollapsesChildrenOfPlanningParent()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
var child1 = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
var child2 = MakeRow("c2", TaskStatus.Idle, "p1");
|
|
|
|
var (vm, _) = VmFactory.Create([parent, child1, child2]);
|
|
|
|
// Initially expanded — children visible in OpenItems
|
|
Assert.Contains(child1, vm.OpenItems);
|
|
Assert.Contains(child2, vm.OpenItems);
|
|
|
|
// Collapse the parent
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
|
|
// Children should no longer appear
|
|
Assert.DoesNotContain(child1, vm.OpenItems);
|
|
Assert.DoesNotContain(child2, vm.OpenItems);
|
|
// Parent still present
|
|
Assert.Contains(parent, vm.OpenItems);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningSession_IgnoresNonIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Queued);
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
|
|
|
Assert.Equal(0, worker.StartPlanningCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OpenPlanningSession_CallsWorkerForIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Idle);
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.OpenPlanningSessionCommand).ExecuteAsync(row);
|
|
|
|
Assert.Equal(1, worker.StartPlanningCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleExpand_ExpandsCollapsedParentAgain()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
|
|
var (vm, _) = VmFactory.Create([parent, child]);
|
|
|
|
// Collapse
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.DoesNotContain(child, vm.OpenItems);
|
|
|
|
// Re-expand
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.Contains(child, vm.OpenItems);
|
|
}
|
|
|
|
[Fact]
|
|
public void Regroup_ChildWithoutParentInView_ReadsAsTopLevelOrphan()
|
|
{
|
|
// Parent not in the view: the child must be flagged orphan so it renders flat, and it
|
|
// surfaces as a normal top-level row rather than an indented Draft.
|
|
var orphan = MakeRow("c1", TaskStatus.Idle, parentId: "missing-parent");
|
|
|
|
var (vm, _) = VmFactory.Create([orphan]);
|
|
|
|
Assert.False(orphan.ParentInView);
|
|
Assert.False(orphan.ShowAsChild);
|
|
Assert.False(orphan.IsDraft);
|
|
Assert.Contains(orphan, vm.OpenItems);
|
|
}
|
|
|
|
[Fact]
|
|
public void Regroup_ChildWithParentPresent_KeepsParentInView()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
|
|
var (_, _) = VmFactory.Create([parent, child]);
|
|
|
|
Assert.True(child.ParentInView);
|
|
Assert.True(child.ShowAsChild);
|
|
}
|
|
}
|
|
|
|
// ── My Day add / remove (real DB) ─────────────────────────────────────────────
|
|
|
|
public sealed class TasksIslandViewModelMyDayTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
[Fact]
|
|
public async Task AddToMyDay_SetsIsMyDay()
|
|
{
|
|
var listId = Guid.NewGuid().ToString("N");
|
|
var taskId = Guid.NewGuid().ToString("N");
|
|
await using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Idle, IsMyDay = false,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
|
var row = new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle, IsMyDay = false };
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.AddToMyDayCommand).ExecuteAsync(row);
|
|
|
|
await using var verify = _db.CreateContext();
|
|
var loaded = await verify.Tasks.FirstAsync(t => t.Id == taskId);
|
|
Assert.True(loaded.IsMyDay);
|
|
Assert.True(row.IsMyDay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveFromMyDay_OnParent_CascadesToEveryChild()
|
|
{
|
|
var listId = Guid.NewGuid().ToString("N");
|
|
var parentId = Guid.NewGuid().ToString("N");
|
|
var child1 = Guid.NewGuid().ToString("N");
|
|
var child2 = Guid.NewGuid().ToString("N");
|
|
|
|
await using (var ctx = _db.CreateContext())
|
|
{
|
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = parentId, ListId = listId, Title = "P", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.WaitingForChildren, PlanningPhase = PlanningPhase.Finalized, IsMyDay = true,
|
|
});
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = child1, ListId = listId, Title = "C1", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
|
});
|
|
ctx.Tasks.Add(new TaskEntity
|
|
{
|
|
Id = child2, ListId = listId, Title = "C2", CreatedAt = DateTime.UtcNow,
|
|
Status = TaskStatus.Idle, ParentTaskId = parentId, IsMyDay = true,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var vm = new TasksIslandViewModel(_db.CreateFactory(), new FakeWorkerClient());
|
|
var parentRow = new TaskRowViewModel { Id = parentId, Status = TaskStatus.WaitingForChildren, IsMyDay = true };
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.RemoveFromMyDayCommand).ExecuteAsync(parentRow);
|
|
|
|
await using var verify = _db.CreateContext();
|
|
Assert.False(await verify.Tasks.AnyAsync(t => t.IsMyDay),
|
|
"removing the parent from My Day must clear IsMyDay on the parent and all its children");
|
|
}
|
|
}
|