Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverInfrastructureTests.cs
T
Joseph Doherty 560b327ee1
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts
nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the
pre-restructure MxGateway.* namespaces across the runtime Galaxy driver,
Galaxy.Browser, and their tests.

Key changes:
- nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it
  from the *.nupkg rule so the packages are tracked
- Directory.Packages.props pins both packages at 0.1.0
- 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/>
- 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*`
- libs/ removed (vendored DLLs + README.md)

GalaxyBrowseSession rewritten around the new lazy API:
- RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes)
  and caches them by TagName instead of bulk-fetching the whole hierarchy
- ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync,
  giving true one-wire-call-per-click instead of in-memory parent/child scan
- _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint)
- AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0)

Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66.
Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
2026-05-29 07:14:18 -04:00

258 lines
12 KiB
C#

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;
/// <summary>
/// Driver-level infrastructure tests for findings Driver.Galaxy-007 (dispose-CTS gate)
/// and Driver.Galaxy-011 (GetMemoryFootprint non-zero estimate).
/// </summary>
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 =====
/// <summary>Verifies that memory footprint is zero when there are no subscriptions.</summary>
[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);
}
/// <summary>Verifies that memory footprint is nonzero after subscriptions are active.</summary>
[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);
}
/// <summary>Verifies that memory footprint decreases after unsubscribing.</summary>
[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 =====
/// <summary>Verifies that Dispose sets disposed flag and blocks further capability calls.</summary>
[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<ObjectDisposedException>(() =>
driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None));
}
/// <summary>Verifies that DisposeAsync can be awaited without deadlock.</summary>
[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 =====
/// <summary>Verifies that ReplayOnSessionLost=false skips resubscription on reconnect.</summary>
[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");
}
/// <summary>Verifies that ReplayOnSessionLost=true runs resubscription on reconnect.</summary>
[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");
}
/// <summary>Tracks subscription calls for replay testing.</summary>
private sealed class ReplayCountingSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
private int _nextHandle = 1;
/// <summary>Gets the count of subscription calls.</summary>
public int SubscribeCalls;
/// <summary>Subscribes to multiple tags and counts the call.</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> 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<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags.</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
// ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply =====
/// <summary>Verifies that ReinitializeAsync rejects non-equivalent config changes.</summary>
[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<NotSupportedException>(async () =>
await driver.ReinitializeAsync(newConfig, CancellationToken.None));
}
/// <summary>Verifies that ReinitializeAsync accepts equivalent config.</summary>
[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 =====
/// <summary>No-op subscriber that returns empty results for testing.</summary>
private sealed class NoOpSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
/// <summary>Subscribes to multiple tags (no-op).</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
var results = fullReferences.Select((r, i) => new SubscribeResult
{
TagAddress = r,
ItemHandle = i + 1,
WasSuccessful = true,
}).ToList();
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags (no-op).</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events (no-op).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
}