Files
ClaudeDo/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs
T
Mika Kuns baba921696 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.
2026-08-24 09:36:00 +02:00

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