diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs index 091cffb..5378a61 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -7,18 +7,21 @@ public sealed class GalaxyHierarchyIndex private GalaxyHierarchyIndex( IReadOnlyList objectViews, IReadOnlyDictionary objectViewsById, - IReadOnlyDictionary tagsByAddress) + IReadOnlyDictionary tagsByAddress, + IReadOnlyDictionary> childrenByParent) { ObjectViews = objectViews; ObjectViewsById = objectViewsById; TagsByAddress = tagsByAddress; + ChildrenByParent = childrenByParent; } /// Gets an empty Galaxy hierarchy index. public static GalaxyHierarchyIndex Empty { get; } = new( Array.Empty(), new Dictionary(), - new Dictionary(StringComparer.OrdinalIgnoreCase)); + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary>()); /// Gets the object views. public IReadOnlyList ObjectViews { get; } @@ -29,6 +32,9 @@ public sealed class GalaxyHierarchyIndex /// Gets tags indexed by address. public IReadOnlyDictionary TagsByAddress { get; } + /// Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase). + public IReadOnlyDictionary> ChildrenByParent { get; } + /// Builds a Galaxy hierarchy index from the given objects. /// The Galaxy objects to index. /// A new Galaxy hierarchy index. @@ -71,10 +77,39 @@ public sealed class GalaxyHierarchyIndex } } + Dictionary> childrenByParent = new(); + foreach (GalaxyObjectView view in views) + { + int parentKey = view.Object.ParentGobjectId; + // Treat self-parented (corrupt) rows as roots; matches DashboardBrowseTreeBuilder. + if (parentKey == view.Object.GobjectId) + { + parentKey = 0; + } + if (!childrenByParent.TryGetValue(parentKey, out List? bucket)) + { + bucket = []; + childrenByParent[parentKey] = bucket; + } + bucket.Add(view); + } + + foreach (List bucket in childrenByParent.Values) + { + bucket.Sort(CompareByAreaThenDisplayName); + } + + Dictionary> readOnlyChildren = new(childrenByParent.Count); + foreach (KeyValuePair> kvp in childrenByParent) + { + readOnlyChildren[kvp.Key] = kvp.Value; + } + return new GalaxyHierarchyIndex( views, viewsById, - tagsByAddress); + tagsByAddress, + readOnlyChildren); } private static string BuildContainedPath( @@ -110,4 +145,27 @@ public sealed class GalaxyHierarchyIndex return obj.TagName; } + + private static int CompareByAreaThenDisplayName(GalaxyObjectView left, GalaxyObjectView right) + { + if (left.Object.IsArea != right.Object.IsArea) + { + return left.Object.IsArea ? -1 : 1; + } + return string.Compare(DisplayNameOf(left), DisplayNameOf(right), StringComparison.OrdinalIgnoreCase); + } + + private static string DisplayNameOf(GalaxyObjectView view) + { + GalaxyObject obj = view.Object; + if (!string.IsNullOrWhiteSpace(obj.BrowseName)) + { + return obj.BrowseName; + } + if (!string.IsNullOrWhiteSpace(obj.ContainedName)) + { + return obj.ContainedName; + } + return obj.TagName; + } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs new file mode 100644 index 0000000..39f6c39 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs @@ -0,0 +1,96 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; + +/// +/// Coverage for — the parent→children +/// index used by the lazy browse projector (Task 3). Verifies root grouping, nested +/// parent→child linkage, corrupt self-parented row handling, and the areas-first +/// ordering rule shared with DashboardBrowseTreeBuilder. +/// +public sealed class GalaxyHierarchyIndexTests +{ + /// Verifies roots (ParentGobjectId == 0) bucket under sentinel key 0. + [Fact] + public void ChildrenByParent_RootsUnderSentinelZero() + { + GalaxyObject root1 = new() { GobjectId = 1, ParentGobjectId = 0, ContainedName = "r1" }; + GalaxyObject root2 = new() { GobjectId = 2, ParentGobjectId = 0, ContainedName = "r2" }; + GalaxyObject root3 = new() { GobjectId = 3, ParentGobjectId = 0, ContainedName = "r3" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root1, root2, root3]); + + Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? roots)); + Assert.NotNull(roots); + Assert.Equal(3, roots!.Count); + Assert.Contains(roots, view => view.Object.GobjectId == 1); + Assert.Contains(roots, view => view.Object.GobjectId == 2); + Assert.Contains(roots, view => view.Object.GobjectId == 3); + } + + /// Verifies a nested A→B→C chain links each parent to its single child bucket. + [Fact] + public void ChildrenByParent_NestedHierarchy_LinksParentToChildren() + { + GalaxyObject areaA = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "A" }; + GalaxyObject objB = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "B" }; + GalaxyObject objC = new() { GobjectId = 3, ParentGobjectId = 2, ContainedName = "C" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([areaA, objB, objC]); + + Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? underRoot)); + Assert.NotNull(underRoot); + Assert.Single(underRoot!); + Assert.Equal(1, underRoot![0].Object.GobjectId); + + Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList? underA)); + Assert.NotNull(underA); + Assert.Single(underA!); + Assert.Equal(2, underA![0].Object.GobjectId); + + Assert.True(index.ChildrenByParent.TryGetValue(2, out IReadOnlyList? underB)); + Assert.NotNull(underB); + Assert.Single(underB!); + Assert.Equal(3, underB![0].Object.GobjectId); + } + + /// Verifies a self-parented (corrupt) row appears under root, not under itself. + [Fact] + public void ChildrenByParent_SelfParentedObject_AppearsAsRoot() + { + GalaxyObject selfParented = new() { GobjectId = 5, ParentGobjectId = 5, ContainedName = "loop" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([selfParented]); + + Assert.True(index.ChildrenByParent.TryGetValue(0, out IReadOnlyList? roots)); + Assert.NotNull(roots); + Assert.Single(roots!); + Assert.Equal(5, roots![0].Object.GobjectId); + + // The self-parented row must not appear as its own child — bucket either absent or empty. + if (index.ChildrenByParent.TryGetValue(5, out IReadOnlyList? underSelf)) + { + Assert.Empty(underSelf!); + } + } + + /// Verifies children sort areas-first, then by display name (case-insensitive). + [Fact] + public void ChildrenByParent_SortsAreasFirstThenByDisplayName() + { + GalaxyObject parent = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Root" }; + GalaxyObject zebraObj = new() { GobjectId = 10, ParentGobjectId = 1, IsArea = false, ContainedName = "zebra" }; + GalaxyObject alphaArea = new() { GobjectId = 11, ParentGobjectId = 1, IsArea = true, ContainedName = "alpha" }; + GalaxyObject betaArea = new() { GobjectId = 12, ParentGobjectId = 1, IsArea = true, ContainedName = "beta" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([parent, zebraObj, alphaArea, betaArea]); + + Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList? children)); + Assert.NotNull(children); + Assert.Equal(3, children!.Count); + Assert.Equal(11, children[0].Object.GobjectId); + Assert.Equal(12, children[1].Object.GobjectId); + Assert.Equal(10, children[2].Object.GobjectId); + } +}