PR 5.1 — Driver.Galaxy.ParityTests project shell + ParityHarness
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) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests.csproj"/>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shape tests for the <see cref="ParityHarness"/> 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 <see cref="ParityHarness.RequireBoth"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Side-by-side fixture that drives both the legacy <see cref="GalaxyProxyDriver"/>
|
||||||
|
/// (talking to an out-of-process <c>OtOpcUa.Driver.Galaxy.Host.exe</c>) and the new
|
||||||
|
/// in-process <see cref="GalaxyDriver"/> (talking to a running <c>mxaccessgw</c>
|
||||||
|
/// gateway) against the same dev Galaxy. Phase 5 scenario tests use this harness
|
||||||
|
/// to capture comparable snapshots from each backend.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Strict-parity tests call <see cref="RequireBoth"/> to skip when either side
|
||||||
|
/// is missing.</item>
|
||||||
|
/// <item>Single-backend smoke tests call <see cref="GetDriver"/> for the backend they
|
||||||
|
/// care about and skip with the recorded <c>SkipReason</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Endpoint overrides come from environment variables so dev VMs and the central
|
||||||
|
/// parity host can target the same suite without touching the test source:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>OTOPCUA_PARITY_GW_ENDPOINT</c> — defaults to <c>http://localhost:5120</c>
|
||||||
|
/// (mxaccessgw <c>launchSettings.json</c> http profile).</item>
|
||||||
|
/// <item><c>OTOPCUA_PARITY_GW_API_KEY</c> — defaults to <c>parity-suite-key</c>.</item>
|
||||||
|
/// <item><c>OTOPCUA_PARITY_CLIENT_NAME</c> — defaults to <c>OtOpcUa-Parity</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Skip the test if either backend isn't available — strict-parity scenarios.</summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a backend driver or skip if it's unavailable.</summary>
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drive the same closure against every available backend. Tests use the
|
||||||
|
/// returned dictionary to diff snapshots — keys are the backends that
|
||||||
|
/// successfully resolved during <see cref="InitializeAsync"/>. If neither
|
||||||
|
/// resolved, the result is empty and the test should skip.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyDictionary<Backend, T>> RunOnAvailableAsync<T>(
|
||||||
|
Func<IDriver, CancellationToken, Task<T>> scenario, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(scenario);
|
||||||
|
var results = new Dictionary<Backend, T>();
|
||||||
|
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<bool> 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<bool> 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<ParityHarness> { }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same shape as <c>Driver.Galaxy.E2E.RecordingAddressSpaceBuilder</c>; 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<RecordedFolder> Folders { get; } = new();
|
||||||
|
public List<RecordedVariable> Variables { get; } = new();
|
||||||
|
public List<RecordedProperty> Properties { get; } = new();
|
||||||
|
public List<RecordedAlarmCondition> AlarmConditions { get; } = new();
|
||||||
|
public List<RecordedAlarmTransition> 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<RecordedAlarmCondition> conditions,
|
||||||
|
List<RecordedAlarmTransition> 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<RecordedAlarmTransition> transitions) : IAlarmConditionSink
|
||||||
|
{
|
||||||
|
public void OnTransition(AlarmEventArgs args)
|
||||||
|
=> transitions.Add(new RecordedAlarmTransition(sourceNodeId, args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<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.7"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Both backends are referenced so a single test class can exercise the same
|
||||||
|
scenario against both and diff the results. The legacy GalaxyProxyDriver
|
||||||
|
spawns an out-of-process Galaxy.Host EXE; the new GalaxyDriver speaks to
|
||||||
|
the mxaccessgw gRPC gateway. See ParityHarness for the discovery + skip rules.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user