From 2c6c764d3cc537f997c2d48a414a10abf0926a76 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 23 Jun 2026 20:34:32 -0400 Subject: [PATCH] test(galaxyrepo): projector + cache tests; dispose semaphores; pack 0.1.0 --- .../GalaxyHierarchyCache.cs | 12 +- .../GalaxyHierarchySnapshotStore.cs | 11 +- .../GalaxyRepositoryOptions.cs | 4 +- .../ZB.MOM.WW.GalaxyRepository.Tests/Fakes.cs | 134 +++++ .../GalaxyHierarchyCacheTests.cs | 236 +++++++++ .../GalaxyHierarchyProjectorTests.cs | 458 ++++++++++++++++++ .../GalaxyHierarchySnapshotStoreTests.cs | 84 ++++ 7 files changed, 935 insertions(+), 4 deletions(-) create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/Fakes.cs create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyCacheTests.cs create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchyProjectorTests.cs create mode 100644 ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyHierarchySnapshotStoreTests.cs 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)); + } +}