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