bac93744ce
R1: NativeAlarmProjector treats Raise-on-Active as a re-statement only when the message matches what
the condition shows; a different alarm appearing during a respawn un-acks and writes its alerts row.
FOCAS condition messages are a deterministic function of the matched set (tested across fresh driver
instances and reordered lists); Runtime E2E now covers both the same alarm and a new one during a
respawn. Residual (source message determinism) documented in AlarmTracking.md.
R2: a non-OK cnc_rdalmmsg2 return is thrown as FocasWireException{Rc} and classified as a CNC
refusal: conditions get a quality-only BadNotSupported once, one Warning, polling continues, and
driver health is untouched (no 90 s watchdog reconnect loop on a production control). Only transport
faults degrade (falsification control in the test).
R3: alarm-path health is tracked apart from the value path and combined in GetHealth: worst state,
later LastSuccessfulRead. A good value read can no longer mask a failing alarm read (no per-poll
/hosts flip) and a good alarm read cannot mask a value Degraded.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
282 lines
11 KiB
C#
282 lines
11 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");
|
|
}
|
|
|
|
/// <summary>Gitea #641 — <c>RetainOnly</c> keeps a still-deployed condition's prior state and forgets a removed
|
|
/// one's (which then starts from the default inactive + acked).</summary>
|
|
[Fact]
|
|
public void RetainOnly_keeps_deployed_conditions_and_forgets_removed_ones()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("kept", Evt(AlarmTransitionKind.Raise));
|
|
sut.Project("removed", Evt(AlarmTransitionKind.Raise));
|
|
|
|
sut.RetainOnly(new HashSet<string>(StringComparer.Ordinal) { "kept" });
|
|
|
|
sut.Project("kept", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeTrue();
|
|
sut.Project("removed", Evt(AlarmTransitionKind.Acknowledge)).Active.ShouldBeFalse();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
// Gitea #626 — a source with a finer severity scale than the four buckets (MTConnect Fault = 800) sets
|
|
// OpcUaSeverity, which supersedes the bucket on the projected condition. Clamped to the Part 9 1..1000.
|
|
[Theory]
|
|
[InlineData(800, 800)]
|
|
[InlineData(500, 500)]
|
|
[InlineData(0, 1)]
|
|
[InlineData(5000, 1000)]
|
|
public void Explicit_opcua_severity_supersedes_the_bucket(int requested, int projected)
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Raise, AlarmSeverity.High) with { OpcUaSeverity = requested });
|
|
|
|
snap.Severity.ShouldBe((ushort)projected);
|
|
}
|
|
|
|
// Gitea #626 — a per-condition source quality (MTConnect Unavailable) rides the snapshot's StatusCode, the
|
|
// same Quality child #477's connectivity annotation writes.
|
|
[Fact]
|
|
public void Supplied_quality_is_projected_onto_the_snapshot()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Clear) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
|
|
|
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
|
}
|
|
|
|
// Gitea #626 — a quality-only annotation (Unspecified kind + quality) must not move Active/Acked: an
|
|
// active, unacknowledged condition stays exactly that while only its Quality changes.
|
|
[Fact]
|
|
public void Quality_only_annotation_keeps_the_prior_alarm_state()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise));
|
|
|
|
var snap = sut.Project("n1", Evt(AlarmTransitionKind.Unspecified) with { QualityStatusCode = OpcUaStatus.BadNoCommunication });
|
|
|
|
snap.Active.ShouldBeTrue();
|
|
snap.Acknowledged.ShouldBeFalse();
|
|
snap.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #647 review — a Raise on a condition that is ALREADY active is a re-statement (a respawned driver
|
|
/// re-reading a still-active alarm it never told the server about): it keeps the acknowledgement and is
|
|
/// flagged so the host writes no duplicate "Activated" row. Only a Retrigger (or inactive → active) un-acks.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_Raise_on_an_already_active_condition_is_a_restatement_that_keeps_the_ack()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var first).Active.ShouldBeTrue();
|
|
first.ShouldBeFalse("inactive → active is a real transition");
|
|
sut.MarkAcknowledged("n1");
|
|
|
|
var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out var restatement);
|
|
restatement.ShouldBeTrue();
|
|
restated.Active.ShouldBeTrue();
|
|
restated.Acknowledged.ShouldBeTrue("a re-statement must not un-acknowledge");
|
|
|
|
var retriggered = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out var retrigger);
|
|
retrigger.ShouldBeFalse();
|
|
retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #647 re-review R1 — a respawned driver cannot tell "the same alarm" from "a different one that
|
|
/// appeared while it was down"; its Raise is a re-statement only when its message matches what the condition
|
|
/// shows. A different message is a new statement: it un-acks and is not flagged (so the alerts row is written).
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_Raise_on_an_active_condition_with_a_different_message_is_a_new_statement()
|
|
{
|
|
var sut = new NativeAlarmProjector();
|
|
sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"));
|
|
sut.MarkAcknowledged("n1");
|
|
|
|
var same = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "401 SERVO (type 4 Servo, axis 1)"), out var r1);
|
|
r1.ShouldBeTrue();
|
|
same.Acknowledged.ShouldBeTrue();
|
|
|
|
var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out var r2);
|
|
r2.ShouldBeFalse("a different alarm is not a re-statement");
|
|
other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm");
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|