Files
scadaproj/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/ReviewP3Tests.cs
T

201 lines
9.1 KiB
C#

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;
/// <summary>Regression tests for GalaxyRepository-003..008 (scadaproj#47).</summary>
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<string> 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<string> 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<GalaxyRepositoryOptions> 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<GalaxyDeployEventInfo> 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<string>? ResolveBrowseSubtrees(global::Grpc.Core.ServerCallContext context) => ["x/*"];
}
private static IHost BuildHost(bool scopeBefore, bool scopeAfter, Dictionary<string, string?>? extra = null)
{
HostApplicationBuilder b = Host.CreateApplicationBuilder();
Dictionary<string, string?> 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<IGalaxyBrowseScopeProvider, MarkerScope>();
b.Services.AddZbGalaxyRepository(b.Configuration, "Galaxy");
if (scopeAfter) b.Services.AddSingleton<IGalaxyBrowseScopeProvider, MarkerScope>();
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<IGalaxyRepository>());
Assert.NotNull(sp.GetRequiredService<IGalaxyDeployNotifier>());
Assert.NotNull(sp.GetRequiredService<IGalaxyHierarchySnapshotStore>());
IGalaxyHierarchyCache cache = sp.GetRequiredService<IGalaxyHierarchyCache>();
Assert.Same(cache, sp.GetRequiredService<IGalaxyHierarchyCache>());
Assert.Contains(sp.GetServices<IHostedService>(), s => s is GalaxyHierarchyRefreshService);
Assert.NotNull(ActivatorUtilities.CreateInstance<GalaxyRepositoryGrpcService>(sp));
Assert.IsType<NullGalaxyBrowseScopeProvider>(sp.GetRequiredService<IGalaxyBrowseScopeProvider>());
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<MarkerScope>(host.Services.GetRequiredService<IGalaxyBrowseScopeProvider>());
}
[Fact]
public void Host_registered_scope_provider_wins_when_registered_after_the_call()
{
using IHost host = BuildHost(scopeBefore: false, scopeAfter: true);
Assert.IsType<MarkerScope>(host.Services.GetRequiredService<IGalaxyBrowseScopeProvider>());
}
// ---- 008 ----
private sealed class CapturingLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
}
[Fact]
public void Snapshot_store_logs_once_when_persistence_is_enabled_but_path_is_empty()
{
CapturingLogger<GalaxyHierarchySnapshotStore> 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<GalaxyHierarchySnapshotStore> quiet = new();
using GalaxyHierarchySnapshotStore __ = new(
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false }), quiet);
Assert.Empty(quiet.Entries);
}
}