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;
+ }
+}