- GalaxyDriver.InitializeAsync now builds the production gw runtime (MxGatewayClient, GalaxyMxSession, GatewayGalaxySubscriber, GatewayGalaxyDataWriter, ReconnectSupervisor, HostConnectivityForwarder, PerPlatformProbeWatcher) when no test seams are pre-injected; Dispose tears the chain down in order. - GetHealth surfaces supervisor.IsDegraded as DriverState.Degraded so a transport drop is observable without polling the supervisor directly. - DiscoverAsync now refreshes the per-platform probe watcher's membership against $WinPlatform / $AppEngine objects after every discovery pass. - OnPumpDataChange routes ScanState changes through the probe watcher in addition to fanning out OnDataChange to ISubscribable consumers. - Server registers GalaxyDriver under "GalaxyMxGateway" alongside the legacy "Galaxy" GalaxyProxyDriver factory so DriverInstance rows can opt in. - Bumped Server.Tests' Microsoft.Extensions.Logging.Abstractions to 10.0.7 to resolve the downgrade pulled in transitively via MxGateway.Client. - Lifecycle factory tests switched to the internal seam-injection ctor so they no longer attempt a real gRPC connect during InitializeAsync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
C#
243 lines
10 KiB
C#
using 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" }
|
||
}
|
||
""";
|
||
|
||
[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();
|
||
}
|
||
|
||
[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();
|
||
}
|
||
|
||
[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");
|
||
}
|
||
|
||
[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");
|
||
}
|
||
|
||
[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");
|
||
}
|
||
|
||
[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);
|
||
}
|
||
|
||
[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
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ReinitializeAsync_RefreshesHealth()
|
||
{
|
||
using var driver = new GalaxyDriver(
|
||
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
|
||
dataWriter: null, subscriber: new NoopSubscriber());
|
||
await driver.InitializeAsync(MinimalConfig, 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(MinimalConfig, 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
|
||
{
|
||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||
|
||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||
=> Task.CompletedTask;
|
||
|
||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||
{
|
||
await Task.CompletedTask;
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
|
||
driver.Dispose();
|
||
Should.NotThrow(() => driver.Dispose());
|
||
}
|
||
|
||
[Fact]
|
||
public async Task InitializeAfterDispose_Throws()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
|
||
driver.Dispose();
|
||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
|
||
}
|
||
|
||
[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>();
|
||
}
|
||
|
||
[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();
|
||
}
|
||
|
||
[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");
|
||
}
|
||
}
|