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
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user