2470f82eea
v2-ci / unit-tests (push) Has been cancelled
Owner decision 2026-11-13 on #872 item 1. StatusCodeMap.FromMxStatus now maps a Pending or Warning row to Uncertain (0x40000000) for any detail, ahead of the detail vocabulary, keeping the once-per-(detail, category) warning. MXAccess sets success = -1 on Pending (accepted, in progress); neither category is a failure. Detail 8001 (automation-object ConfigurationError, write rejected for type conversion, live-captured on windev 2026-11-13) maps to BadTypeMismatch. 8018 stays BadUserAccessDenied via its SecurityError category, now pinned by a test. Data change: the value is published Uncertain. Write reply: the result is Uncertain, never Good, so the server (which commits only Good) reports the write not committed and reverts the node; a later MXAccess commit arrives as a data change. Host status is unchanged (probe decode treats Uncertain and Bad alike, quality < 192 -> Unknown); no alarm path calls the map. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
390 lines
22 KiB
C#
390 lines
22 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Exhaustive table-driven tests for <see cref="StatusCodeMap"/>. 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 <c>HistorianQualityMapper</c> output.
|
|
/// </summary>
|
|
public sealed class StatusCodeMapTests
|
|
{
|
|
/// <summary>Verifies that known quality bytes map to the expected OPC UA status codes.</summary>
|
|
/// <param name="input">The Galaxy quality byte to map.</param>
|
|
/// <param name="expected">The expected OPC UA status code.</param>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that unknown quality bytes fall back to category bucket status codes.</summary>
|
|
/// <param name="input">The unknown Galaxy quality byte to categorize.</param>
|
|
[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).
|
|
|
|
/// <summary>Verifies that null status maps to Good status code.</summary>
|
|
[Fact]
|
|
public void FromMxStatus_NullStatus_IsGood()
|
|
{
|
|
StatusCodeMap.FromMxStatus(null).ShouldBe(StatusCodeMap.Good);
|
|
}
|
|
|
|
/// <summary>Verifies that non-zero success with OK category maps to Good.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that non-zero success with non-OK category does not map to Good.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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).
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
|
|
{
|
|
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadConfigurationError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #866 live capture (VD03 2026-11-11): reading <c>JoePlatform.ScanState</c> 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>The category fallback logs once per (detail, category), not on every data change.</summary>
|
|
[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.
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
/// <param name="rawDetectedBy">The raw MxStatusSource value.</param>
|
|
[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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #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".
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that all-zero status maps to Bad status code.</summary>
|
|
[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.
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unrecognized large details route by category — including 8000
|
|
/// MX_E_AutomationObjectSpecificError, which deliberately stays category-level (the object
|
|
/// supplies its own semantics).
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
|
|
{
|
|
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 216 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
|
}
|
|
|
|
/// <summary>A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866).</summary>
|
|
[Fact]
|
|
public void FromMxStatus_FailureRow_UncertainBandDetail_IsBad()
|
|
{
|
|
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
|
|
}
|
|
|
|
// ===== #872 item 1 (owner decision 2026-11-13): Pending / Warning rows are Uncertain, never Bad =====
|
|
// MXAccess sets success = -1 (VARIANT_TRUE) on a Pending row: accepted, in progress. A Warning is
|
|
// degraded-but-usable. Neither is IsSuccess() (category == Ok), so both reach FromMxStatus, and both map to
|
|
// Uncertain for ANY detail — ahead of the detail vocabulary, so a vocabulary detail cannot turn them Bad.
|
|
|
|
/// <summary>#872: a Pending or Warning row maps to Uncertain for detail 0 and for non-zero details,
|
|
/// including details the vocabulary would otherwise map to a specific Bad code.</summary>
|
|
/// <param name="category">Pending or Warning.</param>
|
|
/// <param name="success">The raw success value (Pending carries -1 live).</param>
|
|
/// <param name="detail">The MxStatusDetail code.</param>
|
|
[Theory]
|
|
[InlineData(MxStatusCategory.Pending, -1, 0)] // the live-captured shape (Suspend/Activate return)
|
|
[InlineData(MxStatusCategory.Pending, -1, 1)] // MX_E_RequestTimedOut would be BadTimeout
|
|
[InlineData(MxStatusCategory.Pending, 0, 1007)] // MX_E_NotWriteable would be BadNotWritable
|
|
[InlineData(MxStatusCategory.Pending, -1, 8001)] // would be BadTypeMismatch
|
|
[InlineData(MxStatusCategory.Pending, -1, 2001)] // unmapped detail
|
|
[InlineData(MxStatusCategory.Warning, 0, 0)]
|
|
[InlineData(MxStatusCategory.Warning, 1, 2)] // MX_E_PlatformCommunicationError would be BadCommunicationError
|
|
[InlineData(MxStatusCategory.Warning, 0, 1004)] // MX_E_DataOutOfRange would be BadOutOfRange
|
|
[InlineData(MxStatusCategory.Warning, 0, 2001)] // unmapped detail
|
|
public void FromMxStatus_PendingOrWarning_AnyDetail_IsUncertain(MxStatusCategory category, int success, int detail)
|
|
{
|
|
var s = new MxStatusProxy { Success = success, Category = category, Detail = detail, RawDetectedBy = 0 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Uncertain);
|
|
}
|
|
|
|
/// <summary>#872: the once-per-(detail, category) warning is kept for Pending / Warning rows.</summary>
|
|
[Fact]
|
|
public void FromMxStatus_PendingRow_LogsOncePerDetailAndCategory()
|
|
{
|
|
var logger = new CapturingLogger();
|
|
// Detail distinct from every other test so the process-wide once-set cannot already hold it.
|
|
var s = new MxStatusProxy { Success = -1, Category = MxStatusCategory.Pending, Detail = 87201 };
|
|
for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Uncertain);
|
|
logger.Entries.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #872 live capture (windev 2026-11-13, mxaccessgw docs/LiveCaptures-2026-11-13.md §3): detail 8001 is
|
|
/// the automation object's ConfigurationError for a write rejected on type conversion (a string written to
|
|
/// a double). It maps to BadTypeMismatch, not the category's BadConfigurationError.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromMxStatus_Detail8001_AutomationObjectTypeConversion_IsTypeMismatch()
|
|
{
|
|
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.ConfigurationError, Detail = 8001, RawDetectedBy = 5 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadTypeMismatch);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #872 live capture: detail 8018 (SecurityError, a verified write naming the writer as verifier) stays
|
|
/// BadUserAccessDenied via its category.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FromMxStatus_Detail8018_VerifiedWriteSelfVerifier_IsUserAccessDenied()
|
|
{
|
|
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 8018, RawDetectedBy = 5 };
|
|
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
|
}
|
|
|
|
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
|
|
[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 =====
|
|
|
|
/// <summary>Verifies that status codes extract to their OPC DA category byte equivalent.</summary>
|
|
/// <param name="statusCode">The OPC UA status code to convert.</param>
|
|
/// <param name="expected">The expected OPC DA category byte.</param>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that ToQualityCategoryByte is the right inverse of FromQualityByte for category bytes.</summary>
|
|
[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);
|
|
}
|
|
}
|