Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
T
Joseph Doherty db751d12a5
v2-ci / build (pull_request) Successful in 4m49s
v2-ci / unit-tests (pull_request) Failing after 3h11m25s
feat(alarms): condition Quality tracks source connectivity (#477)
Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good
so every native + scripted condition reported Good unconditionally — a
comms-lost device still showed a healthy, inactive, Good condition (a
wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs
bucketing on IsGood) could not tell "genuinely inactive" from "lost contact".

Layer 1 — make Quality a real, plumbed field:
- AlarmConditionSnapshot gains OpcUaQuality Quality (default Good).
- MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good).
- WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality
  member so a quality-bucket change fires a Part 9 event.

Layer 2 — drive native quality from driver connectivity (a comms-lost driver
emits no alarm transitions, and an alarm-bearing raw tag has no value variable,
so quality can't come from either existing channel):
- DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting.
- DriverHostActor fans it to every native condition the driver owns as
  OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect).
- New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and
  fires only on a bucket change — never touches Active/Acked/Retain (an active
  alarm that loses comms stays active). Not a full-snapshot re-projection, so it
  can't clobber severity/message and works for a never-fired condition.
  Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the
  reflection forwarding guard). Ungated by redundancy role; no /alerts row.

Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3).

Tests: node-level (materialise/project/no-clobber/unknown-node no-op),
NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor
fan-out, OpcUaPublishActor routing, and the wire-level guard
(Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified
against a simulated pre-fix always-Good server. Existing DriverInstanceActor
parent probes ignore the new ConnectivityChanged.

Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)";
design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
2026-07-17 15:10:04 -04:00

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.Quality.ShouldBe(OpcUaQuality.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";
}
}