A task-based ConPTY session leaves the row Idle in the DB (sessions never touch status), so nothing stopped the queue picker from claiming it too: CanSendToQueue ignored HasInteractiveSession, and both TasksIslandViewModel. SendToQueueAsync and MissionControlViewModel.EnqueueTaskAsync (drag-to-queue) wrote Status=Queued straight via EF, bypassing TaskStateService entirely and its manual/draft-child guards. That let an autonomous claude process spawn in the same worktree a user was hand-editing in the ConPTY pane. Add !HasInteractiveSession to CanSendToQueue, and route both UI enqueue paths through IWorkerClient.SetTaskStatusAsync (worker hub -> TaskStateService. EnqueueAsync) instead of raw EF writes. The interactive-session gate itself stays in the UI: the worker has no notion of a UI-hosted ConPTY pane.
207 lines
6.3 KiB
C#
207 lines
6.3 KiB
C#
using System.IO;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Localization;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.UiVm;
|
|
|
|
public class TaskRowViewModelPlanningTests
|
|
{
|
|
public TaskRowViewModelPlanningTests()
|
|
{
|
|
var dir = AppContext.BaseDirectory;
|
|
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
|
dir = Path.GetDirectoryName(dir);
|
|
Loc.Current = new Localizer(
|
|
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
|
}
|
|
|
|
private static TaskRowViewModel MakeRow(
|
|
TaskStatus status,
|
|
string? parentTaskId = null,
|
|
PlanningPhase phase = PlanningPhase.None)
|
|
=> new TaskRowViewModel { Id = "t", Status = status, ParentTaskId = parentTaskId, PlanningPhase = phase };
|
|
|
|
[Fact]
|
|
public void IdleChild_IsDraft_WhenParentIdIsNotNull()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
|
Assert.True(vm.IsChild);
|
|
Assert.True(vm.IsDraft);
|
|
Assert.False(vm.IsPlanningParent);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePlanning_SetsIsPlanningParent()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
Assert.True(vm.IsPlanningParent);
|
|
Assert.False(vm.IsChild);
|
|
Assert.Equal("PLANNING", vm.PlanningBadge);
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizedPlanning_ShowsPlannedBadge()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
Assert.True(vm.IsPlanningParent);
|
|
Assert.Equal("PLANNED", vm.PlanningBadge);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainIdle_NoBadge()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle);
|
|
Assert.False(vm.IsPlanningParent);
|
|
Assert.Null(vm.PlanningBadge);
|
|
}
|
|
|
|
[Fact]
|
|
public void DraftChild_CannotSendToQueue()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
|
vm.ParentFinalized = false;
|
|
Assert.True(vm.IsDraft);
|
|
Assert.False(vm.IsPlanned);
|
|
Assert.False(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlannedChild_CanSendToQueue()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
|
vm.ParentFinalized = true;
|
|
Assert.False(vm.IsDraft);
|
|
Assert.True(vm.IsPlanned);
|
|
Assert.True(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void StandaloneIdle_CanSendToQueue()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle);
|
|
Assert.False(vm.IsChild);
|
|
Assert.True(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void OpenInteractiveSession_CannotSendToQueue()
|
|
{
|
|
// A hand-driven ConPTY session is still Idle in the DB (sessions never touch status), so
|
|
// this has to be gated on the UI-only HasInteractiveSession flag, not on Status.
|
|
var vm = MakeRow(TaskStatus.Idle);
|
|
Assert.True(vm.CanSendToQueue);
|
|
|
|
vm.HasInteractiveSession = true;
|
|
Assert.False(vm.CanSendToQueue);
|
|
|
|
vm.HasInteractiveSession = false;
|
|
Assert.True(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizedParentWithChildren_CanQueuePlan()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
vm.HasPlanningChildren = true;
|
|
Assert.True(vm.CanQueuePlan);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActiveParentWithChildren_CannotQueuePlan()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
vm.HasPlanningChildren = true;
|
|
Assert.False(vm.CanQueuePlan);
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizedParentWithoutChildren_CannotQueuePlan()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
Assert.False(vm.CanQueuePlan);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePlanningParent_CannotSendToQueue()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
vm.HasPlanningChildren = true;
|
|
Assert.False(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizedParentWithChildren_CanSendToQueue()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
vm.HasPlanningChildren = true;
|
|
Assert.True(vm.CanSendToQueue);
|
|
}
|
|
|
|
[Fact]
|
|
public void ActivePlanning_CanFinalizePlanning()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Active);
|
|
Assert.True(vm.CanFinalizePlanning);
|
|
}
|
|
|
|
[Fact]
|
|
public void FinalizedPlanning_CannotFinalizePlanning()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, phase: PlanningPhase.Finalized);
|
|
Assert.False(vm.CanFinalizePlanning);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainIdle_CannotFinalizePlanning()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle);
|
|
Assert.False(vm.CanFinalizePlanning);
|
|
}
|
|
|
|
[Fact]
|
|
public void ChildWithParentInView_RendersAsChild()
|
|
{
|
|
var vm = MakeRow(TaskStatus.Idle, parentTaskId: "parent-id");
|
|
Assert.True(vm.ParentInView); // default
|
|
Assert.True(vm.ShowAsChild);
|
|
Assert.True(vm.IsDraft);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrphanedChild_RendersFlat_WithNoDraftOrPlannedBadge()
|
|
{
|
|
// Parent absent from the view (e.g. removed from My Day, or daily-prep placed a lone
|
|
// child there): the row stays a child by data but must read as a normal top-level task.
|
|
var draftOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
|
draftOrphan.ParentInView = false;
|
|
Assert.True(draftOrphan.IsChild);
|
|
Assert.False(draftOrphan.ShowAsChild);
|
|
Assert.False(draftOrphan.IsDraft);
|
|
|
|
var plannedOrphan = MakeRow(TaskStatus.Idle, parentTaskId: "missing");
|
|
plannedOrphan.ParentFinalized = true;
|
|
plannedOrphan.ParentInView = false;
|
|
Assert.False(plannedOrphan.ShowAsChild);
|
|
Assert.False(plannedOrphan.IsPlanned);
|
|
Assert.False(plannedOrphan.IsDraft);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanAddToMyDay_TrueOnlyWhenNotInMyDayAndNotDone()
|
|
{
|
|
var row = MakeRow(TaskStatus.Idle);
|
|
Assert.True(row.CanAddToMyDay); // idle, not yet in My Day
|
|
|
|
row.IsMyDay = true;
|
|
Assert.False(row.CanAddToMyDay); // already in My Day
|
|
|
|
row.IsMyDay = false;
|
|
row.Done = true;
|
|
Assert.False(row.CanAddToMyDay); // done tasks don't belong in today's focus
|
|
}
|
|
}
|