From 633f43170a91649b09c63ed09e2d82e2fa7afc5c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 07:17:53 -0500 Subject: [PATCH] test(galaxy): #872 pin the detected_by rule per source The rule fix itself landed in 1a08b4f7 (detail-0 failure rows route on category); its test only exercised raw_detected_by = 3. Add per-source coverage for every MxStatusSource value mxaccessgw's MapSource emits (-1, 0..5, and an out-of-range value) across the failure categories, plus the live-captured shape the old rule got wrong (OperationalError detected by the responding automation object, mxaccessgw docs/AlarmProbeFindings.md) and a RequestingLmx CommunicationError positive control. All 10 new cases fail against the old 'detail 0 && raw_detected_by != 0 => comms' rule. Comment in FromMxStatus now cites MapSource and says why NMX (2/3) is not a transport signal. Item 1 (Warning/Pending) stays open pending a capture. Co-Authored-By: Claude --- .../Runtime/StatusCodeMap.cs | 3 + .../Runtime/StatusCodeMapTests.cs | 70 ++++++++++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs index 0fe9b7b3a..52f11e472 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/StatusCodeMap.cs @@ -133,6 +133,9 @@ internal static class StatusCodeMap // the belief that a non-zero source meant a pre-MXAccess transport failure — but every detected_by value // names an MXAccess layer (0 requesting LMX, 1 responding LMX, 2/3 NMX, 4/5 automation object; the // gateway's own failures arrive as ProtocolStatus, never as a status row), so the category decides (#872). + // The numbering is mxaccessgw's MxStatusProxyConverter.MapSource; NMX (2/3) names the inter-node message + // layer that noticed the failure, not a transport fault — a real comms loss is category CommunicationError + // or detail 2/5, which FromCategory already maps to BadCommunicationError. return FromCategory(status, logger); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs index 64f99ced1..9ed5b850c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs @@ -140,18 +140,72 @@ public sealed class StatusCodeMapTests logger.Entries.Count.ShouldBe(1); } + // ===== #872: detected_by never decides the code — the category does ===== + // raw_detected_by is MXAccess's MxStatusSource, decoded by mxaccessgw's MxStatusProxyConverter.MapSource: + // -1 Unknown, 0 RequestingLmx, 1 RespondingLmx, 2 RequestingNmx, 3 RespondingNmx, + // 4 RequestingAutomationObject, 5 RespondingAutomationObject, anything else Unknown. Every value names the + // MXAccess layer that noticed the failure, not the kind of failure — the live write-refusal capture in + // mxaccessgw docs/AlarmProbeFindings.md is an OperationalError detected by RespondingAutomationObject (5). + // A transport/network failure shows up as category CommunicationError (or detail 2/5), and the gateway's own + // failures arrive as ProtocolStatus, never as a status row. The old rule (detail 0 and raw_detected_by != 0 + // => BadCommunicationError) turned every one of those object/LMX-detected failures into a comms error. + /// - /// #872: detected_by names an MXAccess layer (3 = responding NMX), not a transport failure, so a detail-0 - /// failure row routes on its category — a communication-category row is still BadCommunicationError. + /// #872: a detail-0 failure row maps on its category for every detected_by source — including the + /// NMX sources, which name the layer that noticed the failure, not a transport failure. /// + /// The raw MxStatusSource value. [Theory] - [InlineData(MxStatusCategory.Unspecified, 0x80000000u)] - [InlineData(MxStatusCategory.CommunicationError, 0x80050000u)] - [InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)] - public void FromMxStatus_SuccessZero_DetailZero_RoutesByCategory_NotDetectedBy(MxStatusCategory category, uint expected) + [InlineData(-1)] // Unknown + [InlineData(0)] // RequestingLmx + [InlineData(1)] // RespondingLmx + [InlineData(2)] // RequestingNmx + [InlineData(3)] // RespondingNmx + [InlineData(4)] // RequestingAutomationObject + [InlineData(5)] // RespondingAutomationObject + [InlineData(77)] // outside the documented range -> Unknown + public void FromMxStatus_DetailZero_EverySource_RoutesByCategory(int rawDetectedBy) { - var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3, Category = category }; - StatusCodeMap.FromMxStatus(s).ShouldBe(expected); + Map(MxStatusCategory.OperationalError).ShouldBe(StatusCodeMap.Bad); + Map(MxStatusCategory.SoftwareError).ShouldBe(StatusCodeMap.Bad); + Map(MxStatusCategory.OtherError).ShouldBe(StatusCodeMap.Bad); + Map(MxStatusCategory.Unspecified).ShouldBe(StatusCodeMap.Bad); + Map(MxStatusCategory.ConfigurationError).ShouldBe(StatusCodeMap.BadConfigurationError); + Map(MxStatusCategory.SecurityError).ShouldBe(StatusCodeMap.BadUserAccessDenied); + Map(MxStatusCategory.CommunicationError).ShouldBe(StatusCodeMap.BadCommunicationError); + + uint Map(MxStatusCategory category) => StatusCodeMap.FromMxStatus( + new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = rawDetectedBy, Category = category }); + } + + /// + /// #872: the shape the old rule got wrong — an operational failure the responding automation object + /// detected (raw 5, the live write-refusal source) is not a communication error. + /// + [Fact] + public void FromMxStatus_DetailZero_RespondingAutomationObject_OperationalError_IsNotCommunicationError() + { + var s = new MxStatusProxy + { + Success = 0, Detail = 0, Category = MxStatusCategory.OperationalError, + DetectedBy = MxStatusSource.RespondingAutomationObject, RawDetectedBy = 5, + }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); + } + + /// + /// #872 positive control: a communication-category row stays BadCommunicationError when detected by + /// the requesting LMX (raw 0) — the source the old rule treated as "never a comms error". + /// + [Fact] + public void FromMxStatus_DetailZero_RequestingLmx_CommunicationError_IsCommunicationError() + { + var s = new MxStatusProxy + { + Success = 0, Detail = 0, Category = MxStatusCategory.CommunicationError, + DetectedBy = MxStatusSource.RequestingLmx, RawDetectedBy = 0, + }; + StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError); } /// Verifies that all-zero status maps to Bad status code.