test(galaxyrepo): projector + cache tests; dispose semaphores; pack 0.1.0

This commit is contained in:
Joseph Doherty
2026-06-23 20:34:32 -04:00
parent a30f8551e9
commit 2c6c764d3c
7 changed files with 935 additions and 4 deletions
@@ -0,0 +1,134 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.GalaxyRepository;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// In-memory <see cref="IGalaxyRepository"/> 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.
/// </summary>
internal sealed class FakeGalaxyRepository : IGalaxyRepository
{
private readonly IReadOnlyList<GalaxyHierarchyRow> _hierarchy;
private readonly IReadOnlyList<GalaxyAttributeRow> _attributes;
public FakeGalaxyRepository(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes,
DateTime? deployTime)
{
_hierarchy = hierarchy;
_attributes = attributes;
DeployTime = deployTime;
}
/// <summary>The deploy time returned by <see cref="GetLastDeployTimeAsync"/>; mutate to simulate a redeploy.</summary>
public DateTime? DeployTime { get; set; }
/// <summary>When set, every query throws this exception (simulates an unreachable database).</summary>
public Exception? ThrowOnQuery { get; set; }
public int HierarchyReadCount { get; private set; }
public int AttributeReadCount { get; private set; }
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
ThrowOnQuery is null ? Task.FromResult(true) : throw ThrowOnQuery;
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
if (ThrowOnQuery is not null)
{
throw ThrowOnQuery;
}
return Task.FromResult(DeployTime);
}
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
if (ThrowOnQuery is not null)
{
throw ThrowOnQuery;
}
HierarchyReadCount++;
return Task.FromResult(_hierarchy.ToList());
}
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
if (ThrowOnQuery is not null)
{
throw ThrowOnQuery;
}
AttributeReadCount++;
return Task.FromResult(_attributes.ToList());
}
}
/// <summary>Records published deploy events so tests can assert publication.</summary>
internal sealed class RecordingDeployNotifier : IGalaxyDeployNotifier
{
public List<GalaxyDeployEventInfo> Published { get; } = [];
public GalaxyDeployEventInfo? Latest { get; private set; }
public void Publish(GalaxyDeployEventInfo info)
{
Published.Add(info);
Latest = info;
}
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (Latest is { } latest)
{
yield return latest;
}
await Task.CompletedTask.ConfigureAwait(false);
}
}
/// <summary>
/// In-memory <see cref="IGalaxyHierarchySnapshotStore"/>. Pre-seed <see cref="Snapshot"/>
/// to exercise the restore path; reads <see cref="SaveAsync"/> back to assert persistence.
/// </summary>
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<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
{
LoadCount++;
return Task.FromResult(Snapshot);
}
}
/// <summary>
/// A <see cref="TimeProvider"/> whose UTC clock is fixed (and advanceable) so the cache's
/// staleness projection (which fires after a 5-minute threshold) is deterministic.
/// </summary>
internal sealed class StubTimeProvider(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now += delta;
}
@@ -0,0 +1,236 @@
using ZB.MOM.WW.GalaxyRepository;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// Tests for <see cref="GalaxyHierarchyCache"/> first-load, deploy-gating, snapshot
/// restore, persistence, and status-transition behavior. Uses an in-memory
/// <see cref="IGalaxyRepository"/> and snapshot store plus a fixed
/// <see cref="StubTimeProvider"/> so no SQL is touched and no asserts are time-sensitive.
/// </summary>
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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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();
}
}
@@ -0,0 +1,458 @@
using Grpc.Core;
using ZB.MOM.WW.GalaxyRepository;
using ZB.MOM.WW.GalaxyRepository.Grpc;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// Pure-logic tests for <see cref="GalaxyHierarchyProjector"/> and
/// <see cref="GalaxyBrowseProjector"/>. 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 <see cref="IGalaxyRepository"/> driven through
/// <see cref="GalaxyHierarchyCache.RefreshAsync"/>), so the projectors are exercised
/// against a real <see cref="GalaxyHierarchyIndex"/>.
/// </summary>
public sealed class GalaxyHierarchyProjectorTests
{
/// <summary>
/// Builds a realistic cache entry by driving a fake repository through the cache's
/// own refresh path. This goes through <c>BuildEntry</c> + <see cref="GalaxyHierarchyIndex.Build"/>
/// exactly as production does, rather than reaching for an internal factory.
/// </summary>
private static GalaxyHierarchyCacheEntry BuildEntry(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> 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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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<string>? templates = null) => new()
{
GobjectId = id,
TagName = tagName,
ContainedName = tagName,
BrowseName = tagName,
ParentGobjectId = parent,
IsArea = isArea,
CategoryId = category,
TemplateChain = templates ?? Array.Empty<string>(),
};
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<int> 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<RpcException>(() => 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<RpcException>(() => 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<ArgumentOutOfRangeException>(() =>
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: -1, pageSize: 10));
Assert.Throws<ArgumentOutOfRangeException>(() =>
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<int, bool> 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<RpcException>(() =>
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);
}
}
@@ -0,0 +1,84 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.GalaxyRepository;
namespace ZB.MOM.WW.GalaxyRepository.Tests;
/// <summary>
/// Round-trip tests for the real <see cref="GalaxyHierarchySnapshotStore"/> over a temp
/// file path: save then load, no-op when persistence is disabled, and clean disposal.
/// </summary>
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));
}
}