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