using Grpc.Core; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.GalaxyRepository.Tests; /// /// Pure-logic tests for and /// . No SQL: the cache entry under test is built /// from a small hand-made hierarchy through the same materialization the live cache /// uses (a fake driven through /// ), so the projectors are exercised /// against a real . /// public sealed class GalaxyHierarchyProjectorTests { /// /// Builds a realistic cache entry by driving a fake repository through the cache's /// own refresh path. This goes through BuildEntry + /// exactly as production does, rather than reaching for an internal factory. /// private static GalaxyHierarchyCacheEntry BuildEntry( IReadOnlyList hierarchy, IReadOnlyList attributes) { FakeGalaxyRepository repository = new(hierarchy, attributes, deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier()); cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult(); GalaxyHierarchyCacheEntry entry = cache.Current; Assert.True(entry.HasData); return entry; } // A small but representative galaxy: // PlantArea (area, id 1) // ├─ LineA (area, id 2) // │ ├─ Pump01 (id 10, template "Pump", historized+alarm attr) // │ └─ Valve01 (id 11, template "Valve", plain attr) // └─ Mixer01 (id 12, template "Mixer", alarm attr only) // StandaloneTank (id 20, no parent — a root object) private static GalaxyHierarchyCacheEntry BuildSampleEntry() { List hierarchy = [ Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100), Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100), Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump", "$UserDefined"]), Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]), Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]), Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]), ]; List attributes = [ // Pump01: historized AND alarm-bearing. Attribute(10, "Pump01.PV", historized: true, alarm: true), Attribute(10, "Pump01.SP", historized: false, alarm: false), // Valve01: plain. Attribute(11, "Valve01.Cmd", historized: false, alarm: false), // Mixer01: alarm only. Attribute(12, "Mixer01.Fault", historized: false, alarm: true), // StandaloneTank: historized only. Attribute(20, "StandaloneTank.Level", historized: true, alarm: false), ]; return BuildEntry(hierarchy, attributes); } private static GalaxyHierarchyRow Hierarchy( int id, string tagName, int parent, bool isArea = false, int category = 0, IReadOnlyList? templates = null) => new() { GobjectId = id, TagName = tagName, ContainedName = tagName, BrowseName = tagName, ParentGobjectId = parent, IsArea = isArea, CategoryId = category, TemplateChain = templates ?? Array.Empty(), }; private static GalaxyAttributeRow Attribute( int gobjectId, string fullTagReference, bool historized, bool alarm) => new() { GobjectId = gobjectId, AttributeName = fullTagReference.Split('.')[^1], FullTagReference = fullTagReference, IsHistorized = historized, IsAlarm = alarm, }; [Fact] public void Project_NoFilters_ReturnsEveryObject() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest()); Assert.Equal(6, result.TotalObjectCount); Assert.Equal(6, result.Objects.Count); } [Fact] public void Project_PageSizeAndOffset_SlicesTheOrderedResult() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new(); GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue); GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 2); GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 2, pageSize: 2); GalaxyHierarchyQueryResult page3 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 4, pageSize: 2); // Total is unaffected by paging. Assert.Equal(6, page1.TotalObjectCount); Assert.Equal(2, page1.Objects.Count); Assert.Equal(2, page2.Objects.Count); Assert.Equal(2, page3.Objects.Count); // The three pages reconstruct the full ordered result with no gaps/dupes. List paged = [ .. page1.Objects.Select(o => o.GobjectId), .. page2.Objects.Select(o => o.GobjectId), .. page3.Objects.Select(o => o.GobjectId), ]; Assert.Equal(full.Objects.Select(o => o.GobjectId), paged); } [Fact] public void Project_OffsetPastEnd_ReturnsEmptyPageButRealTotal() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project( entry, new DiscoverHierarchyRequest(), browseSubtreeGlobs: null, offset: 999, pageSize: 10); Assert.Empty(result.Objects); Assert.Equal(6, result.TotalObjectCount); } [Fact] public void Project_PageSignature_IsStableAcrossPagesAndMatchesComputeFilterSignature() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" }; string expected = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null); GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 1); GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 1, pageSize: 1); // The signature a caller computes to mint a page token round-trips: the projector // reports the same signature on every page of the same filter set. Assert.Equal(expected, page1.FilterSignature); Assert.Equal(expected, page2.FilterSignature); } [Fact] public void ComputeFilterSignature_DiffersWhenAnyFilterChanges() { DiscoverHierarchyRequest baseRequest = new() { TagNameGlob = "Pump*" }; DiscoverHierarchyRequest differentGlob = new() { TagNameGlob = "Valve*" }; DiscoverHierarchyRequest differentAlarm = new() { TagNameGlob = "Pump*", AlarmBearingOnly = true }; string baseSig = GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, null); Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentGlob, null)); Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentAlarm, null)); Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, browseSubtreeGlobs: ["PlantArea/*"])); // Same inputs => same signature (deterministic). Assert.Equal(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }, null)); } [Fact] public void Project_MaxDepthZero_FromRoot_ReturnsOnlyTheRoot() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 0 }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); GalaxyObject only = Assert.Single(result.Objects); Assert.Equal(1, only.GobjectId); } [Fact] public void Project_MaxDepthOne_FromRoot_ReturnsRootAndDirectChildrenOnly() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // PlantArea(1) depth 0; LineA(2) and Mixer01(12) depth 1; Pump01/Valve01 depth 2. DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 1 }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); Assert.Equal([1, 2, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id)); } [Fact] public void Project_NegativeMaxDepth_ThrowsInvalidArgument() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { MaxDepth = -1 }; RpcException ex = Assert.Throws(() => GalaxyHierarchyProjector.Project(entry, request)); Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); } [Fact] public void Project_UnknownRoot_ThrowsNotFound() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { RootGobjectId = 99999 }; RpcException ex = Assert.Throws(() => GalaxyHierarchyProjector.Project(entry, request)); Assert.Equal(StatusCode.NotFound, ex.StatusCode); } [Fact] public void Project_HistorizedOnly_ReturnsOnlyObjectsWithAHistorizedAttribute() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { HistorizedOnly = true }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); // Pump01(10) and StandaloneTank(20) carry historized attributes. Assert.Equal([10, 20], result.Objects.Select(o => o.GobjectId).OrderBy(id => id)); } [Fact] public void Project_AlarmBearingOnly_ReturnsOnlyObjectsWithAnAlarmAttribute() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); // Pump01(10) and Mixer01(12) carry alarm attributes. Assert.Equal([10, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id)); } [Fact] public void Project_AlarmAndHistorizedTogether_RequiresBoth() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true, HistorizedOnly = true }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); // Only Pump01(10) carries an attribute set that is both historized and alarm-bearing. GalaxyObject only = Assert.Single(result.Objects); Assert.Equal(10, only.GobjectId); } [Fact] public void Project_TagNameGlob_MatchesAnchoredCaseInsensitive() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); GalaxyHierarchyQueryResult prefix = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }); Assert.Equal([10], prefix.Objects.Select(o => o.GobjectId)); // Case-insensitive. GalaxyHierarchyQueryResult lower = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "pump01" }); Assert.Equal([10], lower.Objects.Select(o => o.GobjectId)); // '?' single-char wildcard: "Pump0?" matches "Pump01". GalaxyHierarchyQueryResult single = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump0?" }); Assert.Equal([10], single.Objects.Select(o => o.GobjectId)); // Anchored: a bare substring that is not a prefix matches nothing. GalaxyHierarchyQueryResult anchored = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "ump01" }); Assert.Empty(anchored.Objects); } [Fact] public void Project_CategoryIds_FilterByObjectCategory() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { CategoryIds = { 200, 201 } }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); // category 200 = Pump01(10), category 201 = Valve01(11). Assert.Equal([10, 11], result.Objects.Select(o => o.GobjectId).OrderBy(id => id)); } [Fact] public void Project_TemplateChainContains_IsSubstringAndCaseInsensitive() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { TemplateChainContains = { "pump" } }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); GalaxyObject only = Assert.Single(result.Objects); Assert.Equal(10, only.GobjectId); } [Fact] public void Project_IncludeAttributesDefault_CarriesAttributes() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); GalaxyObject pump = Assert.Single(result.Objects); Assert.Equal(2, pump.Attributes.Count); } [Fact] public void Project_IncludeAttributesFalse_ReturnsSkeletons() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*", IncludeAttributes = false }; GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request); GalaxyObject pump = Assert.Single(result.Objects); Assert.Empty(pump.Attributes); } [Fact] public void Project_IncludeAttributesFalse_DoesNotMutateTheCachedEntry() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // Project with attributes stripped, then again with attributes included. GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*", IncludeAttributes = false }); GalaxyHierarchyQueryResult included = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }); // The earlier strip cloned the object — the cached entry still holds the attributes. GalaxyObject pump = Assert.Single(included.Objects); Assert.Equal(2, pump.Attributes.Count); } [Fact] public void Project_InvalidOffsetOrPageSize_Throws() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); Assert.Throws(() => GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: -1, pageSize: 10)); Assert.Throws(() => GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: 0, pageSize: 0)); } // ---- GalaxyBrowseProjector ---- [Fact] public void ProjectChildren_OfPlantArea_ReturnsDirectChildrenAreasFirst() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); BrowseChildrenRequest request = new() { ParentGobjectId = 1 }; GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100); // Direct children of PlantArea(1) are LineA(2, area) and Mixer01(12, non-area); // areas sort first. Assert.Equal([2, 12], result.Children.Select(c => c.GobjectId)); Assert.Equal(2, result.TotalChildCount); } [Fact] public void ProjectChildren_ChildHasChildrenFlag_ReflectsPresenceOfChildren() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); BrowseChildrenRequest request = new() { ParentGobjectId = 1 }; GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100); Dictionary hasChildren = result.Children .Select((child, index) => (child.GobjectId, result.ChildHasChildren[index])) .ToDictionary(t => t.GobjectId, t => t.Item2); // LineA(2) contains Pump01/Valve01 -> true; Mixer01(12) is a leaf -> false. Assert.True(hasChildren[2]); Assert.False(hasChildren[12]); } [Fact] public void ProjectChildren_OfRoot_ReturnsTopLevelObjects() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // Empty parent oneof => roots (parent id 0). BrowseChildrenRequest request = new(); GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100); // Roots: PlantArea(1, area) and StandaloneTank(20, non-area); areas first. Assert.Equal([1, 20], result.Children.Select(c => c.GobjectId)); } [Fact] public void ProjectChildren_FilterMatchingDescendant_SurfacesNonMatchingAncestor() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // Pump01 lives two levels under PlantArea. Browsing PlantArea's children with a // Pump glob should still surface LineA (which itself does not match) because it // contains a matching descendant. BrowseChildrenRequest request = new() { ParentGobjectId = 1, TagNameGlob = "Pump*" }; GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100); GalaxyObject surfaced = Assert.Single(result.Children); Assert.Equal(2, surfaced.GobjectId); Assert.True(result.ChildHasChildren[0]); } [Fact] public void ProjectChildren_UnknownParent_ThrowsNotFound() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); BrowseChildrenRequest request = new() { ParentGobjectId = 99999 }; RpcException ex = Assert.Throws(() => GalaxyBrowseProjector.ProjectChildren(entry, request, null, 0, 100)); Assert.Equal(StatusCode.NotFound, ex.StatusCode); } [Fact] public void ProjectChildren_Paging_SlicesAndPreservesTotal() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); // LineA(2) has two direct children: Pump01, Valve01. BrowseChildrenRequest request = new() { ParentGobjectId = 2 }; GalaxyBrowseChildrenResult page1 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 0, pageSize: 1); GalaxyBrowseChildrenResult page2 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 1, pageSize: 1); Assert.Equal(2, page1.TotalChildCount); Assert.Single(page1.Children); Assert.Single(page2.Children); Assert.NotEqual(page1.Children[0].GobjectId, page2.Children[0].GobjectId); // Same filter+parent => same signature on both pages. Assert.Equal(page1.FilterSignature, page2.FilterSignature); } [Fact] public void ResolveParentId_ByTagName_ResolvesToGobjectId() { GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); BrowseChildrenRequest request = new() { ParentTagName = "LineA" }; int id = GalaxyBrowseProjector.ResolveParentId(entry, request); Assert.Equal(2, id); } }