feat(central-ui): InstanceTreeBuilder — area/instance forest for the instance picker
Task 1 of the Instance Picker plan (docs/plans/2026-09-08-instance-picker.md). Pure, DI-free builder that nests a site's areas by ParentAreaId and hangs each area's instances beneath it, mirroring Topology's ordering (child areas first, then that level's instances; OrdinalIgnoreCase sorts) with unparented instances at the root after the root areas. Orphaned areas become roots, a case-insensitive filter keeps matching instances plus their ancestors and drops empty branches, and a ParentAreaId cycle can never recurse forever. Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the Instance Picker's area/instance forest from a site's flat area and
|
||||||
|
/// instance lists. Pure — no Blazor, no DI, no I/O — so the shape is unit-testable
|
||||||
|
/// on its own and the picker component only has to render it.
|
||||||
|
/// </summary>
|
||||||
|
public static class InstanceTreeBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Nests the supplied areas by <see cref="Area.ParentAreaId"/> and hangs each area's
|
||||||
|
/// instances beneath it, mirroring the Topology page's ordering: at every level the
|
||||||
|
/// child areas come first (sorted by name, ordinal-ignore-case), then that level's
|
||||||
|
/// instances (sorted by unique name, ordinal-ignore-case). Instances with no area sit
|
||||||
|
/// at the root after the root areas. An area whose parent id points at an area that is
|
||||||
|
/// not in <paramref name="areas"/> is treated as a root, so a stale row can never hide
|
||||||
|
/// a subtree.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="areas">The site's areas (flat, linked by <see cref="Area.ParentAreaId"/>); null is treated as empty.</param>
|
||||||
|
/// <param name="instances">The site's instances; null is treated as empty.</param>
|
||||||
|
/// <param name="filter">
|
||||||
|
/// Optional case-insensitive substring matched against <see cref="Instance.UniqueName"/>.
|
||||||
|
/// When set, only matching instances are kept along with their ancestor areas; areas left
|
||||||
|
/// with nothing beneath them are dropped. Null or whitespace means no filter.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The root rows of the forest, never null.</returns>
|
||||||
|
public static IReadOnlyList<InstanceTreeNode> Build(
|
||||||
|
IReadOnlyList<Area>? areas,
|
||||||
|
IReadOnlyList<Instance>? instances,
|
||||||
|
string? filter)
|
||||||
|
{
|
||||||
|
areas ??= [];
|
||||||
|
instances ??= [];
|
||||||
|
|
||||||
|
var needle = filter?.Trim();
|
||||||
|
var hasFilter = !string.IsNullOrEmpty(needle);
|
||||||
|
|
||||||
|
var knownAreaIds = areas.Select(a => a.Id).ToHashSet();
|
||||||
|
|
||||||
|
// Child areas by effective parent id — null for a root or an orphaned area.
|
||||||
|
var areasByParent = areas
|
||||||
|
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToLookup(a => a.ParentAreaId is int p && knownAreaIds.Contains(p) ? p : (int?)null);
|
||||||
|
|
||||||
|
var instancesByArea = instances
|
||||||
|
.Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToLookup(i => i.AreaId);
|
||||||
|
|
||||||
|
return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<InstanceTreeNode> BuildLevel(
|
||||||
|
int? parentId,
|
||||||
|
ILookup<int?, Area> areasByParent,
|
||||||
|
ILookup<int?, Instance> instancesByArea,
|
||||||
|
bool hasFilter,
|
||||||
|
HashSet<int> ancestors)
|
||||||
|
{
|
||||||
|
var level = new List<InstanceTreeNode>();
|
||||||
|
|
||||||
|
foreach (var area in areasByParent[parentId])
|
||||||
|
{
|
||||||
|
// Defensive: a cycle in ParentAreaId must never recurse forever.
|
||||||
|
if (!ancestors.Add(area.Id)) continue;
|
||||||
|
|
||||||
|
var node = new InstanceTreeNode($"a:{area.Id}", area.Name, area, Instance: null);
|
||||||
|
node.Children.AddRange(BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors));
|
||||||
|
ancestors.Remove(area.Id);
|
||||||
|
|
||||||
|
// With a filter an area survives only if something matched beneath it.
|
||||||
|
if (!hasFilter || node.HasChildren) level.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
level.AddRange(instancesByArea[parentId].Select(i =>
|
||||||
|
new InstanceTreeNode($"i:{i.Id}", i.UniqueName, Area: null, Instance: i)));
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of the Instance Picker tree: an area branch or an instance leaf.
|
||||||
|
/// Keys are <c>a:{areaId}</c> for areas and <c>i:{instanceId}</c> for instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Key">Stable tree key — <c>a:{areaId}</c> or <c>i:{instanceId}</c>.</param>
|
||||||
|
/// <param name="Label">Text rendered for this row.</param>
|
||||||
|
/// <param name="Area">The area this node represents, or null for an instance leaf.</param>
|
||||||
|
/// <param name="Instance">The instance this node represents, or null for an area branch.</param>
|
||||||
|
public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance)
|
||||||
|
{
|
||||||
|
/// <summary>Gets the child rows nested under this node (child areas first, then instances).</summary>
|
||||||
|
public List<InstanceTreeNode> Children { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this node is a selectable instance leaf.</summary>
|
||||||
|
public bool IsInstance => Instance is not null;
|
||||||
|
|
||||||
|
/// <summary>Gets a value indicating whether this node has any child rows.</summary>
|
||||||
|
public bool HasChildren => Children.Count > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="InstanceTreeBuilder"/> — the pure area/instance forest
|
||||||
|
/// behind the Instance Picker combobox (design 2026-09-08).
|
||||||
|
/// </summary>
|
||||||
|
public class InstanceTreeBuilderTests
|
||||||
|
{
|
||||||
|
private static Area MakeArea(int id, string name, int? parentAreaId = null) =>
|
||||||
|
new(name) { Id = id, SiteId = 1, ParentAreaId = parentAreaId };
|
||||||
|
|
||||||
|
private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) =>
|
||||||
|
new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas()
|
||||||
|
{
|
||||||
|
var line1 = MakeArea(1, "Line1");
|
||||||
|
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
|
||||||
|
var p1 = MakeInstance(10, "P1", areaId: 2);
|
||||||
|
var zulu = MakeInstance(11, "Zulu");
|
||||||
|
var alpha = MakeInstance(12, "Alpha");
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([line1, cellA], [p1, zulu, alpha], filter: null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a:1", "i:12", "i:11" }, roots.Select(n => n.Key));
|
||||||
|
|
||||||
|
var areaNode = roots[0];
|
||||||
|
Assert.Equal("Line1", areaNode.Label);
|
||||||
|
Assert.False(areaNode.IsInstance);
|
||||||
|
Assert.Same(line1, areaNode.Area);
|
||||||
|
Assert.Null(areaNode.Instance);
|
||||||
|
Assert.True(areaNode.HasChildren);
|
||||||
|
|
||||||
|
var cellNode = Assert.Single(areaNode.Children);
|
||||||
|
Assert.Equal("a:2", cellNode.Key);
|
||||||
|
Assert.Equal("Cell A", cellNode.Label);
|
||||||
|
Assert.Same(cellA, cellNode.Area);
|
||||||
|
|
||||||
|
var p1Node = Assert.Single(cellNode.Children);
|
||||||
|
Assert.Equal("i:10", p1Node.Key);
|
||||||
|
Assert.Equal("P1", p1Node.Label);
|
||||||
|
Assert.True(p1Node.IsInstance);
|
||||||
|
Assert.Same(p1, p1Node.Instance);
|
||||||
|
Assert.Null(p1Node.Area);
|
||||||
|
Assert.False(p1Node.HasChildren);
|
||||||
|
|
||||||
|
Assert.Equal("Alpha", roots[1].Label);
|
||||||
|
Assert.Same(alpha, roots[1].Instance);
|
||||||
|
Assert.Equal("Zulu", roots[2].Label);
|
||||||
|
Assert.Same(zulu, roots[2].Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_SortsAreasAndInstancesCaseInsensitively()
|
||||||
|
{
|
||||||
|
var beta = MakeArea(1, "beta");
|
||||||
|
var alphaArea = MakeArea(2, "Alpha");
|
||||||
|
var zebra = MakeInstance(10, "zebra");
|
||||||
|
var apple = MakeInstance(11, "Apple");
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([beta, alphaArea], [zebra, apple], filter: null);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_OrphanedArea_BecomesARoot()
|
||||||
|
{
|
||||||
|
// ParentAreaId points at an area that is not in the supplied list.
|
||||||
|
var orphan = MakeArea(5, "Orphan", parentAreaId: 99);
|
||||||
|
var child = MakeInstance(10, "OrphanChild", areaId: 5);
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([orphan], [child], filter: null);
|
||||||
|
|
||||||
|
var root = Assert.Single(roots);
|
||||||
|
Assert.Equal("a:5", root.Key);
|
||||||
|
var leaf = Assert.Single(root.Children);
|
||||||
|
Assert.Equal("i:10", leaf.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches()
|
||||||
|
{
|
||||||
|
var line1 = MakeArea(1, "Line1");
|
||||||
|
var cellA = MakeArea(2, "Cell A", parentAreaId: 1);
|
||||||
|
var emptyArea = MakeArea(3, "Line2");
|
||||||
|
var p1 = MakeInstance(10, "P1", areaId: 2);
|
||||||
|
var zulu = MakeInstance(11, "Zulu");
|
||||||
|
var alpha = MakeInstance(12, "Alpha");
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([line1, cellA, emptyArea], [p1, zulu, alpha], filter: "p1");
|
||||||
|
|
||||||
|
var root = Assert.Single(roots);
|
||||||
|
Assert.Equal("a:1", root.Key);
|
||||||
|
var cellNode = Assert.Single(root.Children);
|
||||||
|
Assert.Equal("a:2", cellNode.Key);
|
||||||
|
var leaf = Assert.Single(cellNode.Children);
|
||||||
|
Assert.Equal("i:10", leaf.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Filter_WhitespaceMeansNoFilter()
|
||||||
|
{
|
||||||
|
var line1 = MakeArea(1, "Line1");
|
||||||
|
var p1 = MakeInstance(10, "P1", areaId: 1);
|
||||||
|
var zulu = MakeInstance(11, "Zulu");
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([line1], [p1, zulu], filter: " ");
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a:1", "i:11" }, roots.Select(n => n.Key));
|
||||||
|
Assert.Single(roots[0].Children);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_NullOrEmptyInputs_ReturnEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(InstanceTreeBuilder.Build(null, null, filter: null));
|
||||||
|
Assert.Empty(InstanceTreeBuilder.Build([], [], filter: null));
|
||||||
|
Assert.Empty(InstanceTreeBuilder.Build(null, [], filter: "anything"));
|
||||||
|
|
||||||
|
var orphanInstances = InstanceTreeBuilder.Build(null, [MakeInstance(1, "Solo")], filter: null);
|
||||||
|
Assert.Equal("i:1", Assert.Single(orphanInstances).Key);
|
||||||
|
|
||||||
|
var areasOnly = InstanceTreeBuilder.Build([MakeArea(1, "Line1")], null, filter: null);
|
||||||
|
Assert.Equal("a:1", Assert.Single(areasOnly).Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown()
|
||||||
|
{
|
||||||
|
var empty = MakeArea(1, "Empty");
|
||||||
|
|
||||||
|
var roots = InstanceTreeBuilder.Build([empty], [], filter: null);
|
||||||
|
|
||||||
|
var root = Assert.Single(roots);
|
||||||
|
Assert.Equal("a:1", root.Key);
|
||||||
|
Assert.False(root.HasChildren);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user