From 8d042c631b88aa75e8820bc4e5f268858dd6c1fd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 16:29:01 -0400 Subject: [PATCH] =?UTF-8?q?PR=205.6=20=E2=80=94=20History-read=20parity=20?= =?UTF-8?q?scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Galaxy history reads route through the server-owned HistoryRouter (Phase 1, PR 1.3) — neither Galaxy backend implements IHistoryProvider directly. Parity surface here is the routing decision: - Discover_emits_same_historized_attribute_set_for_both_backends — the IsHistorized attribute set must agree symmetric-set-wise; that's what HistoryRouter consumes when deciding whether to route a HistoryRead to the Wonderware historian sidecar. - Neither_Galaxy_backend_implements_IHistoryProvider_directly — pins the architectural decision so a regression that re-introduces a per-driver history path fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HistoryReadParityTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs new file mode 100644 index 0000000..29f68eb --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HistoryReadParityTests.cs @@ -0,0 +1,66 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// PR 5.6 — History-read parity. Phase-1 routing lifted history off the +/// per-driver path onto the server-owned +/// HistoryRouter + WonderwareHistorianBootstrap; neither +/// Galaxy backend implements directly. So +/// the parity surface here is the *routing decision*: both backends must +/// identify the same set of historized attributes and produce the same +/// full-reference for each, so HistoryRouter routes reads identically. +/// +[Trait("Category", "ParityE2E")] +[Collection(nameof(ParityCollection))] +public sealed class HistoryReadParityTests +{ + private readonly ParityHarness _h; + public HistoryReadParityTests(ParityHarness h) => _h = h; + + [Fact] + public async Task Discover_emits_same_historized_attribute_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.Variables + .Where(v => v.AttributeInfo.IsHistorized) + .Select(v => v.AttributeInfo.FullName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + }, CancellationToken.None); + + var legacy = snapshots[ParityHarness.Backend.LegacyHost]; + var mxgw = snapshots[ParityHarness.Backend.MxGateway]; + + if (legacy.Count == 0) + { + Assert.Skip("dev Galaxy has no historized attributes — history routing parity unverified for this rig"); + } + + legacy.Except(mxgw, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( + "every historized attribute discovered by the legacy backend must appear in the mxgw backend"); + mxgw.Except(legacy, StringComparer.OrdinalIgnoreCase).ShouldBeEmpty( + "every historized attribute discovered by the mxgw backend must appear in the legacy backend"); + } + + [Fact] + public async Task Neither_Galaxy_backend_implements_IHistoryProvider_directly() + { + // Pinning the architectural decision from Phase 1 (PR 1.3): per-driver + // IHistoryProvider was retired in favor of the server-owned HistoryRouter. + // If a regression brings IHistoryProvider back on either Galaxy driver, + // this test fires. + _h.RequireBoth(); + + (_h.LegacyDriver as IHistoryProvider).ShouldBeNull( + "legacy GalaxyProxyDriver must not surface IHistoryProvider — history routes through HistoryRouter"); + (_h.MxGatewayDriver as IHistoryProvider).ShouldBeNull( + "in-process GalaxyDriver must not surface IHistoryProvider — history routes through HistoryRouter"); + } +}