From 71443ecbf3096017dd5f0e0ecc1bb3dda9cbe9ed Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 16:24:36 -0400 Subject: [PATCH] =?UTF-8?q?PR=205.2=20=E2=80=94=20Browse=20+=20read=20pari?= =?UTF-8?q?ty=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../BrowseAndReadParityTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs new file mode 100644 index 0000000..bbf9929 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/BrowseAndReadParityTests.cs @@ -0,0 +1,110 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// 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. +/// +[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]}'"); + } + } +}