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; /// /// 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. /// 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( () => 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( () => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef"); } [Fact] public void CreateInstance_MissingClientName_Throws() { const string bad = """{"Gateway":{"Endpoint":"x","ApiKeySecretRef":"y"}}"""; Should.Throw( () => 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()); /// /// Minimum-surface seam — enough to satisfy /// 's "skip production-runtime build" branch /// without driving any actual subscribe/event-pump traffic. /// private sealed class NoopSubscriber : IGalaxySubscriber { public Task> SubscribeBulkAsync( IReadOnlyList fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken) => Task.FromResult>([]); public Task UnsubscribeBulkAsync(IReadOnlyList itemHandles, CancellationToken cancellationToken) => Task.CompletedTask; public async IAsyncEnumerable 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(() => 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(); driver.ShouldBeAssignableTo(); driver.ShouldBeAssignableTo(); driver.ShouldBeAssignableTo(); driver.ShouldBeAssignableTo(); driver.ShouldBeAssignableTo(); } [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"); } }