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"); } /// Gitea #641 — RetainOnly keeps a still-deployed condition's prior state and forgets a removed /// one's (which then starts from the default inactive + acked). [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(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); } /// /// 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. /// [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 bool first).Active.ShouldBeTrue(); first.ShouldBeFalse("inactive → active is a real transition"); sut.MarkAcknowledged("n1"); var restated = sut.Project("n1", Evt(AlarmTransitionKind.Raise), out bool 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 bool retrigger); retrigger.ShouldBeFalse(); retriggered.Acknowledged.ShouldBeFalse("a Retrigger is a new statement and un-acks"); } /// /// 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). /// [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 bool r1); r1.ShouldBeTrue(); same.Acknowledged.ShouldBeTrue(); var other = sut.Project("n1", Evt(AlarmTransitionKind.Raise, msg: "506 OVER TRAVEL (type 2 Overtravel, axis 2)"), out bool r2); r2.ShouldBeFalse("a different alarm is not a re-statement"); other.Acknowledged.ShouldBeFalse("the operator has not seen this alarm"); } // --------------------------------------------------------------------------------------------------------- // Gitea #656 — ActiveAcknowledged + IsRestatement. Every "no row" test is paired with a control that MUST write. // --------------------------------------------------------------------------------------------------------- /// #656 claim 1 — an acked active alarm on a condition the host has never seen (a cold start) projects /// ACTIVE+acked in one update, with an Activated row then an Acknowledged row. Pre-fix Galaxy sent Acknowledge, /// which projected inactive+acked: an alarm active in AVEVA was invisible to OPC UA clients. [Fact] public void ActiveAcknowledged_on_an_unknown_condition_projects_active_and_acked_with_Activated_then_Acknowledged_rows() { var sut = new NativeAlarmProjector(); var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows); snap.Active.ShouldBeTrue(); snap.Acknowledged.ShouldBeTrue(); rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]); } /// #656 — the wider half of claim 1: a KNOWN but inactive prior (cleared before a gateway outage, then went /// active and acked during it) must also activate. [Fact] public void ActiveAcknowledged_on_an_inactive_known_prior_activates() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Raise)); sut.Project("n1", Evt(AlarmTransitionKind.Clear)).Active.ShouldBeFalse(); var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows); snap.Active.ShouldBeTrue(); snap.Acknowledged.ShouldBeTrue(); rows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]); } [Fact] public void ActiveAcknowledged_on_an_active_unacked_condition_writes_only_an_Acknowledged_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Raise)); var snap = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged), out IReadOnlyList rows); snap.Active.ShouldBeTrue(); snap.Acknowledged.ShouldBeTrue(); rows.ShouldBe([AlarmTransitionKind.Acknowledge]); } /// #656 related item 2 — a Galaxy reconnect replays an ActiveAcked entry the host already shows; that /// is no row. Control: the same entry with a different message is a different alarm and writes both rows. [Fact] public void ActiveAcknowledged_on_an_active_acked_condition_with_the_same_message_writes_no_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi")); var same = sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hi"), out IReadOnlyList sameRows); sameRows.ShouldBeEmpty(); same.Active.ShouldBeTrue(); same.Acknowledged.ShouldBeTrue(); // Control: a different message is a different alarm. sut.Project("n1", Evt(AlarmTransitionKind.ActiveAcknowledged, msg: "hihi"), out IReadOnlyList otherRows); otherRows.ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]); } /// #656 claim 2 — a re-stated Retrigger (Galaxy snapshot replay, OpcUaClient first sight) on a condition /// already Active+unacked with the same message and severity writes no row; the node does not change. [Fact] public void A_restated_Retrigger_on_an_active_unacked_condition_with_same_message_and_severity_writes_no_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList first); first.ShouldBe([AlarmTransitionKind.Retrigger], "the first statement on an unknown condition is a real activation"); var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList rows); rows.ShouldBeEmpty(); snap.Active.ShouldBeTrue(); snap.Acknowledged.ShouldBeFalse(); } /// Control (a): an UNFLAGGED Retrigger — a live Galaxy re-occurrence, a FOCAS key swap — with the same /// everything is a genuine re-occurrence and keeps its row. FOCAS/MTConnect/AbCip behaviour is unchanged. [Fact] public void An_unflagged_Retrigger_on_an_active_unacked_condition_still_writes_an_Activated_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger)); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger), out IReadOnlyList rows); rows.ShouldBe([AlarmTransitionKind.Retrigger]); } /// Control (b): a restated Retrigger whose SEVERITY changed (MTConnect Warning→Fault with a detail text /// keeps the message) is a new statement. [Fact] public void A_restated_Retrigger_with_a_different_severity_writes_an_Activated_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.Medium, restated: true)); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, AlarmSeverity.High, restated: true), out IReadOnlyList rows); rows.ShouldBe([AlarmTransitionKind.Retrigger]); } /// Control (b'): the exact-severity path () is compared too. [Fact] public void A_restated_Retrigger_with_a_different_exact_severity_writes_an_Activated_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 500 }); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true) with { OpcUaSeverity = 800 }, out IReadOnlyList rows); rows.ShouldBe([AlarmTransitionKind.Retrigger]); } /// Control (c): a restated Retrigger with a different message is a different alarm. [Fact] public void A_restated_Retrigger_with_a_different_message_writes_an_Activated_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "a", restated: true)); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, msg: "b", restated: true), out IReadOnlyList rows); rows.ShouldBe([AlarmTransitionKind.Retrigger]); } /// Control (d): a restated Retrigger on an Active+ACKED condition un-acks (upstream's ack state is /// authoritative), so the node changes and the row is written. [Fact] public void A_restated_Retrigger_on_an_active_acked_condition_unacks_and_writes_an_Activated_row() { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(AlarmTransitionKind.Retrigger)); sut.MarkAcknowledged("n1"); var snap = sut.Project("n1", Evt(AlarmTransitionKind.Retrigger, restated: true), out IReadOnlyList rows); snap.Acknowledged.ShouldBeFalse(); rows.ShouldBe([AlarmTransitionKind.Retrigger]); } /// #656 related item 3 — a restated Clear (Galaxy Inactive snapshot entry) on a condition that is already /// inactive writes no row; the unflagged control and a restated Clear of an ACTIVE condition both write one. [Fact] public void A_restated_Clear_on_an_inactive_condition_writes_no_row() { var sut = new NativeAlarmProjector(); sut.Project("unknown", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList unknownRows); unknownRows.ShouldBeEmpty(); sut.Project("n1", Evt(AlarmTransitionKind.Raise)); sut.Project("n1", Evt(AlarmTransitionKind.Clear)); sut.Project("n1", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList rows); rows.ShouldBeEmpty(); // Controls: unflagged on inactive keeps its row (unchanged behaviour); restated on ACTIVE is a real clear. sut.Project("n1", Evt(AlarmTransitionKind.Clear), out IReadOnlyList unflagged); unflagged.ShouldBe([AlarmTransitionKind.Clear]); sut.Project("n2", Evt(AlarmTransitionKind.Raise)); sut.Project("n2", Evt(AlarmTransitionKind.Clear, restated: true), out IReadOnlyList active); active.ShouldBe([AlarmTransitionKind.Clear]); } /// #656 — the existing per-kind rows for unflagged sources are exactly one row of the event's own kind /// (what the host wrote before, byte-identical for FOCAS/MTConnect/AbCip). [Theory] [InlineData(AlarmTransitionKind.Raise)] [InlineData(AlarmTransitionKind.Retrigger)] [InlineData(AlarmTransitionKind.Acknowledge)] [InlineData(AlarmTransitionKind.Clear)] [InlineData(AlarmTransitionKind.Unspecified)] public void An_unflagged_transition_writes_one_row_of_its_own_kind(AlarmTransitionKind kind) { var sut = new NativeAlarmProjector(); sut.Project("n1", Evt(kind), out IReadOnlyList rows); rows.ShouldBe([kind]); } private static AlarmEventArgs Evt( AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High, string msg = "m", bool restated = false) => new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind, IsRestatement: restated); private sealed class H : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; } }