Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/StatusCodeMapTests.cs
T
Joseph Doherty 20a816dbf4 feat(galaxy): consume the gateway's correlated write statuses; close archreview 06/S-1
The mxaccessgw OnWriteComplete correlation is live (server b948e69, worker
53f69cd — all four unary write kinds), so every write reply's statuses[0] now
carries the real COM commit outcome. Live-gating the cutover on windev found
and fixed three defects:

- StatusCodeMap.FromMxStatus truncated the correlated failure row's MxStatus
  detail code to a byte (1007 & 0xFF = 239 -> OPC DA Good band), mapping a
  REFUSED write to Good. New invariant: a row that is not IsSuccess() never
  maps Good — details > 0xFF (and contradictory Good-band substatuses) route
  by category (SECURITY_ERROR -> BadUserAccessDenied, named const covered by
  StatusCodeParityTests; COMMUNICATION/CONFIGURATION keep their codes).

- The borrow-from-subscription-registry write optimization could never commit:
  a subscription handle is plain-advised, and MXAccess refuses both the
  no-login write through it (SECURITY_ERROR detail 1008, live-captured) and
  the supervisory advise upgrade (E_INVALIDARG) — one advise per HANDLE, not
  per attribute. Removed: writer subscribedHandleSource ctor param + borrow
  branch, GalaxyDriver wiring, SubscriptionRegistry.TryResolveItemHandle +
  its forward index and test file. The writer always AddItems its own
  supervisory-advised handle, which commits fine while the same attribute is
  subscribed (live-proven; pinned by the new write-while-subscribed smoke).

- (gateway-side, fixed there) the correlation initially covered WriteSecured
  only, while the dominant no-login path emits plain Write.

Live gate, all legs PASSED against windev: happy write Good off a confirmed
status row with galaxy.writes.unconfirmed + advise_failed both FLAT; read-only
refusal surfaces Bad-family (the #5 node-revert input Galaxy could never
produce before); WriteCompletionWaitMilliseconds=0 control -> provisional Good
+ metered unconfirmed (override removed after, restoration re-verified). New
env-gated smokes: Live_correlated_write_statuses_confirm_commit_and_surface_refusal,
Live_write_to_subscribed_tag_commits_via_own_handle (replaces the borrow
smoke), GatewayGalaxyWriteMeterLiveTests (run filtered — meters are
process-wide).
2026-08-09 20:11:55 -04:00

213 lines
11 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>Verifies that known detail codes map to their specific status codes.</summary>
[Fact]
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 /* Bad_NotConnected */ };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
}
/// <summary>Verifies that non-zero DetectedBy maps to communication error when detail is zero.</summary>
[Fact]
public void FromMxStatus_SuccessZero_DetailZero_DetectedByNonZero_IsCommunicationError()
{
var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3 };
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>A refused write (operational error, MxStatus detail code) must map to a Bad-family code.</summary>
[Fact]
public void FromMxStatus_WriteRefusal_OperationalError_Detail1007_IsBad()
{
// Live shape: writeRaw to read-only TestMachine_002.Tagname.
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 1007, RawDetectedBy = 5 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
}
/// <summary>A security-refused write must map to BadUserAccessDenied, not Good.</summary>
[Fact]
public void FromMxStatus_WriteRefusal_SecurityError_Detail1008_IsUserAccessDenied()
{
// Live shape: writeRaw through a plain-advised (subscription) handle — no supervisory advise.
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SecurityError, Detail = 1008, RawDetectedBy = 5 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
}
/// <summary>Category-level routing for large details preserves communication/configuration specificity.</summary>
[Theory]
[InlineData(MxStatusCategory.CommunicationError, 0x80050000u)] // BadCommunicationError
[InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)] // BadConfigurationError
[InlineData(MxStatusCategory.SoftwareError, 0x80000000u)] // unmapped category → generic Bad
public void FromMxStatus_LargeDetail_RoutesByCategory(MxStatusCategory category, uint expected)
{
var s = new MxStatusProxy { Success = 0, Category = category, Detail = 2001 };
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
}
/// <summary>
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality
/// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to
/// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good).
/// </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>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
[Fact]
public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode()
{
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue);
}
/// <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);
}
}