Queuing (update_task_status, batch_update_task_status) and run_task_now now surface a non-blocking baseDirty warning (separate modified/untracked counts) when the list's working dir has uncommitted changes at enqueue time, since a new worktree forks from the commit tip and silently misses them. BaseDirtyChecker caches per working dir for a few seconds so a batch queue over many tasks in one list only shells out to git once. The UI surfaces the same warning via the footer error strip on queue actions.
406 lines
22 KiB
C#
406 lines
22 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 List<(string TaskId, TaskStatus Status)> SetTaskStatusCalls { get; } = new();
|
|
|
|
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 event Action<string, string, string>? TaskQuestionAskedEvent;
|
|
public event Action<string, string>? TaskQuestionResolvedEvent;
|
|
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
|
|
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 AnswerTaskQuestionAsync(string taskId, string questionId, string answer) => Task.CompletedTask;
|
|
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId) => Task.FromResult<PendingQuestionDto?>(null);
|
|
public Task ResetTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task CancelTaskAsync(string taskId) => Task.CompletedTask;
|
|
public Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId) => Task.FromResult<(bool, string?)>((true, null));
|
|
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<List<string>> GetRepoImportFoldersAsync() => Task.FromResult(new List<string>());
|
|
public Task SetRepoImportFoldersAsync(List<string> folders) => Task.CompletedTask;
|
|
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
|
public Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
|
|
public Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
|
|
public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
|
|
public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask;
|
|
public Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status)
|
|
{
|
|
SetTaskStatusCalls.Add((taskId, status));
|
|
return Task.FromResult<BaseDirtyWarningDto?>(null);
|
|
}
|
|
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<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 SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
|
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
|
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
|
public Task<string> CreateMergeHelperTaskAsync(
|
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
|
=> Task.FromResult(Guid.NewGuid().ToString());
|
|
public Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
|
|
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
|
|
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
|
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
|
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
|
public int PlanningStartSpecCalls { get; private set; }
|
|
public int PlanningResumeSpecCalls { get; private set; }
|
|
public Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
|
{ PlanningStartSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
|
|
public Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
|
{ PlanningResumeSpecCalls++; return Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); }
|
|
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 Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(System.Array.Empty<WorkerLogEntry>());
|
|
|
|
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>, bool>? PlanningMergeConflictEvent;
|
|
public event Action<string>? PlanningMergeAbortedEvent;
|
|
public event Action<string>? PlanningCompletedEvent;
|
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
|
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
|
#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<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
|
|
=> Task.FromResult<IReadOnlyList<PlanningMergeConflictStateDto>>(Array.Empty<PlanningMergeConflictStateDto>());
|
|
|
|
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<WorkerBuildInfoDto?> GetWorkerBuildInfoAsync() => Task.FromResult<WorkerBuildInfoDto?>(null);
|
|
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;
|
|
public IReadOnlyList<ActiveTask> GetActiveTasks() => System.Array.Empty<ActiveTask>();
|
|
|
|
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult<UsageSnapshotDto?>(null);
|
|
public Task<UsageSnapshotDto?> RefreshUsageAsync() => Task.FromResult<UsageSnapshotDto?>(null);
|
|
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsageAsync(DateOnly from, DateOnly to)
|
|
=> Task.FromResult<IReadOnlyList<ModelUsageRowDto>>(System.Array.Empty<ModelUsageRowDto>());
|
|
public Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsageAsync(DateOnly from, DateOnly to)
|
|
=> Task.FromResult<IReadOnlyList<TaskUsageRowDto>>(System.Array.Empty<TaskUsageRowDto>());
|
|
}
|
|
|
|
// ── 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 PlanningParentWithChildren_CollapsedByDefault_ToggleExpands()
|
|
{
|
|
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]);
|
|
|
|
// Collapsed by default — children hidden, parent still present
|
|
Assert.DoesNotContain(child1, vm.OpenItems);
|
|
Assert.DoesNotContain(child2, vm.OpenItems);
|
|
Assert.Contains(parent, vm.OpenItems);
|
|
|
|
// Expand the parent
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
|
|
// Children now visible
|
|
Assert.Contains(child1, vm.OpenItems);
|
|
Assert.Contains(child2, vm.OpenItems);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpenPlanningSession_IgnoresNonIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Queued);
|
|
var (vm, _) = VmFactory.Create([row]);
|
|
(string TaskId, bool Resume)? raised = null;
|
|
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
|
|
|
|
vm.OpenPlanningSessionCommand.Execute(row);
|
|
|
|
Assert.Null(raised);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpenPlanningSession_RequestsConPtyPaneForIdleRow()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Idle);
|
|
var (vm, _) = VmFactory.Create([row]);
|
|
(string TaskId, bool Resume)? raised = null;
|
|
vm.OpenPlanningConPtyRequested += (id, resume) => raised = (id, resume);
|
|
|
|
vm.OpenPlanningSessionCommand.Execute(row);
|
|
|
|
Assert.Equal(("t1", false), raised);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleExpand_TogglesParentExpansion()
|
|
{
|
|
var parent = MakeRow("p1", TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
var child = MakeRow("c1", TaskStatus.Idle, "p1");
|
|
|
|
var (vm, _) = VmFactory.Create([parent, child]);
|
|
|
|
// Collapsed by default
|
|
Assert.DoesNotContain(child, vm.OpenItems);
|
|
|
|
// Expand
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.Contains(child, vm.OpenItems);
|
|
|
|
// Collapse again
|
|
vm.ToggleExpandCommand.Execute(parent);
|
|
Assert.DoesNotContain(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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendToQueueAsync_RoutesThroughWorkerHub_NotRawEf()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Idle);
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.SendToQueueCommand).ExecuteAsync(row);
|
|
|
|
Assert.Equal(("t1", TaskStatus.Queued), Assert.Single(worker.SetTaskStatusCalls));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendToQueueAsync_TaskHasOpenInteractiveSession_DoesNotQueue()
|
|
{
|
|
var row = MakeRow("t1", TaskStatus.Idle);
|
|
row.HasInteractiveSession = true;
|
|
var (vm, worker) = VmFactory.Create([row]);
|
|
|
|
await ((IAsyncRelayCommand<TaskRowViewModel?>)vm.SendToQueueCommand).ExecuteAsync(row);
|
|
|
|
Assert.Empty(worker.SetTaskStatusCalls);
|
|
}
|
|
}
|
|
|
|
// ── 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");
|
|
}
|
|
}
|