galaxy: add ChildrenByParent index for level-at-a-time browse

This commit is contained in:
Joseph Doherty
2026-05-28 12:51:48 -04:00
parent 2c5c5e5c7e
commit d9eaf4b056
2 changed files with 157 additions and 3 deletions
@@ -7,18 +7,21 @@ public sealed class GalaxyHierarchyIndex
private GalaxyHierarchyIndex(
IReadOnlyList<GalaxyObjectView> objectViews,
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent)
{
ObjectViews = objectViews;
ObjectViewsById = objectViewsById;
TagsByAddress = tagsByAddress;
ChildrenByParent = childrenByParent;
}
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>());
/// <summary>Gets the object views.</summary>
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
@@ -29,6 +32,9 @@ public sealed class GalaxyHierarchyIndex
/// <summary>Gets tags indexed by address.</summary>
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
/// <summary>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).</summary>
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
/// <param name="objects">The Galaxy objects to index.</param>
/// <returns>A new Galaxy hierarchy index.</returns>
@@ -71,10 +77,39 @@ public sealed class GalaxyHierarchyIndex
}
}
Dictionary<int, List<GalaxyObjectView>> 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<GalaxyObjectView>? bucket))
{
bucket = [];
childrenByParent[parentKey] = bucket;
}
bucket.Add(view);
}
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
{
bucket.Sort(CompareByAreaThenDisplayName);
}
Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
foreach (KeyValuePair<int, List<GalaxyObjectView>> 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;
}
}
@@ -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;
/// <summary>
/// Coverage for <see cref="GalaxyHierarchyIndex.ChildrenByParent"/> — 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 <c>DashboardBrowseTreeBuilder</c>.
/// </summary>
public sealed class GalaxyHierarchyIndexTests
{
/// <summary>Verifies roots (ParentGobjectId == 0) bucket under sentinel key 0.</summary>
[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<GalaxyObjectView>? 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);
}
/// <summary>Verifies a nested A→B→C chain links each parent to its single child bucket.</summary>
[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<GalaxyObjectView>? underRoot));
Assert.NotNull(underRoot);
Assert.Single(underRoot!);
Assert.Equal(1, underRoot![0].Object.GobjectId);
Assert.True(index.ChildrenByParent.TryGetValue(1, out IReadOnlyList<GalaxyObjectView>? underA));
Assert.NotNull(underA);
Assert.Single(underA!);
Assert.Equal(2, underA![0].Object.GobjectId);
Assert.True(index.ChildrenByParent.TryGetValue(2, out IReadOnlyList<GalaxyObjectView>? underB));
Assert.NotNull(underB);
Assert.Single(underB!);
Assert.Equal(3, underB![0].Object.GobjectId);
}
/// <summary>Verifies a self-parented (corrupt) row appears under root, not under itself.</summary>
[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<GalaxyObjectView>? 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<GalaxyObjectView>? underSelf))
{
Assert.Empty(underSelf!);
}
}
/// <summary>Verifies children sort areas-first, then by display name (case-insensitive).</summary>
[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<GalaxyObjectView>? 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);
}
}