feat(dcl): populate obtainable NativeAlarmTransition fields from OPC UA and MxGateway (#27, M2.13)

OPC UA (RealOpcUaClient):
- Append 5 new SelectClauses at indices 13–17 (never renumber 0–12):
  - 13: AlarmConditionType/ActiveState/TransitionTime → OriginalRaiseTime
  - 14–17: LimitAlarmType HighHighLimit/HighLimit/LowLimit/LowLowLimit → LimitValue
- New OpcUaAlarmMapper.PickLimitValue helper: first non-null in HiHi→Hi→Lo→LoLo
  priority order, InvariantCulture-formatted; empty string for non-limit alarm types.
- HandleAlarmEvent reads new indices with fields.Count > N guards; hard minimum (6)
  unchanged so base ConditionType events still process without the limit fields.
- Document unavailable-by-protocol fields (Category, Description, OperatorUser,
  CurrentValue) inline in BuildAlarmEventFilter and HandleAlarmEvent.

MxGateway (MxGatewayAlarmMapper):
- MapTransition: CurrentValue and LimitValue now populated via MxValueToString
  (uses MxValueExtensions.ToClrValue + InvariantCulture) from OnAlarmTransitionEvent
  proto fields current_value/limit_value.
- MapSnapshot: same — populated from ActiveAlarmSnapshot.current_value/limit_value.
- MxValueToString helper (internal): null-safe MxValue → string conversion.

Tests (17 new, 40 total pass):
- OpcUaAlarmMapperTests: PickLimitValue priority, InvariantCulture, all-null case.
- MxGatewayAlarmMapperTests: CurrentValue/LimitValue populate from double/string
  MxValue; absent fields yield empty strings.
- RealOpcUaClientAlarmFilterTests: index alignment assertions (count=18, per-index
  TypeDefinitionId+BrowsePath), regression guard on existing indices 0–12.
This commit is contained in:
Joseph Doherty
2026-06-16 06:37:19 -04:00
parent e2b31a9fd2
commit 722b8663c1
8 changed files with 338 additions and 9 deletions
@@ -73,4 +73,94 @@ public class RealOpcUaClientAlarmFilterTests
var filter = RealOpcUaClient.BuildAlarmEventFilter(parsed);
Assert.Empty(filter.WhereClause.Elements);
}
// ── SelectClause index alignment (M2.13 / #27) ───────────────────────────
// CRITICAL: HandleAlarmEvent reads fields[N] by position. Verify new clauses
// are APPENDED at indices 1317 so existing mappings (012) are undisturbed.
[Fact]
public void BuildAlarmEventFilter_HasExactly18SelectClauses()
{
// Baseline: 6 base fields + 7 A&C sub-state fields + 5 new appended fields = 18.
// If this count changes, review HandleAlarmEvent index mappings immediately.
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
Assert.Equal(18, filter.SelectClauses.Count);
}
[Fact]
public void BuildAlarmEventFilter_Index13_IsAlarmConditionType_ActiveState_TransitionTime()
{
// Index 13 must be AlarmConditionType/ActiveState/TransitionTime → OriginalRaiseTime.
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[13];
Assert.Equal(ObjectTypeIds.AlarmConditionType, clause.TypeDefinitionId);
Assert.Equal(2, clause.BrowsePath.Count);
Assert.Equal("ActiveState", clause.BrowsePath[0].Name);
Assert.Equal("TransitionTime", clause.BrowsePath[1].Name);
}
[Fact]
public void BuildAlarmEventFilter_Index14_IsLimitAlarmType_HighHighLimit()
{
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[14];
Assert.Equal(ObjectTypeIds.LimitAlarmType, clause.TypeDefinitionId);
Assert.Equal("HighHighLimit", clause.BrowsePath[0].Name);
}
[Fact]
public void BuildAlarmEventFilter_Index15_IsLimitAlarmType_HighLimit()
{
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[15];
Assert.Equal(ObjectTypeIds.LimitAlarmType, clause.TypeDefinitionId);
Assert.Equal("HighLimit", clause.BrowsePath[0].Name);
}
[Fact]
public void BuildAlarmEventFilter_Index16_IsLimitAlarmType_LowLimit()
{
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[16];
Assert.Equal(ObjectTypeIds.LimitAlarmType, clause.TypeDefinitionId);
Assert.Equal("LowLimit", clause.BrowsePath[0].Name);
}
[Fact]
public void BuildAlarmEventFilter_Index17_IsLimitAlarmType_LowLowLimit()
{
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
var clause = filter.SelectClauses[17];
Assert.Equal(ObjectTypeIds.LimitAlarmType, clause.TypeDefinitionId);
Assert.Equal("LowLowLimit", clause.BrowsePath[0].Name);
}
[Fact]
public void BuildAlarmEventFilter_ExistingIndices0To12_Unchanged()
{
// Guard: the first 13 SelectClauses (indices 012) must remain unchanged so
// that existing HandleAlarmEvent logic is not silently broken by future edits.
var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
// Indices 05: base event fields (EventType…Severity) from BaseEventType.
for (var i = 0; i <= 5; i++)
Assert.Equal(ObjectTypeIds.BaseEventType, filter.SelectClauses[i].TypeDefinitionId);
// Index 6: AlarmConditionType/ActiveState/Id
Assert.Equal(ObjectTypeIds.AlarmConditionType, filter.SelectClauses[6].TypeDefinitionId);
Assert.Equal("ActiveState", filter.SelectClauses[6].BrowsePath[0].Name);
Assert.Equal("Id", filter.SelectClauses[6].BrowsePath[1].Name);
// Index 7: AcknowledgeableConditionType/AckedState/Id
Assert.Equal(ObjectTypeIds.AcknowledgeableConditionType, filter.SelectClauses[7].TypeDefinitionId);
Assert.Equal("AckedState", filter.SelectClauses[7].BrowsePath[0].Name);
// Index 11: ConditionType/ConditionName
Assert.Equal(ObjectTypeIds.ConditionType, filter.SelectClauses[11].TypeDefinitionId);
Assert.Equal("ConditionName", filter.SelectClauses[11].BrowsePath[0].Name);
// Index 12: ConditionType/Comment
Assert.Equal(ObjectTypeIds.ConditionType, filter.SelectClauses[12].TypeDefinitionId);
Assert.Equal("Comment", filter.SelectClauses[12].BrowsePath[0].Name);
}
}
@@ -1,3 +1,4 @@
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;
@@ -63,4 +64,91 @@ public class MxGatewayAlarmMapperTests
Assert.False(t.Condition.Acknowledged);
Assert.Equal(1000, t.Condition.Severity);
}
// ── 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
}
}
@@ -55,4 +55,54 @@ public class OpcUaAlarmMapperTests
{
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);
}
}
@@ -22,6 +22,8 @@
uses a plain [Fact] — it never needs the server.
-->
<PackageReference Include="Xunit.SkippableFact" />
<!-- MxGateway.Client brings MxValueExtensions (ToClrValue) used by MxGatewayAlarmMapper tests. -->
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
</ItemGroup>
<ItemGroup>