PR 5.2 — Browse + read parity scenarios

Three scenarios using ParityHarness.RequireBoth:

- Discover_emits_same_variable_set_for_both_backends — symmetric set diff
  on the full-reference set must be empty.
- Discover_emits_same_DataType_and_SecurityClass_per_attribute — meta
  triple (DriverDataType, SecurityClass, IsHistorized) must match per
  attribute.
- Read_returns_same_value_and_status_for_a_sampled_attribute — samples
  the first 5 discovered variables, reads through both backends, asserts
  StatusCode equality and value-CLR-type equality (raw values may drift
  between the two reads on a live Galaxy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-29 16:24:36 -04:00
parent 82cdf460c5
commit 71443ecbf3

View File

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