Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTreeRowsTests.cs
T
Joseph Doherty 2a788ed8ac perf(adminui): RawTree renders a flat Virtualize list, pages 500 per level, caches menus (R04 P5)
The recursive RenderNode rendered every row of an expanded device, and built a
fresh context-menu list + closures per node per render. RawTreeRows flattens the
expanded tree (explicit stack) into rows rendered by one Virtualize; each level
shows 500 children, then a 'Show more' row. RawNodeMenuCache keys menus weakly
on the node instance and rebuilds on a picker-mode or Enabled change.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:33:45 -04:00

141 lines
5.1 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>R04 P5: the Raw tree renders a flat, paged row list through Virtualize, with cached menus.</summary>
[Trait("Category", "Unit")]
public sealed class RawTreeRowsTests
{
private static RawNode Node(string key, RawNodeKind kind = RawNodeKind.Tag, bool lazy = false, bool enabled = true) =>
new() { Kind = kind, Key = key, DisplayName = key, HasLazyChildren = lazy, Enabled = enabled };
private static RawNode Device(string key, int tags)
{
var d = Node(key, RawNodeKind.Device, lazy: true);
for (var i = 0; i < tags; i++) d.Children.Add(Node($"{key}/t{i}"));
d.Loaded = true;
d.Expanded = true;
return d;
}
[Fact]
public void Collapsed_nodes_show_no_children()
{
var dev = Device("dev", 3);
dev.Expanded = false;
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev"]);
}
[Fact]
public void Expanded_nodes_flatten_depth_first_in_display_order_with_depth()
{
var root = Node("cluster", RawNodeKind.Cluster, lazy: true);
root.Expanded = true;
var drv = Node("drv", RawNodeKind.Driver, lazy: true);
drv.Expanded = true;
drv.Children.Add(Device("devA", 2));
root.Children.Add(drv);
root.Children.Add(Node("folderB", RawNodeKind.Folder, lazy: true));
var rows = RawTreeRows.Flatten([root]);
rows.Select(r => (r.Key, r.Depth)).ShouldBe(
[
("cluster", 0), ("drv", 1), ("devA", 2), ("devA/t0", 3), ("devA/t1", 3), ("folderB", 1),
]);
}
[Fact]
public void A_5k_tag_device_shows_one_page_then_a_show_more_row()
{
var dev = Device("dev", 5000);
var rows = RawTreeRows.Flatten([dev]);
rows.Count.ShouldBe(1 + RawTreeRows.DefaultPageSize + 1);
var more = rows[^1];
more.Kind.ShouldBe(RawTreeRowKind.ShowMore);
more.Remaining.ShouldBe(5000 - RawTreeRows.DefaultPageSize);
more.Depth.ShouldBe(1);
RawTreeRows.ShowMore(dev);
var after = RawTreeRows.Flatten([dev]);
after.Count(r => r.Kind == RawTreeRowKind.Node).ShouldBe(1 + 2 * RawTreeRows.DefaultPageSize);
after[^1].Remaining.ShouldBe(5000 - 2 * RawTreeRows.DefaultPageSize);
}
[Fact]
public void A_level_at_or_under_the_page_size_has_no_show_more_row()
{
var rows = RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize)]);
rows.ShouldNotContain(r => r.Kind == RawTreeRowKind.ShowMore);
// Positive control: one more child does produce one.
RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize + 1)])
.Count(r => r.Kind == RawTreeRowKind.ShowMore).ShouldBe(1);
}
[Fact]
public void Show_more_row_follows_the_whole_subtree_of_the_last_shown_child()
{
var dev = Node("dev", RawNodeKind.Device, lazy: true);
dev.Expanded = true;
dev.VisibleChildLimit = 1;
var grp = Node("grp", RawNodeKind.TagGroup, lazy: true);
grp.Expanded = true;
grp.Children.Add(Node("grp/t"));
dev.Children.Add(grp);
dev.Children.Add(Node("dev/hidden"));
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev", "grp", "grp/t", "more:dev"]);
}
[Fact]
public void Loading_and_error_rows_replace_children()
{
var loading = Device("a", 2);
loading.Loading = true;
var failed = Device("b", 2);
failed.Error = "boom";
RawTreeRows.Flatten([loading, failed]).Select(r => r.Kind).ShouldBe(
[
RawTreeRowKind.Node, RawTreeRowKind.Loading, RawTreeRowKind.Node, RawTreeRowKind.Error,
]);
}
[Fact]
public void Menu_cache_reuses_the_list_and_rebuilds_when_a_dependency_changes()
{
var cache = new RawNodeMenuCache();
var builds = 0;
IReadOnlyList<ContextMenuItem> Build(RawNode _) { builds++; return [new ContextMenuItem { Label = "x" }]; }
var node = Node("drv", RawNodeKind.Driver);
var first = cache.GetOrBuild(node, pickerMode: false, Build);
cache.GetOrBuild(node, pickerMode: false, Build).ShouldBeSameAs(first);
builds.ShouldBe(1);
// Picker mode is a different menu entirely.
cache.GetOrBuild(node, pickerMode: true, Build).ShouldNotBeSameAs(first);
builds.ShouldBe(2);
// A reload replaces the node instance (Enabled is init-only), so the new node gets its own menu.
var reloaded = Node("drv", RawNodeKind.Driver, enabled: false);
cache.GetOrBuild(reloaded, pickerMode: true, Build);
builds.ShouldBe(3);
}
[Fact]
public void RawTree_renders_through_Virtualize_and_the_menu_cache()
{
var src = AdminUiSource.Read("Components", "Shared", "Raw", "RawTree.razor");
src.ShouldContain("<Virtualize Items=\"@RawTreeRows.Flatten(Roots)\"");
src.ShouldContain("_menus.GetOrBuild(node, PickerMode, MenuFor)");
src.ShouldNotContain("@RenderNode(child, depth + 1)");
}
}