PR 4.W — Galaxy:Backend wiring + server-side factory registration
- 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>
This commit is contained in:
@@ -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;
|
||||
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||
/// </remarks>
|
||||
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<string, SecurityClassification>
|
||||
_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,
|
||||
/// </summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>Fires when the gateway signals a deploy-time change (PR 4.6 DeployWatcher).</summary>
|
||||
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||
|
||||
/// <summary>Fires when a host transitions Running ↔ Stopped (PR 4.7 HostStatusAggregator).</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -115,18 +146,141 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
internal GalaxyDriverOptions Options => _options;
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the production gw client + session + per-capability runtime components
|
||||
/// from <c>_options</c>. Sets up the reconnect supervisor's reopen / replay
|
||||
/// callbacks so a transport drop replays every active subscription on the
|
||||
/// restored session.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reopen callback for <see cref="ReconnectSupervisor"/>: re-Register the gw session.
|
||||
/// If the session never connected, this is a fresh ConnectAsync; otherwise it's a
|
||||
/// reconnect against the existing client.
|
||||
/// </summary>
|
||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ownedMxSession is null) return;
|
||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>ReplaySubscriptionsCommand</c> once it ships.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -149,7 +303,21 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable,
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => _hostStatuses.Snapshot();
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forwards every fan-out event to the public <see cref="OnDataChange"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
||||
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> 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;
|
||||
|
||||
@@ -110,7 +110,13 @@ builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
|
||||
@@ -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());
|
||||
|
||||
/// <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()
|
||||
{
|
||||
@@ -163,4 +201,42 @@ public sealed class GalaxyDriverFactoryTests
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
|
||||
Reference in New Issue
Block a user