01bcca992c
MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.
Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.
Provenance is honest, never fabricated:
- OPC UA A&C supplies a TRUE ack instant, so we now select it:
AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
index 18, APPENDED so the positional reads at 0-17 keep their meaning.
Servers that omit the field fall back to the event's own Time.
- MxAccess Gateway supplies none, so the ack transition's own timestamp is
used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
re-subscribe snapshot restores one from LastTransitionTimestamp rather
than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.
Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.
Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.
Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
228 lines
8.9 KiB
C#
228 lines
8.9 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>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
|
|
public class OpcUaAlarmMapperTests
|
|
{
|
|
// ── Gitea #17: routing identity must be the binding, not the event name ──
|
|
|
|
[Fact]
|
|
public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName()
|
|
{
|
|
// OtOpcUa v3 post-#473: 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildIdentity_NoConditionName_UsesSourceNameAlone()
|
|
{
|
|
var (sourceRef, _) = OpcUaAlarmMapper.BuildIdentity(
|
|
subscriptionSourceReference: "nsu=urn:x;s=Tank01",
|
|
sourceName: "Tank01",
|
|
conditionName: null);
|
|
|
|
Assert.Equal("Tank01", sourceRef);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void NormalizeSeverity_ClampsTo0_1000()
|
|
{
|
|
Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000));
|
|
Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1));
|
|
Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeriveKind_AckEdge_YieldsAcknowledge()
|
|
{
|
|
Assert.Equal(AlarmTransitionKind.Acknowledge,
|
|
OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeriveKind_ActiveEdge_YieldsRaise()
|
|
{
|
|
Assert.Equal(AlarmTransitionKind.Raise,
|
|
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeriveKind_InactiveEdge_YieldsClear()
|
|
{
|
|
Assert.Equal(AlarmTransitionKind.Clear,
|
|
OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false));
|
|
}
|
|
|
|
[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 (M2.13 / #27) ─────────────────────────────────────────
|
|
|
|
[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));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
}
|