2d20aeb779
#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
180 lines
5.8 KiB
C#
180 lines
5.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
public class NativeAlarmProjectorTests
|
|
{
|
|
[Fact]
|
|
public void Raise_projects_active_unacked_with_constant_fields_and_mapped_severity()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "boom"));
|
|
|
|
snap.Active.ShouldBeTrue();
|
|
snap.Acknowledged.ShouldBeFalse();
|
|
snap.Confirmed.ShouldBeTrue();
|
|
snap.Enabled.ShouldBeTrue();
|
|
snap.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
|
|
snap.Severity.ShouldBe((ushort)700);
|
|
snap.Message.ShouldBe("boom");
|
|
}
|
|
|
|
[Fact]
|
|
public void Retrigger_projects_active_unacked()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger));
|
|
|
|
snap.Active.ShouldBeTrue();
|
|
snap.Acknowledged.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_after_raise_acks_and_keeps_active()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
|
|
|
snap.Active.ShouldBeTrue();
|
|
snap.Acknowledged.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_after_raise_deactivates_and_keeps_prior_ack()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
|
|
|
|
snap.Active.ShouldBeFalse();
|
|
snap.Acknowledged.ShouldBeFalse(); // preserved prior (unacked) ack state
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_after_acknowledge_deactivates_and_keeps_acked()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge)); // active, acked
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear));
|
|
|
|
snap.Active.ShouldBeFalse();
|
|
snap.Acknowledged.ShouldBeTrue(); // preserved prior (acked) ack state
|
|
}
|
|
|
|
[Fact]
|
|
public void Unspecified_carries_prior_active_and_ack_state()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // active, unacked
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified));
|
|
|
|
snap.Active.ShouldBeTrue();
|
|
snap.Acknowledged.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Cold_state_defaults_to_inactive_acked()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
// Acknowledge on a never-seen node: prior Active defaults false, ack set true.
|
|
var snap = sut.Project("nNew", Evt(AlarmTransitionKind.Acknowledge));
|
|
|
|
snap.Active.ShouldBeFalse();
|
|
snap.Acknowledged.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Per_node_state_is_isolated()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active
|
|
|
|
// n2 is cold; a Clear on it must stay inactive and not borrow n1's active state.
|
|
var n2 = sut.Project("n2", Evt(AlarmTransitionKind.Clear));
|
|
|
|
n2.Active.ShouldBeFalse();
|
|
|
|
// n1 is unaffected by the n2 transition.
|
|
var n1 = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
|
n1.Active.ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AlarmSeverity.Low, (ushort)200)]
|
|
[InlineData(AlarmSeverity.Medium, (ushort)500)]
|
|
[InlineData(AlarmSeverity.High, (ushort)700)]
|
|
[InlineData(AlarmSeverity.Critical, (ushort)900)]
|
|
public void Severity_buckets_map_to_opcua_scale(AlarmSeverity sev, ushort expected)
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, sev));
|
|
|
|
snap.Severity.ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void Severity_and_message_always_come_from_the_event()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High, "first"));
|
|
|
|
// A later Clear carries its own severity + message; the snapshot reflects the event, not prior.
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear, AlarmSeverity.Low, "second"));
|
|
|
|
snap.Severity.ShouldBe((ushort)200);
|
|
snap.Message.ShouldBe("second");
|
|
}
|
|
|
|
[Fact]
|
|
public void Clear_resets_tracked_state()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise)); // n1 active, unacked
|
|
|
|
sut.Clear();
|
|
|
|
// After Clear() the node is cold again: an Acknowledge sees prior Active=false.
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
|
|
snap.Active.ShouldBeFalse();
|
|
snap.Acknowledged.ShouldBeTrue();
|
|
}
|
|
|
|
// #477 — native transitions carry Good quality; comms-loss quality is applied out-of-band via the
|
|
// dedicated WriteAlarmQuality path (connectivity → DriverHostActor), NOT through the projector, so the
|
|
// projector stays quality-agnostic and its transition snapshots default to Good.
|
|
[Fact]
|
|
public void Transition_projection_defaults_to_good_quality()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
|
|
|
snap.StatusCode.ShouldBe(OpcUaStatus.Good);
|
|
}
|
|
|
|
private static AlarmEventArgs Evt(
|
|
AlarmTransitionKind kind,
|
|
AlarmSeverity sev = AlarmSeverity.High,
|
|
string msg = "m")
|
|
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
|
|
|
|
private sealed class H : IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "t";
|
|
}
|
|
}
|