From 51dc7bd61b5f75e5dfe0542a0505693a8a2328c5 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 21 Aug 2026 14:34:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Task-Zeile=20entdichten=20und=20Kon?= =?UTF-8?q?textmen=C3=BC=20gruppieren=20(UX-Struktur=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gruppe 1 (Send to queue, Remove from queue, Cancel execution, Open quick session, Refine task) ist jetzt fix sichtbar und gated per IsEnabled + Grund statt komplett auszublenden. Planning/Schedule wandern in Untermenüs (Mark as existierte bereits); der Planning-Kopf zeigt einen Grund, wenn er sonst leer wäre. Refine-Button, ungesetzter Star und Dequeue-X erscheinen nur bei Hover-oder-Selektion (TaskRowViewModel.ShowRowActions); ein gesetzter Star und der Refining-Spinner bleiben immer sichtbar. Der Branch-Chip wandert aus der Zeile in eine Meta-Zeile in TaskHeaderBar. A3 headless verifiziert: Avalonia 12 zeigt ToolTips auf IsEnabled=false Controls nur mit ToolTip.ShowOnDisabled="True" (Default ist false) — dieses Attached Property existiert bereits und wird für die neuen Disabled-Reason- Tooltips genutzt. --- src/ClaudeDo.Localization/locales/de.json | 16 ++ src/ClaudeDo.Localization/locales/en.json | 16 ++ .../ViewModels/Islands/TaskRowViewModel.cs | 87 +++++++++- .../Views/Islands/Detail/TaskHeaderBar.axaml | 10 ++ .../Views/Islands/TaskRowView.axaml | 39 +++-- .../Views/Islands/TaskRowView.axaml.cs | 79 +++++++-- .../ViewModels/TaskRowContextMenuTests.cs | 162 ++++++++++++++++++ 7 files changed, 376 insertions(+), 33 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 9efceff1..18e975a0 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -167,7 +167,23 @@ "manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf", "ctxMarkManual": "Als manuell markieren", "ctxMarkClaudeTask": "Als Claude-Aufgabe markieren", + "ctxRefineTask": "Aufgabe verfeinern", + "ctxPlanning": "Planung", + "ctxSchedule": "Zeitplan", "ctxDeleteTask": "Aufgabe löschen", + "reasonAlreadyRunning": "Aufgabe läuft bereits", + "reasonAlreadyQueued": "Bereits in der Warteschlange", + "reasonWaitingForReview": "Wartet auf Prüfung", + "reasonSubtasksQueued": "Teilaufgaben sind bereits eingereiht", + "reasonPlanNotFinalized": "Plan ist noch nicht finalisiert", + "reasonPlanningActive": "Planungssitzung ist aktiv — zuerst finalisieren", + "reasonInteractiveSession": "Für diese Aufgabe ist eine interaktive Sitzung offen", + "reasonNotRunning": "Aufgabe läuft nicht", + "reasonRefining": "Verfeinerung läuft bereits", + "reasonNotIdle": "Nur im Leerlauf verfügbar", + "reasonIsPlanningParent": "Teil eines Plans", + "reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung", + "reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert", "approve": "Genehmigen", "approveTip": "Genehmigen — als Erledigt markieren", "reject": "Ablehnen", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index e9a07b2c..43735c28 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -167,7 +167,23 @@ "manualTip": "Manual reminder — Claude never picks this up", "ctxMarkManual": "Mark as manual", "ctxMarkClaudeTask": "Mark as Claude task", + "ctxRefineTask": "Refine task", + "ctxPlanning": "Planning", + "ctxSchedule": "Schedule", "ctxDeleteTask": "Delete task", + "reasonAlreadyRunning": "Task is already running", + "reasonAlreadyQueued": "Already in the queue", + "reasonWaitingForReview": "Waiting for review", + "reasonSubtasksQueued": "Subtasks are already queued", + "reasonPlanNotFinalized": "Plan isn't finalized yet", + "reasonPlanningActive": "Planning session is active — finalize the plan first", + "reasonInteractiveSession": "An interactive session is open for this task", + "reasonNotRunning": "Task isn't running", + "reasonRefining": "Refine is already running", + "reasonNotIdle": "Only available for idle tasks", + "reasonIsPlanningParent": "Part of a plan", + "reasonIsChildTask": "Subtasks don't have their own planning session", + "reasonPlanAlreadyFinalized": "Plan is already finalized", "approve": "Approve", "approveTip": "Approve — mark Done", "reject": "Reject", diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 57b52327..cd35ea5f 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -58,6 +58,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase // Set by the custom drag while this row is being dragged — drives the "grabbed" row style. [ObservableProperty] private bool _isDragging; + // Set from PointerEntered/PointerExited in TaskRowView.axaml.cs. Drives ShowRowActions + // together with IsSelected, so hover-only affordances (refine button, unset star, dequeue-X) + // stay reachable via keyboard/selection, not just mouse hover. + [ObservableProperty] private bool _isHovered; + // Transient: set from HubBroadcaster's OperationProgress while the worker is still creating // this task's worktree (the silent gap between Queued and the first agent output). Cleared // by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's @@ -68,6 +73,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase // suppress the ordinary hover highlight/transitions so they don't fight the hint. public bool IsDropTarget => DropHintAbove || DropHintBelow; + // Hover-or-selected gate for the row's optional action affordances (refine button, unset + // 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; @@ -142,6 +151,60 @@ public sealed partial class TaskRowViewModel : ViewModelBase && !HasQueuedSubtasks; // User-triggered finalize for a planning parent whose session was closed before finalizing. public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild; + + // 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 + { + 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; + } + } + + public string? CancelDisabledReason => IsRunning ? null : Loc.T("tasks.reasonNotRunning"); + + // "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. + public string? QuickSessionDisabledReason => null; + + public string? RefineDisabledReason + { + 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"); + } + } + + // 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 + { + 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"); + } + } + 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 // today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay). @@ -243,6 +306,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanRefine)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); + OnPropertyChanged(nameof(CancelDisabledReason)); + OnPropertyChanged(nameof(RefineDisabledReason)); + OnPropertyChanged(nameof(PlanningDisabledReason)); } partial void OnParentTaskIdChanged(string? value) @@ -254,6 +321,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsPlanned)); OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanOpenPlanningSession)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); + OnPropertyChanged(nameof(PlanningDisabledReason)); } partial void OnParentInViewChanged(bool value) @@ -270,6 +339,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(IsDraft)); OnPropertyChanged(nameof(IsPlanned)); OnPropertyChanged(nameof(CanSendToQueue)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); } partial void OnPlanningPhaseChanged(PlanningPhase value) @@ -285,9 +355,16 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanFinalizePlanning)); OnPropertyChanged(nameof(CanRefine)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); + OnPropertyChanged(nameof(RefineDisabledReason)); + OnPropertyChanged(nameof(PlanningDisabledReason)); } - partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine)); + partial void OnIsRefiningChanged(bool value) + { + OnPropertyChanged(nameof(CanRefine)); + OnPropertyChanged(nameof(RefineDisabledReason)); + } partial void OnIsManualChanged(bool value) { @@ -295,6 +372,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(CanRefine)); OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanOpenPlanningSession)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); + OnPropertyChanged(nameof(RefineDisabledReason)); + OnPropertyChanged(nameof(PlanningDisabledReason)); } partial void OnHasInteractiveSessionChanged(bool value) @@ -305,6 +385,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(ShowStatusChip)); OnPropertyChanged(nameof(StatusChipTooltip)); OnPropertyChanged(nameof(CanSendToQueue)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); } partial void OnFailureReasonChanged(string? value) @@ -323,8 +404,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanSendToQueue)); OnPropertyChanged(nameof(CanQueuePlan)); + OnPropertyChanged(nameof(SendToQueueDisabledReason)); } + partial void OnIsHoveredChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions)); + partial void OnIsSelectedChanged(bool value) => OnPropertyChanged(nameof(ShowRowActions)); + partial void OnBlockedByTaskIdChanged(string? value) { OnPropertyChanged(nameof(IsQueued)); diff --git a/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml b/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml index 6fcf4cbb..b37c21fe 100644 --- a/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml @@ -25,6 +25,16 @@ TextWrapping="Wrap" AcceptsReturn="False" Padding="0"/> + + + + + + diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 928b059e..df95483a 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -41,7 +41,9 @@ Classes.dragging="{Binding IsDragging}" Classes.drop-target="{Binding IsDropTarget}" Classes.done="{Binding Done}" - ContextRequested="OnRowContextRequested"> + ContextRequested="OnRowContextRequested" + PointerEntered="OnRowPointerEntered" + PointerExited="OnRowPointerExited"> @@ -149,12 +151,18 @@ - + @@ -168,16 +176,6 @@ - - - - - - - - @@ -203,11 +201,16 @@ diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs index a0af3960..cc733aa1 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs @@ -21,8 +21,8 @@ public partial class TaskRowView : UserControl this.GetVisualAncestors().OfType() .Select(ic => ic.DataContext).OfType().FirstOrDefault(); - // The context menu is built here instead of declared in XAML so the ~18 MenuItems only - // exist while a row's menu is actually open, not for every row rendered in the list. + // The context menu is built here instead of declared in XAML so the MenuItems only exist + // while a row's menu is actually open, not for every row rendered in the list. private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e) { if (sender is not Border border) return; @@ -35,19 +35,36 @@ public partial class TaskRowView : UserControl var menu = new ContextMenu { DataContext = row }; - MenuItem MakeItem(string key, EventHandler click, bool isVisible = true, string? tag = null) + // reason non-null => IsEnabled=false with that text as the tooltip (ToolTip.ShowOnDisabled + // renders it even though the item is disabled — verified headlessly on Avalonia 12, see + // task A3). Used for Group 1's four gated entries; submenu items keep isVisible hiding. + MenuItem MakeItem(string key, EventHandler click, bool isVisible = true, string? tag = null, string? reason = null) { var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible }; if (tag is not null) item.Tag = tag; + if (reason is not null) + { + item.IsEnabled = false; + ToolTip.SetTip(item, reason); + ToolTip.SetShowOnDisabled(item, true); + } item.Click += click; return item; } - menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue)); - menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue)); - menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning)); + // Group 1 — fixed length and order, always visible; an unmet precondition grays the + // entry out instead of hiding it (Remove from queue has no dedicated reason text, only + // the IsEnabled toggle). + menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, reason: row.SendToQueueDisabledReason)); + var removeFromQueueItem = MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick); + removeFromQueueItem.IsEnabled = row.CanRemoveFromQueue; + menu.Items.Add(removeFromQueueItem); + menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, reason: row.CancelDisabledReason)); + menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick, reason: row.QuickSessionDisabledReason)); + menu.Items.Add(MakeItem("tasks.ctxRefineTask", OnRefineTaskClick, reason: row.RefineDisabledReason)); menu.Items.Add(new Separator()); + // "Mark as" — exclusive-choice hiding inside is unchanged; the header is never empty. var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row }; markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done")); markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled")); @@ -56,17 +73,29 @@ public partial class TaskRowView : UserControl markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual)); menu.Items.Add(markAs); - menu.Items.Add(new Separator()); - menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick)); - menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession)); - menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning)); - menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning)); - menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning)); - menu.Items.Add(new Separator()); - menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick)); - menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule)); - menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay)); - menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay)); + // "Planning" — the header itself disables + explains when it would otherwise open empty. + var planning = new MenuItem { Header = Loc.T("tasks.ctxPlanning"), DataContext = row }; + if (row.PlanningDisabledReason is { } planningReason) + { + planning.IsEnabled = false; + ToolTip.SetTip(planning, planningReason); + ToolTip.SetShowOnDisabled(planning, true); + } + planning.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession)); + planning.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning)); + planning.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning)); + planning.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning)); + menu.Items.Add(planning); + + // "Schedule" — always has at least "Schedule for…", so the header is never empty. + var schedule = new MenuItem { Header = Loc.T("tasks.ctxSchedule"), DataContext = row }; + schedule.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick)); + schedule.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule)); + schedule.Items.Add(new Separator()); + schedule.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay)); + schedule.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay)); + menu.Items.Add(schedule); + menu.Items.Add(new Separator()); menu.Items.Add(MakeItem("tasks.ctxDeleteTask", OnDeleteTaskClick)); @@ -92,6 +121,12 @@ public partial class TaskRowView : UserControl await vm.CancelRunningTaskCommand.ExecuteAsync(row); } + private async void OnRefineTaskClick(object? sender, RoutedEventArgs e) + { + if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) + await vm.RefineTaskCommand.ExecuteAsync(row); + } + private async void OnClearScheduleClick(object? sender, RoutedEventArgs e) { if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) @@ -194,4 +229,14 @@ public partial class TaskRowView : UserControl ScheduleAnchor.Flyout?.Hide(); _pendingScheduleRow = null; } + + private void OnRowPointerEntered(object? sender, PointerEventArgs e) + { + if (DataContext is TaskRowViewModel row) row.IsHovered = true; + } + + private void OnRowPointerExited(object? sender, PointerEventArgs e) + { + if (DataContext is TaskRowViewModel row) row.IsHovered = false; + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs new file mode 100644 index 00000000..b191a8ad --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowContextMenuTests.cs @@ -0,0 +1,162 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +/// +/// [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. +/// +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); + } +}