PR 5.6 — History-read parity scenarios

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-29 16:29:01 -04:00
parent bbdbdf8afb
commit 8d042c631b

View File

@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
/// <summary>
/// PR 5.6 — History-read parity. Phase-1 routing lifted history off the
/// per-driver <see cref="IHistoryProvider"/> path onto the server-owned
/// <c>HistoryRouter</c> + <c>WonderwareHistorianBootstrap</c>; neither
/// Galaxy backend implements <see cref="IHistoryProvider"/> 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.
/// </summary>
[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");
}
}