Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/StatusCodeParityTests.cs
T
Joseph Doherty 2d20aeb779 fix(opcua,drivers): carry driver status sub-codes to clients (#519) + health-write ordering (#517)
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact.
DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum
and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication,
BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every
driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in
logs only. Resolved by option (b): the raw uint rides AttributeValuePublished ->
AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and
severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad).

The surface was wider than the issue costed (41 files, not "the sink and 3
implementations"): OpcUaQuality had non-driver producers with no uint to carry.
Each got its natural code — BadInternalError for a failed virtual-tag script,
BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of
mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the
engine already held. AddressSpaceApplier's removal writes deliberately keep the
generic Bad: not losing a code the driver had is this issue; inventing one for the
deploy path is a separate call.

Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change
rather than a severity-bucket change. While the annotation was 3-state those were the
same predicate; now a move between two Bad reasons changes what a subscriber reads.

New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and
StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so
it can only see a named constant, never an inline literal.

#517 — the symptom is real but the issue named the wrong path and its fix was unsafe.
ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the
mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth,
has no production call site.) InitializeAsync can: it runs on a fire-and-forget
Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a
catch which disposes the client before writing Faulted.

Moving the write earlier, as suggested, inverts the terminal state — teardown drains
in-flight loops that write health as they unwind, so a late loop could leave Healthy at
rest with every connection closed. The fix is a bracket (conservative before, terminal
after), applied to MTConnect's init-failure path only; shutdown keeps its single
trailing write, and the rule plus the reachability table are documented on
IDriver.ShutdownAsync.

CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing —
Healthy before initialize, after shutdown and after dispose. Now derived from _running.
Its test, named Health_is_always_Connected, asserted the defect and is replaced.

Every new guard was falsified by breaking the production code and watching it go red.
The two sub-code tests each assert two different Bad codes, since a single-code
assertion could pass against a hard-coded constant under the old projection.

Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in
Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in
Host.IntegrationTests.

Closes #517
Closes #519

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 05:25:27 -04:00

191 lines
9.8 KiB
C#

using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards every driver's hard-coded OPC UA status-code constant against
/// <see cref="Opc.Ua.StatusCodes"/> — the pinned SDK is the oracle, and a constant whose
/// <em>name</em> disagrees with its <em>value</em> fails here.
/// <para><b>Why the drivers hard-code these at all.</b> The driver layer is deliberately SDK-free: a
/// driver assembly must not drag in the OPC UA stack just to spell a status code, so each one declares
/// the handful it needs as bare <c>uint</c> literals. That is a sound layering decision with one
/// failure mode — nothing checks the literal against the name — and it is exactly the failure mode
/// Gitea #497 found in six places across four drivers.</para>
/// <para><b>Discovery is reflection-driven, not a hand-copied list</b> (the same convention
/// <see cref="DriverTypeNamesGuardTests"/> uses): the test scans every
/// <c>ZB.MOM.WW.OtOpcUa.Driver.*.dll</c> deployed to its output directory for <c>const uint</c> fields
/// whose name reads as a status code, so a brand-new driver assembly referenced by this project is
/// covered automatically, with no edit here.</para>
/// <para><b>Commons is in scope too</b> (Gitea #519). <c>Commons.OpcUa.OpcUaStatus</c> is SDK-free for
/// the same layering reason the drivers are — <c>IOpcUaAddressSpaceSink</c> exists so Runtime need not
/// reference <c>Opc.Ua.Server</c> — so its constants carry the identical name-vs-value risk. Commons
/// arrives here transitively through <c>Core</c>.</para>
/// <para><b>An inline literal is invisible to this guard.</b> Reflection can only see a <em>named</em>
/// constant, so <c>StatusCode: 0x800B0000u, // BadTimeout</c> written at a call site is unguarded —
/// which is how <c>GalaxyDriver</c> shipped <c>BadServiceUnsupported</c> under a <c>BadTimeout</c>
/// comment. Hoist a status literal into a named constant rather than writing it at the call site.</para>
/// </summary>
public sealed class StatusCodeParityTests
{
/// <summary>
/// Name prefixes that mark a <c>uint</c> constant as an OPC UA status code. These are the three
/// Part 4 severity categories, so they cover the whole space by construction — a status constant
/// that does not start with one of them is not a status constant.
/// </summary>
private static readonly string[] StatusPrefixes = ["Good", "Uncertain", "Bad"];
/// <summary>
/// Field-name prefixes stripped before the name is looked up on <see cref="Opc.Ua.StatusCodes"/>.
/// Drivers vary the spelling (<c>SampleMapper.StatusBadNoData</c> vs
/// <c>SqlStatusCodes.BadTimeout</c>); the SDK member is <c>BadNoData</c> either way.
/// </summary>
private static readonly string[] NamePrefixesToStrip = ["Status"];
/// <summary>Every <c>Opc.Ua.StatusCodes</c> member, by name — the oracle this test checks against.</summary>
private static readonly IReadOnlyDictionary<string, uint> SdkStatusCodes =
typeof(Opc.Ua.StatusCodes)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(uint))
.ToDictionary(f => f.Name, f => (uint)f.GetRawConstantValue()!, StringComparer.Ordinal);
/// <summary>
/// Every status-shaped <c>const uint</c> declared anywhere in the deployed driver assemblies, as
/// <c>(assembly, declaring type, field name, SDK member name, declared value)</c>.
/// </summary>
/// <remarks>
/// Non-public types and fields are included on purpose — <c>SampleMapper.StatusBadNoData</c> is a
/// <c>private const</c> on an <c>internal</c> class, and it carried one of the #497 defects. Access
/// modifiers say nothing about whether a value reaches an OPC UA client.
/// </remarks>
public static TheoryData<string, string, string, string, uint> DeclaredStatusConstants()
{
var data = new TheoryData<string, string, string, string, uint>();
var binDir = Path.GetDirectoryName(typeof(StatusCodeParityTests).Assembly.Location)!;
var scanned = Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Driver.*.dll")
.Concat(Directory.GetFiles(binDir, "ZB.MOM.WW.OtOpcUa.Commons.dll"))
.OrderBy(p => p, StringComparer.Ordinal);
foreach (var dll in scanned)
{
Assembly asm;
try { asm = Assembly.LoadFrom(dll); }
catch { continue; }
Type?[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
foreach (var type in types)
{
if (type is null) continue;
var fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly);
foreach (var field in fields)
{
if (!field.IsLiteral || field.IsInitOnly || field.FieldType != typeof(uint)) continue;
var sdkName = StripKnownPrefix(field.Name);
if (!StatusPrefixes.Any(p => sdkName.StartsWith(p, StringComparison.Ordinal))) continue;
data.Add(
asm.GetName().Name!,
type.FullName ?? type.Name,
field.Name,
sdkName,
(uint)field.GetRawConstantValue()!);
}
}
}
return data;
}
/// <summary>Removes a leading <c>Status</c>-style prefix so the remainder can be looked up on the SDK type.</summary>
private static string StripKnownPrefix(string fieldName)
{
foreach (var prefix in NamePrefixesToStrip)
{
if (fieldName.Length > prefix.Length && fieldName.StartsWith(prefix, StringComparison.Ordinal))
return fieldName[prefix.Length..];
}
return fieldName;
}
/// <summary>
/// A driver's status constant must carry the value the SDK gives the code it is named after.
/// </summary>
/// <param name="assembly">The declaring driver assembly (failure-message context).</param>
/// <param name="type">The declaring type's full name (failure-message context).</param>
/// <param name="field">The constant's name as declared.</param>
/// <param name="sdkName">The <see cref="Opc.Ua.StatusCodes"/> member the name resolves to.</param>
/// <param name="declared">The value the driver declares.</param>
[Theory]
[MemberData(nameof(DeclaredStatusConstants))]
public void Driver_status_constant_matches_the_pinned_SDK(
string assembly, string type, string field, string sdkName, uint declared)
{
SdkStatusCodes.ShouldContainKey(sdkName,
$"{type}.{field} (in {assembly}) reads as an OPC UA status code, but '{sdkName}' is not a member " +
"of Opc.Ua.StatusCodes. Either the constant is misnamed, or it is not a status code and should " +
"not start with Good/Uncertain/Bad.");
var expected = SdkStatusCodes[sdkName];
declared.ShouldBe(expected,
$"{type}.{field} (in {assembly}) is declared 0x{declared:X8}, but Opc.Ua.StatusCodes.{sdkName} " +
$"is 0x{expected:X8}" +
(SdkStatusCodes.FirstOrDefault(kv => kv.Value == declared) is { Key: { } actual } && actual.Length > 0
? $" — 0x{declared:X8} is actually {actual}, which is what OPC UA clients would branch on."
: ". The declared value is not any OPC UA status code."));
}
/// <summary>
/// Discovery must find constants in more than one driver assembly. A zero (or near-zero) here means
/// the bin scan broke or the drivers stopped declaring these by convention — either way the theory
/// above would pass vacuously, which is the one outcome a guard test must never do quietly.
/// </summary>
[Fact]
public void Discovery_finds_status_constants_across_multiple_driver_assemblies()
{
var found = DeclaredStatusConstants();
found.Count.ShouldBeGreaterThan(20,
"the driver bin scan found almost no status constants — the assemblies did not deploy to bin, " +
"or the naming convention changed");
var assemblies = found
.Select(row => row.Data.Item1)
.Distinct(StringComparer.Ordinal)
.ToArray();
assemblies.Length.ShouldBeGreaterThan(3,
"status constants were found in fewer than four driver assemblies: " + string.Join(", ", assemblies));
}
/// <summary>
/// Positive control for the Commons half of the scan (#519). Widening the glob is worthless if
/// <c>Commons.dll</c> does not actually deploy to this project's bin — the theory would simply run
/// over the driver rows and pass, reporting nothing. Naming the type explicitly means a broken
/// transitive reference fails here instead of silently un-guarding the engine's status constants.
/// </summary>
[Fact]
public void Discovery_covers_the_SDK_free_Commons_status_constants()
{
var commonsRows = DeclaredStatusConstants()
.Select(row => row.Data)
.Where(r => r.Item2 == "ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus")
.ToArray();
commonsRows.Length.ShouldBeGreaterThan(3,
"OpcUaStatus' constants were not discovered — ZB.MOM.WW.OtOpcUa.Commons.dll did not deploy to " +
"this project's bin, so the engine-layer status codes are unguarded despite the widened scan.");
commonsRows.Select(r => r.Item3).ShouldContain(nameof(OpcUaStatus.BadNoCommunication));
}
}