refactor(claude-do): merge TaskRowViewModel: CanX und DisabledReason aus einer Gate-Met
ClaudeDo-Task: 6c4b27aa-1b5e-4d73-98f5-dcafa3935247
This commit is contained in:
@@ -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.
|
// star, dequeue-X) — a set star and the IsRefining spinner are exempt, they show always.
|
||||||
public bool ShowRowActions => IsHovered || IsSelected;
|
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 string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
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
|
// 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
|
// hand-editing in an interactive pane would spawn an autonomous run racing it in the same
|
||||||
// worktree.
|
// worktree.
|
||||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
public bool CanSendToQueue => SendToQueueGate().Can;
|
||||||
&& (!IsChild || ParentFinalized)
|
|
||||||
&& PlanningPhase != PlanningPhase.Active
|
|
||||||
&& !IsManual
|
|
||||||
&& !HasInteractiveSession;
|
|
||||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
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
|
// 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",
|
// 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
|
// verified headlessly to actually render on Avalonia 12 — see task A3). Each pair (CanX,
|
||||||
// negation of its CanX so exactly one branch matches whenever CanX is false.
|
// XDisabledReason) is derived from one private "gate" method so there is a single source of
|
||||||
public string? SendToQueueDisabledReason
|
// truth for the condition instead of a CanX expression and a hand-written negation of it.
|
||||||
|
private (bool Can, string? Reason) SendToQueueGate()
|
||||||
{
|
{
|
||||||
get
|
if (IsRunning) return (false, Loc.T("tasks.reasonAlreadyRunning"));
|
||||||
{
|
if (IsQueued) return (false, Loc.T("tasks.reasonAlreadyQueued"));
|
||||||
if (CanSendToQueue) return null;
|
if (IsWaitingForReview) return (false, Loc.T("tasks.reasonWaitingForReview"));
|
||||||
if (IsRunning) return Loc.T("tasks.reasonAlreadyRunning");
|
if (HasQueuedSubtasks) return (false, Loc.T("tasks.reasonSubtasksQueued"));
|
||||||
if (IsQueued) return Loc.T("tasks.reasonAlreadyQueued");
|
if (IsChild && !ParentFinalized) return (false, Loc.T("tasks.reasonPlanNotFinalized"));
|
||||||
if (IsWaitingForReview) return Loc.T("tasks.reasonWaitingForReview");
|
if (PlanningPhase == PlanningPhase.Active) return (false, Loc.T("tasks.reasonPlanningActive"));
|
||||||
if (HasQueuedSubtasks) return Loc.T("tasks.reasonSubtasksQueued");
|
if (IsManual) return (false, Loc.T("tasks.manualTip"));
|
||||||
if (IsChild && !ParentFinalized) return Loc.T("tasks.reasonPlanNotFinalized");
|
if (HasInteractiveSession) return (false, Loc.T("tasks.reasonInteractiveSession"));
|
||||||
if (PlanningPhase == PlanningPhase.Active) return Loc.T("tasks.reasonPlanningActive");
|
return (true, null);
|
||||||
if (IsManual) return Loc.T("tasks.manualTip");
|
|
||||||
if (HasInteractiveSession) return Loc.T("tasks.reasonInteractiveSession");
|
|
||||||
return 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
|
// "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.
|
// 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
|
// 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
|
// 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.
|
// section do before offering "open". Only read when the context menu is built, not per row;
|
||||||
public bool CanOpenWorktree =>
|
// each call to the gate does exactly one Directory.Exists (no caching).
|
||||||
!string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
|
private (bool Can, string? Reason) OpenWorktreeGate()
|
||||||
public string? OpenWorktreeDisabledReason => CanOpenWorktree ? null : Loc.T("tasks.reasonNoWorktree");
|
|
||||||
|
|
||||||
public string? RefineDisabledReason
|
|
||||||
{
|
{
|
||||||
get
|
var can = !string.IsNullOrWhiteSpace(WorktreePath) && System.IO.Directory.Exists(WorktreePath);
|
||||||
{
|
return (can, can ? null : Loc.T("tasks.reasonNoWorktree"));
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// Gates the "Planning" submenu header — null unless Open/Resume-or-Discard/Finalize would
|
||||||
// all be hidden, i.e. the submenu would otherwise open empty.
|
// all be hidden, i.e. the submenu would otherwise open empty. The three CanX below stay
|
||||||
public string? PlanningDisabledReason
|
// 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 (true, null);
|
||||||
{
|
if (IsManual) return (false, Loc.T("tasks.manualTip"));
|
||||||
if (CanOpenPlanningSession || CanResumeOrDiscardPlanning || CanFinalizePlanning) return null;
|
if (IsChild) return (false, Loc.T("tasks.reasonIsChildTask"));
|
||||||
if (IsManual) return Loc.T("tasks.manualTip");
|
if (PlanningPhase == PlanningPhase.Finalized) return (false, Loc.T("tasks.reasonPlanAlreadyFinalized"));
|
||||||
if (IsChild) return Loc.T("tasks.reasonIsChildTask");
|
return (false, Loc.T("tasks.reasonNotIdle"));
|
||||||
if (PlanningPhase == PlanningPhase.Finalized) return Loc.T("tasks.reasonPlanAlreadyFinalized");
|
|
||||||
return Loc.T("tasks.reasonNotIdle");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
public string? PlanningDisabledReason => PlanningGate().Reason;
|
||||||
|
|
||||||
public bool HasSchedule => ScheduledFor.HasValue;
|
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
|
// "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;
|
row.IsSelected = isSelected;
|
||||||
Assert.Equal(expected, row.ShowRowActions);
|
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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user