galaxy: add GalaxyBrowseProjector for direct-children projection

This commit is contained in:
Joseph Doherty
2026-05-28 12:58:07 -04:00
parent d9eaf4b056
commit 87e22dd529
3 changed files with 596 additions and 0 deletions
@@ -0,0 +1,311 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
/// <summary>
/// Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
/// resolution (gobject id / tag name / contained path), paging across siblings,
/// filter parity with <see cref="GalaxyHierarchyProjector"/>, the
/// <c>child_has_children</c> hint, browse-subtree constraints, and the
/// attribute-skeleton mode.
/// </summary>
public sealed class GalaxyBrowseProjectorTests
{
/// <summary>Verifies that an empty parent oneof returns the root area.</summary>
[Fact]
public void Project_NoParent_ReturnsRootArea()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest(),
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
Assert.Single(result.Children);
Assert.Equal("Plant", result.Children[0].TagName);
Assert.True(result.ChildHasChildren[0]);
}
/// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
[Fact]
public void Project_ByParentGobjectId_ReturnsDirectChildren()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 1 },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
string[] names = result.Children.Select(child => child.TagName).ToArray();
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
Assert.Equal(4, result.TotalChildCount);
}
/// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
[Fact]
public void Project_ByParentTagName_ResolvesParent()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentTagName = "Plant" },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
string[] names = result.Children.Select(child => child.TagName).ToArray();
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
}
/// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
[Fact]
public void Project_ByParentContainedPath_ResolvesParent()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentContainedPath = "Plant" },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
string[] names = result.Children.Select(child => child.TagName).ToArray();
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
}
/// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
[Fact]
public void Project_UnknownParent_ThrowsNotFound()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 999 },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10));
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
}
/// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
[Fact]
public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 1 },
browseSubtreeGlobs: null,
offset: 0,
pageSize: 2);
GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 1 },
browseSubtreeGlobs: null,
offset: 2,
pageSize: 2);
List<string> collected = first.Children
.Concat(second.Children)
.Select(child => child.TagName)
.ToList();
Assert.Equal(4, collected.Count);
Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
Assert.Equal(
new HashSet<string>(StringComparer.Ordinal)
{
"Plant.Line_A",
"Plant.Mixer_001",
"Plant.Mixer_002",
"Plant.Pump_001",
},
new HashSet<string>(collected, StringComparer.Ordinal));
}
/// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
[Fact]
public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest
{
ParentGobjectId = 1,
TagNameGlob = "*Mixer*",
},
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
string[] names = result.Children.Select(child => child.TagName).ToArray();
Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
}
/// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
[Fact]
public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest
{
ParentGobjectId = 1,
HistorizedOnly = true,
},
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
// Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
// so the subtree match keeps Line_A in the result with has-children = true.
// Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
// no historized descendants -> filtered out entirely.
Assert.Single(result.Children);
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
}
/// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
[Fact]
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest
{
ParentGobjectId = 1,
IncludeAttributes = false,
},
browseSubtreeGlobs: null,
offset: 0,
pageSize: 10);
GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
Assert.Empty(mixer.Attributes);
}
/// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
[Fact]
public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
{
GalaxyHierarchyCacheEntry entry = CreateEntry();
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
new BrowseChildrenRequest { ParentGobjectId = 1 },
browseSubtreeGlobs: new[] { "Plant/Line_*" },
offset: 0,
pageSize: 10);
Assert.Single(result.Children);
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
}
private static GalaxyHierarchyCacheEntry CreateEntry()
{
IReadOnlyList<GalaxyObject> objects = CreateObjects();
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 1,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private static IReadOnlyList<GalaxyObject> CreateObjects()
{
GalaxyObject plant = new()
{
GobjectId = 1,
ParentGobjectId = 0,
IsArea = true,
ContainedName = "Plant",
BrowseName = "Plant",
TagName = "Plant",
};
GalaxyObject mixer001 = new()
{
GobjectId = 2,
ParentGobjectId = 1,
ContainedName = "Mixer_001",
BrowseName = "Mixer_001",
TagName = "Plant.Mixer_001",
};
mixer001.Attributes.Add(new GalaxyAttribute
{
AttributeName = "Speed",
FullTagReference = "Plant.Mixer_001.Speed",
});
GalaxyObject mixer002 = new()
{
GobjectId = 3,
ParentGobjectId = 1,
ContainedName = "Mixer_002",
BrowseName = "Mixer_002",
TagName = "Plant.Mixer_002",
};
GalaxyObject lineA = new()
{
GobjectId = 4,
ParentGobjectId = 1,
IsArea = true,
ContainedName = "Line_A",
BrowseName = "Line_A",
TagName = "Plant.Line_A",
};
GalaxyObject sensorA1 = new()
{
GobjectId = 5,
ParentGobjectId = 4,
ContainedName = "Sensor_A1",
BrowseName = "Sensor_A1",
TagName = "Plant.Line_A.Sensor_A1",
};
sensorA1.Attributes.Add(new GalaxyAttribute
{
AttributeName = "Value",
FullTagReference = "Plant.Line_A.Sensor_A1.Value",
IsHistorized = true,
});
GalaxyObject pump001 = new()
{
GobjectId = 6,
ParentGobjectId = 1,
ContainedName = "Pump_001",
BrowseName = "Pump_001",
TagName = "Plant.Pump_001",
};
return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
}
}