using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared; /// /// bUnit tests for TreeView's WAI-ARIA tree keyboard navigation /// (https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). Covers the roving /// tabindex (exactly one tabbable treeitem), Arrow/Home/End movement over the /// flattened visible node order, ArrowRight/ArrowLeft expand-collapse /// semantics, Enter/Space activation in both selection modes, and the /// aria-level / aria-posinset / aria-setsize structure. /// /// bUnit moves no real browser focus, so "focus" is asserted through the rendered /// tabindex attribute (the roving-tabindex target) and through the /// selection callbacks, not through a focused element. /// public class TreeViewKeyboardNavigationTests : BunitContext { private record TestNode(string Key, string Label, List Children); // Alpha (branch) // ├── Alpha-1 (leaf) // └── Alpha-2 (branch) // └── Alpha-2-X (leaf) // Beta (leaf) private static List SimpleRoots() => new() { new("a", "Alpha", new() { new("a1", "Alpha-1", new()), new("a2", "Alpha-2", new() { new("a2x", "Alpha-2-X", new()) }) }), new("b", "Beta", new()), }; private IRenderedComponent> RenderTreeView( List? items = null, Func? initiallyExpanded = null, bool selectable = false, object? selectedKey = null, Action? onSelectedKeyChanged = null, TreeViewSelectionMode selectionMode = TreeViewSelectionMode.Single, HashSet? selectedKeys = null, Action>? onSelectedKeysChanged = null) { if (selectionMode == TreeViewSelectionMode.Checkbox) { // Checkbox mode pushes the tri-state via JS interop after every render; // stub it so bUnit's strict mode doesn't blow up on the unmocked call. JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true); } return Render>(parameters => { parameters .Add(p => p.Items, items ?? SimpleRoots()) .Add(p => p.ChildrenSelector, n => n.Children) .Add(p => p.HasChildrenSelector, n => n.Children.Count > 0) .Add(p => p.KeySelector, n => n.Key) .Add(p => p.NodeContent, node => builder => { builder.AddMarkupContent(0, $"{node.Label}"); }) .Add(p => p.InitiallyExpanded, initiallyExpanded) .Add(p => p.Selectable, selectable) .Add(p => p.SelectedKey, selectedKey) .Add(p => p.SelectionMode, selectionMode) .Add(p => p.SelectedKeys, selectedKeys); if (onSelectedKeyChanged != null) { parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged); } if (onSelectedKeysChanged != null) { parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged); } }); } // `li[role=treeitem]` elements come back in document order, which for a nested //
    /
  • tree is exactly the flattened visible order the arrow keys walk. // So an index into this list is an index into the visible node sequence. private static IReadOnlyList TreeItems( IRenderedComponent> cut) => cut.FindAll("li[role='treeitem']").ToList(); /// Index of the single tabbable node; also asserts that exactly one exists. private static int TabbableIndex(IRenderedComponent> cut) { var items = TreeItems(cut); var found = -1; for (var i = 0; i < items.Count; i++) { var tabindex = items[i].GetAttribute("tabindex"); if (tabindex == "0") { Assert.Equal(-1, found); found = i; } else { // Every other node must be explicitly removed from the tab order. Assert.Equal("-1", tabindex); } } Assert.NotEqual(-1, found); return found; } private static void PressKey( IRenderedComponent> cut, int nodeIndex, string key) => TreeItems(cut)[nodeIndex].KeyDown(new KeyboardEventArgs { Key = key }); // ── Roving tabindex ──────────────────────────────────────────────────────── [Fact] public void InitialRender_ExactlyOneNodeIsTabbable_AndItIsTheFirst() { var cut = RenderTreeView(); // Two collapsed roots; Alpha (index 0) owns the tab stop. Assert.Equal(2, TreeItems(cut).Count); Assert.Equal(0, TabbableIndex(cut)); } [Fact] public void InitialRender_SelectedNode_OwnsTheTabStop() { // With a selection present the tab stop defaults to it, not to the first node. var cut = RenderTreeView(selectable: true, selectedKey: "b"); Assert.Equal(1, TabbableIndex(cut)); } [Fact] public void TabStop_FallsBackToFirstNode_WhenFocusedNodeIsCollapsedAway() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); // Visible: Alpha, Alpha-1, Alpha-2, Beta. Move onto Alpha-1. PressKey(cut, 0, "ArrowDown"); Assert.Equal(1, TabbableIndex(cut)); // Collapsing Alpha removes Alpha-1 from the visible set; the tab stop must // not vanish with it. cut.InvokeAsync(() => cut.Instance.CollapseAll()); Assert.Equal(0, TabbableIndex(cut)); } // ── ArrowDown / ArrowUp ──────────────────────────────────────────────────── [Fact] public void ArrowDown_MovesToNextVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); // Visible order: Alpha(0), Alpha-1(1), Alpha-2(2), Beta(3). Assert.Equal(4, TreeItems(cut).Count); PressKey(cut, 0, "ArrowDown"); Assert.Equal(1, TabbableIndex(cut)); PressKey(cut, 1, "ArrowDown"); Assert.Equal(2, TabbableIndex(cut)); } [Fact] public void ArrowDown_SkipsChildrenOfCollapsedBranch() { var cut = RenderTreeView(); // Alpha is collapsed, so the next visible node is Beta — not Alpha-1. PressKey(cut, 0, "ArrowDown"); Assert.Equal(2, TreeItems(cut).Count); Assert.Equal(1, TabbableIndex(cut)); } [Fact] public void ArrowDown_AtLastVisibleNode_IsNoOp() { var cut = RenderTreeView(); // Walk onto Beta (the last visible node) first, then try to go past it. PressKey(cut, 0, "ArrowDown"); PressKey(cut, 1, "ArrowDown"); Assert.Equal(1, TabbableIndex(cut)); } [Fact] public void ArrowUp_MovesToPreviousVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); PressKey(cut, 3, "ArrowUp"); Assert.Equal(2, TabbableIndex(cut)); PressKey(cut, 2, "ArrowUp"); Assert.Equal(1, TabbableIndex(cut)); } [Fact] public void ArrowUp_AtFirstVisibleNode_IsNoOp() { var cut = RenderTreeView(); PressKey(cut, 0, "ArrowUp"); Assert.Equal(0, TabbableIndex(cut)); } // ── ArrowRight ───────────────────────────────────────────────────────────── [Fact] public void ArrowRight_OnCollapsedBranch_Expands_WithoutMovingFocus() { var cut = RenderTreeView(); PressKey(cut, 0, "ArrowRight"); var items = TreeItems(cut); Assert.Equal("true", items[0].GetAttribute("aria-expanded")); Assert.Equal(4, items.Count); // Alpha, Alpha-1, Alpha-2, Beta Assert.Equal(0, TabbableIndex(cut)); } [Fact] public void ArrowRight_OnExpandedBranch_MovesToFirstChild() { var cut = RenderTreeView(); PressKey(cut, 0, "ArrowRight"); // expand PressKey(cut, 0, "ArrowRight"); // move onto Alpha-1 Assert.Equal(1, TabbableIndex(cut)); } [Fact] public void ArrowRight_OnLeaf_IsNoOp() { var cut = RenderTreeView(); // Beta is a leaf: no expansion, no movement, no new nodes. PressKey(cut, 0, "ArrowDown"); PressKey(cut, 1, "ArrowRight"); var items = TreeItems(cut); Assert.Equal(2, items.Count); Assert.Null(items[1].GetAttribute("aria-expanded")); Assert.Equal(1, TabbableIndex(cut)); } // ── ArrowLeft ────────────────────────────────────────────────────────────── [Fact] public void ArrowLeft_OnExpandedBranch_Collapses_WithoutMovingFocus() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); PressKey(cut, 0, "ArrowLeft"); var items = TreeItems(cut); Assert.Equal("false", items[0].GetAttribute("aria-expanded")); Assert.Equal(2, items.Count); Assert.Equal(0, TabbableIndex(cut)); } [Fact] public void ArrowLeft_OnChildNode_MovesToParent() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); // Alpha-1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha. PressKey(cut, 1, "ArrowLeft"); Assert.Equal(0, TabbableIndex(cut)); // The parent must NOT have collapsed as a side effect. Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded")); } [Fact] public void ArrowLeft_OnCollapsedBranchChild_MovesToParent() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); // Alpha-2 (index 2) is a branch but collapsed → move to its parent Alpha. PressKey(cut, 2, "ArrowLeft"); Assert.Equal(0, TabbableIndex(cut)); } [Fact] public void ArrowLeft_OnRootLeaf_IsNoOp() { var cut = RenderTreeView(); PressKey(cut, 0, "ArrowDown"); PressKey(cut, 1, "ArrowLeft"); // Beta — root level, nothing to collapse Assert.Equal(2, TreeItems(cut).Count); Assert.Equal(1, TabbableIndex(cut)); } // ── Home / End ───────────────────────────────────────────────────────────── [Fact] public void Home_MovesToFirstVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); PressKey(cut, 3, "Home"); Assert.Equal(0, TabbableIndex(cut)); } [Fact] public void End_MovesToLastVisibleNode() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4). Assert.Equal(5, TreeItems(cut).Count); PressKey(cut, 0, "End"); Assert.Equal(4, TabbableIndex(cut)); } // ── Enter / Space activation ─────────────────────────────────────────────── [Fact] public void Enter_SelectsNode_InSingleMode() { object? selected = null; var cut = RenderTreeView( initiallyExpanded: n => n.Key == "a", selectable: true, onSelectedKeyChanged: k => selected = k); PressKey(cut, 1, "Enter"); // Alpha-1 Assert.Equal("a1", selected); } [Fact] public void Space_SelectsNode_InSingleMode() { object? selected = null; var cut = RenderTreeView( selectable: true, onSelectedKeyChanged: k => selected = k); PressKey(cut, 1, " "); // Beta Assert.Equal("b", selected); } [Fact] public void Enter_WhenNotSelectable_RaisesNoCallback() { object? selected = null; var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k); PressKey(cut, 0, "Enter"); Assert.Null(selected); } [Fact] public void Space_InCheckboxMode_TogglesTheNode() { HashSet? captured = null; var cut = RenderTreeView( initiallyExpanded: _ => true, selectionMode: TreeViewSelectionMode.Checkbox, selectedKeys: new HashSet(), onSelectedKeysChanged: keys => captured = keys); // Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4). // Space on Alpha-1 selects that one leaf. PressKey(cut, 1, " "); Assert.NotNull(captured); Assert.Equal(new HashSet { "a1" }, captured); } [Fact] public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves() { HashSet? captured = null; var cut = RenderTreeView( initiallyExpanded: _ => true, selectionMode: TreeViewSelectionMode.Checkbox, selectedKeys: new HashSet(), onSelectedKeysChanged: keys => captured = keys); PressKey(cut, 0, "Enter"); // Alpha → leaves a1 + a2x Assert.NotNull(captured); Assert.Equal(2, captured!.Count); Assert.Contains((object)"a1", captured); Assert.Contains((object)"a2x", captured); } [Fact] public void Keydown_OnNestedNode_DoesNotAlsoActivateItsAncestor() { // The treeitem stops keydown propagation, so a key pressed on a child must // not be handled a second time by every enclosing
  • . object? selected = null; var cut = RenderTreeView( initiallyExpanded: n => n.Key == "a", selectable: true, onSelectedKeyChanged: k => selected = k); PressKey(cut, 1, "Enter"); // Alpha-1, nested inside Alpha Assert.Equal("a1", selected); } // ── Existing chevron behaviour (regression) ──────────────────────────────── [Fact] public void ChevronToggle_EnterKey_StillExpands_AndDoesNotSelect() { object? selected = null; var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k); cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = "Enter" }); Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded")); // The chevron stops propagation, so the treeitem never sees the Enter. Assert.Null(selected); } [Fact] public void ChevronToggle_SpaceKey_StillExpands() { var cut = RenderTreeView(); cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = " " }); Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded")); } // ── ARIA structure ───────────────────────────────────────────────────────── [Fact] public void TreeItems_CarryOneBasedAriaLevel() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); var items = TreeItems(cut); // Alpha, Alpha-1, Alpha-2, Alpha-2-X, Beta Assert.Equal("1", items[0].GetAttribute("aria-level")); Assert.Equal("2", items[1].GetAttribute("aria-level")); Assert.Equal("2", items[2].GetAttribute("aria-level")); Assert.Equal("3", items[3].GetAttribute("aria-level")); Assert.Equal("1", items[4].GetAttribute("aria-level")); } [Fact] public void TreeItems_CarryAriaPosInSetAndSetSize() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); var items = TreeItems(cut); // Two roots. Assert.Equal("1", items[0].GetAttribute("aria-posinset")); Assert.Equal("2", items[0].GetAttribute("aria-setsize")); // Alpha's two children. Assert.Equal("1", items[1].GetAttribute("aria-posinset")); Assert.Equal("2", items[1].GetAttribute("aria-setsize")); Assert.Equal("2", items[2].GetAttribute("aria-posinset")); Assert.Equal("2", items[2].GetAttribute("aria-setsize")); // Beta — second of the two roots. Assert.Equal("2", items[3].GetAttribute("aria-posinset")); Assert.Equal("2", items[3].GetAttribute("aria-setsize")); } [Fact] public void TreeStructure_UsesRoleTree_RoleGroup_AndRoleTreeitem() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); Assert.NotNull(cut.Find("ul[role='tree']")); Assert.Single(cut.FindAll("ul[role='group']")); Assert.Equal(4, TreeItems(cut).Count); } [Fact] public void AriaSelected_IsPresentOnSelectableNodes_AndTracksSelection() { var cut = RenderTreeView(selectable: true, selectedKey: "a"); var items = TreeItems(cut); Assert.Equal("true", items[0].GetAttribute("aria-selected")); Assert.Equal("false", items[1].GetAttribute("aria-selected")); } [Fact] public void RootTree_SuppressesBrowserScrollDefaults_ForNavigationKeys() { // Space/Arrow/Home/End would otherwise scroll the page while a treeitem holds // focus. bUnit runs no scripts, so this locks the native guard's presence and // the key set it covers — not its runtime effect. var cut = RenderTreeView(); var handler = cut.Find("ul[role='tree']").GetAttribute("onkeydown"); Assert.NotNull(handler); Assert.Contains("preventDefault", handler!); // Scoped to the treeitem itself so a consumer's buttons inside NodeContent keep // their Enter/Space activation. Assert.Contains("'treeitem'", handler); foreach (var key in new[] { "' '", "'ArrowUp'", "'ArrowDown'", "'ArrowLeft'", "'ArrowRight'", "'Home'", "'End'" }) { Assert.Contains(key, handler); } } [Fact] public void AriaSelected_IsAbsent_WhenTreeIsNotSelectable() { var cut = RenderTreeView(selectable: false); Assert.Null(TreeItems(cut)[0].GetAttribute("aria-selected")); } }