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; /// /// bUnit tests for the TreeView component covering core rendering, /// expand/collapse behavior, ARIA attributes, and indentation. /// public class TreeViewTests : BunitContext { private record TestNode(string Key, string Label, List Children); 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, RenderFragment? emptyContent = null, int indentPx = 24, Func? initiallyExpanded = null, bool selectable = false, object? selectedKey = null, Action? onSelectedKeyChanged = null, string? selectedCssClass = null, string? storageKey = null, RenderFragment? contextMenu = null) { 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.IndentPx, indentPx) .Add(p => p.EmptyContent, emptyContent) .Add(p => p.InitiallyExpanded, initiallyExpanded) .Add(p => p.Selectable, selectable) .Add(p => p.SelectedKey, selectedKey); if (onSelectedKeyChanged != null) { parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged); } if (selectedCssClass != null) { parameters.Add(p => p.SelectedCssClass, selectedCssClass); } if (storageKey != null) { parameters.Add(p => p.StorageKey, storageKey); } if (contextMenu != null) { parameters.Add(p => p.ContextMenu, contextMenu); } }); } /// Verifies that root-level items render with the correct labels. [Fact] public void RendersRootLevelItems_WithCorrectLabels() { var cut = RenderTreeView(); var labels = cut.FindAll(".node-label"); // Only root-level items visible (children collapsed) Assert.Equal(2, labels.Count); Assert.Equal("Alpha", labels[0].TextContent); Assert.Equal("Beta", labels[1].TextContent); } /// Verifies that empty content renders when there are no items. [Fact] public void RendersEmptyContent_WhenItemsEmpty() { var cut = RenderTreeView( items: new List(), emptyContent: builder => { builder.AddMarkupContent(0, "

Nothing here

"); }); var msg = cut.Find(".empty-msg"); Assert.Equal("Nothing here", msg.TextContent); Assert.Throws(() => cut.Find("ul[role='tree']")); } /// Verifies that leaf nodes have no expand toggle. [Fact] public void LeafNodes_HaveNoToggle() { var cut = RenderTreeView(); // Beta is a leaf (index 1 in the li list) var treeItems = cut.FindAll("li[role='treeitem']"); var betaLi = treeItems[1]; // Beta is second root Assert.Throws(() => betaLi.QuerySelector(".tv-toggle") ?? throw new Bunit.ElementNotFoundException(".tv-toggle")); // Should have spacer instead Assert.NotNull(betaLi.QuerySelector(".tv-spacer")); } /// Verifies that branch nodes show a collapsed toggle. [Fact] public void BranchNodes_ShowCollapsedToggle() { var cut = RenderTreeView(); var alphaLi = cut.FindAll("li[role='treeitem']")[0]; Assert.Equal("false", alphaLi.GetAttribute("aria-expanded")); var toggle = alphaLi.QuerySelector(".tv-toggle"); Assert.NotNull(toggle); // V2 spec: toggle uses Bootstrap Icons chevron-right; CSS rotates on aria-expanded. Assert.NotNull(toggle!.QuerySelector("i.bi.bi-chevron-right")); } /// Verifies that the children of a collapsed branch are not in the DOM. [Fact] public void CollapsedBranch_ChildrenNotInDom() { var cut = RenderTreeView(); // Alpha is collapsed by default, children should not be in DOM var groups = cut.FindAll("ul[role='group']"); Assert.Empty(groups); } /// Verifies that clicking the toggle expands the node and shows its children. [Fact] public void ClickToggle_ExpandsNode_ShowsChildren() { var cut = RenderTreeView(); // Click Alpha's toggle var toggle = cut.Find(".tv-toggle"); toggle.Click(); // Alpha should now be expanded var alphaLi = cut.FindAll("li[role='treeitem']")[0]; Assert.Equal("true", alphaLi.GetAttribute("aria-expanded")); // Children should appear var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } /// Verifies that clicking an expanded toggle collapses the node and hides its children. [Fact] public void ClickExpandedToggle_Collapses_HidesChildren() { var cut = RenderTreeView(); // Expand Alpha var toggle = cut.Find(".tv-toggle"); toggle.Click(); // Verify children visible Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); // Collapse Alpha - find the toggle again (DOM changed) var toggleAgain = cut.Find(".tv-toggle"); toggleAgain.Click(); // Children gone var labels = cut.FindAll(".node-label"); Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); Assert.Empty(cut.FindAll("ul[role='group']")); } /// Verifies that expanding a parent and then its child shows the grandchildren. [Fact] public void DeepNesting_ExpandParentThenChild_ShowsGrandchildren() { var cut = RenderTreeView(); // Expand Alpha cut.Find(".tv-toggle").Click(); // Now find Alpha 2's toggle (Alpha 2 is a branch) var toggles = cut.FindAll(".tv-toggle"); // toggles[0] = Alpha (now expanded, shows minus), toggles[1] = Alpha 2 Assert.True(toggles.Count >= 2); toggles[1].Click(); // Alpha 2 X should be visible var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } /// Verifies that the initially-expanded predicate expands the matching nodes. [Fact] public void InitiallyExpanded_ExpandsMatchingNodes() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); // Alpha and Alpha 2 should be expanded, so Alpha 2 X should be visible var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); Assert.Contains(labels, l => l.TextContent == "Alpha-2"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } /// Verifies that the root list has the tree role. [Fact] public void RootUl_HasRoleTree() { var cut = RenderTreeView(); var rootUl = cut.Find("ul[role='tree']"); Assert.NotNull(rootUl); } /// Verifies that each node item has the treeitem role. [Fact] public void NodeLi_HasRoleTreeitem() { var cut = RenderTreeView(); var items = cut.FindAll("li[role='treeitem']"); Assert.Equal(2, items.Count); // Two root nodes } /// Verifies that an expanded branch has aria-expanded set to true. [Fact] public void ExpandedBranch_HasAriaExpandedTrue() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); var alphaLi = cut.FindAll("li[role='treeitem']")[0]; Assert.Equal("true", alphaLi.GetAttribute("aria-expanded")); } /// Verifies that a child list has the group role. [Fact] public void ChildGroup_HasRoleGroup() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); var groups = cut.FindAll("ul[role='group']"); Assert.Single(groups); } /// Verifies that children are indented by the configured pixels per depth level. [Fact] public void Children_IndentedByIndentPxPerDepth() { var cut = RenderTreeView(indentPx: 30, initiallyExpanded: n => n.Key == "a" || n.Key == "a2"); var rows = cut.FindAll(".tv-row"); // Root nodes at depth 0: padding-left: 0px // Children at depth 1: padding-left: 30px // Grandchildren at depth 2: padding-left: 60px // Find Alpha row (depth 0) var alphaRow = rows[0]; Assert.Contains("padding-left: 0px", alphaRow.GetAttribute("style")); // Find Alpha 1 row (depth 1) var alpha1Row = rows[1]; Assert.Contains("padding-left: 30px", alpha1Row.GetAttribute("style")); // Find Alpha 2 X row (depth 2) - it's after Alpha 2 at index 3 var alpha2xRow = rows[3]; Assert.Contains("padding-left: 60px", alpha2xRow.GetAttribute("style")); } /// Verifies that clicking a node with selection disabled does not fire the callback. [Fact] public void Selection_Disabled_ClickDoesNotFireCallback() { object? selected = null; var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k); cut.Find(".tv-content").Click(); Assert.Null(selected); } /// Verifies that clicking node content with selection enabled fires the callback. [Fact] public void Selection_Enabled_ClickContentFiresCallback() { object? selected = null; var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k); cut.Find(".tv-content").Click(); Assert.Equal("a", selected); } /// Verifies that clicking the toggle does not change the selection. [Fact] public void Selection_ClickToggle_DoesNotChangeSelection() { object? selected = null; var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k); cut.Find(".tv-toggle").Click(); Assert.Null(selected); } /// Verifies that the selected node has the selected CSS class. [Fact] public void Selection_SelectedNode_HasCssClass() { var cut = RenderTreeView(selectable: true, selectedKey: "a"); var alphaRow = cut.FindAll(".tv-row")[0]; Assert.Contains("bg-primary", alphaRow.GetAttribute("class")); } /// Verifies that a custom selected CSS class is applied. [Fact] public void Selection_CustomCssClass_Applied() { var cut = RenderTreeView(selectable: true, selectedKey: "a", selectedCssClass: "my-highlight"); var alphaRow = cut.FindAll(".tv-row")[0]; Assert.Contains("my-highlight", alphaRow.GetAttribute("class")); } /// Verifies that aria-selected is set on the selected node. [Fact] public void Selection_AriaSelected_SetOnSelectedNode() { var cut = RenderTreeView(selectable: true, selectedKey: "a"); var alphaLi = cut.FindAll("li[role='treeitem']")[0]; Assert.Equal("true", alphaLi.GetAttribute("aria-selected")); } /// Verifies that a null storage key makes no JS interop calls. [Fact] public void SessionStorage_NullKey_NoJsInteropCalls() { var cut = RenderTreeView(); // Expand Alpha cut.Find(".tv-toggle").Click(); // No JS interop calls should have been made Assert.Empty(JSInterop.Invocations); } /// Verifies that expanding a node writes the expanded state to session storage. [Fact] public void SessionStorage_Set_ExpandWritesToStorage() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView(storageKey: "test-tree"); // Expand Alpha cut.Find(".tv-toggle").Click(); // Verify save was called var saveInvocations = JSInterop.Invocations .Where(i => i.Identifier == "treeviewStorage.save") .ToList(); Assert.Single(saveInvocations); } /// Verifies that the expanded state is restored from session storage on mount. [Fact] public void SessionStorage_RestoresExpandedOnMount() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[\"a\"]"); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView(storageKey: "test-tree"); // Alpha's children should be visible because "a" was restored from storage var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } /// Verifies that stored session state takes precedence over the initially-expanded predicate. [Fact] public void SessionStorage_TakesPrecedenceOverInitiallyExpanded() { // Storage returns empty array — meaning user explicitly collapsed everything JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[]"); JSInterop.SetupVoid("treeviewStorage.save", _ => true).SetVoidResult(); var cut = RenderTreeView( storageKey: "test-tree", initiallyExpanded: n => n.Key == "a"); // Alpha should NOT be expanded — storage (empty) wins over InitiallyExpanded var labels = cut.FindAll(".node-label"); Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); } /// Verifies that ExpandAll expands all branches. [Fact] public void ExpandAll_ExpandsAllBranches() { var cut = RenderTreeView(); // Everything collapsed initially Assert.Equal(2, cut.FindAll(".node-label").Count); cut.InvokeAsync(() => cut.Instance.ExpandAll()); var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); Assert.Contains(labels, l => l.TextContent == "Alpha-2"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); } /// Verifies that CollapseAll collapses all branches. [Fact] public void CollapseAll_CollapsesAllBranches() { var cut = RenderTreeView(initiallyExpanded: _ => true); // Verify deep content is visible var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); cut.InvokeAsync(() => cut.Instance.CollapseAll()); // Only roots should be visible labels = cut.FindAll(".node-label"); Assert.Equal(2, labels.Count); Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1"); } /// Verifies that revealing a node expands its ancestors. [Fact] public void RevealNode_ExpandsAncestors() { var cut = RenderTreeView(); // Everything collapsed initially Assert.Equal(2, cut.FindAll(".node-label").Count); cut.InvokeAsync(() => cut.Instance.RevealNode("a2x")); // Alpha 2 X should now be visible (Alpha and Alpha 2 expanded) var labels = cut.FindAll(".node-label"); Assert.Contains(labels, l => l.TextContent == "Alpha-2-X"); Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded Assert.Contains(labels, l => l.TextContent == "Alpha-2"); } /// Verifies that revealing a node with select also selects it. [Fact] public void RevealNode_WithSelect_SelectsNode() { object? selected = null; var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k); cut.InvokeAsync(() => cut.Instance.RevealNode("a2x", select: true)); Assert.Equal("a2x", selected); } /// Verifies that revealing an unknown key has no effect. [Fact] public void RevealNode_UnknownKey_NoOp() { var cut = RenderTreeView(); cut.InvokeAsync(() => cut.Instance.RevealNode("nonexistent")); // Alpha should still be collapsed var labels = cut.FindAll(".node-label"); Assert.Equal(2, labels.Count); } // ── External filtering tests (R8) ────────────────────────────────── /// Verifies that a reduced item list hides the removed roots. [Fact] public void Filtering_ReducedItems_HidesRemovedRoots() { var fullItems = SimpleRoots(); var cut = RenderTreeView(items: fullItems); // Both roots visible var labels = cut.FindAll(".node-label"); Assert.Equal(2, labels.Count); // Re-render with only Alpha (Beta removed) var alphaOnly = new List { fullItems[0] }; cut.Render(parameters => { parameters .Add(p => p.Items, alphaOnly) .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) .Add(p => p.KeySelector, (Func)(n => n.Key)) .Add(p => p.NodeContent, (RenderFragment)(node => builder => { builder.AddMarkupContent(0, $"{node.Label}"); })); }); labels = cut.FindAll(".node-label"); Assert.Single(labels); Assert.Equal("Alpha", labels[0].TextContent); } /// Verifies that the expansion state is preserved when the items are filtered. [Fact] public void Filtering_ExpansionStatePreserved() { var fullItems = SimpleRoots(); var cut = RenderTreeView(items: fullItems); // Expand Alpha cut.Find(".tv-toggle").Click(); Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); // Re-render with only Alpha var alphaOnly = new List { fullItems[0] }; cut.Render(parameters => { parameters .Add(p => p.Items, alphaOnly) .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) .Add(p => p.KeySelector, (Func)(n => n.Key)) .Add(p => p.NodeContent, (RenderFragment)(node => builder => { builder.AddMarkupContent(0, $"{node.Label}"); })); }); // Alpha 1 still visible (expansion state preserved) Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); // Re-render with full list again cut.Render(parameters => { parameters .Add(p => p.Items, fullItems) .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) .Add(p => p.KeySelector, (Func)(n => n.Key)) .Add(p => p.NodeContent, (RenderFragment)(node => builder => { builder.AddMarkupContent(0, $"{node.Label}"); })); }); // Alpha 1 still visible after restoration Assert.Contains(cut.FindAll(".node-label"), l => l.TextContent == "Alpha-1"); } /// Verifies that the selection is cleared when the selected node disappears. [Fact] public void Filtering_SelectionCleared_WhenNodeDisappears() { var fullItems = SimpleRoots(); object? lastSelected = "b"; // track the last value passed to callback var cut = RenderTreeView( items: fullItems, selectable: true, selectedKey: "b", onSelectedKeyChanged: k => lastSelected = k); // Re-render with only Alpha (Beta disappears) var alphaOnly = new List { fullItems[0] }; cut.Render(parameters => { parameters .Add(p => p.Items, alphaOnly) .Add(p => p.ChildrenSelector, (Func>)(n => n.Children)) .Add(p => p.HasChildrenSelector, (Func)(n => n.Children.Count > 0)) .Add(p => p.KeySelector, (Func)(n => n.Key)) .Add(p => p.NodeContent, (RenderFragment)(node => builder => { builder.AddMarkupContent(0, $"{node.Label}"); })) .Add(p => p.Selectable, true) .Add(p => p.SelectedKey, (object?)"b") .Add(p => p.SelectedKeyChanged, (Action)(k => lastSelected = k)); }); // SelectedKeyChanged should have been called with null Assert.Null(lastSelected); } // ── Context menu tests ────────────────────────────────────────────── /// Verifies that no menu renders when the context menu is null. [Fact] public void ContextMenu_Null_NoMenuRendered() { var cut = RenderTreeView(); // Right-click Alpha var row = cut.Find(".tv-row"); row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); // No dropdown-menu should appear Assert.Throws(() => cut.Find(".dropdown-menu")); } /// Verifies that a right-click shows the context menu. [Fact] public void ContextMenu_RightClickShowsMenu() { var cut = RenderTreeView(contextMenu: node => builder => { builder.AddMarkupContent(0, $""); }); // Right-click Alpha var row = cut.Find(".tv-row"); row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); // Dropdown menu should contain the button for Alpha var menu = cut.Find(".dropdown-menu"); Assert.NotNull(menu); var btn = menu.QuerySelector(".ctx-btn"); Assert.NotNull(btn); Assert.Equal("Alpha", btn!.TextContent); } /// Verifies that pressing Escape dismisses the context menu. [Fact] public void ContextMenu_EscapeKeyDismissesMenu() { var cut = RenderTreeView(contextMenu: node => builder => { builder.AddMarkupContent(0, $""); }); var row = cut.Find(".tv-row"); row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); Assert.NotNull(cut.Find(".dropdown-menu")); // Press Escape on the menu — R15 spec requires it to dismiss. var menu = cut.Find(".dropdown-menu"); menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" }); Assert.Empty(cut.FindAll(".dropdown-menu")); } /// Verifies that a key other than Escape does not dismiss the context menu. [Fact] public void ContextMenu_NonEscapeKey_DoesNotDismiss() { var cut = RenderTreeView(contextMenu: node => builder => { builder.AddMarkupContent(0, $""); }); var row = cut.Find(".tv-row"); row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); var menu = cut.Find(".dropdown-menu"); menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" }); Assert.NotEmpty(cut.FindAll(".dropdown-menu")); } /// Verifies that right-clicking a different node replaces the open menu. [Fact] public void ContextMenu_RightClickDifferentNode_ReplacesMenu() { var cut = RenderTreeView( initiallyExpanded: n => n.Key == "a", contextMenu: node => builder => { builder.AddMarkupContent(0, $""); }); // Right-click Alpha var rows = cut.FindAll(".tv-row"); rows[0].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 }); // Now right-click Alpha 1 rows = cut.FindAll(".tv-row"); rows[1].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 150, ClientY = 250 }); // Should be only one dropdown-menu, showing Alpha 1 var menus = cut.FindAll(".dropdown-menu"); Assert.Single(menus); var btn = menus[0].QuerySelector(".ctx-btn"); Assert.NotNull(btn); Assert.Equal("Alpha-1", btn!.TextContent); } }