362ba8d75f
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
201 lines
9.1 KiB
C#
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);
|
|
}
|
|
}
|