using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using ScadaLink.CentralUI.Components.Shared; namespace ScadaLink.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); } }); } [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); } [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']")); } [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")); } [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); Assert.Equal("+", toggle!.TextContent); } [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); } [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"); } [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']")); } [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"); } [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"); } [Fact] public void RootUl_HasRoleTree() { var cut = RenderTreeView(); var rootUl = cut.Find("ul[role='tree']"); Assert.NotNull(rootUl); } [Fact] public void NodeLi_HasRoleTreeitem() { var cut = RenderTreeView(); var items = cut.FindAll("li[role='treeitem']"); Assert.Equal(2, items.Count); // Two root nodes } [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")); } [Fact] public void ChildGroup_HasRoleGroup() { var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a"); var groups = cut.FindAll("ul[role='group']"); Assert.Single(groups); } [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")); } [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); } [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); } [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); } [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")); } [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")); } [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")); } [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); } [Fact] public void SessionStorage_Set_ExpandWritesToStorage() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult(null); JSInterop.SetupVoid("treeviewStorage.save", _ => true); 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); } [Fact] public void SessionStorage_RestoresExpandedOnMount() { JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[\"a\"]"); JSInterop.SetupVoid("treeviewStorage.save", _ => true); 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"); } [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); 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"); } [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"); } [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"); } [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"); } [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); } [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) ────────────────────────────────── [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); } [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"); } [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 ────────────────────────────────────────────── [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")); } [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); } [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); } }