using ZB.MOM.WW.MxGateway.Contracts.Proto; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; // MxStatusCategory needed for Driver.Galaxy-003 regression tests. namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Exhaustive table-driven tests for . Pinning the byte→uint /// mapping here protects against accidental drift — every Galaxy deployment that /// reaches the parity matrix in PR 5.2 depends on these specific OPC UA StatusCode /// values matching the legacy HistorianQualityMapper output. /// public sealed class StatusCodeMapTests { /// Verifies that known quality bytes map to the expected OPC UA status codes. /// The Galaxy quality byte to map. /// The expected OPC UA status code. [Theory] [InlineData((byte)192, 0x00000000u)] // Good [InlineData((byte)216, 0x00960000u)] // Good_LocalOverride [InlineData((byte)64, 0x40000000u)] // Uncertain [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal [InlineData((byte)0, 0x80000000u)] // Bad [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected [InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure [InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure [InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError [InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService [InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData public void FromQualityByte_KnownValues_MapToOpcUaStatusCode(byte input, uint expected) { StatusCodeMap.FromQualityByte(input).ShouldBe(expected); } /// Verifies that unknown quality bytes fall back to category bucket status codes. /// The unknown Galaxy quality byte to categorize. [Theory] [InlineData((byte)200)] // Unknown Good — falls back to category bucket [InlineData((byte)100)] // Unknown Uncertain [InlineData((byte)40)] // Unknown Bad public void FromQualityByte_UnknownValues_FallBackToCategoryBucket(byte input) { var mapped = StatusCodeMap.FromQualityByte(input); if (input >= 192) mapped.ShouldBe(StatusCodeMap.Good); else if (input >= 64) mapped.ShouldBe(StatusCodeMap.Uncertain); else mapped.ShouldBe(StatusCodeMap.Bad); } // ===== Driver.Galaxy-003 regression: FromMxStatus uses IsSuccess() (category + success) ===== // The proto doc: "clients should branch on category, not on a specific success value." // IsSuccess() requires BOTH success != 0 AND category == Ok — checking success alone // would invert the mapping when the worker sets success=1 for an error code (the numeric // value is NOT a boolean). /// Verifies that null status maps to Good status code. [Fact] public void FromMxStatus_NullStatus_IsGood() { StatusCodeMap.FromMxStatus(null).ShouldBe(StatusCodeMap.Good); } /// Verifies that non-zero success with OK category maps to Good. [Fact] public void FromMxStatus_SuccessNonZeroAndCategoryOk_IsGood() { // Both conditions required — this is the canonical OK path. var s = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok }; StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Good); } /// Verifies that non-zero success with non-OK category does not map to Good. [Fact] public void FromMxStatus_SuccessNonZeroButCategoryNotOk_IsNotGood() { // Bug fixed by Driver.Galaxy-003: success != 0 alone used to return Good even when // category indicates an error. The proto says success is NOT a boolean — category wins. var s = new MxStatusProxy { Success = 1, Category = MxStatusCategory.CommunicationError }; StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good); } /// /// #866: a low detail is an MxStatusDetail code, not an OPC DA quality byte. Detail 8 is /// MX_E_InvalidObjectId — it used to surface as BadNotConnected (quality byte 8). /// [Fact] public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode() { var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 }; StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadConfigurationError); } /// /// #866 live capture (VD03 2026-11-11): reading JoePlatform.ScanState on a deployed but /// not-running platform returns a failure row with detail 2 = MX_E_PlatformCommunicationError, /// which logged "Unrecognised MXAccess quality byte 0x02" and published generic Bad. /// [Fact] public void FromMxStatus_PlatformCommunicationError_IsCommunicationError_AndDoesNotLogAsQuality() { var logger = new CapturingLogger(); var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 }; StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.BadCommunicationError); logger.Entries.ShouldBeEmpty(); } /// Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category. [Theory] [InlineData(1, 0x800A0000u)] // MX_E_RequestTimedOut → BadTimeout [InlineData(2, 0x80050000u)] // MX_E_PlatformCommunicationError → BadCommunicationError [InlineData(5, 0x80050000u)] // MX_E_EngineCommunicationError → BadCommunicationError [InlineData(3, 0x80890000u)] // MX_E_InvalidPlatformId → BadConfigurationError [InlineData(4, 0x80890000u)] // MX_E_InvalidEngineId (was BadConfigurationError by quality-byte accident) [InlineData(6, 0x80890000u)] // MX_E_InvalidReference [InlineData(12, 0x80000000u)] // MX_E_ResolvingObject → category (was BadDeviceFailure) [InlineData(13, 0x80740000u)] // MX_E_WrongDataType → BadTypeMismatch [InlineData(16, 0x80000000u)] // MX_E_IndexOutOfOrder → category (was BadSensorFailure) [InlineData(19, 0x80740000u)] // MX_E_UnableToConvertString → BadTypeMismatch [InlineData(20, 0x803C0000u)] // MX_E_Overflow → BadOutOfRange (was BadCommunicationError) [InlineData(24, 0x80000000u)] // MX_E_LmxInvalidCommand → category (was BadOutOfService) [InlineData(32, 0x80000000u)] // not a vocabulary code → category (was BadWaitingForInitialData) public void FromMxStatus_LowDetailCodes_MapByVocabulary(int detail, uint expected) { var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = detail }; StatusCodeMap.FromMxStatus(s).ShouldBe(expected); } /// The category fallback logs once per (detail, category), not on every data change. [Fact] public void FromMxStatus_CategoryFallback_LogsOncePerDetailAndCategory() { var logger = new CapturingLogger(); var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SoftwareError, Detail = 23 /* MX_E_LmxVersionMismatch */ }; for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Bad); 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: 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(-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) { 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. [Fact] public void FromMxStatus_SuccessZero_AllZero_IsBad() { var s = new MxStatusProxy(); StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); } // ===== 06/S-1 write-completion rows: detail is an MxStatus detail code, NOT a quality byte ===== // The gateway (≥ 53f69cd) correlates the worker's OnWriteComplete onto the unary write reply as // statuses[0]. Those rows carry MxStatus detail codes (live-captured on windev: 1007 operational // refusal on a read-only attribute, 1008 security refusal on a plain-advised handle). The old // truncation `(byte)(Detail & 0xFF)` pushed 1007 → 239 and 1008 → 240 into the OPC DA Good band, // mapping a REFUSED write to Good — no node revert, silently-lost write reported committed. /// /// Per-code mapping from the MxStatusDetail vocabulary (mxaccessgw gateway.md @ 75c71ad). /// The 1007/1008 rows are the exact live-captured shapes from the 06/S-1 gate. /// [Theory] [InlineData(1004, MxStatusCategory.OperationalError, 0x803C0000u)] // MX_E_DataOutOfRange → BadOutOfRange [InlineData(1005, MxStatusCategory.OperationalError, 0x80740000u)] // MX_E_IncorrectDataType → BadTypeMismatch [InlineData(1006, MxStatusCategory.OperationalError, 0x803A0000u)] // MX_E_NotReadable → BadNotReadable [InlineData(1007, MxStatusCategory.OperationalError, 0x803B0000u)] // MX_E_NotWriteable → BadNotWritable (live: read-only Tagname) [InlineData(1008, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_WriteAccessDenied → BadUserAccessDenied (live: plain-advised write) [InlineData(1012, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_SecuredWrite → BadUserAccessDenied [InlineData(1013, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifiedWrite → BadUserAccessDenied [InlineData(1016, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_UserNotHavingAccessRights → BadUserAccessDenied [InlineData(1017, MxStatusCategory.SecurityError, 0x801F0000u)] // MX_E_VerifierNotHavingVerifyRights → BadUserAccessDenied [InlineData(1010, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_ObjectInitializing → BadOutOfService (transient) [InlineData(1011, MxStatusCategory.OperationalError, 0x808D0000u)] // MX_E_EngineInitializing → BadOutOfService (transient) public void FromMxStatus_KnownDetailCodes_MapPerCode(int detail, MxStatusCategory category, uint expected) { var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail, RawDetectedBy = 5 }; StatusCodeMap.FromMxStatus(s).ShouldBe(expected); } /// /// Unrecognized large details route by category — including 8000 /// MX_E_AutomationObjectSpecificError, which deliberately stays category-level (the object /// supplies its own semantics). /// [Theory] [InlineData(2001, MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError [InlineData(2001, MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError [InlineData(2001, MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad [InlineData(8000, MxStatusCategory.OperationalError, 0x80000000u)] // MX_E_AutomationObjectSpecificError → category-level [InlineData(8000, MxStatusCategory.SecurityError, 0x801F0000u)] // ... which for a security row is BadUserAccessDenied public void FromMxStatus_UnmappedLargeDetail_RoutesByCategory(int detail, MxStatusCategory category, uint expected) { var s = new MxStatusProxy { Success = 0, Category = category, Detail = detail }; StatusCodeMap.FromMxStatus(s).ShouldBe(expected); } /// /// Invariant: a row that is not IsSuccess() must NEVER map to Good — a detail that happens to sit in /// the OPC DA Good band (216) is not a quality byte and routes on the category. /// [Fact] public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood() { var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 216 }; StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied); } /// A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866). [Fact] public void FromMxStatus_FailureRow_UncertainBandDetail_IsBad() { var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 }; StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad); } /// Verifies that top two bits of status codes follow OPC UA convention. [Fact] public void TopByteCategoryBits_StayWithinOpcUaConvention() { // Sanity check that every Bad code we mint actually has the Bad top byte (0x80…), // every Uncertain has 0x40…, every Good has 0x00…. Pins the OPC UA Part 4 invariant. StatusCodeMap.Good.ShouldBeLessThan(0x40000000u); StatusCodeMap.GoodLocalOverride.ShouldBeLessThan(0x40000000u); ((StatusCodeMap.Uncertain >> 30) & 0x3u).ShouldBe(1u); ((StatusCodeMap.UncertainLastUsableValue >> 30) & 0x3u).ShouldBe(1u); ((StatusCodeMap.Bad >> 30) & 0x3u).ShouldBe(2u); ((StatusCodeMap.BadNotConnected >> 30) & 0x3u).ShouldBe(2u); ((StatusCodeMap.BadOutOfService >> 30) & 0x3u).ShouldBe(2u); } // ===== Driver.Galaxy-004 regression: ToQualityCategoryByte lives next to its inverse ===== /// Verifies that status codes extract to their OPC DA category byte equivalent. /// The OPC UA status code to convert. /// The expected OPC DA category byte. [Theory] [InlineData(0x00000000u, (byte)192)] // Good [InlineData(0x00960000u, (byte)192)] // GoodLocalOverride — still Good category [InlineData(0x40000000u, (byte)64)] // Uncertain [InlineData(0x40950000u, (byte)64)] // UncertainSubNormal — still Uncertain category [InlineData(0x80000000u, (byte)0)] // Bad [InlineData(0x808A0000u, (byte)0)] // BadNotConnected — still Bad category [InlineData(0x80020000u, (byte)0)] // BadInternalError — still Bad category public void ToQualityCategoryByte_ExtractsTopTwoBitsAsOpcDaByte(uint statusCode, byte expected) { StatusCodeMap.ToQualityCategoryByte(statusCode).ShouldBe(expected); } /// Verifies that ToQualityCategoryByte is the right inverse of FromQualityByte for category bytes. [Fact] public void ToQualityCategoryByte_IsRightInverseOfFromQualityByte_ForCategoryBytes() { // The category bytes the probe watcher uses are 0, 64, 192. Round-trip: a value // that came FROM those bytes should map back to the same byte. StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(0)).ShouldBe((byte)0); StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(64)).ShouldBe((byte)64); StatusCodeMap.ToQualityCategoryByte(StatusCodeMap.FromQualityByte(192)).ShouldBe((byte)192); } }