diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs new file mode 100644 index 00000000..7b470188 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs @@ -0,0 +1,82 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// 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. +/// +public static class InstanceTreeBuilder +{ + /// + /// Nests the supplied areas by 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 is treated as a root, so a stale row can never hide + /// a subtree. + /// + /// The site's areas (flat, linked by ); null is treated as empty. + /// The site's instances; null is treated as empty. + /// + /// Optional case-insensitive substring matched against . + /// 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. + /// + /// The root rows of the forest, never null. + public static IReadOnlyList Build( + IReadOnlyList? areas, + IReadOnlyList? 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 BuildLevel( + int? parentId, + ILookup areasByParent, + ILookup instancesByArea, + bool hasFilter, + HashSet ancestors) + { + var level = new List(); + + 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs new file mode 100644 index 00000000..e882fa96 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; + +/// +/// One row of the Instance Picker tree: an area branch or an instance leaf. +/// Keys are a:{areaId} for areas and i:{instanceId} for instances. +/// +/// Stable tree key — a:{areaId} or i:{instanceId}. +/// Text rendered for this row. +/// The area this node represents, or null for an instance leaf. +/// The instance this node represents, or null for an area branch. +public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance) +{ + /// Gets the child rows nested under this node (child areas first, then instances). + public List Children { get; } = new(); + + /// Gets a value indicating whether this node is a selectable instance leaf. + public bool IsInstance => Instance is not null; + + /// Gets a value indicating whether this node has any child rows. + public bool HasChildren => Children.Count > 0; +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs new file mode 100644 index 00000000..8e012661 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs @@ -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; + +/// +/// Unit tests for — the pure area/instance forest +/// behind the Instance Picker combobox (design 2026-09-08). +/// +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); + } +}