refactor(ui): derive TaskRowViewModel CanX/DisabledReason pairs from one gate method

SendToQueue, Cancel, Refine, Planning and OpenWorktree each had a CanX property
and a hand-written negation of it for the DisabledReason tooltip, kept in sync
only by a comment's promise. Replace each pair with a private gate method
returning (Can, Reason) so there is one source of truth per condition, and add
a state-matrix test pinning down the CanX == (Reason == null) invariant that
the old comment only asserted.
This commit is contained in:
Mika Kuns
2026-08-24 09:36:00 +02:00
parent 29171b104b
commit baba921696
2 changed files with 120 additions and 49 deletions
@@ -78,9 +78,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// star, dequeue-X) — a set star and the IsRefining spinner are exempt, they show always.
public bool ShowRowActions => IsHovered || IsSelected;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
public DateTime CreatedAt { get; init; }
@@ -140,11 +137,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// touches task status), so this gate has to live here: queueing a task the user is actively
// hand-editing in an interactive pane would spawn an autonomous run racing it in the same
// worktree.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active
&& !IsManual
&& !HasInteractiveSession;
public bool CanSendToQueue => SendToQueueGate().Can;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren
@@ -155,26 +148,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Context-menu Group 1 reasons: null when the paired CanX is true, otherwise the specific
// blocking condition — shown as a disabled MenuItem's tooltip (ToolTip.ShowOnDisabled="True",
// verified headlessly to actually render on Avalonia 12 — see task A3). Each mirrors the
// negation of its CanX so exactly one branch matches whenever CanX is false.
public string? SendToQueueDisabledReason
// verified headlessly to actually render on Avalonia 12 — see task A3). Each pair (CanX,
// XDisabledReason) is derived from one private "gate" method so there is a single source of
// truth for the condition instead of a CanX expression and a hand-written negation of it.
private (bool Can, string? Reason) SendToQueueGate()
{
get
{
if (CanSendToQueue) return null;
if (IsRunning) return Loc.T("tasks.reasonAlreadyRunning");
if (IsQueued) return Loc.T("tasks.reasonAlreadyQueued");
if (IsWaitingForReview) return Loc.T("tasks.reasonWaitingForReview");
if (HasQueuedSubtasks) return Loc.T("tasks.reasonSubtasksQueued");
if (IsChild && !ParentFinalized) return Loc.T("tasks.reasonPlanNotFinalized");
if (PlanningPhase == PlanningPhase.Active) return Loc.T("tasks.reasonPlanningActive");
if (IsManual) return Loc.T("tasks.manualTip");
if (HasInteractiveSession) return Loc.T("tasks.reasonInteractiveSession");
return null;
}
if (IsRunning) return (false, Loc.T("tasks.reasonAlreadyRunning"));
if (IsQueued) return (false, Loc.T("tasks.reasonAlreadyQueued"));
if (IsWaitingForReview) return (false, Loc.T("tasks.reasonWaitingForReview"));
if (HasQueuedSubtasks) return (false, Loc.T("tasks.reasonSubtasksQueued"));
if (IsChild && !ParentFinalized) return (false, Loc.T("tasks.reasonPlanNotFinalized"));
if (PlanningPhase == PlanningPhase.Active) return (false, Loc.T("tasks.reasonPlanningActive"));
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (HasInteractiveSession) return (false, Loc.T("tasks.reasonInteractiveSession"));
return (true, null);
}
public string? SendToQueueDisabledReason => SendToQueueGate().Reason;
public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning");
// IsRunning is a standalone status property used well beyond this one gate, so it stays as
// is; only the reason text is derived from a gate method for consistency with the others.
private (bool Can, string? Reason) CancelGate() =>
IsRunning ? (true, null) : (false, Loc.T("tasks.reasonNotRunning"));
public string? CancelDisabledReason => CancelGate().Reason;
// "Open quick session" has no precondition today (see A1's file header) — kept as a
// placeholder so the menu wiring is uniform; always null until a real gate exists.
@@ -182,36 +177,40 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// The row keeps a merged/discarded worktree's recorded Path, so a non-empty string is not
// proof the folder is still there — same on-disk check the worktrees overview and the merge
// section do before offering "open". Only read when the context menu is built, not per row.
public bool CanOpenWorktree =>
!string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree");
public string? RefineDisabledReason
// section do before offering "open". Only read when the context menu is built, not per row;
// each call to the gate does exactly one Directory.Exists (no caching).
private (bool Can, string? Reason) OpenWorktreeGate()
{
get
{
if (CanRefine) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsRefining) return Loc.T("tasks.reasonRefining");
if (PlanningPhase != PlanningPhase.None) return Loc.T("tasks.reasonIsPlanningParent");
return Loc.T("tasks.reasonNotIdle");
}
var can = !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
return (can, can ? null : Loc.T("tasks.reasonNoWorktree"));
}
public bool CanOpenWorktree => OpenWorktreeGate().Can;
public string? OpenWorktreeDisabledReason => OpenWorktreeGate().Reason;
private (bool Can, string? Reason) RefineGate()
{
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (IsRefining) return (false, Loc.T("tasks.reasonRefining"));
if (PlanningPhase != PlanningPhase.None) return (false, Loc.T("tasks.reasonIsPlanningParent"));
if (Status != TaskStatus.Idle) return (false, Loc.T("tasks.reasonNotIdle"));
return (true, null);
}
public bool CanRefine => RefineGate().Can;
public string? RefineDisabledReason => RefineGate().Reason;
// Gates the "Planning" submenu header — null unless Open/Resume-or-Discard/Finalize would
// all be hidden, i.e. the submenu would otherwise open empty.
public string? PlanningDisabledReason
// all be hidden, i.e. the submenu would otherwise open empty. The three CanX below stay
// standalone properties (bindings/tests use them individually); the gate only derives the
// combined reason.
private (bool Can, string? Reason) PlanningGate()
{
get
{
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return null;
if (IsManual) return Loc.T("tasks.manualTip");
if (IsChild) return Loc.T("tasks.reasonIsChildTask");
if (PlanningPhase == PlanningPhase.Finalized) return Loc.T("tasks.reasonPlanAlreadyFinalized");
return Loc.T("tasks.reasonNotIdle");
}
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return (true, null);
if (IsManual) return (false, Loc.T("tasks.manualTip"));
if (IsChild) return (false, Loc.T("tasks.reasonIsChildTask"));
if (PlanningPhase == PlanningPhase.Finalized) return (false, Loc.T("tasks.reasonPlanAlreadyFinalized"));
return (false, Loc.T("tasks.reasonNotIdle"));
}
public string? PlanningDisabledReason => PlanningGate().Reason;
public bool HasSchedule => ScheduledFor.HasValue;
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
@@ -159,4 +159,76 @@ public class TaskRowContextMenuTests
row.IsSelected = isSelected;
Assert.Equal(expected, row.ShowRowActions);
}
// Pins down what the gate methods guarantee by construction: each of the five CanX/reason
// pairs must agree on every reachable state, not just the handful of examples above. Swept
// across the full state matrix (every TaskStatus x PlanningPhase x IsChild/ParentFinalized x
// IsManual x HasInteractiveSession x HasQueuedSubtasks x IsRefining) rather than one example
// per gate, so a future edit to a gate's condition without touching its Reason branch (or
// vice versa) fails loudly instead of silently drifting.
public static IEnumerable<object[]> GateStateMatrix()
{
foreach (var status in Enum.GetValues<TaskStatus>())
foreach (var phase in Enum.GetValues<PlanningPhase>())
foreach (var isChild in Bools)
foreach (var parentFinalized in Bools)
foreach (var isManual in Bools)
foreach (var hasInteractiveSession in Bools)
foreach (var hasQueuedSubtasks in Bools)
foreach (var isRefining in Bools)
yield return new object[]
{
status, phase, isChild, parentFinalized, isManual,
hasInteractiveSession, hasQueuedSubtasks, isRefining,
};
}
private static readonly bool[] Bools = { false, true };
[Theory]
[MemberData(nameof(GateStateMatrix))]
public void CanX_Agrees_With_DisabledReason_Across_State_Matrix(
TaskStatus status, PlanningPhase phase, bool isChild, bool parentFinalized, bool isManual,
bool hasInteractiveSession, bool hasQueuedSubtasks, bool isRefining)
{
var row = new TaskRowViewModel
{
Id = "t1",
Status = status,
PlanningPhase = phase,
ParentTaskId = isChild ? "parent-id" : null,
ParentFinalized = parentFinalized,
IsManual = isManual,
HasInteractiveSession = hasInteractiveSession,
HasQueuedSubtasks = hasQueuedSubtasks,
IsRefining = isRefining,
};
Assert.Equal(row.CanSendToQueue, row.SendToQueueDisabledReason is null);
Assert.Equal(row.IsRunning, row.CancelDisabledReason is null);
Assert.Equal(row.CanRefine, row.RefineDisabledReason is null);
var canOpenPlanningMenu = row.CanOpenPlanningSession || row.CanResumeOrDiscardPlanning || row.CanFinalizePlanning;
Assert.Equal(canOpenPlanningMenu, row.PlanningDisabledReason is null);
}
[Fact]
public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_NoPath()
{
var row = MakeRow();
Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null);
}
[Fact]
public void OpenWorktreeDisabledReason_Agrees_With_CanOpenWorktree_When_Path_Exists()
{
var dir = Directory.CreateTempSubdirectory();
try
{
var row = MakeRow();
row.WorktreePath = dir.FullName;
Assert.Equal(row.CanOpenWorktree, row.OpenWorktreeDisabledReason is null);
}
finally { dir.Delete(true); }
}
}