Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 837172ab39 | |||
| 80a0ca2651 | |||
| 8d042c631b | |||
| bbdbdf8afb | |||
| 982771df9a | |||
| 9db6da9c20 | |||
| 71443ecbf3 |
@@ -0,0 +1,84 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.5 — Alarm-condition + transition parity. Both backends discover the
|
||||||
|
/// same set of alarm-bearing attributes with matching <see cref="AlarmConditionInfo"/>
|
||||||
|
/// metadata; transition events from a live alarm flap must arrive with matching
|
||||||
|
/// severity, message, and source-node-id on each side.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Alarm-event persistence parity (the SQLite store-and-forward → Wonderware
|
||||||
|
/// historian event store path called out in the impl plan) is exercised
|
||||||
|
/// end-to-end in PR 5.6 against the historian sidecar; here we focus on the
|
||||||
|
/// in-process transition stream that <see cref="IAlarmConditionSink"/> emits.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class AlarmTransitionParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public AlarmTransitionParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_emits_same_AlarmConditionInfo_per_alarm_attribute()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
return b.AlarmConditions.ToDictionary(
|
||||||
|
ac => ac.SourceNodeId,
|
||||||
|
ac => ac.Info,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
if (legacy.Count == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("dev Galaxy has no alarm-marked attributes — alarm parity unverified for this rig");
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ShouldBe(mxgw.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase),
|
||||||
|
"alarm source-node-id set must match across backends");
|
||||||
|
|
||||||
|
foreach (var kvp in legacy)
|
||||||
|
{
|
||||||
|
mxgw[kvp.Key].InitialSeverity.ShouldBe(kvp.Value.InitialSeverity,
|
||||||
|
$"alarm severity parity for '{kvp.Key}'");
|
||||||
|
mxgw[kvp.Key].SourceName.ShouldBe(kvp.Value.SourceName,
|
||||||
|
$"alarm SourceName parity for '{kvp.Key}'");
|
||||||
|
mxgw[kvp.Key].InAlarmRef.ShouldBe(kvp.Value.InAlarmRef,
|
||||||
|
$"alarm InAlarmRef parity for '{kvp.Key}'");
|
||||||
|
mxgw[kvp.Key].DescAttrNameRef.ShouldBe(kvp.Value.DescAttrNameRef,
|
||||||
|
$"alarm DescAttrNameRef parity for '{kvp.Key}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_marks_at_least_one_alarm_attribute_when_dev_Galaxy_has_alarms()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
return b.Variables.Count(v => v.AttributeInfo.IsAlarm);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
// Soft pin — count must match across backends. Whether the count is non-zero
|
||||||
|
// depends on the rig's Galaxy content, so we don't gate on a positive number.
|
||||||
|
snapshots[ParityHarness.Backend.LegacyHost]
|
||||||
|
.ShouldBe(snapshots[ParityHarness.Backend.MxGateway],
|
||||||
|
"IsAlarm-marked variable count parity");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.2 — Browse + read parity. Discovers the address space through both
|
||||||
|
/// backends and asserts the surface they expose matches: same folder set,
|
||||||
|
/// same variable set, same DataType / SecurityClass / IsHistorized flags.
|
||||||
|
/// Then reads a sample of resolved variables and diffs the snapshot triplets.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class BrowseAndReadParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public BrowseAndReadParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_emits_same_variable_set_for_both_backends()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
return b;
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
var legacyRefs = legacy.Variables.Select(v => v.AttributeInfo.FullName)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var mxgwRefs = mxgw.Variables.Select(v => v.AttributeInfo.FullName)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Symmetric difference must be empty — the in-process driver and the legacy
|
||||||
|
// proxy walk the same Galaxy ZB hierarchy, so their full-reference sets
|
||||||
|
// must agree exactly.
|
||||||
|
legacyRefs.Except(mxgwRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty();
|
||||||
|
mxgwRefs.Except(legacyRefs, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_emits_same_DataType_and_SecurityClass_per_attribute()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
return b.Variables.ToDictionary(
|
||||||
|
v => v.AttributeInfo.FullName,
|
||||||
|
v => (v.AttributeInfo.DriverDataType, v.AttributeInfo.SecurityClass, v.AttributeInfo.IsHistorized),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
foreach (var kvp in legacy)
|
||||||
|
{
|
||||||
|
var fullRef = kvp.Key;
|
||||||
|
mxgw.ShouldContainKey(fullRef);
|
||||||
|
mxgw[fullRef].ShouldBe(kvp.Value,
|
||||||
|
$"DataType/SecurityClass/IsHistorized must match for '{fullRef}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_returns_same_value_and_status_for_a_sampled_attribute()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
// Discover via the legacy backend, pick a sample, then read the same address
|
||||||
|
// through both backends. We sample a small handful so the test stays fast and
|
||||||
|
// doesn't hammer ZB / the gateway.
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||||
|
|
||||||
|
var sample = b.Variables.Take(5).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||||
|
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||||
|
|
||||||
|
var reads = await _h.RunOnAvailableAsync(
|
||||||
|
(driver, ct) => ((IReadable)driver).ReadAsync(sample, ct),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var legacyReads = reads[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgwReads = reads[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
legacyReads.Count.ShouldBe(sample.Length);
|
||||||
|
mxgwReads.Count.ShouldBe(sample.Length);
|
||||||
|
|
||||||
|
for (var i = 0; i < sample.Length; i++)
|
||||||
|
{
|
||||||
|
// Status codes must agree on a per-tag basis. Values may legitimately differ
|
||||||
|
// when the dev Galaxy is live (a setpoint can change between the two reads),
|
||||||
|
// so we accept structural equality on type rather than value equality.
|
||||||
|
(mxgwReads[i].StatusCode == legacyReads[i].StatusCode).ShouldBeTrue(
|
||||||
|
$"StatusCode parity for '{sample[i]}': legacy=0x{legacyReads[i].StatusCode:X8}, mxgw=0x{mxgwReads[i].StatusCode:X8}");
|
||||||
|
(mxgwReads[i].Value?.GetType() ?? typeof(object))
|
||||||
|
.ShouldBe(legacyReads[i].Value?.GetType() ?? typeof(object),
|
||||||
|
$"value CLR type parity for '{sample[i]}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.6 — History-read parity. Phase-1 routing lifted history off the
|
||||||
|
/// per-driver <see cref="IHistoryProvider"/> path onto the server-owned
|
||||||
|
/// <c>HistoryRouter</c> + <c>WonderwareHistorianBootstrap</c>; neither
|
||||||
|
/// Galaxy backend implements <see cref="IHistoryProvider"/> directly. So
|
||||||
|
/// the parity surface here is the *routing decision*: both backends must
|
||||||
|
/// identify the same set of historized attributes and produce the same
|
||||||
|
/// full-reference for each, so HistoryRouter routes reads identically.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class HistoryReadParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public HistoryReadParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Discover_emits_same_historized_attribute_set_for_both_backends()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
return b.Variables
|
||||||
|
.Where(v => v.AttributeInfo.IsHistorized)
|
||||||
|
.Select(v => v.AttributeInfo.FullName)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
if (legacy.Count == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("dev Galaxy has no historized attributes — history routing parity unverified for this rig");
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy.Except(mxgw, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||||
|
"every historized attribute discovered by the legacy backend must appear in the mxgw backend");
|
||||||
|
mxgw.Except(legacy, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||||
|
"every historized attribute discovered by the mxgw backend must appear in the legacy backend");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Neither_Galaxy_backend_implements_IHistoryProvider_directly()
|
||||||
|
{
|
||||||
|
// Pinning the architectural decision from Phase 1 (PR 1.3): per-driver
|
||||||
|
// IHistoryProvider was retired in favor of the server-owned HistoryRouter.
|
||||||
|
// If a regression brings IHistoryProvider back on either Galaxy driver,
|
||||||
|
// this test fires.
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
(_h.LegacyDriver as IHistoryProvider).ShouldBeNull(
|
||||||
|
"legacy GalaxyProxyDriver must not surface IHistoryProvider — history routes through HistoryRouter");
|
||||||
|
(_h.MxGatewayDriver as IHistoryProvider).ShouldBeNull(
|
||||||
|
"in-process GalaxyDriver must not surface IHistoryProvider — history routes through HistoryRouter");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.7 — Reconnect / disruption parity. After <see cref="IDriver.ReinitializeAsync"/>
|
||||||
|
/// both backends must return to <see cref="DriverState.Healthy"/> and continue serving
|
||||||
|
/// reads against the same Galaxy. Recovery time isn't pinned tightly because the
|
||||||
|
/// legacy proxy reconnects the named pipe + Galaxy.Host's MxAccess client while the
|
||||||
|
/// mxgw driver re-Registers the gateway session — different latencies are expected,
|
||||||
|
/// but both must converge.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class ReconnectParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public ReconnectParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_returns_both_backends_to_Healthy()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
// Capture an initial read off both backends so we have a comparison baseline.
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||||
|
var sample = b.Variables.Take(3).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||||
|
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||||
|
|
||||||
|
await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
await driver.ReinitializeAsync(driverConfigJson: "{}", ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
health.State.ShouldBe(DriverState.Healthy,
|
||||||
|
$"{driver.DriverType} must return to Healthy after Reinitialize");
|
||||||
|
return health.State;
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
// Reads must continue to succeed after reinit on both sides.
|
||||||
|
var reads = await _h.RunOnAvailableAsync(
|
||||||
|
(driver, ct) => ((IReadable)driver).ReadAsync(sample, ct),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
reads[ParityHarness.Backend.LegacyHost].Count.ShouldBe(sample.Length);
|
||||||
|
reads[ParityHarness.Backend.MxGateway].Count.ShouldBe(sample.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Health_state_diverges_only_when_one_backend_is_in_recovery()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var legacyHealth = _h.LegacyDriver!.GetHealth().State;
|
||||||
|
var mxgwHealth = _h.MxGatewayDriver!.GetHealth().State;
|
||||||
|
|
||||||
|
// Both backends were Healthy at end of InitializeAsync. If either has gone
|
||||||
|
// Degraded, that's a real issue — surface it directly.
|
||||||
|
legacyHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded);
|
||||||
|
mxgwHealth.ShouldBeOneOf(DriverState.Healthy, DriverState.Degraded);
|
||||||
|
|
||||||
|
// For now we don't pin them to be identical because the supervisor's
|
||||||
|
// sampling cadence differs between backends. The 5.7 follow-up scenario
|
||||||
|
// (when we introduce a toxiproxy-style fault injection) tightens this.
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.8 — Per-platform <c>ScanState</c> probe parity. The legacy backend's
|
||||||
|
/// <c>GalaxyRuntimeProbeManager</c> and the in-process backend's
|
||||||
|
/// <c>PerPlatformProbeWatcher</c> (PR 4.7) must surface the same per-host
|
||||||
|
/// <see cref="HostConnectivityStatus"/> stream after Discover: same host name
|
||||||
|
/// set, matching <see cref="HostState"/> per host.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class ScanStateProbeParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public ScanStateProbeParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHostStatuses_emits_same_host_set_after_Discover()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
// Probe-watcher membership only refreshes after a Discover pass — drive that
|
||||||
|
// first so both backends have populated their per-platform tracker.
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
// Give the probe watcher a beat to land its initial ScanState reads —
|
||||||
|
// PR 4.7 subscribes per platform with bufferedUpdateIntervalMs=0 so the
|
||||||
|
// first push lands within ~publishingInterval (1s default).
|
||||||
|
await Task.Delay(1_500, ct);
|
||||||
|
return ((IHostConnectivityProbe)driver).GetHostStatuses();
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
// Legacy reports: client-name transport entry + every $WinPlatform/$AppEngine
|
||||||
|
// probe. Mxgw reports the same shape (PR 4.7). The host-name set must agree
|
||||||
|
// case-insensitively.
|
||||||
|
var legacyHosts = legacy.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var mxgwHosts = mxgw.Select(s => s.HostName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (legacyHosts.Count == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("legacy backend reported no host probes — dev Galaxy may not be a multi-platform deployment");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transport-entry host names differ by design — legacy uses the legacy
|
||||||
|
// host's process-level identity, mxgw uses MxAccess.ClientName. Compare
|
||||||
|
// only the platform-host subset (anything that's NOT either side's transport).
|
||||||
|
var legacyPlatformHosts = legacyHosts.Where(h => !h.Contains("Galaxy.Host", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var mxgwPlatformHosts = mxgwHosts.Where(h => !h.Contains("OtOpcUa-Parity", StringComparison.OrdinalIgnoreCase)).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
legacyPlatformHosts.Except(mxgwPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||||
|
"every $WinPlatform / $AppEngine probed by the legacy backend must appear in the mxgw probe set");
|
||||||
|
mxgwPlatformHosts.Except(legacyPlatformHosts, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty(
|
||||||
|
"every $WinPlatform / $AppEngine probed by the mxgw backend must appear in the legacy probe set");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetHostStatuses_state_per_platform_matches_across_backends()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
||||||
|
await Task.Delay(1_500, ct);
|
||||||
|
return ((IHostConnectivityProbe)driver).GetHostStatuses()
|
||||||
|
.ToDictionary(s => s.HostName, s => s.State, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var legacy = snapshots[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgw = snapshots[ParityHarness.Backend.MxGateway];
|
||||||
|
|
||||||
|
if (legacy.Count == 0 || mxgw.Count == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("one or both backends reported no host probes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the transport entry per backend (different by design); compare the
|
||||||
|
// platform-host overlap.
|
||||||
|
var commonHosts = legacy.Keys.Intersect(mxgw.Keys, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
|
if (commonHosts.Length == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("no overlapping platform hosts between backends — likely the transport names differ but no $WinPlatform was discovered");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var host in commonHosts)
|
||||||
|
{
|
||||||
|
mxgw[host].ShouldBe(legacy[host], $"HostState parity for '{host}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.3 — Subscribe + event-rate parity. Both backends must accept the same
|
||||||
|
/// full-reference list, return a usable subscription handle, and dispatch a
|
||||||
|
/// similar number of OnDataChange events for the same observation window.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class SubscribeAndEventRateParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public SubscribeAndEventRateParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_returns_a_handle_for_each_backend()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var sample = await PickSampleAsync(5);
|
||||||
|
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||||
|
|
||||||
|
var handles = await _h.RunOnAvailableAsync(
|
||||||
|
(driver, ct) => ((ISubscribable)driver).SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), ct),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
handles[ParityHarness.Backend.LegacyHost].ShouldNotBeNull();
|
||||||
|
handles[ParityHarness.Backend.MxGateway].ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Clean up so we don't leave dangling advises in either backend.
|
||||||
|
foreach (var (backend, handle) in handles)
|
||||||
|
{
|
||||||
|
await ((ISubscribable)_h.GetDriver(backend))
|
||||||
|
.UnsubscribeAsync(handle, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_event_rate_within_tolerance_for_a_3s_window()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var sample = await PickSampleAsync(5);
|
||||||
|
if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables");
|
||||||
|
|
||||||
|
var counts = new Dictionary<ParityHarness.Backend, int>();
|
||||||
|
var subs = new Dictionary<ParityHarness.Backend, ISubscriptionHandle>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var backend in new[] { ParityHarness.Backend.LegacyHost, ParityHarness.Backend.MxGateway })
|
||||||
|
{
|
||||||
|
var driver = _h.GetDriver(backend);
|
||||||
|
var local = 0;
|
||||||
|
EventHandler<DataChangeEventArgs> handler = (_, _) => Interlocked.Increment(ref local);
|
||||||
|
((ISubscribable)driver).OnDataChange += handler;
|
||||||
|
var handle = await ((ISubscribable)driver)
|
||||||
|
.SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), CancellationToken.None);
|
||||||
|
subs[backend] = handle;
|
||||||
|
|
||||||
|
await Task.Delay(3_000, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
((ISubscribable)driver).OnDataChange -= handler;
|
||||||
|
counts[backend] = Volatile.Read(ref local);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tolerance is generous because both backends are looking at the same
|
||||||
|
// physical Galaxy; the gateway's StreamEvents pump and the legacy
|
||||||
|
// OnDataChange COM advises are fed by the same MXAccess subscriptions
|
||||||
|
// upstream. ±50% absorbs scheduler jitter without hiding a wholesale
|
||||||
|
// event-rate regression.
|
||||||
|
var legacyCount = counts[ParityHarness.Backend.LegacyHost];
|
||||||
|
var mxgwCount = counts[ParityHarness.Backend.MxGateway];
|
||||||
|
if (legacyCount + mxgwCount == 0)
|
||||||
|
{
|
||||||
|
Assert.Skip("no value changes observed in 3s window — sample may be all static configuration tags");
|
||||||
|
}
|
||||||
|
var ratio = (double)mxgwCount / Math.Max(legacyCount, 1);
|
||||||
|
ratio.ShouldBeInRange(0.5, 1.5,
|
||||||
|
$"event-rate parity within ±50%: legacy={legacyCount}, mxgw={mxgwCount}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var (backend, handle) in subs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ((ISubscribable)_h.GetDriver(backend))
|
||||||
|
.UnsubscribeAsync(handle, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string[]> PickSampleAsync(int count)
|
||||||
|
{
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||||
|
return b.Variables.Take(count).Select(v => v.AttributeInfo.FullName).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.4 — Write-by-classification parity. Each driver routes writes by the
|
||||||
|
/// attribute's <see cref="SecurityClassification"/>: <c>FreeAccess</c> /
|
||||||
|
/// <c>Operate</c> use plain <c>Write</c>; <c>Tune</c> / <c>Configure</c> /
|
||||||
|
/// <c>VerifiedWrite</c> use <c>WriteSecured</c>. Both backends must surface the
|
||||||
|
/// same StatusCode for the same write request — successful for FreeAccess /
|
||||||
|
/// Operate (assuming the dev Galaxy has at least one writable attribute) and
|
||||||
|
/// failure for Configure when no auth principal is supplied.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "ParityE2E")]
|
||||||
|
[Collection(nameof(ParityCollection))]
|
||||||
|
public sealed class WriteByClassificationParityTests
|
||||||
|
{
|
||||||
|
private readonly ParityHarness _h;
|
||||||
|
public WriteByClassificationParityTests(ParityHarness h) => _h = h;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||||
|
|
||||||
|
var target = b.Variables.FirstOrDefault(v =>
|
||||||
|
v.AttributeInfo.SecurityClass is SecurityClassification.FreeAccess or SecurityClassification.Operate
|
||||||
|
&& v.AttributeInfo.DriverDataType is DriverDataType.Float32 or DriverDataType.Float64 or DriverDataType.Int32);
|
||||||
|
if (target is null) Assert.Skip("no FreeAccess/Operate numeric writable attribute on dev Galaxy");
|
||||||
|
|
||||||
|
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
||||||
|
var results = await _h.RunOnAvailableAsync(
|
||||||
|
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
results[ParityHarness.Backend.LegacyHost][0].StatusCode
|
||||||
|
.ShouldBe(results[ParityHarness.Backend.MxGateway][0].StatusCode,
|
||||||
|
$"FreeAccess/Operate StatusCode parity for '{target.AttributeInfo.FullName}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Configure_class_write_routes_through_secured_path_on_both_backends()
|
||||||
|
{
|
||||||
|
_h.RequireBoth();
|
||||||
|
|
||||||
|
var b = new RecordingAddressSpaceBuilder();
|
||||||
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
||||||
|
|
||||||
|
var target = b.Variables.FirstOrDefault(v =>
|
||||||
|
v.AttributeInfo.SecurityClass is SecurityClassification.Configure or SecurityClassification.Tune);
|
||||||
|
if (target is null) Assert.Skip("no Configure/Tune attribute on dev Galaxy");
|
||||||
|
|
||||||
|
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
||||||
|
var results = await _h.RunOnAvailableAsync(
|
||||||
|
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Both backends route through the secured-write path. The exact StatusCode
|
||||||
|
// depends on whether the running test identity has write permission on the
|
||||||
|
// dev Galaxy — what matters here is that they agree, not which value they
|
||||||
|
// produce. (Parity, not policy.)
|
||||||
|
results[ParityHarness.Backend.LegacyHost][0].StatusCode
|
||||||
|
.ShouldBe(results[ParityHarness.Backend.MxGateway][0].StatusCode,
|
||||||
|
$"Secured-write StatusCode parity for '{target.AttributeInfo.FullName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user