diff --git a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json
index 0301494f..b61da6d3 100644
--- a/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json
+++ b/docs/plans/2026-06-15-stillpending-m2-implementation.md.tasks.json
@@ -11,9 +11,9 @@
{"id": 39, "ref": "M2.7", "subject": "M2.7 #20+#21: return-type + argument-type compatibility checks", "class": "standard", "status": "completed", "commits": ["958229e", "a8e9e99"]},
{"id": 40, "ref": "M2.8", "subject": "M2.8 #23: binding-completeness Error + name-exists-at-site", "class": "standard", "status": "completed", "commits": ["7c14a69", "21b801b"]},
{"id": 41, "ref": "M2.9", "subject": "M2.9 #17: MachineDataDb fail-fast (reverts Host-008)", "class": "small", "status": "completed", "commits": ["76198b3"]},
- {"id": 42, "ref": "M2.10", "subject": "M2.10 #18: CI grep-guard against UPDATE/DELETE on AuditLog", "class": "small", "status": "pending"},
- {"id": 43, "ref": "M2.11", "subject": "M2.11 #24: debug snapshot unknown-instance returns error", "class": "small", "status": "pending"},
- {"id": 44, "ref": "M2.12", "subject": "M2.12 #25: recursion-limit error to site event log", "class": "small", "status": "pending"},
+ {"id": 42, "ref": "M2.10", "subject": "M2.10 #18: CI grep-guard against UPDATE/DELETE on AuditLog", "class": "small", "status": "completed", "commits": ["e7b6fe3", "9cd62aa"]},
+ {"id": 43, "ref": "M2.11", "subject": "M2.11 #24: debug snapshot unknown-instance returns error", "class": "small", "status": "completed", "commits": ["dbf44b9", "d160c7f"]},
+ {"id": 44, "ref": "M2.12", "subject": "M2.12 #25: recursion-limit error to site event log", "class": "small", "status": "completed", "commits": ["f08038d", "e2b31a9"]},
{"id": 45, "ref": "M2.13", "subject": "M2.13 #27: populate obtainable OPC UA/MxGateway transition fields", "class": "small", "status": "pending"},
{"id": 46, "ref": "M2.14", "subject": "M2.14 #28: readiness gate checks required cluster singletons", "class": "standard", "status": "pending"},
{"id": 47, "ref": "M2.15", "subject": "M2.15 #29: register site active-node purge gate (DI)", "class": "small", "status": "pending"},
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs
index ca06d0b9..e3a7b7c5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
@@ -67,6 +69,19 @@ public static class MxGatewayAlarmMapper
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
+ ///
+ /// Converts an union to a display-only string using
+ /// and invariant culture formatting,
+ /// so numeric values always use '.' as the decimal separator. Null or unset
+ /// values produce an empty string.
+ ///
+ internal static string MxValueToString(MxValue? mxVal)
+ {
+ if (mxVal is null) return "";
+ var clr = mxVal.ToClrValue();
+ return clr is null ? "" : Convert.ToString(clr, CultureInfo.InvariantCulture) ?? "";
+ }
+
/// Maps a live to a transition.
/// The gateway alarm transition event proto message to map.
/// The protocol-neutral .
@@ -83,8 +98,8 @@ public static class MxGatewayAlarmMapper
OperatorComment: body.OperatorComment,
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
- CurrentValue: "",
- LimitValue: "");
+ CurrentValue: MxValueToString(body.CurrentValue),
+ LimitValue: MxValueToString(body.LimitValue));
/// The end-of-snapshot sentinel transition (no condition payload).
/// A with AlarmTransitionKind.SnapshotComplete.
@@ -109,6 +124,6 @@ public static class MxGatewayAlarmMapper
OperatorComment: snapshot.OperatorComment,
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
- CurrentValue: "",
- LimitValue: "");
+ CurrentValue: MxValueToString(snapshot.CurrentValue),
+ LimitValue: MxValueToString(snapshot.LimitValue));
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs
index 8165ede0..2537a42e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs
@@ -65,4 +65,40 @@ public static class OpcUaAlarmMapper
null or "Unshelved" => AlarmShelveState.Unshelved,
_ => AlarmShelveState.OneShotShelved
};
+
+ ///
+ /// Picks a representative display-only limit value from the four standard
+ /// LimitAlarmType set-point fields (HighHighLimit, HighLimit, LowLimit,
+ /// LowLowLimit) returned by the OPC UA event SelectClause.
+ ///
+ ///
+ /// The fields are absent (null raw value) on non-limit alarm types (discrete,
+ /// off-normal, etc.). When present, the first non-null value is returned in
+ /// priority order: HighHigh → High → Low → LowLow. The caller may use
+ /// AlarmTypeName or ConditionName to determine which specific
+ /// limit is active; this method intentionally returns the coarsest useful value
+ /// for the common single-limit case without requiring callers to understand the
+ /// OPC UA limit hierarchy.
+ ///
+ ///
+ /// Raw HighHighLimit field value (null when absent).
+ /// Raw HighLimit field value (null when absent).
+ /// Raw LowLimit field value (null when absent).
+ /// Raw LowLowLimit field value (null when absent).
+ ///
+ /// A formatted string representation of the first non-null limit value, or an
+ /// empty string when all four fields are absent (non-limit alarm type).
+ ///
+ public static string PickLimitValue(object? highHighRaw, object? highRaw, object? lowRaw, object? lowLowRaw)
+ {
+ // Standard OPC UA LimitAlarmType limit values are numeric (Double/Float/Int).
+ // Convert with InvariantCulture so the decimal separator is always '.' regardless
+ // of the server's locale.
+ foreach (var raw in new[] { highHighRaw, highRaw, lowRaw, lowLowRaw })
+ {
+ if (raw is not null)
+ return Convert.ToString(raw, System.Globalization.CultureInfo.InvariantCulture) ?? "";
+ }
+ return "";
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index ea3d6950..82988318 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -393,6 +393,34 @@ public class RealOpcUaClient : IOpcUaClient
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
+ // APPENDED fields (indices 13+): optional — only present on specific derived types.
+ // Guard all reads with fields.Count > N so base-ConditionType events still process.
+
+ // 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
+ // last flipped to TRUE. Mapped to OriginalRaiseTime; absent on non-AlarmCondition
+ // events (ConditionType base events rarely carry it).
+ filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
+
+ // 14–17: LimitAlarmType limit thresholds — configuration-time set-points exposed as
+ // event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
+ // Level/Deviation/RateOfChange). Absent on non-limit alarm types (e.g. discrete,
+ // off-normal) — guarded by fields.Count > N below.
+ filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
+ filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
+ filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
+ filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
+
+ // UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
+ // maintainers know these were considered, not overlooked):
+ // Category — not a standard event field; server-specific extensions only.
+ // Description — not a per-event text field; the OPC UA Description attribute is a
+ // static node property, not carried in event notifications.
+ // OperatorUser — not available on the standard ConditionRefresh replay stream;
+ // present on Acknowledge/Confirm method call results, but those do
+ // not flow through the monitored-item subscription.
+ // CurrentValue — the live process variable value is NOT a standard A&C event field;
+ // it would require a separate data subscription on the source node.
+
ApplyServerSideTypeWhereClause(filter, conditionFilter);
return filter;
}
@@ -507,6 +535,23 @@ public class RealOpcUaClient : IOpcUaClient
var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
+ // Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
+ // transitioned to TRUE). Absent on non-AlarmCondition events → guard + null fallback.
+ DateTimeOffset? originalRaiseTime = null;
+ if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
+ originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);
+
+ // Indices 14–17: LimitAlarmType set-point thresholds (HighHighLimit/HighLimit/
+ // LowLimit/LowLowLimit). Absent on non-limit alarm types → null when missing.
+ // Pick the first non-null value in priority order (HiHi > Hi > Lo > LoLo) as a
+ // display-only representative limit; the caller is responsible for interpreting
+ // which limit is active using AlarmTypeName or ConditionName.
+ var limitValue = OpcUaAlarmMapper.PickLimitValue(
+ fields.Count > 14 ? fields[14].Value : null,
+ fields.Count > 15 ? fields[15].Value : null,
+ fields.Count > 16 ? fields[16].Value : null,
+ fields.Count > 17 ? fields[17].Value : null);
+
var inRefresh = _alarmInRefresh.GetValueOrDefault(handle);
var lastState = _alarmLastState.GetValueOrDefault(handle);
var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true);
@@ -524,15 +569,18 @@ public class RealOpcUaClient : IOpcUaClient
AlarmTypeName: ResolveAlarmTypeName(eventType),
Kind: kind,
Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity),
+ // UNAVAILABLE via standard OPC UA A&C event fields — see BuildAlarmEventFilter comments.
Category: "",
Description: "",
Message: message,
+ // UNAVAILABLE: OperatorUser not on refresh stream — see BuildAlarmEventFilter comments.
OperatorUser: "",
OperatorComment: comment,
- OriginalRaiseTime: null,
+ OriginalRaiseTime: originalRaiseTime,
TransitionTime: time,
+ // UNAVAILABLE: CurrentValue not a standard A&C event field — see BuildAlarmEventFilter.
CurrentValue: "",
- LimitValue: ""));
+ LimitValue: limitValue));
}
private static NativeAlarmTransition SnapshotComplete() => new(
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
index ba93e822..db747f45 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientAlarmFilterTests.cs
@@ -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 13–17 so existing mappings (0–12) 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 0–12) must remain unchanged so
+ // that existing HandleAlarmEvent logic is not silently broken by future edits.
+ var filter = RealOpcUaClient.BuildAlarmEventFilter(AlarmConditionFilter.AllowAll);
+
+ // Indices 0–5: 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);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs
index 74c117af..5f8eaf3e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs
@@ -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
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs
index df034bce..6944399b 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs
@@ -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);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
index 207b1e85..45040861 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj
@@ -22,6 +22,8 @@
uses a plain [Fact] — it never needs the server.
-->
+
+