Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs
T
Joseph Doherty 636c755b04 v3(galaxy): re-key GalaxyDriver to RawPath identity (dial attributeRef)
Wave-B driver re-keying. Under v3 the server addresses Galaxy nodes by RawPath
and the driver dials the Galaxy attributeRef (tag_name.AttributeName). The
dialled address moved into TagConfig under the camelCase key `attributeRef`
(renamed from the pre-v3 PascalCase FullName) and rides on RawTagEntry.

Options + factory:
- GalaxyDriverOptions gains `IReadOnlyList<RawTagEntry> RawTags` (Contracts now
  references the zero-dep Core.Abstractions leaf for RawTagEntry — same as
  Modbus.Contracts). Factory binds `List<RawTagEntry>? RawTags` from DriverConfig.

Boundary translation (built once from options.RawTags):
- RawPath -> attributeRef (forward dial) on Read / Write / Subscribe / Ack.
- attributeRef -> RawPath (reverse) on the value fan-out (OnDataChange) and the
  alarm ConditionId — so the alarm resolves back to the same RawPath the value
  path surfaces. Both legs fall back to identity on a miss, so the live-browse
  discovery path (no authored tags) and synthetic alarm sub-refs
  (attributeRef.InAlarm) dial straight through unchanged.
- WriteAsync dials the attributeRef but resolves security via the RawPath-keyed
  map the discoverer captured (reverse-maps in the resolver closure).

Discovery + alarms:
- GalaxyDiscoverer emits FullName = RawPath for authored attributes (falls back
  to attributeRef), threads RawTagEntry.WriteIdempotent onto DriverAttributeInfo,
  and gains an optional attributeRef -> RawTagEntry resolver ctor param.
- AlarmRefBuilder.Build(conditionReference, dialReference): SourceName/ConditionId
  = RawPath identity; the five live-state/ack sub-refs dial off the attributeRef.

ReinitializeAsync equivalence now compares the session-shape sections field-wise,
excluding RawTags (address-space state re-applied via rediscovery, and a record's
synthesized equality would compare the lists by reference).

Tests: +9 (discoverer RawPath/WriteIdempotent + alarm identity; factory RawTags
binding; driver read/write/subscribe/alarm boundary translation). Existing 304
preserved via identity fallback. 313 pass, 5 live-gated skips.
2026-07-15 20:12:22 -04:00

303 lines
14 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 the factory binds the v3 RawTags array from the driver config JSON.</summary>
[Fact]
public void CreateInstance_BindsRawTags_FromConfig()
{
const string configWithRawTags = """
{
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
"MxAccess": { "ClientName": "OtOpcUa-A" },
"RawTags": [
{ "RawPath": "area/line/tank1/level", "TagConfig": "{\"attributeRef\":\"Tank1.Level\"}", "WriteIdempotent": true },
{ "RawPath": "area/line/tank1/sp", "TagConfig": "{\"attributeRef\":\"Tank1.SP\"}", "WriteIdempotent": false }
]
}
""";
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-rawtags", configWithRawTags);
driver.Options.RawTags.Count.ShouldBe(2);
driver.Options.RawTags[0].RawPath.ShouldBe("area/line/tank1/level");
driver.Options.RawTags[0].TagConfig.ShouldContain("Tank1.Level");
driver.Options.RawTags[0].WriteIdempotent.ShouldBeTrue();
driver.Options.RawTags[1].WriteIdempotent.ShouldBeFalse();
}
/// <summary>Verifies that config without a RawTags array binds to an empty list (identity path).</summary>
[Fact]
public void CreateInstance_NoRawTags_DefaultsToEmpty()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-none", MinimalConfig);
driver.Options.RawTags.ShouldBeEmpty();
}
/// <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");
}
}