Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs
T
Joseph Doherty dd4f1c2aa3 fix(alarms): Severity runs one direction, higher = more urgent; raw priority kept (#129)
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9
Severity (higher = more severe) while MxGateway native alarms and computed
alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer
ranks higher as worse, so Worst Severity, the severity filter/sort and the
site event-log Error threshold picked the LEAST urgent MxGateway/computed
alarms.

- AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887,
  b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500,
  751-999 -> 250; <= 0 clamps to Critical, > 999 to Low.
- MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority);
  AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged.
- AlarmConditionState gains trailing Priority (int?): the raw source number,
  exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire
  AlarmStateUpdate.priority keep that meaning (no proto change); central derives
  Severity by kind (SeverityFromPriority), so an older site decodes correctly.
- ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES
  API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved
  from a.Severity to a.Priority, so MES output is unchanged.
- AlarmActor raise logs Error when the banded severity >= 700 (same threshold
  as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700).
- NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate.
- Debug snapshot decode restores Condition.Priority from the row's priority.
- Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI,
  HighLevelReqs, MES alarm-status plan, CLAUDE.md.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 14:02:54 -05:00

252 lines
11 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
public class OpcUaAlarmMapperTests
{
// ── Routing identity must be the binding, not the event name ──
/// <summary>Verifies that a NodeId binding routes on the binding rather than the event name.</summary>
[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);
}
/// <summary>Verifies that a legacy index binding routes verbatim.</summary>
[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);
}
/// <summary>Verifies that an aggregate feed keeps an empty identity.</summary>
[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);
}
/// <summary>Verifies that a missing condition name uses the source name alone.</summary>
[Fact]
public void BuildIdentity_NoConditionName_UsesSourceNameAlone()
{
var (sourceRef, _) = OpcUaAlarmMapper.BuildIdentity(
subscriptionSourceReference: "nsu=urn:x;s=Tank01",
sourceName: "Tank01",
conditionName: null);
Assert.Equal("Tank01", sourceRef);
}
/// <summary>Verifies that a missing source name falls back to the binding for the key.</summary>
[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);
}
/// <summary>Verifies that severity normalization clamps to the range 0 to 1000.</summary>
[Fact]
public void NormalizeSeverity_ClampsTo0_1000()
{
Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000));
Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1));
Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500));
}
/// <summary>Verifies that an active, unacknowledged event builds the expected condition state.</summary>
[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(700, c.Priority); // OPC UA has no separate priority (#129)
Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
}
/// <summary>Verifies that an acknowledge edge yields an Acknowledge transition.</summary>
[Fact]
public void DeriveKind_AckEdge_YieldsAcknowledge()
{
Assert.Equal(AlarmTransitionKind.Acknowledge,
OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true));
}
/// <summary>Verifies that an active edge yields a Raise transition.</summary>
[Fact]
public void DeriveKind_ActiveEdge_YieldsRaise()
{
Assert.Equal(AlarmTransitionKind.Raise,
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true));
}
/// <summary>Verifies that an inactive edge yields a Clear transition.</summary>
[Fact]
public void DeriveKind_InactiveEdge_YieldsClear()
{
Assert.Equal(AlarmTransitionKind.Clear,
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false));
}
/// <summary>Verifies that the current shelving state name maps to the expected shelve state.</summary>
/// <param name="name">The shelving state name reported by the source, or null.</param>
/// <param name="expected">The expected shelve state.</param>
[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 ─────────────────────────────────────────
/// <summary>Verifies that a set of all-null limits returns an empty value.</summary>
[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));
}
/// <summary>Verifies that a present high-high limit is returned.</summary>
[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);
}
/// <summary>Verifies that when only the high limit is present it is returned.</summary>
[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);
}
/// <summary>Verifies that the high-high limit wins over the high limit when both are present.</summary>
[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);
}
/// <summary>Verifies that when only the low-low limit is present it is returned.</summary>
[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);
}
/// <summary>Verifies that limit values are formatted using the invariant culture.</summary>
[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);
/// <summary>Verifies that an active, acknowledged alarm prefers the source acknowledgement time.</summary>
[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));
}
/// <summary>Verifies that an active, acknowledged alarm without a source time falls back to the observation time.</summary>
[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));
}
/// <summary>Verifies that an unacknowledged alarm has no acknowledgement time even when the source reports an acknowledgement transition.</summary>
[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));
}
/// <summary>Verifies that an inactive alarm has no acknowledgement time.</summary>
[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));
}
}