diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs new file mode 100644 index 0000000..fe5955e --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/SubscribeAndEventRateParityTests.cs @@ -0,0 +1,105 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// PR 5.3 — Subscribe + event-rate parity. Both backends must accept the same +/// full-reference list, return a usable subscription handle, and dispatch a +/// similar number of OnDataChange events for the same observation window. +/// +[Trait("Category", "ParityE2E")] +[Collection(nameof(ParityCollection))] +public sealed class SubscribeAndEventRateParityTests +{ + private readonly ParityHarness _h; + public SubscribeAndEventRateParityTests(ParityHarness h) => _h = h; + + [Fact] + public async Task Subscribe_returns_a_handle_for_each_backend() + { + _h.RequireBoth(); + + var sample = await PickSampleAsync(5); + if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); + + var handles = await _h.RunOnAvailableAsync( + (driver, ct) => ((ISubscribable)driver).SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), ct), + CancellationToken.None); + + handles[ParityHarness.Backend.LegacyHost].ShouldNotBeNull(); + handles[ParityHarness.Backend.MxGateway].ShouldNotBeNull(); + + // Clean up so we don't leave dangling advises in either backend. + foreach (var (backend, handle) in handles) + { + await ((ISubscribable)_h.GetDriver(backend)) + .UnsubscribeAsync(handle, CancellationToken.None); + } + } + + [Fact] + public async Task Subscribe_event_rate_within_tolerance_for_a_3s_window() + { + _h.RequireBoth(); + + var sample = await PickSampleAsync(5); + if (sample.Length == 0) Assert.Skip("dev Galaxy has no discoverable variables"); + + var counts = new Dictionary(); + var subs = new Dictionary(); + try + { + foreach (var backend in new[] { ParityHarness.Backend.LegacyHost, ParityHarness.Backend.MxGateway }) + { + var driver = _h.GetDriver(backend); + var local = 0; + EventHandler handler = (_, _) => Interlocked.Increment(ref local); + ((ISubscribable)driver).OnDataChange += handler; + var handle = await ((ISubscribable)driver) + .SubscribeAsync(sample, TimeSpan.FromMilliseconds(500), CancellationToken.None); + subs[backend] = handle; + + await Task.Delay(3_000, TestContext.Current.CancellationToken); + + ((ISubscribable)driver).OnDataChange -= handler; + counts[backend] = Volatile.Read(ref local); + } + + // Tolerance is generous because both backends are looking at the same + // physical Galaxy; the gateway's StreamEvents pump and the legacy + // OnDataChange COM advises are fed by the same MXAccess subscriptions + // upstream. ±50% absorbs scheduler jitter without hiding a wholesale + // event-rate regression. + var legacyCount = counts[ParityHarness.Backend.LegacyHost]; + var mxgwCount = counts[ParityHarness.Backend.MxGateway]; + if (legacyCount + mxgwCount == 0) + { + Assert.Skip("no value changes observed in 3s window — sample may be all static configuration tags"); + } + var ratio = (double)mxgwCount / Math.Max(legacyCount, 1); + ratio.ShouldBeInRange(0.5, 1.5, + $"event-rate parity within ±50%: legacy={legacyCount}, mxgw={mxgwCount}"); + } + finally + { + foreach (var (backend, handle) in subs) + { + try + { + await ((ISubscribable)_h.GetDriver(backend)) + .UnsubscribeAsync(handle, CancellationToken.None); + } + catch { /* best-effort cleanup */ } + } + } + } + + private async Task PickSampleAsync(int count) + { + var b = new RecordingAddressSpaceBuilder(); + await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); + return b.Variables.Take(count).Select(v => v.AttributeInfo.FullName).ToArray(); + } +}