After running the matrix end-to-end against the live rig for the first time, three of the nine failures were false positives — bugs in the harness and test invariants, not real backend deltas: 1. ParityHarness configured the legacy backend with OTOPCUA_GALAXY_BACKEND=db, which is Discover-only. Reads, writes, and reinits all returned "MXAccess code lift pending — DB-backed backend covers Discover only". Switched to mxaccess backend; the ZB connection string still drives the discovery path. 2. HistoryReadParityTests asserted "neither backend implements IHistoryProvider" — but the legacy GalaxyProxyDriver still does (it's an accepted back-compat delta retired in PR 7.2). The architectural pin we *want* is "the new path doesn't regress to per-driver history", so the test now asserts only the mxgw side. 3. AlarmTransitionParityTests strict-pinned the five sub-attribute refs (InAlarmRef, etc.) on the legacy condition. PR 2.1 added those refs specifically so the new mxgw driver could populate them via AlarmRefBuilder; legacy pre-dates PR 2.1 and leaves them null — that's correct, not a regression. Test now asserts a one-way invariant: when legacy populated a ref, mxgw must match. When legacy is null, mxgw is free to populate (the mxgw → server-side AlarmConditionService direction). The six remaining failures are real: - 2 from the gw-side `[]` array suffix (filed in mxaccessgw/requirements-array-suffix-fix.md) - 2 write-StatusCode mapping deltas (0x80050000 vs 0x80020000) — Bad-status both ways but mapped to different OPC UA codes - 1 event-rate ratio of 5x (mxgw dispatches 5x legacy in the same 3s window) - (Plus the 2 ScanState scenarios that skip cleanly — single-platform rig as documented) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
4.5 KiB
C#
104 lines
4.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
|
|
|
/// <summary>
|
|
/// PR 5.5 — Alarm-condition + transition parity. Both backends discover the
|
|
/// same set of alarm-bearing attributes with matching <see cref="AlarmConditionInfo"/>
|
|
/// metadata; transition events from a live alarm flap must arrive with matching
|
|
/// severity, message, and source-node-id on each side.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Alarm-event persistence parity (the SQLite store-and-forward → Wonderware
|
|
/// historian event store path called out in the impl plan) is exercised
|
|
/// end-to-end in PR 5.6 against the historian sidecar; here we focus on the
|
|
/// in-process transition stream that <see cref="IAlarmConditionSink"/> emits.
|
|
/// </remarks>
|
|
[Trait("Category", "ParityE2E")]
|
|
[Collection(nameof(ParityCollection))]
|
|
public sealed class AlarmTransitionParityTests
|
|
{
|
|
private readonly ParityHarness _h;
|
|
public AlarmTransitionParityTests(ParityHarness h) => _h = h;
|
|
|
|
[Fact]
|
|
public async Task Discover_emits_same_AlarmConditionInfo_per_alarm_attribute()
|
|
{
|
|
_h.RequireBoth();
|
|
|
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
|
{
|
|
var b = new RecordingAddressSpaceBuilder();
|
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
|
return b.AlarmConditions.ToDictionary(
|
|
ac => ac.SourceNodeId,
|
|
ac => ac.Info,
|
|
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 alarm-marked attributes — alarm parity unverified for this rig");
|
|
}
|
|
|
|
legacy.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
|
.ShouldBe(mxgw.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase),
|
|
"alarm source-node-id set must match across backends");
|
|
|
|
foreach (var kvp in legacy)
|
|
{
|
|
mxgw[kvp.Key].InitialSeverity.ShouldBe(kvp.Value.InitialSeverity,
|
|
$"alarm severity parity for '{kvp.Key}'");
|
|
mxgw[kvp.Key].SourceName.ShouldBe(kvp.Value.SourceName,
|
|
$"alarm SourceName parity for '{kvp.Key}'");
|
|
|
|
// PR 2.1 added the five sub-attribute refs (InAlarmRef / PriorityRef /
|
|
// DescAttrNameRef / AckedRef / AckMsgWriteRef) so the new server-side
|
|
// AlarmConditionService can subscribe + ack-write without help from the
|
|
// driver. The new mxgw GalaxyDriver populates them via AlarmRefBuilder
|
|
// (PR 4.1). The legacy GalaxyProxyDriver pre-dates PR 2.1 and leaves them
|
|
// null — that's an accepted delta until the legacy backend retires in
|
|
// PR 7.2. Asserting "mxgw populated when legacy didn't" is *correct*
|
|
// behavior, not a regression.
|
|
//
|
|
// We pin the weaker invariant: if legacy populated a ref, mxgw must
|
|
// populate the same value. If legacy is null, mxgw is allowed to be
|
|
// either null or populated (the population-from-AlarmRefBuilder direction).
|
|
if (kvp.Value.InAlarmRef is not null)
|
|
{
|
|
mxgw[kvp.Key].InAlarmRef.ShouldBe(kvp.Value.InAlarmRef,
|
|
$"alarm InAlarmRef parity for '{kvp.Key}' (both populated)");
|
|
}
|
|
if (kvp.Value.DescAttrNameRef is not null)
|
|
{
|
|
mxgw[kvp.Key].DescAttrNameRef.ShouldBe(kvp.Value.DescAttrNameRef,
|
|
$"alarm DescAttrNameRef parity for '{kvp.Key}' (both populated)");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Discover_marks_at_least_one_alarm_attribute_when_dev_Galaxy_has_alarms()
|
|
{
|
|
_h.RequireBoth();
|
|
|
|
var snapshots = await _h.RunOnAvailableAsync(async (driver, ct) =>
|
|
{
|
|
var b = new RecordingAddressSpaceBuilder();
|
|
await ((ITagDiscovery)driver).DiscoverAsync(b, ct);
|
|
return b.Variables.Count(v => v.AttributeInfo.IsAlarm);
|
|
}, CancellationToken.None);
|
|
|
|
// Soft pin — count must match across backends. Whether the count is non-zero
|
|
// depends on the rig's Galaxy content, so we don't gate on a positive number.
|
|
snapshots[ParityHarness.Backend.LegacyHost]
|
|
.ShouldBe(snapshots[ParityHarness.Backend.MxGateway],
|
|
"IsAlarm-marked variable count parity");
|
|
}
|
|
}
|