using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// Pure OPC UA A&C field → AlarmConditionState/transition mapping. public class OpcUaAlarmMapperTests { // ── Routing identity must be the binding, not the event name ── /// Verifies that a NodeId binding routes on the binding rather than the event name. [Fact] public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName() { // OtOpcUa v3 post-migration: SourceName is the RawPath, ConditionName the leaf. The // binding is a durable node reference. The bug was SourceObjectReference = SourceName, // which never prefix-matches a NodeId binding, so every transition was dropped. var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity( subscriptionSourceReference: "nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200", sourceName: "pymodbus/plc/HR200", conditionName: "HR200"); // Routing identity == the binding, verbatim → DataConnectionActor exact-matches. Assert.Equal("nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200", sourceObjectRef); // Per-condition key stays the readable RawPath + condition, kept distinct. Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef); } /// Verifies that a legacy index binding routes verbatim. [Fact] public void BuildIdentity_LegacyIndexBinding_RoutesVerbatim() { // A binding stored in the old ns= form routes just as well — the point of using // the binding verbatim is that it is form-agnostic. var (_, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity( subscriptionSourceReference: "ns=2;s=pymodbus/plc/HR200", sourceName: "pymodbus/plc/HR200", conditionName: "HR200"); Assert.Equal("ns=2;s=pymodbus/plc/HR200", sourceObjectRef); } /// Verifies that an aggregate feed keeps an empty identity. [Fact] public void BuildIdentity_AggregateFeed_StaysEmpty() { // Empty binding = the Server-object aggregate feed: it must reach only "mirror // everything" subscribers (StartsWith("") matches all) and never a specific-node // binding, so the routing identity stays empty while the per-condition key is real. var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity( subscriptionSourceReference: "", sourceName: "pymodbus/plc/HR200", conditionName: "HR200"); Assert.Equal("", sourceObjectRef); Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef); } /// Verifies that a missing condition name uses the source name alone. [Fact] public void BuildIdentity_NoConditionName_UsesSourceNameAlone() { var (sourceRef, _) = OpcUaAlarmMapper.BuildIdentity( subscriptionSourceReference: "nsu=urn:x;s=Tank01", sourceName: "Tank01", conditionName: null); Assert.Equal("Tank01", sourceRef); } /// Verifies that a missing source name falls back to the binding for the key. [Fact] public void BuildIdentity_NoSourceName_FallsBackToBindingForKey() { // A server that leaves SourceName unset still yields a stable, non-empty key. var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity( subscriptionSourceReference: "ns=2;s=X", sourceName: null, conditionName: "Hi"); Assert.Equal("ns=2;s=X.Hi", sourceRef); Assert.Equal("ns=2;s=X", sourceObjectRef); } /// Verifies that severity normalization clamps to the range 0 to 1000. [Fact] public void NormalizeSeverity_ClampsTo0_1000() { Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000)); Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1)); Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500)); } /// Verifies that an active, unacknowledged event builds the expected condition state. [Fact] public void BuildCondition_ActiveUnacked() { var c = OpcUaAlarmMapper.BuildCondition(active: true, acked: false, confirmed: null, shelve: AlarmShelveState.Unshelved, suppressed: false, severity: 700); Assert.True(c.Active); Assert.False(c.Acknowledged); Assert.Equal(700, c.Severity); Assert.Equal(AlarmShelveState.Unshelved, c.Shelve); } /// Verifies that an acknowledge edge yields an Acknowledge transition. [Fact] public void DeriveKind_AckEdge_YieldsAcknowledge() { Assert.Equal(AlarmTransitionKind.Acknowledge, OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true)); } /// Verifies that an active edge yields a Raise transition. [Fact] public void DeriveKind_ActiveEdge_YieldsRaise() { Assert.Equal(AlarmTransitionKind.Raise, OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true)); } /// Verifies that an inactive edge yields a Clear transition. [Fact] public void DeriveKind_InactiveEdge_YieldsClear() { Assert.Equal(AlarmTransitionKind.Clear, OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false)); } /// Verifies that the current shelving state name maps to the expected shelve state. /// The shelving state name reported by the source, or null. /// The expected shelve state. [Theory] [InlineData("OneShotShelved", AlarmShelveState.OneShotShelved)] [InlineData("TimedShelved", AlarmShelveState.TimedShelved)] [InlineData("Unshelved", AlarmShelveState.Unshelved)] [InlineData(null, AlarmShelveState.Unshelved)] public void MapShelve_MapsCurrentStateName(string? name, AlarmShelveState expected) { Assert.Equal(expected, OpcUaAlarmMapper.MapShelve(name)); } // ── PickLimitValue ───────────────────────────────────────── /// Verifies that a set of all-null limits returns an empty value. [Fact] public void PickLimitValue_AllNull_ReturnsEmpty() { // All four limit fields absent (non-limit alarm type) → empty string. Assert.Equal("", OpcUaAlarmMapper.PickLimitValue(null, null, null, null)); } /// Verifies that a present high-high limit is returned. [Fact] public void PickLimitValue_HighHighLimitPresent_ReturnsIt() { // HighHighLimit takes top priority; other fields are null (absent). var result = OpcUaAlarmMapper.PickLimitValue(100.5, null, null, null); Assert.Equal("100.5", result); } /// Verifies that when only the high limit is present it is returned. [Fact] public void PickLimitValue_OnlyHighLimit_ReturnsHighLimit() { // Only HighLimit present (HighHighLimit absent on this alarm type). var result = OpcUaAlarmMapper.PickLimitValue(null, 80.0, null, null); Assert.Equal("80", result); } /// Verifies that the high-high limit wins over the high limit when both are present. [Fact] public void PickLimitValue_PriorityOrder_HighHighWinsOverHigh() { // When multiple limits are present, HighHighLimit takes precedence. var result = OpcUaAlarmMapper.PickLimitValue(95.0, 80.0, 20.0, 5.0); Assert.Equal("95", result); } /// Verifies that when only the low-low limit is present it is returned. [Fact] public void PickLimitValue_OnlyLowLow_ReturnsLowLow() { // LowLowLimit only — last in priority, but should still be returned. var result = OpcUaAlarmMapper.PickLimitValue(null, null, null, -10.5); Assert.Equal("-10.5", result); } /// Verifies that limit values are formatted using the invariant culture. [Fact] public void PickLimitValue_UsesInvariantCulture() { // Decimal separator must always be '.' regardless of thread culture. var result = OpcUaAlarmMapper.PickLimitValue(1.5, null, null, null); Assert.Contains('.', result); // invariant culture: '.' not ',' Assert.Equal("1.5", result); } // ── MES alarm-status API §6.4: AckTime stamping ── private static readonly DateTimeOffset SourceAck = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Observed = new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero); /// Verifies that an active, acknowledged alarm prefers the source acknowledgement time. [Fact] public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant() { // OPC UA A&C DOES supply a true ack time (AckedState/TransitionTime). When the // server sends it, it must win over the coarser observation time — that is the // whole point of selecting field 18. Assert.Equal( SourceAck, OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed)); } /// Verifies that an active, acknowledged alarm without a source time falls back to the observation time. [Fact] public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime() { // Servers may omit AckedState/TransitionTime (base ConditionType events, or a // server that does not expose it). The fallback is the event's own time — real, // just coarser. Never fabricated, never null-when-acked. Assert.Equal( Observed, OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed)); } /// Verifies that an unacknowledged alarm has no acknowledgement time even when the source reports an acknowledgement transition. [Fact] public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition() { // AckedState/TransitionTime also stamps the flip BACK to unacked on a re-raise, // so it is present-but-meaningless there. The acked check is what makes "null // while unacked" and "cleared on re-raise" hold. Assert.Null(OpcUaAlarmMapper.DeriveAckTime( active: true, acked: false, sourceAckTime: SourceAck, Observed)); } /// Verifies that an inactive alarm has no acknowledgement time. [Fact] public void DeriveAckTime_Inactive_IsNull() { // A cleared condition is on its way out of the mirror and reports no ack time, // matching the MxGateway mapper (where INACTIVE maps to acked = true). Assert.Null(OpcUaAlarmMapper.DeriveAckTime( active: false, acked: true, sourceAckTime: SourceAck, Observed)); } }