636c755b04
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.
303 lines
14 KiB
C#
303 lines
14 KiB
C#
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.1–4.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");
|
||
}
|
||
}
|