From cc57c857b85f887944fa658c54f479f5465613ea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 25 Jun 2026 12:28:34 -0400 Subject: [PATCH] test: cover GalaxyBrowseProjector, GalaxyDeployNotifier, GalaxyHierarchyRefreshService (ported from mxaccessgw on adoption) --- .../GalaxyBrowseProjectorTests.cs | 371 ++++++++++++++++++ .../GalaxyDeployNotifierTests.cs | 94 +++++ .../GalaxyHierarchyRefreshServiceTests.cs | 88 +++++ 3 files changed, 553 insertions(+) create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyBrowseProjectorTests.cs create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyDeployNotifierTests.cs create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyRefreshServiceTests.cs diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyBrowseProjectorTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyBrowseProjectorTests.cs new file mode 100644 index 0000000..988001a --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyBrowseProjectorTests.cs @@ -0,0 +1,371 @@ +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 }; + } +} diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyDeployNotifierTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyDeployNotifierTests.cs new file mode 100644 index 0000000..fabf087 --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyDeployNotifierTests.cs @@ -0,0 +1,94 @@ +using ZB.MOM.WW.GalaxyRepository; + +namespace ZB.MOM.WW.GalaxyRepository.Tests; + +public sealed class GalaxyDeployNotifierTests +{ + /// Verifies that a subscriber blocks until a deploy event is published. + [Fact] + public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish() + { + GalaxyDeployNotifier notifier = new(); + using CancellationTokenSource cts = new(); + + IAsyncEnumerator enumerator = notifier + .SubscribeAsync(cts.Token) + .GetAsyncEnumerator(cts.Token); + + ValueTask moveNext = enumerator.MoveNextAsync(); + Assert.False(moveNext.IsCompleted); + + GalaxyDeployEventInfo published = new( + Sequence: 1, + ObservedAt: DateTimeOffset.UtcNow, + TimeOfLastDeploy: DateTimeOffset.UtcNow, + ObjectCount: 5, + AttributeCount: 25); + notifier.Publish(published); + + Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1))); + Assert.Same(published, enumerator.Current); + + await cts.CancelAsync(); + await enumerator.DisposeAsync(); + } + + /// Verifies that a subscriber immediately receives a cached latest deploy event. + [Fact] + public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately() + { + GalaxyDeployNotifier notifier = new(); + GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9); + notifier.Publish(first); + + using CancellationTokenSource cts = new(); + await using IAsyncEnumerator enumerator = notifier + .SubscribeAsync(cts.Token) + .GetAsyncEnumerator(cts.Token); + + Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); + Assert.Same(first, enumerator.Current); + + await cts.CancelAsync(); + } + + /// Verifies that published events fan out to all active subscribers. + [Fact] + public async Task Publish_FansOutToAllSubscribers() + { + GalaxyDeployNotifier notifier = new(); + using CancellationTokenSource cts = new(); + + await using IAsyncEnumerator a = notifier + .SubscribeAsync(cts.Token) + .GetAsyncEnumerator(cts.Token); + await using IAsyncEnumerator b = notifier + .SubscribeAsync(cts.Token) + .GetAsyncEnumerator(cts.Token); + + GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0); + notifier.Publish(info); + + Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); + Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); + Assert.Same(info, a.Current); + Assert.Same(info, b.Current); + + await cts.CancelAsync(); + } + + /// Verifies that the Latest property tracks the most recently published event. + [Fact] + public void Latest_TracksMostRecentPublish() + { + GalaxyDeployNotifier notifier = new(); + Assert.Null(notifier.Latest); + + GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0); + GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0); + notifier.Publish(first); + notifier.Publish(second); + + Assert.Same(second, notifier.Latest); + } +} diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyRefreshServiceTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyRefreshServiceTests.cs new file mode 100644 index 0000000..6fb8680 --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyRefreshServiceTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.GalaxyRepository; + +namespace ZB.MOM.WW.GalaxyRepository.Tests; + +/// +/// Server-005 regression: the initial RefreshAsync call in +/// must not let a transient, +/// non-cancellation first-load failure (e.g. a +/// or from connection +/// establishment) escape and fault the host BackgroundService. +/// +public sealed class GalaxyHierarchyRefreshServiceTests +{ + /// Verifies that the background service does not fault when the first refresh throws a non-cancellation exception. + [Fact] + public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService() + { + ThrowingCache cache = new(new TimeoutException("connection establishment timed out")); + GalaxyHierarchyRefreshService service = CreateService(cache); + + using CancellationTokenSource cts = new(); + + await service.StartAsync(cts.Token); + + // Wait until the first RefreshAsync has actually been attempted (and + // thrown) before cancelling, so cancellation cannot race ahead of the + // first-load path under test — this is what made the test flaky under + // parallel load. + await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10)); + + await cts.CancelAsync(); + + // The background loop must have stopped cleanly: ExecuteTask reaches a + // terminal state that is not Faulted (RanToCompletion or Canceled) + // rather than faulting on the first refresh. WhenAny is used so a + // Canceled task does not rethrow before the IsFaulted assertion. + Task? executeTask = service.ExecuteTask; + Assert.NotNull(executeTask); + Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.Same(executeTask, completed); + Assert.False(executeTask.IsFaulted); + Assert.Equal(1, cache.RefreshCallCount); + + await service.StopAsync(CancellationToken.None); + } + + private static GalaxyHierarchyRefreshService CreateService(IGalaxyHierarchyCache cache) + { + GalaxyRepositoryOptions options = new() + { + DashboardRefreshIntervalSeconds = 3600, + }; + return new GalaxyHierarchyRefreshService( + cache, + Options.Create(options), + NullLogger.Instance); + } + + private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache + { + private readonly TaskCompletionSource firstRefreshAttempted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the number of refresh calls. + public int RefreshCallCount { get; private set; } + + /// Gets a task that completes once refresh has been invoked at least once. + public Task FirstRefreshAttempted => firstRefreshAttempted.Task; + + /// Gets the current cache entry. + public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty; + + /// Refreshes the cache asynchronously and throws the configured exception. + /// Token to observe for cancellation. + public Task RefreshAsync(CancellationToken cancellationToken) + { + RefreshCallCount++; + firstRefreshAttempted.TrySetResult(); + throw toThrow; + } + + /// Waits for the first load and completes immediately. + /// Token to observe for cancellation. + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +}