Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs
T
Joseph Doherty 01bcca992c feat(alarms): thread an additive AckTime through the native-alarm mirror
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.
2026-08-01 13:12:04 -04:00

322 lines
12 KiB
C#

using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
using CommonsTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>Task-12: pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
public class MxGatewayAlarmMapperTests
{
[Fact]
public void MapTransition_AckTransition_IsAcknowledgedWithOperator()
{
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Acknowledge,
Severity = 600,
OperatorUser = "operator1",
OperatorComment = "ack",
Category = "Process",
Description = "hi"
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
Assert.True(t.Condition.Active);
Assert.True(t.Condition.Acknowledged);
Assert.Equal(600, t.Condition.Severity);
Assert.Equal("operator1", t.OperatorUser);
Assert.Equal("Tank01", t.SourceObjectReference);
}
[Fact]
public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue()
{
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
Assert.True(c.Active);
Assert.True(c.Acknowledged);
Assert.Equal(600, c.Severity);
}
[Fact]
public void MapSnapshot_ActiveUnacked_IsSnapshotKind()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.Hi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.Hi",
CurrentState = ProtoConditionState.Active,
Severity = 1500 // out of range — must clamp
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
Assert.True(t.Condition.Active);
Assert.False(t.Condition.Acknowledged);
Assert.Equal(1000, t.Condition.Severity);
}
[Fact]
public void SourceReference_IsObjectRelative_NotFullProviderReference()
{
// The condition identity surfaced upward is the object-relative reference
// (e.g. "Z28061.HeartbeatTimeoutAlarm"), not the gateway's full provider
// reference ("Galaxy!<area>.<object>.<alarm>"). Area lives in Category.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm",
SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm",
AlarmTypeName = "Syst",
Category = "CVDAisle_1",
CurrentState = ProtoConditionState.Active,
Severity = 400
};
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Galaxy!CVDAisle_1.Z28061.HeartbeatTimeoutAlarm",
SourceObjectReference = "Z28061.HeartbeatTimeoutAlarm",
AlarmTypeName = "Syst",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 400
};
var snapT = MxGatewayAlarmMapper.MapSnapshot(snap);
var liveT = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", snapT.SourceReference);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", liveT.SourceReference);
Assert.Equal("CVDAisle_1", snapT.Category);
}
[Fact]
public void SourceReference_FallsBackToFullReference_WhenObjectReferenceEmpty()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!Area.Obj.Alarm",
SourceObjectReference = "",
CurrentState = ProtoConditionState.Active,
Severity = 100
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("Galaxy!Area.Obj.Alarm", t.SourceReference);
}
// ── CurrentValue / LimitValue (M2.13 / #27) ──────────────────────────────
[Fact]
public void MapTransition_CurrentAndLimitValue_PopulatedFromProto()
{
// The gateway proto OnAlarmTransitionEvent carries current_value and
// limit_value as MxValue union fields. Verify both are mapped through
// MxValueToString into the neutral NativeAlarmTransition strings.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 800,
CurrentValue = 95.3.ToMxValue(),
LimitValue = 90.0.ToMxValue()
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("95.3", t.CurrentValue);
Assert.Equal("90", t.LimitValue);
}
[Fact]
public void MapTransition_AbsentCurrentAndLimitValue_YieldsEmpty()
{
// When the gateway sends events without current/limit value fields (optional),
// the resulting transition must have empty strings — never null.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.Hi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.Hi",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 600
// CurrentValue and LimitValue not set → proto default (null reference)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal("", t.CurrentValue);
Assert.Equal("", t.LimitValue);
}
[Fact]
public void MapSnapshot_CurrentAndLimitValue_PopulatedFromProto()
{
// ActiveAlarmSnapshot also carries current_value and limit_value.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Pump01.Vibration.HiHi",
SourceObjectReference = "Pump01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
CurrentState = ProtoConditionState.Active,
Severity = 900,
CurrentValue = 12.7.ToMxValue(),
LimitValue = 10.0.ToMxValue()
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("12.7", t.CurrentValue);
Assert.Equal("10", t.LimitValue);
}
[Fact]
public void MapSnapshot_StringMxValue_ProducesStringCurrentValue()
{
// MxValue can carry string values (e.g. for discrete/string-type tags).
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Mode.Alarm",
SourceObjectReference = "Mode",
AlarmTypeName = "DiscreteAlarm",
CurrentState = ProtoConditionState.Active,
Severity = 500,
CurrentValue = "FAULT".ToMxValue()
};
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
Assert.Equal("FAULT", t.CurrentValue);
Assert.Equal("", t.LimitValue); // not set
}
// ── MES alarm-status API §6.4: AckTime stamping ──
private static readonly DateTimeOffset GatewayTime =
new(2026, 8, 1, 9, 30, 0, TimeSpan.Zero);
[Fact]
public void MapTransition_Acknowledge_StampsTheObservedTransitionTimeAsAckTime()
{
// The gateway feed carries NO dedicated ack timestamp, so the ack transition's own
// time is what gets stamped — accurate to when the system saw the ack.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = ProtoTransitionKind.Acknowledge,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.Equal(GatewayTime, t.AckTime);
Assert.Equal(t.TransitionTime, t.AckTime);
}
[Fact]
public void MapTransition_Raise_HasNoAckTime()
{
// Null while unacknowledged.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
[Fact]
public void MapTransition_Retrigger_ClearsTheAckTime()
{
// "Cleared on re-raise": a Retrigger arrives unacknowledged, so no ack time is
// reported even though the condition was acked a moment earlier.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Retrigger,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapTransition(ev).AckTime);
}
[Fact]
public void MapTransition_Clear_HasNoAckTime_DespiteInactiveMappingToAcked()
{
// The gateway maps INACTIVE to acked = true. Without the active check every
// return-to-normal would claim an ack the system never observed.
var ev = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
TransitionKind = ProtoTransitionKind.Clear,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
var t = MxGatewayAlarmMapper.MapTransition(ev);
Assert.True(t.Condition.Acknowledged); // the gateway's own mapping
Assert.Null(t.AckTime); // …but no ack was observed
}
[Fact]
public void MapSnapshot_ActiveAcked_RestoresAnAckTimeFromTheLastTransition()
{
// A (re)subscribe snapshot must not silently drop the ack instant of an already
// acknowledged alarm — the last transition time is the best the feed supplies.
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.ActiveAcked,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Equal(GatewayTime, MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
[Fact]
public void MapSnapshot_ActiveUnacked_HasNoAckTime()
{
var snap = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
CurrentState = ProtoConditionState.Active,
Severity = 600,
LastTransitionTimestamp = Timestamp.FromDateTimeOffset(GatewayTime)
};
Assert.Null(MxGatewayAlarmMapper.MapSnapshot(snap).AckTime);
}
[Fact]
public void SnapshotComplete_Sentinel_CarriesNoAckTime()
{
// The end-of-snapshot sentinel has no condition payload at all.
Assert.Null(MxGatewayAlarmMapper.SnapshotComplete().AckTime);
}
}