using System.Threading.Channels; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts.Proto; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; /// /// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate) /// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate). /// public sealed class GalaxyDriverInfrastructureTests { private static GalaxyDriverOptions Opts() => new( new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), new GalaxyMxAccessOptions("InfraTest"), new GalaxyRepositoryOptions(WatchDeployEvents: false), new GalaxyReconnectOptions()); // ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size ===== [Fact] public void GetMemoryFootprint_IsZeroWhenNoSubscriptions() { var sub = new NoOpSubscriber(); using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); driver.GetMemoryFootprint().ShouldBe(0L); } [Fact] public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe() { // When subscriptions are active the footprint estimate must be > 0 so the // server's memory-pressure mechanism sees the Galaxy driver as a participant. var sub = new NoOpSubscriber(); using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None); driver.GetMemoryFootprint().ShouldBeGreaterThan(0L); } [Fact] public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe() { var sub = new NoOpSubscriber(); using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); var handle = await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None); var afterSubscribe = driver.GetMemoryFootprint(); afterSubscribe.ShouldBeGreaterThan(0L); await driver.UnsubscribeAsync(handle, CancellationToken.None); var afterUnsubscribe = driver.GetMemoryFootprint(); afterUnsubscribe.ShouldBeLessThan(afterSubscribe, "footprint must decrease when subscriptions are removed"); } // ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS ===== [Fact] public async Task Dispose_SetsDisposedFlag_BlockingFurtherCapabilityCalls() { var sub = new NoOpSubscriber(); var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); driver.Dispose(); // Capability entry points all check ObjectDisposedException.ThrowIf — SubscribeAsync // is representative and is guarded on line 1 of its body. await Should.ThrowAsync(() => driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None)); } [Fact] public async Task DisposeAsync_CanBeAwaitedWithoutDeadlock() { var sub = new NoOpSubscriber(); var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); // IAsyncDisposable.DisposeAsync must not block or throw. await Should.NotThrowAsync(async () => await driver.DisposeAsync()); } // ===== Driver.Galaxy-013 regression: ReplayOnSessionLost gates the replay step ===== [Fact] public async Task ReplayOnSessionLost_False_SkipsResubscribeBulk() { // ReplayOnSessionLost was a dangling option — defined + documented but never // read. After the fix, setting it to false makes the reconnect replay path // skip SubscribeBulk (operator opts out of replay; the gateway's session-level // ReplaySubscriptions handles state restoration). var sub = new ReplayCountingSubscriber(); var opts = new GalaxyDriverOptions( new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), new GalaxyMxAccessOptions("InfraTest"), new GalaxyRepositoryOptions(WatchDeployEvents: false), new GalaxyReconnectOptions(ReplayOnSessionLost: false)); using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub); // Establish a subscription so the replay path has something to walk. await driver.SubscribeAsync(["Tag.A", "Tag.B"], TimeSpan.Zero, CancellationToken.None); sub.SubscribeCalls.ShouldBe(1); // Invoke the replay path directly via the internal test seam — the supervisor's // ReportTransportFailure spins it up async; for a deterministic assertion we // call the helper that ReplayAsync is wired against. await driver.InvokeReplayForTestAsync(CancellationToken.None); sub.SubscribeCalls.ShouldBe(1, "ReplayOnSessionLost=false must skip the re-SubscribeBulk fan-out on reconnect"); } [Fact] public async Task ReplayOnSessionLost_True_RunsResubscribeBulk() { var sub = new ReplayCountingSubscriber(); var opts = new GalaxyDriverOptions( new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), new GalaxyMxAccessOptions("InfraTest"), new GalaxyRepositoryOptions(WatchDeployEvents: false), new GalaxyReconnectOptions(ReplayOnSessionLost: true)); using var driver = new GalaxyDriver("drv-1", opts, null, null, null, sub); await driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None); sub.SubscribeCalls.ShouldBe(1); await driver.InvokeReplayForTestAsync(CancellationToken.None); sub.SubscribeCalls.ShouldBe(2, "default ReplayOnSessionLost=true must re-issue SubscribeBulk after a transport drop"); } private sealed class ReplayCountingSubscriber : IGalaxySubscriber { private readonly Channel _stream = Channel.CreateUnbounded(); private int _nextHandle = 1; public int SubscribeCalls; public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) { Interlocked.Increment(ref SubscribeCalls); var results = fullReferences.Select(r => new SubscribeResult { TagAddress = r, ItemHandle = Interlocked.Increment(ref _nextHandle), WasSuccessful = true, }).ToList(); return Task.FromResult>(results); } public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken) => _stream.Reader.ReadAllAsync(cancellationToken); } // ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply ===== [Fact] public async Task ReinitializeAsync_RejectsNonEquivalentConfigChange() { // ReinitializeAsync was previously a silent no-op that ignored driverConfigJson. // After the fix it either applies an equivalent config (no-op) or throws // NotSupportedException so a config change isn't silently dropped. var sub = new NoOpSubscriber(); using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); const string newConfig = "{\"Gateway\":{\"Endpoint\":\"https://other.test:5001\",\"ApiKeySecretRef\":\"dev:other\"}}"; // The driver must NOT pretend the change was applied — either no-op equivalence // or an explicit rejection is acceptable. Silently dropping the new config // (the previous behaviour) is not. await Should.ThrowAsync(async () => await driver.ReinitializeAsync(newConfig, CancellationToken.None)); } [Fact] public async Task ReinitializeAsync_AcceptsEquivalentConfig() { var sub = new NoOpSubscriber(); using var driver = new GalaxyDriver("drv-1", Opts(), null, null, null, sub); // An empty / null-equivalent config reapply (no field changes) must not throw — // it's a legitimate "refresh health" path. Pass a JSON object that round-trips // to the driver's current options. var json = "{\"Gateway\":{\"Endpoint\":\"https://mxgw.test:5001\",\"ApiKeySecretRef\":\"key\"}," + "\"MxAccess\":{\"ClientName\":\"InfraTest\"}," + "\"Repository\":{\"WatchDeployEvents\":false}," + "\"Reconnect\":{}}"; await Should.NotThrowAsync(async () => await driver.ReinitializeAsync(json, CancellationToken.None)); } // ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls ===== private sealed class NoOpSubscriber : IGalaxySubscriber { private readonly Channel _stream = Channel.CreateUnbounded(); public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) { var results = fullReferences.Select((r, i) => new SubscribeResult { TagAddress = r, ItemHandle = i + 1, WasSuccessful = true, }).ToList(); return Task.FromResult>(results); } public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; public IAsyncEnumerable StreamEventsAsync(CancellationToken cancellationToken) => _stream.Reader.ReadAllAsync(cancellationToken); } }