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++) { // StatusCode must agree on the same status *class* (Good / Uncertain / Bad). // Per Galaxy.ParityMatrix.md "Accepted deltas", legacy and mxgw map // MxAccess HRESULTs to different exact OPC UA codes — pinning the class // is the parity invariant. (legacyReads[i].StatusCode & 0xC0000000u) .ShouldBe(mxgwReads[i].StatusCode & 0xC0000000u, $"StatusCode class parity for '{sample[i]}': legacy=0x{legacyReads[i].StatusCode:X8}, mxgw=0x{mxgwReads[i].StatusCode:X8}"); // Value-CLR-type parity is intentionally NOT asserted. Legacy returns the // raw VARIANT (e.g. byte[]) for an attribute that hasn't received its first // value cycle from MxAccess yet, while mxgw returns the typed value // (Float, Int32, etc.) — and both null-vs-typed combinations occur on a // live galaxy. The status-class assertion above pins the parity invariant // that *matters* (Bad-vs-Good). The encoding-specific CLR type isn't // load-bearing for the parity gate. Accepted delta — see // Galaxy.ParityMatrix.md. } } }