using Bunit; using Microsoft.AspNetCore.Components; 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) { 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); } }); } [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")); } }