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.
235 lines
7.6 KiB
C#
235 lines
7.6 KiB
C#
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using Xunit;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
|
|
/// <summary>
|
|
/// [UX-Struktur A2/A4] Group 1's disabled-reason properties (grayed out with an explanation
|
|
/// instead of hidden — see task A1) and the hover-or-selected gate for the optional row actions.
|
|
/// </summary>
|
|
public class TaskRowContextMenuTests
|
|
{
|
|
private static TaskRowViewModel MakeRow() => new() { Id = "t1" };
|
|
|
|
[Fact]
|
|
public void SendToQueueDisabledReason_Null_When_Sendable()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
Assert.True(row.CanSendToQueue);
|
|
Assert.Null(row.SendToQueueDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void SendToQueueDisabledReason_Set_When_AlreadyQueued()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Queued;
|
|
Assert.False(row.CanSendToQueue);
|
|
Assert.NotNull(row.SendToQueueDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void SendToQueueDisabledReason_Set_When_Manual()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.IsManual = true;
|
|
Assert.False(row.CanSendToQueue);
|
|
Assert.NotNull(row.SendToQueueDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void CancelDisabledReason_Null_While_Running()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Running;
|
|
Assert.Null(row.CancelDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void CancelDisabledReason_Set_When_NotRunning()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
Assert.NotNull(row.CancelDisabledReason);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(TaskStatus.Idle)]
|
|
[InlineData(TaskStatus.Running)]
|
|
[InlineData(TaskStatus.Done)]
|
|
public void QuickSessionDisabledReason_Is_Always_Null(TaskStatus status)
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = status;
|
|
row.IsManual = true;
|
|
Assert.Null(row.QuickSessionDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefineDisabledReason_Null_When_Refinable()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
Assert.True(row.CanRefine);
|
|
Assert.Null(row.RefineDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefineDisabledReason_Set_When_Manual()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.IsManual = true;
|
|
Assert.False(row.CanRefine);
|
|
Assert.NotNull(row.RefineDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefineDisabledReason_Set_When_Refining()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.IsRefining = true;
|
|
Assert.False(row.CanRefine);
|
|
Assert.NotNull(row.RefineDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefineDisabledReason_Set_When_NotIdle()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Running;
|
|
Assert.False(row.CanRefine);
|
|
Assert.NotNull(row.RefineDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanningDisabledReason_Null_When_OpenAvailable()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
Assert.True(row.CanOpenPlanningSession);
|
|
Assert.Null(row.PlanningDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanningDisabledReason_Null_When_PlanningActive()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.PlanningPhase = PlanningPhase.Active;
|
|
Assert.True(row.CanResumeOrDiscardPlanning);
|
|
Assert.Null(row.PlanningDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanningDisabledReason_Set_When_Finalized()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.PlanningPhase = PlanningPhase.Finalized;
|
|
Assert.False(row.CanOpenPlanningSession);
|
|
Assert.False(row.CanResumeOrDiscardPlanning);
|
|
Assert.False(row.CanFinalizePlanning);
|
|
Assert.NotNull(row.PlanningDisabledReason);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanningDisabledReason_Set_When_Manual()
|
|
{
|
|
var row = MakeRow();
|
|
row.Status = TaskStatus.Idle;
|
|
row.IsManual = true;
|
|
Assert.NotNull(row.PlanningDisabledReason);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false, false, false)]
|
|
[InlineData(true, false, true)]
|
|
[InlineData(false, true, true)]
|
|
[InlineData(true, true, true)]
|
|
public void ShowRowActions_Follows_Hover_Or_Selection(bool isHovered, bool isSelected, bool expected)
|
|
{
|
|
var row = MakeRow();
|
|
row.IsHovered = isHovered;
|
|
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); }
|
|
}
|
|
}
|