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
@@ -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); }
}
}