using Grpc.Core; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.GalaxyRepository.Tests; /// /// Direct coverage for . Validates parent /// resolution (gobject id / tag name / contained path), paging across siblings, /// filter parity with , the /// child_has_children hint, browse-subtree constraints, and the /// attribute-skeleton mode. /// public sealed class GalaxyBrowseProjectorTests { /// Verifies that an empty parent oneof returns the root area. [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]); } /// Verifies that resolving the parent by gobject id returns sorted direct children. [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); } /// Verifies that resolving the parent by tag name returns the same direct children. [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); } /// Verifies that resolving the parent by contained path returns the same direct children. [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); } /// Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound. [Fact] public void Project_UnknownParent_ThrowsNotFound() { GalaxyHierarchyCacheEntry entry = CreateEntry(); RpcException exception = Assert.Throws(() => GalaxyBrowseProjector.ProjectChildren( entry, new BrowseChildrenRequest { ParentGobjectId = 999 }, browseSubtreeGlobs: null, offset: 0, pageSize: 10)); Assert.Equal(StatusCode.NotFound, exception.StatusCode); } /// Verifies that paging across siblings returns every sibling exactly once. [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 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(StringComparer.Ordinal) { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001", }, new HashSet(collected, StringComparer.Ordinal)); } /// Verifies that a tag-name glob filters direct children and clears the has-children hint. [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()); } /// Verifies that historized-only filtering also drives the has-children hint via descendants. [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()); } /// Verifies that IncludeAttributes=false returns object skeletons. [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); } /// Verifies that browse-subtree globs constrain the returned children. [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); } /// /// Verifies terminates when the Galaxy data /// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in /// HasMatchingDescendant, the depth-first walk would loop forever; the /// 5-second xUnit timeout asserts termination. /// [Fact(Timeout = 5000)] public async Task Project_CyclicDescendants_DoesNotInfiniteLoop() { await Task.Yield(); // Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId // points to the next, so GalaxyHierarchyIndex.ChildrenByParent has // [12] = [A], [10] = [B], [11] = [C]. // None of them are historized, so HistorizedOnly=true forces the projector to // call HasMatchingDescendant on every direct child, exercising the cycle walk. GalaxyObject a = new() { GobjectId = 10, ParentGobjectId = 12, ContainedName = "A", BrowseName = "A", TagName = "A", }; GalaxyObject b = new() { GobjectId = 11, ParentGobjectId = 10, ContainedName = "B", BrowseName = "B", TagName = "B", }; GalaxyObject c = new() { GobjectId = 12, ParentGobjectId = 11, ContainedName = "C", BrowseName = "C", TagName = "C", }; IReadOnlyList objects = new[] { a, b, c }; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 1, LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), ObjectCount = objects.Count, }; // Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the // projector falls back to HasMatchingDescendant(B), which walks B→C→A→B… // without the visited-id guard. With the guard, the walk terminates and returns // an empty page (no historized descendants exist anywhere in the cycle). GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( entry, new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true }, browseSubtreeGlobs: null, offset: 0, pageSize: 10); Assert.Empty(result.Children); Assert.Equal(0, result.TotalChildCount); } private static GalaxyHierarchyCacheEntry CreateEntry() { IReadOnlyList objects = CreateObjects(); return GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 1, LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), ObjectCount = objects.Count, }; } private static IReadOnlyList 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 }; } }