Files
2026-03-24 16:19:39 -04:00

639 lines
22 KiB
C#

using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests;
/// <summary>
/// bUnit tests for the TreeView component covering core rendering,
/// expand/collapse behavior, ARIA attributes, and indentation.
/// </summary>
public class TreeViewTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> 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<TreeView<TestNode>> RenderTreeView(
List<TestNode>? items = null,
RenderFragment? emptyContent = null,
int indentPx = 24,
Func<TestNode, bool>? initiallyExpanded = null,
bool selectable = false,
object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null,
string? selectedCssClass = null,
string? storageKey = null,
RenderFragment<TestNode>? contextMenu = null)
{
return Render<TreeView<TestNode>>(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, $"<span class=\"node-label\">{node.Label}</span>");
})
.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<TestNode>(),
emptyContent: builder =>
{
builder.AddMarkupContent(0, "<p class=\"empty-msg\">Nothing here</p>");
});
var msg = cut.Find(".empty-msg");
Assert.Equal("Nothing here", msg.TextContent);
Assert.Throws<Bunit.ElementNotFoundException>(() => 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<Bunit.ElementNotFoundException>(() => 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<string?>("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<string?>("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<string?>("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<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
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<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// 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<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}));
});
// 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<TestNode> { fullItems[0] };
cut.Render(parameters =>
{
parameters
.Add(p => p.Items, alphaOnly)
.Add(p => p.ChildrenSelector, (Func<TestNode, IReadOnlyList<TestNode>>)(n => n.Children))
.Add(p => p.HasChildrenSelector, (Func<TestNode, bool>)(n => n.Children.Count > 0))
.Add(p => p.KeySelector, (Func<TestNode, object>)(n => n.Key))
.Add(p => p.NodeContent, (RenderFragment<TestNode>)(node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
}))
.Add(p => p.Selectable, true)
.Add(p => p.SelectedKey, (object?)"b")
.Add(p => p.SelectedKeyChanged, (Action<object?>)(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<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickShowsMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// 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, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// 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);
}
}