Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.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

271 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
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>
/// Smoke tests for the PR 4.0 driver skeleton. The IDriver shape, factory parsing,
/// and lifecycle methods land in PR 4.0; capability bodies (browse / read / write /
/// subscribe / health forwarder / probe watcher) are tested in PRs 4.14.7 each
/// against their own seam.
/// </summary>
public sealed class GalaxyDriverFactoryTests
{
private const string MinimalConfig = """
{
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
"MxAccess": { "ClientName": "OtOpcUa-A" }
}
""";
/// <summary>Verifies that minimal config is parsed and defaults are applied.</summary>
[Fact]
public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-instance-a", MinimalConfig);
driver.DriverInstanceId.ShouldBe("galaxy-instance-a");
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
driver.Options.Gateway.Endpoint.ShouldBe("https://mxgw.test:5001");
driver.Options.Gateway.ApiKeySecretRef.ShouldBe("galaxy:apiKey");
driver.Options.Gateway.UseTls.ShouldBeTrue();
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(10);
driver.Options.MxAccess.ClientName.ShouldBe("OtOpcUa-A");
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(1000);
driver.Options.Repository.DiscoverPageSize.ShouldBe(5000);
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
/// <summary>Verifies that defaults are overridden from full config.</summary>
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
{
const string fullConfig = """
{
"Gateway": {
"Endpoint": "https://mxgw.prod:5001",
"ApiKeySecretRef": "secret:abc",
"UseTls": false,
"CaCertificatePath": "C:/certs/ca.crt",
"ConnectTimeoutSeconds": 5,
"DefaultCallTimeoutSeconds": 3,
"StreamTimeoutSeconds": 60
},
"MxAccess": {
"ClientName": "OtOpcUa-Prod",
"PublishingIntervalMs": 250,
"WriteUserId": 17
},
"Repository": { "DiscoverPageSize": 1000, "WatchDeployEvents": false },
"Reconnect": { "InitialBackoffMs": 100, "MaxBackoffMs": 5000, "ReplayOnSessionLost": false }
}
""";
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-prod", fullConfig);
driver.Options.Gateway.UseTls.ShouldBeFalse();
driver.Options.Gateway.CaCertificatePath.ShouldBe("C:/certs/ca.crt");
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(5);
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(250);
driver.Options.MxAccess.WriteUserId.ShouldBe(17);
driver.Options.Repository.DiscoverPageSize.ShouldBe(1000);
driver.Options.Repository.WatchDeployEvents.ShouldBeFalse();
driver.Options.Reconnect.InitialBackoffMs.ShouldBe(100);
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
}
/// <summary>Verifies that missing endpoint throws an exception.</summary>
[Fact]
public void CreateInstance_MissingEndpoint_Throws()
{
const string bad = """{"Gateway":{"ApiKeySecretRef":"x"},"MxAccess":{"ClientName":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint");
}
/// <summary>Verifies that missing API key throws an exception.</summary>
[Fact]
public void CreateInstance_MissingApiKey_Throws()
{
const string bad = """{"Gateway":{"Endpoint":"x"},"MxAccess":{"ClientName":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef");
}
/// <summary>Verifies that missing client name throws an exception.</summary>
[Fact]
public void CreateInstance_MissingClientName_Throws()
{
const string bad = """{"Gateway":{"Endpoint":"x","ApiKeySecretRef":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("MxAccess.ClientName");
}
/// <summary>Verifies that the factory is registered in the driver factory registry.</summary>
[Fact]
public void Register_AddsFactoryToRegistry()
{
var registry = new DriverFactoryRegistry();
GalaxyDriverFactoryExtensions.Register(registry);
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
var factory = registry.TryGet(GalaxyDriverFactoryExtensions.DriverTypeName);
factory.ShouldNotBeNull();
var driver = factory!.Invoke("galaxy-x", MinimalConfig);
driver.ShouldNotBeNull();
driver.DriverInstanceId.ShouldBe("galaxy-x");
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
}
/// <summary>Verifies that driver lifecycle toggles health state on initialize and shutdown.</summary>
[Fact]
public async Task DriverLifecycle_InitializeShutdown_ToggleHealth()
{
// Inject a no-op subscriber seam so InitializeAsync skips BuildProductionRuntimeAsync
// (no gw connect attempt). The factory tests only care about the lifecycle health
// toggle; real-runtime wire-up is exercised in PR 4.W's BuildProductionRuntime tests.
using var driver = new GalaxyDriver(
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await driver.ShutdownAsync(CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
driver.GetMemoryFootprint().ShouldBe(0);
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
[Fact]
public async Task ReinitializeAsync_RefreshesHealth_WhenConfigIsEquivalent()
{
// Driver.Galaxy-013: ReinitializeAsync now compares the incoming JSON to the
// live options. An equivalent config is accepted and refreshes health; a
// non-equivalent reapply throws NotSupportedException (covered in
// GalaxyDriverInfrastructureTests.ReinitializeAsync_RejectsNonEquivalentConfigChange).
// Build a config JSON whose parsed shape equals BuildOptions() so the
// equivalence check passes.
const string equivalentConfig = """
{
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "key" },
"MxAccess": { "ClientName": "OtOpcUa-A" }
}
""";
using var driver = new GalaxyDriver(
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
await driver.InitializeAsync(equivalentConfig, CancellationToken.None);
var firstStamp = driver.GetHealth().LastSuccessfulRead!.Value;
// Force a measurable clock delta so the comparison is stable on fast machines.
await Task.Delay(20);
await driver.ReinitializeAsync(equivalentConfig, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp);
}
private static GalaxyDriverOptions BuildOptions() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
/// <summary>
/// Minimum-surface <see cref="IGalaxySubscriber"/> seam — enough to satisfy
/// <see cref="GalaxyDriver.InitializeAsync"/>'s "skip production-runtime build" branch
/// without driving any actual subscribe/event-pump traffic.
/// </summary>
private sealed class NoopSubscriber : IGalaxySubscriber
{
/// <inheritdoc />
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
/// <summary>Verifies that dispose is idempotent and shutdown after dispose is harmless.</summary>
[Fact]
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
driver.Dispose();
Should.NotThrow(() => driver.Dispose());
}
/// <summary>Verifies that initializing after dispose throws an exception.</summary>
[Fact]
public async Task InitializeAfterDispose_Throws()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
driver.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
}
/// <summary>Verifies that the driver implements all Phase 4 capabilities.</summary>
[Fact]
public void DriverImplementsAllPhase4Capabilities()
{
// PR 4.W contract pin — every capability the in-process Galaxy driver must
// expose for parity with GalaxyProxyDriver. If any of these regress, the
// Galaxy:Backend flag flip in PR 7.1 will silently lose surface.
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
driver.ShouldBeAssignableTo<ITagDiscovery>();
driver.ShouldBeAssignableTo<IReadable>();
driver.ShouldBeAssignableTo<IWritable>();
driver.ShouldBeAssignableTo<ISubscribable>();
driver.ShouldBeAssignableTo<IRediscoverable>();
driver.ShouldBeAssignableTo<IHostConnectivityProbe>();
}
/// <summary>Verifies that GetHostStatuses returns an empty snapshot after initialization with seam.</summary>
[Fact]
public async Task GetHostStatuses_AfterInitWithSeam_ReturnsEmptySnapshot()
{
// PR 4.W wire-up assertion: when InitializeAsync skips the production-runtime
// build (seam injected), no transport-forwarder or probe-watcher pushes a
// status, so the aggregator snapshot is empty. The forwarder + watcher have
// their own unit tests in PR 4.7.
using var driver = new GalaxyDriver(
"g", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
driver.GetHostStatuses().ShouldBeEmpty();
}
/// <summary>Verifies that the driver type is GalaxyMxGateway, not the legacy Galaxy type.</summary>
[Fact]
public void DriverType_IsGalaxyMxGateway_NotLegacyGalaxy()
{
// Must match GalaxyProxyDriver's DriverType ("Galaxy") side-by-side without
// collision so DriverInstanceBootstrapper can resolve both factories.
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
driver.DriverType.ShouldBe("GalaxyMxGateway");
}
}