End-to-end run on the live ZB galaxy with mxaccessgw on http://localhost:5120: 14 passed / 1 skipped / 0 failed in 18m53s. PR 7.2's matrix-gate condition met. Three resolution patches in this commit; the matrix doc records the new state. 1. Discoverer: defensive `[]` array-suffix strip ---------------------------------------------------- The gw's GalaxyRepository.cs:173-175 appends `[]` to array-typed full_tag_reference values, but MxAccess COM IInstance.AddItem doesn't accept `[]`-suffixed addresses. GalaxyDiscoverer.StripArraySuffix removes the suffix client-side so SubscribeBulk / Read / Write paths see the canonical form. Tracked in mxaccessgw/requirements-array-suffix-fix.md; this workaround is removed when the gw fix lands. 2. WriteByClassification: pin status class, not exact code --------------------------------------------------------- Legacy MxAccessGalaxyBackend.WriteValuesAsync flat-maps every failure to BadInternalError (0x80020000); mxgw's GatewayGalaxyDataWriter.TranslateReply uses MxStatusProxy.RawDetectedBy to distinguish gw-layer faults (BadCommunicationError, 0x80050000) from MxAccess HRESULT faults. Both yield Bad-status — the parity invariant is the status class (Good/Uncertain/Bad), not the exact code. Both write tests now use AssertStatusClassMatches; legacy mapping retires alongside GalaxyProxyDriver in PR 7.2. 3. BrowseAndReadParity Read scenario: drop CLR-type assertion ------------------------------------------------------------ Legacy returns the raw VARIANT (e.g. byte[]) for an attribute that hasn't received its first value cycle from MxAccess yet, while mxgw returns the typed value (Single, Int32, etc.). Once a real value is written or scanned, both converge. Pinning CLR-type equality across the uninitialized window adds noise without a real parity invariant — the StatusCode-class assertion already covers the "did the read succeed" question. The test still pins StatusCode-class parity per scenario. 4. Galaxy.ParityMatrix.md — first-rig results captured ----------------------------------------------------- Per-row status flipped from "n/a unverified" to actual green / yellow / deferred outcomes from this run. Four new accepted-deltas added (read-value CLR type, write-status code mapping, single-platform ScanState scope, gw `[]` suffix workaround), bringing the total to nine. Outstanding deltas section flipped to "none as of 2026-04-30." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
4.7 KiB
C#
93 lines
4.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
|
|
|
|
/// <summary>
|
|
/// PR 5.4 — Write-by-classification parity. Each driver routes writes by the
|
|
/// attribute's <see cref="SecurityClassification"/>: <c>FreeAccess</c> /
|
|
/// <c>Operate</c> use plain <c>Write</c>; <c>Tune</c> / <c>Configure</c> /
|
|
/// <c>VerifiedWrite</c> use <c>WriteSecured</c>. Both backends must surface the
|
|
/// same StatusCode for the same write request — successful for FreeAccess /
|
|
/// Operate (assuming the dev Galaxy has at least one writable attribute) and
|
|
/// failure for Configure when no auth principal is supplied.
|
|
/// </summary>
|
|
[Trait("Category", "ParityE2E")]
|
|
[Collection(nameof(ParityCollection))]
|
|
public sealed class WriteByClassificationParityTests
|
|
{
|
|
private readonly ParityHarness _h;
|
|
public WriteByClassificationParityTests(ParityHarness h) => _h = h;
|
|
|
|
[Fact]
|
|
public async Task FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends()
|
|
{
|
|
_h.RequireBoth();
|
|
|
|
var b = new RecordingAddressSpaceBuilder();
|
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
|
|
|
var target = b.Variables.FirstOrDefault(v =>
|
|
v.AttributeInfo.SecurityClass is SecurityClassification.FreeAccess or SecurityClassification.Operate
|
|
&& v.AttributeInfo.DriverDataType is DriverDataType.Float32 or DriverDataType.Float64 or DriverDataType.Int32);
|
|
if (target is null) Assert.Skip("no FreeAccess/Operate numeric writable attribute on dev Galaxy");
|
|
|
|
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
|
var results = await _h.RunOnAvailableAsync(
|
|
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
|
CancellationToken.None);
|
|
|
|
var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode;
|
|
var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode;
|
|
AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Configure_class_write_routes_through_secured_path_on_both_backends()
|
|
{
|
|
_h.RequireBoth();
|
|
|
|
var b = new RecordingAddressSpaceBuilder();
|
|
await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None);
|
|
|
|
var target = b.Variables.FirstOrDefault(v =>
|
|
v.AttributeInfo.SecurityClass is SecurityClassification.Configure or SecurityClassification.Tune);
|
|
if (target is null) Assert.Skip("no Configure/Tune attribute on dev Galaxy");
|
|
|
|
var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) };
|
|
var results = await _h.RunOnAvailableAsync(
|
|
(driver, ct) => ((IWritable)driver).WriteAsync(request, ct),
|
|
CancellationToken.None);
|
|
|
|
// Both backends route through the secured-write path. The exact StatusCode
|
|
// depends on whether the running test identity has write permission on the
|
|
// dev Galaxy — what matters here is that they agree on the status *class*
|
|
// (Good vs Bad vs Uncertain), not which exact code they produce.
|
|
var legacyCode = results[ParityHarness.Backend.LegacyHost][0].StatusCode;
|
|
var mxgwCode = results[ParityHarness.Backend.MxGateway][0].StatusCode;
|
|
AssertStatusClassMatches(legacyCode, mxgwCode, target.AttributeInfo.FullName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pin the parity invariant that *matters*: both backends classify the same
|
|
/// write outcome as Good / Uncertain / Bad. The exact OPC UA code can diverge
|
|
/// because legacy <c>MxAccessGalaxyBackend</c> flat-maps every failure to
|
|
/// <c>BadInternalError</c> while the new <c>GatewayGalaxyDataWriter</c> uses
|
|
/// <c>MxStatusProxy.RawDetectedBy</c> to distinguish gateway-layer faults
|
|
/// (<c>BadCommunicationError</c>) from MxAccess HRESULT faults — see
|
|
/// <c>docs/v2/Galaxy.ParityMatrix.md</c> "Accepted deltas". Tighter mapping
|
|
/// parity isn't worth investing in: legacy retires in PR 7.2.
|
|
/// </summary>
|
|
private static void AssertStatusClassMatches(uint legacyCode, uint mxgwCode, string tag)
|
|
{
|
|
IsBadStatus(legacyCode).ShouldBe(IsBadStatus(mxgwCode),
|
|
$"status-class (Bad) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}");
|
|
IsGoodStatus(legacyCode).ShouldBe(IsGoodStatus(mxgwCode),
|
|
$"status-class (Good) parity for '{tag}': legacy=0x{legacyCode:X8}, mxgw=0x{mxgwCode:X8}");
|
|
}
|
|
|
|
private static bool IsBadStatus(uint code) => (code & 0xC0000000u) == 0x80000000u;
|
|
private static bool IsGoodStatus(uint code) => (code & 0xC0000000u) == 0x00000000u;
|
|
}
|