PR 5.8 — Per-platform ScanState probe parity scenarios

Closes Phase 5 scenario coverage. Both
GalaxyRuntimeProbeManager (legacy) and PerPlatformProbeWatcher (PR 4.7)
must surface the same per-host status stream:

- GetHostStatuses_emits_same_host_set_after_Discover — drives Discover
  on both backends, waits 1.5s for the probe watcher's first push, then
  asserts the platform-host set agrees (transport-entry names differ
  by design — legacy uses the Galaxy.Host process identity, mxgw uses
  MxAccess.ClientName, so we strip those before comparing).
- GetHostStatuses_state_per_platform_matches_across_backends — for
  every overlapping platform host, the HostState must be identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-29 16:31:09 -04:00
parent 80a0ca2651
commit 837172ab39

View File

@@ -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}'");
}
}
}