From 82cdf460c50cce09f35984ce867b7fb06730f045 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 16:22:04 -0400 Subject: [PATCH] =?UTF-8?q?PR=205.1=20=E2=80=94=20Driver.Galaxy.ParityTest?= =?UTF-8?q?s=20project=20shell=20+=20ParityHarness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-by-side fixture that boots both backends against the same dev Galaxy: - Legacy GalaxyProxyDriver against an out-of-process Galaxy.Host EXE (skipped when ZB SQL on localhost:1433 isn't reachable or when the EXE hasn't been built). - New in-process GalaxyDriver against an mxaccessgw gateway at http://localhost:5120 by default (skipped when the gateway isn't reachable). Endpoint, API key, and client name are env-var overridable for the central parity host. Per-backend availability is independent — each scenario decides whether to RequireBoth, GetDriver(specific), or use RunOnAvailableAsync to drive both with the same closure and diff snapshots. PR 5.2–5.8 land scenarios on top of this shell. Co-Authored-By: Claude Opus 4.7 (1M context) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../HarnessShapeTests.cs | 36 +++ .../ParityHarness.cs | 285 ++++++++++++++++++ .../RecordingAddressSpaceBuilder.cs | 59 ++++ ...W.OtOpcUa.Driver.Galaxy.ParityTests.csproj | 39 +++ 5 files changed, 420 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index d54070b..6231325 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -51,6 +51,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs new file mode 100644 index 0000000..c5d01d3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs @@ -0,0 +1,36 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// Shape tests for the itself — these run regardless of +/// dev-environment availability. The scenario tests in PR 5.2–5.8 carry the actual +/// parity assertions and are guarded by . +/// +[Collection(nameof(ParityCollection))] +public sealed class HarnessShapeTests +{ + private readonly ParityHarness _h; + public HarnessShapeTests(ParityHarness h) => _h = h; + + [Fact] + public void Harness_records_a_skip_reason_for_each_unavailable_backend() + { + // Either the backend resolved (driver != null, skipReason == null) or it didn't + // (driver == null, skipReason populated). Asserting the invariant lets the parity + // matrix doc (PR 5.W) faithfully report "n/a, reason: ..." for unreachable rigs. + (_h.LegacyDriver is null).ShouldBe(_h.LegacySkipReason is not null); + (_h.MxGatewayDriver is null).ShouldBe(_h.MxGatewaySkipReason is not null); + } + + [Fact] + public async Task RunOnAvailableAsync_yields_one_entry_per_resolved_backend() + { + var calls = await _h.RunOnAvailableAsync( + (_, _) => Task.FromResult(1), CancellationToken.None); + + var expected = (_h.LegacyDriver is null ? 0 : 1) + (_h.MxGatewayDriver is null ? 0 : 1); + calls.Count.ShouldBe(expected); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs new file mode 100644 index 0000000..bdd362c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs @@ -0,0 +1,285 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Reflection; +using System.Security.Principal; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// Side-by-side fixture that drives both the legacy +/// (talking to an out-of-process OtOpcUa.Driver.Galaxy.Host.exe) and the new +/// in-process (talking to a running mxaccessgw +/// gateway) against the same dev Galaxy. Phase 5 scenario tests use this harness +/// to capture comparable snapshots from each backend. +/// +/// +/// Per-backend availability is independent — a developer running just the legacy +/// Galaxy.Host EXE without an mxaccessgw process up will see the legacy driver +/// resolve and the mxgw driver mark itself unavailable. Each test decides how to +/// handle partial availability: +/// +/// Strict-parity tests call to skip when either side +/// is missing. +/// Single-backend smoke tests call for the backend they +/// care about and skip with the recorded SkipReason. +/// +/// Endpoint overrides come from environment variables so dev VMs and the central +/// parity host can target the same suite without touching the test source: +/// +/// OTOPCUA_PARITY_GW_ENDPOINT — defaults to http://localhost:5120 +/// (mxaccessgw launchSettings.json http profile). +/// OTOPCUA_PARITY_GW_API_KEY — defaults to parity-suite-key. +/// OTOPCUA_PARITY_CLIENT_NAME — defaults to OtOpcUa-Parity. +/// +/// +public sealed class ParityHarness : IAsyncLifetime +{ + public enum Backend { LegacyHost, MxGateway } + + private const string LegacySecret = "parity-suite-secret"; + private const string DefaultGwEndpoint = "http://localhost:5120"; + private const string DefaultGwApiKey = "parity-suite-key"; + private const string DefaultClientName = "OtOpcUa-Parity"; + + public IDriver? LegacyDriver { get; private set; } + public string? LegacySkipReason { get; private set; } + + public IDriver? MxGatewayDriver { get; private set; } + public string? MxGatewaySkipReason { get; private set; } + + private Process? _legacyHost; + + public async ValueTask InitializeAsync() + { + if (!OperatingSystem.IsWindows()) + { + LegacySkipReason = "Windows-only"; + MxGatewaySkipReason = "Windows-only"; + return; + } + + await InitializeLegacyAsync(); + await InitializeMxGatewayAsync(); + } + + public async ValueTask DisposeAsync() + { + // Independent teardown — failure on one side must not prevent the other from + // releasing its resources (esp. the legacy Host EXE subprocess). + if (LegacyDriver is not null) + { + try { await LegacyDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ } + (LegacyDriver as IDisposable)?.Dispose(); + LegacyDriver = null; + } + if (_legacyHost is not null && !_legacyHost.HasExited) + { + try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ } + try { _legacyHost.WaitForExit(5_000); } catch { /* ignore */ } + } + _legacyHost?.Dispose(); + _legacyHost = null; + + if (MxGatewayDriver is not null) + { + try { await MxGatewayDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ } + (MxGatewayDriver as IDisposable)?.Dispose(); + MxGatewayDriver = null; + } + } + + /// Skip the test if either backend isn't available — strict-parity scenarios. + public void RequireBoth() + { + if (LegacySkipReason is not null) Assert.Skip($"legacy backend unavailable: {LegacySkipReason}"); + if (MxGatewaySkipReason is not null) Assert.Skip($"mxgateway backend unavailable: {MxGatewaySkipReason}"); + } + + /// Get a backend driver or skip if it's unavailable. + public IDriver GetDriver(Backend backend) + { + return backend switch + { + Backend.LegacyHost when LegacyDriver is not null => LegacyDriver, + Backend.LegacyHost => SkipAndThrow($"legacy backend unavailable: {LegacySkipReason}"), + Backend.MxGateway when MxGatewayDriver is not null => MxGatewayDriver, + Backend.MxGateway => SkipAndThrow($"mxgateway backend unavailable: {MxGatewaySkipReason}"), + _ => throw new ArgumentOutOfRangeException(nameof(backend), backend, null), + }; + } + + /// + /// Drive the same closure against every available backend. Tests use the + /// returned dictionary to diff snapshots — keys are the backends that + /// successfully resolved during . If neither + /// resolved, the result is empty and the test should skip. + /// + public async Task> RunOnAvailableAsync( + Func> scenario, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(scenario); + var results = new Dictionary(); + if (LegacyDriver is not null) + { + results[Backend.LegacyHost] = await scenario(LegacyDriver, cancellationToken).ConfigureAwait(false); + } + if (MxGatewayDriver is not null) + { + results[Backend.MxGateway] = await scenario(MxGatewayDriver, cancellationToken).ConfigureAwait(false); + } + return results; + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private async Task InitializeLegacyAsync() + { + if (!await ZbReachableAsync()) + { + LegacySkipReason = "Galaxy ZB SQL not reachable on localhost:1433"; + return; + } + var hostExe = FindLegacyHostExe(); + if (hostExe is null) + { + LegacySkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`"; + return; + } + + var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}"; + using var identity = WindowsIdentity.GetCurrent(); + var sid = identity.User!.Value; + + var psi = new ProcessStartInfo(hostExe) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + EnvironmentVariables = + { + ["OTOPCUA_GALAXY_PIPE"] = pipe, + ["OTOPCUA_ALLOWED_SID"] = sid, + ["OTOPCUA_GALAXY_SECRET"] = LegacySecret, + ["OTOPCUA_GALAXY_BACKEND"] = "db", + ["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", + }, + }; + + try + { + _legacyHost = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE"); + await Task.Delay(2_000); // PipeServer warm-up — ParityFixture's settled value + + var driver = new GalaxyProxyDriver(new GalaxyProxyOptions + { + DriverInstanceId = "parity-legacy", + PipeName = pipe, + SharedSecret = LegacySecret, + ConnectTimeout = TimeSpan.FromSeconds(5), + }); + await driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None); + LegacyDriver = driver; + } + catch (Exception ex) + { + LegacySkipReason = $"legacy backend boot failed: {ex.Message}"; + if (_legacyHost is not null && !_legacyHost.HasExited) + { + try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ } + } + } + } + + private async Task InitializeMxGatewayAsync() + { + var endpoint = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_ENDPOINT") ?? DefaultGwEndpoint; + var apiKey = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_API_KEY") ?? DefaultGwApiKey; + var clientName = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_CLIENT_NAME") ?? DefaultClientName; + + if (!await GwReachableAsync(endpoint)) + { + MxGatewaySkipReason = $"mxaccessgw not reachable at {endpoint}"; + return; + } + + var configJson = $$""" + { + "Gateway": { + "Endpoint": "{{endpoint}}", + "ApiKeySecretRef": "{{apiKey}}", + "UseTls": {{(endpoint.StartsWith("https") ? "true" : "false")}} + }, + "MxAccess": { "ClientName": "{{clientName}}" } + } + """; + + try + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("parity-mxgw", configJson); + await driver.InitializeAsync(configJson, CancellationToken.None); + MxGatewayDriver = driver; + } + catch (Exception ex) + { + MxGatewaySkipReason = $"mxgateway backend boot failed: {ex.GetType().Name}: {ex.Message}"; + } + } + + private static IDriver SkipAndThrow(string reason) + { + Assert.Skip(reason); + throw new UnreachableException(); // Assert.Skip throws SkipException; this satisfies the compiler + } + + private static async Task ZbReachableAsync() + { + try + { + using var client = new TcpClient(); + var task = client.ConnectAsync("localhost", 1433); + return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; + } + catch { return false; } + } + + private static async Task GwReachableAsync(string endpoint) + { + // Lightweight TCP probe — avoids spending the full gRPC connect timeout when the + // gateway just isn't running. We can't validate the API-key handshake here without + // doing a real RPC, so a successful TCP connect is the "available" signal and any + // auth/protocol failure surfaces during InitializeAsync below. + try + { + var uri = new Uri(endpoint, UriKind.Absolute); + using var client = new TcpClient(); + var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80); + var task = client.ConnectAsync(uri.Host, port); + return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected; + } + catch { return false; } + } + + private static string? FindLegacyHostExe() + { + var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var solutionRoot = asmDir; + for (var i = 0; i < 8 && solutionRoot is not null; i++) + { + if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break; + solutionRoot = Path.GetDirectoryName(solutionRoot); + } + if (solutionRoot is null) return null; + + var path = Path.Combine(solutionRoot, + "src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48", + "OtOpcUa.Driver.Galaxy.Host.exe"); + return File.Exists(path) ? path : null; + } +} + +[CollectionDefinition(nameof(ParityCollection))] +public sealed class ParityCollection : ICollectionFixture { } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs new file mode 100644 index 0000000..f7309d9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs @@ -0,0 +1,59 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// Same shape as Driver.Galaxy.E2E.RecordingAddressSpaceBuilder; duplicated +/// here so the parity-tests project doesn't take a hard project reference on the +/// E2E project (which would double-register E2E test classes during discovery). +/// +public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder +{ + public List Folders { get; } = new(); + public List Variables { get; } = new(); + public List Properties { get; } = new(); + public List AlarmConditions { get; } = new(); + public List AlarmTransitions { get; } = new(); + + public IAddressSpaceBuilder Folder(string browseName, string displayName) + { + Folders.Add(new RecordedFolder(browseName, displayName)); + return this; + } + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + { + Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo)); + return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions, AlarmTransitions); + } + + public void AddProperty(string browseName, DriverDataType dataType, object? value) + => Properties.Add(new RecordedProperty(browseName, dataType, value)); + + public sealed record RecordedFolder(string BrowseName, string DisplayName); + public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo); + public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value); + public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info); + public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args); + + private sealed class RecordedVariableHandle( + string fullReference, + List conditions, + List transitions) : IVariableHandle + { + public string FullReference => fullReference; + + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) + { + conditions.Add(new RecordedAlarmCondition(fullReference, info)); + return new RecordingSink(fullReference, transitions); + } + + private sealed class RecordingSink( + string sourceNodeId, List transitions) : IAlarmConditionSink + { + public void OnTransition(AlarmEventArgs args) + => transitions.Add(new RecordedAlarmTransition(sourceNodeId, args)); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj new file mode 100644 index 0000000..2ccc171 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + +