diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs
index 5b14d17..51ebd0c 100644
--- a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchyCache.cs
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.GalaxyRepository;
/// snapshot (as ) so clients can browse
/// last-known data when the Galaxy database is unreachable on a cold start.
///
-public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
+public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
@@ -88,6 +88,16 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
return _firstLoad.Task.WaitAsync(cancellationToken);
}
+ ///
+ /// Disposes the refresh gate. As a DI singleton the cache is disposed once at host
+ /// shutdown, after the refresh has stopped,
+ /// so no in-flight refresh can be holding the gate.
+ ///
+ public void Dispose()
+ {
+ _refreshGate.Dispose();
+ }
+
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
{
// First refresh only: seed the cache from the on-disk snapshot before
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs
index de2236c..e3f74e9 100644
--- a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyHierarchySnapshotStore.cs
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.GalaxyRepository;
/// both operations are no-ops. The snapshot path is fully consumer-supplied;
/// this store imposes no platform-specific default, so it is cross-platform.
///
-public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
+public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore, IDisposable
{
///
/// On-disk format version. Bump this whenever the persisted shape changes
@@ -138,6 +138,15 @@ public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
}
}
+ ///
+ /// Disposes the I/O gate. As a DI singleton the store is disposed once at host
+ /// shutdown, by which point no save/load is in flight.
+ ///
+ public void Dispose()
+ {
+ _ioGate.Dispose();
+ }
+
/// On-disk envelope: a schema version plus the snapshot payload.
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
}
diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs
index 6975f7d..35fa93b 100644
--- a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs
+++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/GalaxyRepositoryOptions.cs
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.GalaxyRepository;
///
/// is a generic default; the DI extension accepts an explicit
/// configuration section path so a consumer can bind from its own section (e.g.
-/// MxGateway:Galaxy).
+/// HistorianGateway:Galaxy).
///
///
public sealed class GalaxyRepositoryOptions
@@ -13,7 +13,7 @@ public sealed class GalaxyRepositoryOptions
///
/// Generic default configuration section name. The DI extension accepts an explicit
/// section path, so a consumer may bind from a different section (e.g.
- /// MxGateway:Galaxy).
+ /// HistorianGateway:Galaxy).
///
public const string SectionName = "GalaxyRepository";
diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/Fakes.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/Fakes.cs
new file mode 100644
index 0000000..799443d
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/Fakes.cs
@@ -0,0 +1,134 @@
+using System.Runtime.CompilerServices;
+using ZB.MOM.WW.GalaxyRepository;
+
+namespace ZB.MOM.WW.GalaxyRepository.Tests;
+
+///
+/// In-memory returning canned rowsets. Counts the heavy
+/// hierarchy/attribute reads so tests can assert deploy-gated skips, and can be flipped to
+/// throw so the failure path is exercisable.
+///
+internal sealed class FakeGalaxyRepository : IGalaxyRepository
+{
+ private readonly IReadOnlyList _hierarchy;
+ private readonly IReadOnlyList _attributes;
+
+ public FakeGalaxyRepository(
+ IReadOnlyList hierarchy,
+ IReadOnlyList attributes,
+ DateTime? deployTime)
+ {
+ _hierarchy = hierarchy;
+ _attributes = attributes;
+ DeployTime = deployTime;
+ }
+
+ /// The deploy time returned by ; mutate to simulate a redeploy.
+ public DateTime? DeployTime { get; set; }
+
+ /// When set, every query throws this exception (simulates an unreachable database).
+ public Exception? ThrowOnQuery { get; set; }
+
+ public int HierarchyReadCount { get; private set; }
+
+ public int AttributeReadCount { get; private set; }
+
+ public Task TestConnectionAsync(CancellationToken ct = default) =>
+ ThrowOnQuery is null ? Task.FromResult(true) : throw ThrowOnQuery;
+
+ public Task GetLastDeployTimeAsync(CancellationToken ct = default)
+ {
+ if (ThrowOnQuery is not null)
+ {
+ throw ThrowOnQuery;
+ }
+
+ return Task.FromResult(DeployTime);
+ }
+
+ public Task> GetHierarchyAsync(CancellationToken ct = default)
+ {
+ if (ThrowOnQuery is not null)
+ {
+ throw ThrowOnQuery;
+ }
+
+ HierarchyReadCount++;
+ return Task.FromResult(_hierarchy.ToList());
+ }
+
+ public Task> GetAttributesAsync(CancellationToken ct = default)
+ {
+ if (ThrowOnQuery is not null)
+ {
+ throw ThrowOnQuery;
+ }
+
+ AttributeReadCount++;
+ return Task.FromResult(_attributes.ToList());
+ }
+}
+
+/// Records published deploy events so tests can assert publication.
+internal sealed class RecordingDeployNotifier : IGalaxyDeployNotifier
+{
+ public List Published { get; } = [];
+
+ public GalaxyDeployEventInfo? Latest { get; private set; }
+
+ public void Publish(GalaxyDeployEventInfo info)
+ {
+ Published.Add(info);
+ Latest = info;
+ }
+
+ public async IAsyncEnumerable SubscribeAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (Latest is { } latest)
+ {
+ yield return latest;
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+}
+
+///
+/// In-memory . Pre-seed
+/// to exercise the restore path; reads back to assert persistence.
+///
+internal sealed class FakeSnapshotStore : IGalaxyHierarchySnapshotStore
+{
+ public GalaxyHierarchySnapshot? Snapshot { get; set; }
+
+ public int SaveCount { get; private set; }
+
+ public int LoadCount { get; private set; }
+
+ public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
+ {
+ SaveCount++;
+ Snapshot = snapshot;
+ return Task.CompletedTask;
+ }
+
+ public Task TryLoadAsync(CancellationToken cancellationToken)
+ {
+ LoadCount++;
+ return Task.FromResult(Snapshot);
+ }
+}
+
+///
+/// A whose UTC clock is fixed (and advanceable) so the cache's
+/// staleness projection (which fires after a 5-minute threshold) is deterministic.
+///
+internal sealed class StubTimeProvider(DateTimeOffset start) : TimeProvider
+{
+ private DateTimeOffset _now = start;
+
+ public override DateTimeOffset GetUtcNow() => _now;
+
+ public void Advance(TimeSpan delta) => _now += delta;
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyCacheTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyCacheTests.cs
new file mode 100644
index 0000000..4134c43
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyCacheTests.cs
@@ -0,0 +1,236 @@
+using ZB.MOM.WW.GalaxyRepository;
+
+namespace ZB.MOM.WW.GalaxyRepository.Tests;
+
+///
+/// Tests for first-load, deploy-gating, snapshot
+/// restore, persistence, and status-transition behavior. Uses an in-memory
+/// and snapshot store plus a fixed
+/// so no SQL is touched and no asserts are time-sensitive.
+///
+public sealed class GalaxyHierarchyCacheTests
+{
+ private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
+ private static readonly DateTime DeployTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ private static List SampleHierarchy() =>
+ [
+ new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", IsArea = true },
+ new() { GobjectId = 2, TagName = "Pump01", ContainedName = "Pump01", BrowseName = "Pump01", ParentGobjectId = 1 },
+ ];
+
+ private static List SampleAttributes() =>
+ [
+ new() { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true, IsAlarm = true },
+ ];
+
+ [Fact]
+ public async Task RefreshAsync_FirstLoad_PopulatesCurrentWithDataAndUnblocksWaitForFirstLoad()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ RecordingDeployNotifier notifier = new();
+ using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
+
+ // Before refresh, the gate is unset and there is no data.
+ Assert.False(cache.Current.HasData);
+ Assert.Equal(GalaxyCacheStatus.Unknown, cache.Current.Status);
+
+ await cache.RefreshAsync(CancellationToken.None);
+
+ // First load completes (does not hang) and Current now holds usable data.
+ await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
+ GalaxyHierarchyCacheEntry current = cache.Current;
+ Assert.True(current.HasData);
+ Assert.Equal(GalaxyCacheStatus.Healthy, current.Status);
+ Assert.Equal(2, current.ObjectCount);
+ Assert.Equal(1, current.AreaCount);
+ Assert.Equal(1, current.AttributeCount);
+ Assert.Equal(1, current.HistorizedAttributeCount);
+ Assert.Equal(1, current.AlarmAttributeCount);
+
+ // The heavy queries ran exactly once and a deploy event was published.
+ Assert.Equal(1, repository.HierarchyReadCount);
+ Assert.Equal(1, repository.AttributeReadCount);
+ GalaxyDeployEventInfo published = Assert.Single(notifier.Published);
+ Assert.Equal(2, published.ObjectCount);
+ Assert.Equal(1, published.AttributeCount);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_NoDeployChange_SkipsHeavyQueriesOnSecondRefresh()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
+
+ await cache.RefreshAsync(CancellationToken.None);
+ await cache.RefreshAsync(CancellationToken.None);
+
+ // Deploy time unchanged => the heavy hierarchy/attribute reads happened only once.
+ Assert.Equal(1, repository.HierarchyReadCount);
+ Assert.Equal(1, repository.AttributeReadCount);
+ Assert.True(cache.Current.HasData);
+ Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_DeployAdvances_RebuildsAndBumpsSequence()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ RecordingDeployNotifier notifier = new();
+ using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
+
+ await cache.RefreshAsync(CancellationToken.None);
+ long firstSequence = cache.Current.Sequence;
+
+ repository.DeployTime = DeployTime.AddHours(1);
+ await cache.RefreshAsync(CancellationToken.None);
+
+ Assert.Equal(2, repository.HierarchyReadCount);
+ Assert.Equal(firstSequence + 1, cache.Current.Sequence);
+ Assert.Equal(2, notifier.Published.Count);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_FirstQueryFailsNoPriorData_StatusUnavailableButFirstLoadStillCompletes()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
+ {
+ ThrowOnQuery = new TimeoutException("galaxy db unreachable"),
+ };
+ using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
+
+ await cache.RefreshAsync(CancellationToken.None);
+
+ // First load must complete so callers do not hang, even though the query failed.
+ await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
+ Assert.False(cache.Current.HasData);
+ Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
+ Assert.Contains("unreachable", cache.Current.LastError);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_QueryFailsAfterPriorData_DegradesToStaleAndKeepsData()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
+
+ await cache.RefreshAsync(CancellationToken.None);
+ Assert.True(cache.Current.HasData);
+
+ // A later refresh fails: data is retained but flagged Stale.
+ repository.DeployTime = DeployTime.AddHours(1);
+ repository.ThrowOnQuery = new InvalidOperationException("transient");
+ await cache.RefreshAsync(CancellationToken.None);
+
+ Assert.True(cache.Current.HasData);
+ Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
+ Assert.Equal(2, cache.Current.ObjectCount);
+ }
+
+ [Fact]
+ public async Task Current_AfterStalenessThreshold_ProjectsHealthyToStale()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ StubTimeProvider clock = new(FixedNow);
+ using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), clock);
+
+ await cache.RefreshAsync(CancellationToken.None);
+ Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
+
+ // Advance past the 5-minute staleness threshold with no successful refresh.
+ clock.Advance(TimeSpan.FromMinutes(6));
+
+ Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
+ // Data is still present — Stale means "old", not "gone".
+ Assert.True(cache.Current.HasData);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_PersistsSnapshotAfterSuccessfulHeavyRefresh()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ FakeSnapshotStore store = new();
+ using GalaxyHierarchyCache cache = new(
+ repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
+
+ await cache.RefreshAsync(CancellationToken.None);
+
+ Assert.Equal(1, store.SaveCount);
+ Assert.NotNull(store.Snapshot);
+ Assert.Equal(2, store.Snapshot!.Hierarchy.Count);
+ Assert.Single(store.Snapshot.Attributes);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_SnapshotRestore_ServesLastKnownDataAsStaleWhenDatabaseUnreachable()
+ {
+ // The snapshot store already holds a persisted dataset (last-known browse data).
+ FakeSnapshotStore store = new()
+ {
+ Snapshot = new GalaxyHierarchySnapshot(
+ LastDeployTime: DeployTime,
+ SavedAt: FixedNow.AddMinutes(-1),
+ Hierarchy: SampleHierarchy(),
+ Attributes: SampleAttributes()),
+ };
+
+ // The Galaxy database is unreachable on this cold start.
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
+ {
+ ThrowOnQuery = new TimeoutException("cold start, db down"),
+ };
+ RecordingDeployNotifier notifier = new();
+ using GalaxyHierarchyCache cache = new(
+ repository, notifier, new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
+
+ await cache.RefreshAsync(CancellationToken.None);
+
+ // First load is satisfied by the restored snapshot, not by SQL.
+ await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
+ Assert.Equal(1, store.LoadCount);
+ GalaxyHierarchyCacheEntry current = cache.Current;
+ Assert.True(current.HasData);
+ // Restored data is "last-known", surfaced as Stale until the live DB confirms.
+ Assert.Equal(GalaxyCacheStatus.Stale, current.Status);
+ Assert.Equal(2, current.ObjectCount);
+ Assert.Equal(DeployTime, current.LastDeployTime!.Value.UtcDateTime);
+
+ // A deploy event was published for the restored data.
+ Assert.Single(notifier.Published);
+ }
+
+ [Fact]
+ public async Task RefreshAsync_SnapshotRestoreThenLiveQuery_PromotesRestoredDataToHealthy()
+ {
+ FakeSnapshotStore store = new()
+ {
+ Snapshot = new GalaxyHierarchySnapshot(
+ LastDeployTime: DeployTime,
+ SavedAt: FixedNow.AddMinutes(-1),
+ Hierarchy: SampleHierarchy(),
+ Attributes: SampleAttributes()),
+ };
+ // DB is reachable and reports the SAME deploy time the snapshot was pulled at.
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ using GalaxyHierarchyCache cache = new(
+ repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
+
+ await cache.RefreshAsync(CancellationToken.None);
+
+ // Restore seeds Stale data; the same-deploy live query promotes it to Healthy
+ // without re-running the heavy hierarchy/attribute reads.
+ Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
+ Assert.Equal(0, repository.HierarchyReadCount);
+ Assert.True(cache.Current.HasData);
+ }
+
+ [Fact]
+ public void Dispose_CanBeCalledWithoutHavingRefreshed()
+ {
+ FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
+ GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
+
+ // Dispose must be safe even when no refresh ever ran (semaphore never entered).
+ cache.Dispose();
+ }
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyProjectorTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyProjectorTests.cs
new file mode 100644
index 0000000..d784609
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyProjectorTests.cs
@@ -0,0 +1,458 @@
+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);
+ }
+}
diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchySnapshotStoreTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchySnapshotStoreTests.cs
new file mode 100644
index 0000000..1d58f6e
--- /dev/null
+++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchySnapshotStoreTests.cs
@@ -0,0 +1,84 @@
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.GalaxyRepository;
+
+namespace ZB.MOM.WW.GalaxyRepository.Tests;
+
+///
+/// Round-trip tests for the real over a temp
+/// file path: save then load, no-op when persistence is disabled, and clean disposal.
+///
+public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
+{
+ private readonly string _path = Path.Combine(
+ Path.GetTempPath(),
+ $"galaxyrepo-snap-{Guid.NewGuid():N}.json");
+
+ public void Dispose()
+ {
+ if (File.Exists(_path))
+ {
+ File.Delete(_path);
+ }
+ }
+
+ private static GalaxyHierarchySnapshot SampleSnapshot() => new(
+ LastDeployTime: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
+ SavedAt: new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero),
+ Hierarchy:
+ [
+ new GalaxyHierarchyRow { GobjectId = 1, TagName = "Area1", IsArea = true },
+ new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump01", ParentGobjectId = 1 },
+ ],
+ Attributes:
+ [
+ new GalaxyAttributeRow { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true },
+ ]);
+
+ [Fact]
+ public async Task SaveThenLoad_RoundTripsTheSnapshot()
+ {
+ using GalaxyHierarchySnapshotStore store = new(
+ Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
+
+ await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
+ GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(2, loaded!.Hierarchy.Count);
+ Assert.Single(loaded.Attributes);
+ Assert.Equal("Pump01.PV", loaded.Attributes[0].FullTagReference);
+ Assert.True(loaded.Attributes[0].IsHistorized);
+ Assert.Equal(SampleSnapshot().LastDeployTime, loaded.LastDeployTime);
+ }
+
+ [Fact]
+ public async Task SaveAndLoad_AreNoOps_WhenPersistenceDisabled()
+ {
+ using GalaxyHierarchySnapshotStore store = new(
+ Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false, SnapshotCachePath = _path }));
+
+ await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
+
+ Assert.False(File.Exists(_path));
+ Assert.Null(await store.TryLoadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task TryLoad_ReturnsNull_WhenNoFileExists()
+ {
+ using GalaxyHierarchySnapshotStore store = new(
+ Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
+
+ Assert.Null(await store.TryLoadAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task TryLoad_ReturnsNull_WhenFileIsNotValidJson()
+ {
+ await File.WriteAllTextAsync(_path, "{ this is not valid json");
+ using GalaxyHierarchySnapshotStore store = new(
+ Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
+
+ Assert.Null(await store.TryLoadAsync(CancellationToken.None));
+ }
+}