using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.GalaxyRepository.DependencyInjection; using ZB.MOM.WW.GalaxyRepository.Grpc; namespace ZB.MOM.WW.GalaxyRepository.Tests; /// Regression tests for GalaxyRepository-003..008 (scadaproj#47). public sealed class ReviewP3Tests { private static GalaxyObject Obj(int id, int parent, string name) { GalaxyObject o = new() { GobjectId = id, ParentGobjectId = parent, ContainedName = name, BrowseName = name, TagName = name }; return o; } // ---- 003 ---- [Fact] public void ParentCycle_members_are_reachable_from_the_root_browse() { GalaxyObject[] objects = [ Obj(1, 0, "Plant"), Obj(10, 12, "A"), Obj(11, 10, "B"), Obj(12, 11, "C"), Obj(13, 12, "Leaf"), // hangs off the cycle ]; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 1, LastSuccessAt = DateTimeOffset.UtcNow, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), ObjectCount = objects.Length, }; HashSet seen = []; Walk(entry, new BrowseChildrenRequest(), seen, 0); Assert.Equal(new[] { "A", "B", "C", "Leaf", "Plant" }, seen.Order(StringComparer.Ordinal)); } private static void Walk(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest req, HashSet seen, int depth) { Assert.True(depth < 20, "browse did not terminate"); GalaxyBrowseChildrenResult r = GalaxyBrowseProjector.ProjectChildren(entry, req, null, 0, 100); foreach (GalaxyObject child in r.Children) { if (seen.Add(child.TagName)) { Walk(entry, new BrowseChildrenRequest { ParentTagName = child.TagName }, seen, depth + 1); } } } // ---- 004 ---- [Fact] public async Task SlowRefreshInterval_does_not_report_Stale_before_three_intervals() { DateTimeOffset now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); FakeGalaxyRepository repo = new( [new() { GobjectId = 1, TagName = "A", ContainedName = "A", BrowseName = "A" }], [], new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); StubTimeProvider clock = new(now); IOptions opts = Options.Create(new GalaxyRepositoryOptions { DashboardRefreshIntervalSeconds = 600 }); using GalaxyHierarchyCache cache = new(repo, new RecordingDeployNotifier(), clock, options: opts); await cache.RefreshAsync(CancellationToken.None); clock.Advance(TimeSpan.FromMinutes(10)); // one 600 s interval Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status); clock.Advance(TimeSpan.FromMinutes(21)); // > 3 intervals Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); } [Fact] public async Task Stale_projection_returns_the_same_instance_so_projector_memos_stay_warm() { StubTimeProvider clock = new(new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero)); FakeGalaxyRepository repo = new( [new() { GobjectId = 1, TagName = "A", ContainedName = "A", BrowseName = "A" }], [], new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); using GalaxyHierarchyCache cache = new(repo, new RecordingDeployNotifier(), clock); await cache.RefreshAsync(CancellationToken.None); clock.Advance(TimeSpan.FromMinutes(6)); Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status); Assert.Same(cache.Current, cache.Current); } // ---- 005 ---- [Fact] public async Task Subscriber_never_yields_an_older_event_after_a_newer_one() { GalaxyDeployNotifier notifier = new(); GalaxyDeployEventInfo older = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1); GalaxyDeployEventInfo newer = new(2, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1); notifier.Publish(older); using CancellationTokenSource cts = new(); await using IAsyncEnumerator e = notifier.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token); Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2))); Assert.Same(older, e.Current); // Simulate the race outcome: channel holds [newer, older] -> publish newer then replay older. notifier.Publish(newer); notifier.Publish(older); Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2))); Assert.Same(newer, e.Current); notifier.Publish(new GalaxyDeployEventInfo(3, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 1, 1)); Assert.True(await e.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2))); Assert.Equal(3, e.Current.Sequence); // the stale replay of 1 was skipped } // ---- 006 ---- private sealed class MarkerScope : IGalaxyBrowseScopeProvider { public IReadOnlyList? ResolveBrowseSubtrees(global::Grpc.Core.ServerCallContext context) => ["x/*"]; } private static IHost BuildHost(bool scopeBefore, bool scopeAfter, Dictionary? extra = null) { HostApplicationBuilder b = Host.CreateApplicationBuilder(); Dictionary cfg = new() { ["Galaxy:ConnectionString"] = "Server=127.0.0.1,1;Database=ZB;User Id=x;Password=x;Connect Timeout=1;Encrypt=false", ["Galaxy:DashboardRefreshIntervalSeconds"] = "1", }; if (extra is not null) { foreach (var kv in extra) cfg[kv.Key] = kv.Value; } b.Configuration.AddInMemoryCollection(cfg); b.Services.AddGrpc(); if (scopeBefore) b.Services.AddSingleton(); b.Services.AddZbGalaxyRepository(b.Configuration, "Galaxy"); if (scopeAfter) b.Services.AddSingleton(); return b.Build(); } [Fact] public async Task AddZbGalaxyRepository_builds_resolves_and_starts_and_stops_against_an_unreachable_db() { using IHost host = BuildHost(false, false); IServiceProvider sp = host.Services; Assert.NotNull(sp.GetRequiredService()); Assert.NotNull(sp.GetRequiredService()); Assert.NotNull(sp.GetRequiredService()); IGalaxyHierarchyCache cache = sp.GetRequiredService(); Assert.Same(cache, sp.GetRequiredService()); Assert.Contains(sp.GetServices(), s => s is GalaxyHierarchyRefreshService); Assert.NotNull(ActivatorUtilities.CreateInstance(sp)); Assert.IsType(sp.GetRequiredService()); await host.StartAsync(); using CancellationTokenSource cts = new(TimeSpan.FromSeconds(30)); await cache.WaitForFirstLoadAsync(cts.Token); Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status); await host.StopAsync(); } [Fact] public void Host_registered_scope_provider_wins_when_registered_before_the_call() { using IHost host = BuildHost(scopeBefore: true, scopeAfter: false); Assert.IsType(host.Services.GetRequiredService()); } [Fact] public void Host_registered_scope_provider_wins_when_registered_after_the_call() { using IHost host = BuildHost(scopeBefore: false, scopeAfter: true); Assert.IsType(host.Services.GetRequiredService()); } // ---- 008 ---- private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => Entries.Add((logLevel, formatter(state, exception))); } [Fact] public void Snapshot_store_logs_once_when_persistence_is_enabled_but_path_is_empty() { CapturingLogger log = new(); using GalaxyHierarchySnapshotStore _ = new( Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = "" }), log); Assert.Single(log.Entries, e => e.Level == LogLevel.Information && e.Message.Contains("SnapshotCachePath")); CapturingLogger quiet = new(); using GalaxyHierarchySnapshotStore __ = new( Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false }), quiet); Assert.Empty(quiet.Entries); } }