From 21cac4c8c40ae30b57022eaa06ff141da8482076 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 16:10:31 -0400 Subject: [PATCH] =?UTF-8?q?PR=204.W=20=E2=80=94=20Galaxy:Backend=20wiring?= =?UTF-8?q?=20+=20server-side=20factory=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../GalaxyDriver.cs | 249 ++++++++++++++++-- src/ZB.MOM.WW.OtOpcUa.Server/Program.cs | 6 + .../ZB.MOM.WW.OtOpcUa.Server.csproj | 1 + .../GalaxyDriverFactoryTests.cs | 80 +++++- .../ZB.MOM.WW.OtOpcUa.Server.Tests.csproj | 2 +- 5 files changed, 320 insertions(+), 18 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 3fb5b1a..cb8d6a7 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -4,6 +4,7 @@ using MxGateway.Client; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; @@ -23,7 +24,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; /// registers under driver-type name /// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing. /// -public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IDisposable +public sealed class GalaxyDriver + : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IDisposable { private readonly string _driverInstanceId; private readonly GalaxyDriverOptions _options; @@ -41,13 +43,13 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, // pump; until then ReadAsync throws NotSupportedException when the reader is null // (legacy-host backend handles reads in production via DriverNodeManager's // capability-routing). - private readonly IGalaxyDataReader? _dataReader; + private IGalaxyDataReader? _dataReader; // PR 4.3 — IGalaxyDataWriter is the test seam for IWritable. Production wraps // GalaxyMxSession via GatewayGalaxyDataWriter (Write / WriteSecured routing). The // per-tag SecurityClassification map is populated during ITagDiscovery and consumed // here at write time. - private readonly IGalaxyDataWriter? _dataWriter; + private IGalaxyDataWriter? _dataWriter; private readonly System.Collections.Concurrent.ConcurrentDictionary _securityByFullRef = new(StringComparer.OrdinalIgnoreCase); @@ -55,11 +57,31 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, // out OnDataChange events to every registered driver subscription via the registry's // reverse map. The subscriber is the test seam — production uses // GatewayGalaxySubscriber over a connected GalaxyMxSession. - private readonly IGalaxySubscriber? _subscriber; + private IGalaxySubscriber? _subscriber; private readonly SubscriptionRegistry _subscriptions = new(); private EventPump? _eventPump; private readonly Lock _pumpLock = new(); + // PR 4.W — production runtime owned by InitializeAsync. The driver builds these + // when it opens a real gw session; tests bypass them by injecting seams via the + // internal ctor. + private GalaxyMxSession? _ownedMxSession; + private MxGatewayClient? _ownedMxClient; + + // PR 4.5 — reconnect supervisor. Reflects in DriverState.Degraded while not Healthy. + private ReconnectSupervisor? _supervisor; + + // PR 4.6 — IRediscoverable plumbing. + private DeployWatcher? _deployWatcher; + + // PR 4.7 — IHostConnectivityProbe plumbing. The aggregator owns the merged + // transport+per-platform view; the forwarder is fed from the supervisor on + // transport state transitions; the probe watcher subscribes ScanState attributes + // for every discovered platform and pushes value changes to the aggregator. + private readonly HostStatusAggregator _hostStatuses = new(); + private HostConnectivityForwarder? _transportForwarder; + private PerPlatformProbeWatcher? _probeWatcher; + private DriverHealth _health = new(DriverState.Unknown, null, null); private bool _disposed; @@ -70,6 +92,12 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, /// public event EventHandler? OnDataChange; + /// Fires when the gateway signals a deploy-time change (PR 4.6 DeployWatcher). + public event EventHandler? OnRediscoveryNeeded; + + /// Fires when a host transitions Running ↔ Stopped (PR 4.7 HostStatusAggregator). + public event EventHandler? OnHostStatusChanged; + public GalaxyDriver( string driverInstanceId, GalaxyDriverOptions options, @@ -103,6 +131,9 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, _dataReader = dataReader; _dataWriter = dataWriter; _subscriber = subscriber; + + // Forward the aggregator's transitions through IHostConnectivityProbe. + _hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args); } /// @@ -115,18 +146,141 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, internal GalaxyDriverOptions Options => _options; /// - public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - // PR 4.0 skeleton — capability bodies (PRs 4.1-4.7) replace this stub with real - // MxGatewayClient session opening. The skeleton keeps the IDriver shape buildable - // so the Galaxy:Backend flag (PR 4.W) can register the driver factory now. + // Tests inject seams via the internal ctor; production InitializeAsync builds + // the gateway client + session + per-capability runtime components from + // GalaxyDriverOptions. When seams are pre-injected we leave them alone (the + // test exercises the wired surface without a real gw round-trip). + if (_subscriber is null && _dataWriter is null && _hierarchySource is null) + { + await BuildProductionRuntimeAsync(cancellationToken).ConfigureAwait(false); + } + else + { + _logger.LogDebug( + "GalaxyDriver {InstanceId} initializing with pre-injected seams — production runtime build skipped", + _driverInstanceId); + } + + StartDeployWatcher(); _logger.LogInformation( - "GalaxyDriver {InstanceId} initializing — endpoint={Endpoint} clientName={ClientName} (skeleton; real gateway connect in PR 4.1+)", + "GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}", _driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName); _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); - return Task.CompletedTask; + } + + /// + /// Build the production gw client + session + per-capability runtime components + /// from _options. Sets up the reconnect supervisor's reopen / replay + /// callbacks so a transport drop replays every active subscription on the + /// restored session. + /// + private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken) + { + var clientOptions = BuildClientOptions(_options.Gateway); + _ownedMxClient = MxGatewayClient.Create(clientOptions); + _ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger); + await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); + + _subscriber = new GatewayGalaxySubscriber(_ownedMxSession); + _dataWriter = new GatewayGalaxyDataWriter(_ownedMxSession, _options.MxAccess.WriteUserId, _logger); + + _supervisor = new ReconnectSupervisor( + reopen: ReopenAsync, + replay: ReplayAsync, + options: new ReconnectOptions( + InitialBackoffOverride: TimeSpan.FromMilliseconds(_options.Reconnect.InitialBackoffMs), + MaxBackoffOverride: TimeSpan.FromMilliseconds(_options.Reconnect.MaxBackoffMs)), + logger: _logger); + + _transportForwarder = new HostConnectivityForwarder(_options.MxAccess.ClientName, _hostStatuses, _logger); + _transportForwarder.SetTransport(HostState.Running); // initial state — we just connected + + _supervisor.StateChanged += OnSupervisorStateChanged; + + _probeWatcher = new PerPlatformProbeWatcher(_subscriber, _hostStatuses, _logger); + } + + /// + /// Reopen callback for : re-Register the gw session. + /// If the session never connected, this is a fresh ConnectAsync; otherwise it's a + /// reconnect against the existing client. + /// + private async Task ReopenAsync(CancellationToken cancellationToken) + { + if (_ownedMxSession is null) return; + var clientOptions = BuildClientOptions(_options.Gateway); + await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false); + } + + /// + /// Replay callback. Walks every active subscription's bindings and re-issues + /// SubscribeBulk for the tag list. PR 6.x can swap this for the gw's batched + /// ReplaySubscriptionsCommand once it ships. + /// + private async Task ReplayAsync(CancellationToken cancellationToken) + { + if (_subscriber is null) return; + var bindings = _subscriptions.SnapshotAllBindings(); + if (bindings.Count == 0) return; + + var refs = bindings.Select(b => b.FullReference).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + await _subscriber.SubscribeBulkAsync( + refs, _options.MxAccess.PublishingIntervalMs, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( + "GalaxyDriver {InstanceId} replay completed — {Count} tags re-subscribed", + _driverInstanceId, refs.Length); + } + + private void OnSupervisorStateChanged(object? sender, StateTransition transition) + { + // Reflect supervisor state in DriverHealth + transport forwarder. + _health = transition.Next switch + { + ReconnectSupervisor.State.Healthy => new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null), + _ => new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, transition.Cause), + }; + if (_transportForwarder is not null) + { + var hostState = transition.Next == ReconnectSupervisor.State.Healthy + ? HostState.Running + : HostState.Stopped; + _transportForwarder.SetTransport(hostState); + } + } + + private static MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new() + { + Endpoint = new Uri(gw.Endpoint, UriKind.Absolute), + ApiKey = gw.ApiKeySecretRef, + UseTls = gw.UseTls, + CaCertificatePath = gw.CaCertificatePath, + ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds), + DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds), + StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null, + }; + + private void StartDeployWatcher() + { + if (!_options.Repository.WatchDeployEvents) return; + if (_ownedRepositoryClient is null && _hierarchySource is null) return; + + // Reuse the lazily-built repository client (DiscoverAsync constructs it on demand). + // If discovery hasn't run yet, build the client here so the watcher has a target. + if (_ownedRepositoryClient is null) + { + _ownedRepositoryClient = MxGateway.Client.GalaxyRepositoryClient.Create( + BuildClientOptions(_options.Gateway)); + } + + var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient); + _deployWatcher = new DeployWatcher(source, _logger); + _deployWatcher.OnRediscoveryNeeded += (_, args) => OnRediscoveryNeeded?.Invoke(this, args); + + _ = _deployWatcher.StartAsync(CancellationToken.None); } /// @@ -149,7 +303,21 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, } /// - public DriverHealth GetHealth() => _health; + public DriverHealth GetHealth() + { + // Reconnect supervisor wins when degraded — the cached _health reflects the last + // successful operation, but ongoing recovery should surface as Degraded. + if (_supervisor?.IsDegraded == true) + { + return new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, _supervisor.LastError); + } + return _health; + } + + // ===== IHostConnectivityProbe (PR 4.7 wire-up) ===== + + /// + public IReadOnlyList GetHostStatuses() => _hostStatuses.Snapshot(); /// public long GetMemoryFootprint() => 0; // PR 4.4 sets this from SubscriptionRegistry size. @@ -165,13 +333,25 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(builder); - // PR 4.3 — wrap the supplied builder in a capturing proxy that records each - // attribute's SecurityClassification into _securityByFullRef. The wrapper is - // transparent to GalaxyDiscoverer; it forwards every call to the real builder. + // PR 4.3 — capture SecurityClassification per attribute. PR 4.W — also refresh + // the per-platform probe watcher's membership after discovery so newly-added + // $WinPlatform / $AppEngine objects start advising their ScanState attribute. var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); var source = _hierarchySource ??= BuildDefaultHierarchySource(); var discoverer = new GalaxyDiscoverer(source); await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false); + + if (_probeWatcher is not null) + { + var hierarchy = await source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false); + var platforms = hierarchy + .Where(o => o.TemplateChain.Any(t => + string.Equals(t, "$WinPlatform", StringComparison.OrdinalIgnoreCase) + || string.Equals(t, "$AppEngine", StringComparison.OrdinalIgnoreCase))) + .Select(o => o.TagName) + .Where(name => !string.IsNullOrEmpty(name)); + await _probeWatcher.SyncPlatformsAsync(platforms, cancellationToken).ConfigureAwait(false); + } } private SecurityClassification ResolveSecurity(string fullReference) => @@ -324,12 +504,37 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, { if (_eventPump is not null) return _eventPump; _eventPump = new EventPump(_subscriber!, _subscriptions, _logger); - _eventPump.OnDataChange += (_, args) => OnDataChange?.Invoke(this, args); + _eventPump.OnDataChange += OnPumpDataChange; _eventPump.Start(); return _eventPump; } } + /// + /// Forwards every fan-out event to the public for + /// ISubscribable consumers, AND routes ScanState changes to the per-platform + /// probe watcher (PR 4.7) so platform health entries update without the watcher + /// consuming the event stream itself. + /// + private void OnPumpDataChange(object? sender, DataChangeEventArgs args) + { + OnDataChange?.Invoke(this, args); + + if (_probeWatcher is not null + && args.FullReference.EndsWith(PerPlatformProbeWatcher.ProbeSuffix, StringComparison.OrdinalIgnoreCase)) + { + // The probe decoder takes a raw quality byte; recover it from the StatusCode + // top byte (Good=0x00 → byte 192, Uncertain=0x40 → byte 64, Bad=0x80 → byte 0). + var qualityByte = (byte)((args.Snapshot.StatusCode >> 30) & 0x3) switch + { + 0 => 192, + 1 => 64, + _ => 0, + }; + _probeWatcher.OnProbeValueChanged(args.FullReference, args.Snapshot.Value, (byte)qualityByte); + } + } + /// /// Lazily builds the default from /// _options.Gateway. Owned is disposed in @@ -362,10 +567,24 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, if (_disposed) return; _disposed = true; + // Order: stop deploy watcher, supervisor, probe watcher, pump, then sessions and + // clients. Each step is best-effort — disposal during a faulted state shouldn't + // throw and prevent the rest of the cleanup. + try { _deployWatcher?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "DeployWatcher dispose failed"); } + try { _supervisor?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "ReconnectSupervisor dispose failed"); } + try { _probeWatcher?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "ProbeWatcher dispose failed"); } + try { _transportForwarder?.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Transport forwarder dispose failed"); } + EventPump? pump; lock (_pumpLock) { pump = _eventPump; _eventPump = null; } pump?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _ownedMxSession?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _ownedMxSession = null; + + _ownedMxClient?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _ownedMxClient = null; + _ownedRepositoryClient?.DisposeAsync().AsTask().GetAwaiter().GetResult(); _ownedRepositoryClient = null; _hierarchySource = null; diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 54d2f3f..ec8da46 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -110,7 +110,13 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(_ => { var registry = new DriverFactoryRegistry(); + // Both Galaxy backends register side-by-side under distinct DriverType names + // ("Galaxy" → legacy GalaxyProxyDriver, "GalaxyMxGateway" → in-process GalaxyDriver + // over the gRPC mxaccessgw). The DriverInstance row's DriverType selects between + // them at bootstrap time — see lmx_mxgw.md / PR 4.W. Phase 7 retires the legacy + // factory once parity tests pin both. GalaxyProxyDriverFactoryExtensions.Register(registry); + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry); FocasDriverFactoryExtensions.Register(registry); ModbusDriverFactoryExtensions.Register(registry); AbCipDriverFactoryExtensions.Register(registry); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index 5b3b87b..4555272 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs index e0ffc6d..b566bdc 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -1,7 +1,10 @@ +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; @@ -118,7 +121,12 @@ public sealed class GalaxyDriverFactoryTests [Fact] public async Task DriverLifecycle_InitializeShutdown_ToggleHealth() { - var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + // 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); @@ -135,7 +143,9 @@ public sealed class GalaxyDriverFactoryTests [Fact] public async Task ReinitializeAsync_RefreshesHealth() { - var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + 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; @@ -147,6 +157,34 @@ public sealed class GalaxyDriverFactoryTests 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() { @@ -163,4 +201,42 @@ public sealed class GalaxyDriverFactoryTests 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"); + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj index ef2f2c5..28859f9 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj @@ -13,7 +13,7 @@ - +