diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs index 7b470188..5f9ff9d1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeBuilder.cs @@ -14,9 +14,9 @@ public static class InstanceTreeBuilder /// 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. + /// at the root after the root areas. A row whose parent points at an area that is not in + /// is treated as a root — both an orphaned area and an instance + /// with an unknown area id — so a stale row can never hide a subtree or an instance. /// /// The site's areas (flat, linked by ); null is treated as empty. /// The site's instances; null is treated as empty. @@ -47,7 +47,7 @@ public static class InstanceTreeBuilder var instancesByArea = instances .Where(i => !hasFilter || i.UniqueName.Contains(needle!, StringComparison.OrdinalIgnoreCase)) .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase) - .ToLookup(i => i.AreaId); + .ToLookup(i => i.AreaId is int a && knownAreaIds.Contains(a) ? a : (int?)null); return BuildLevel(parentId: null, areasByParent, instancesByArea, hasFilter, ancestors: []); } @@ -67,7 +67,11 @@ public static class InstanceTreeBuilder 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)); + foreach (var child in BuildLevel(area.Id, areasByParent, instancesByArea, hasFilter, ancestors)) + { + node.AddChild(child); + } + ancestors.Remove(area.Id); // With a filter an area survives only if something matched beneath it. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs index e882fa96..91d02518 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/InstanceTreeNode.cs @@ -5,6 +5,7 @@ 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. +/// Children are populated by and read-only to callers. /// /// Stable tree key — a:{areaId} or i:{instanceId}. /// Text rendered for this row. @@ -12,12 +13,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared; /// The instance this node represents, or null for an area branch. public sealed record InstanceTreeNode(string Key, string Label, Area? Area, Instance? Instance) { + private readonly List _children = new(); + /// Gets the child rows nested under this node (child areas first, then instances). - public List Children { get; } = new(); + public IReadOnlyList Children => _children; /// 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; + public bool HasChildren => _children.Count > 0; + + /// Appends a child row. Only the builder composes the tree. + /// The child row to append. + internal void AddChild(InstanceTreeNode child) => _children.Add(child); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs index 8e012661..97462223 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/InstanceTreeBuilderTests.cs @@ -82,6 +82,84 @@ public class InstanceTreeBuilderTests Assert.Equal("i:10", leaf.Key); } + [Fact] + public void Build_InstanceWithUnknownArea_SurfacesAtRoot() + { + // AreaId points at an area that is not in the supplied list — the instance must not + // vanish; it is listed at the root like an instance with no area at all. + var line1 = MakeArea(1, "Line1"); + var stranded = MakeInstance(10, "Stranded", areaId: 99); + + var roots = InstanceTreeBuilder.Build([line1], [stranded], filter: null); + + Assert.Equal(new[] { "a:1", "i:10" }, roots.Select(n => n.Key)); + Assert.Same(stranded, roots[1].Instance); + } + + [Fact] + public void Build_InstanceWithUnknownArea_StaysFilterable() + { + var line1 = MakeArea(1, "Line1"); + var stranded = MakeInstance(10, "Stranded", areaId: 99); + + var matched = InstanceTreeBuilder.Build([line1], [stranded], filter: "rand"); + + Assert.Equal("i:10", Assert.Single(matched).Key); + Assert.Empty(InstanceTreeBuilder.Build([line1], [stranded], filter: "nomatch")); + } + + [Fact] + public void Build_DeepNesting_WiresChildrenAtEveryLevel() + { + var plant = MakeArea(1, "Plant"); + var line = MakeArea(2, "Line", parentAreaId: 1); + var cell = MakeArea(3, "Cell", parentAreaId: 2); + var deep = MakeInstance(10, "Deep", areaId: 3); + + var roots = InstanceTreeBuilder.Build([plant, line, cell], [deep], filter: null); + + var plantNode = Assert.Single(roots); + Assert.Equal("a:1", plantNode.Key); + var lineNode = Assert.Single(plantNode.Children); + Assert.Equal("a:2", lineNode.Key); + var cellNode = Assert.Single(lineNode.Children); + Assert.Equal("a:3", cellNode.Key); + var leaf = Assert.Single(cellNode.Children); + Assert.Equal("i:10", leaf.Key); + Assert.False(leaf.HasChildren); + } + + [Fact] + public void Build_CyclicParentAreaIds_DoNotLoopOrThrow() + { + // A bad row pair: each area names the other as its parent. Neither is reachable + // from a root, so the forest is simply empty — and building it must terminate. + var a = MakeArea(1, "A", parentAreaId: 2); + var b = MakeArea(2, "B", parentAreaId: 1); + var inA = MakeInstance(10, "InA", areaId: 1); + var inB = MakeInstance(11, "InB", areaId: 2); + + var roots = InstanceTreeBuilder.Build([a, b], [inA, inB], filter: null); + + Assert.Empty(roots); + } + + [Fact] + public void Build_Filter_MatchesInstanceNamesOnly_AreaNameMatchIsNotEnough() + { + // "Line1" contains the needle but holds no matching instance — it is dropped. + var line1 = MakeArea(1, "Line1"); + var line2 = MakeArea(2, "Line2"); + var other = MakeInstance(10, "Pump", areaId: 1); + var hit = MakeInstance(11, "Line1Feeder", areaId: 2); + + var roots = InstanceTreeBuilder.Build([line1, line2], [other, hit], filter: "line1"); + + var root = Assert.Single(roots); + Assert.Equal("a:2", root.Key); + Assert.Equal("i:11", Assert.Single(root.Children).Key); + } + [Fact] public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches() {