237 lines
11 KiB
C#
237 lines
11 KiB
C#
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();
|
|
}
|
|
}
|