From 722b8663c110acb113345ea9ea93dc97ab979efc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 06:37:19 -0400 Subject: [PATCH] feat(dcl): populate obtainable NativeAlarmTransition fields from OPC UA and MxGateway (#27, M2.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...illpending-m2-implementation.md.tasks.json | 6 +- .../Adapters/MxGatewayAlarmMapper.cs | 23 ++++- .../Adapters/OpcUaAlarmMapper.cs | 36 ++++++++ .../Adapters/RealOpcUaClient.cs | 52 ++++++++++- .../RealOpcUaClientAlarmFilterTests.cs | 90 +++++++++++++++++++ .../MxGatewayAlarmMapperTests.cs | 88 ++++++++++++++++++ .../OpcUaAlarmMapperTests.cs | 50 +++++++++++ ...adaBridge.DataConnectionLayer.Tests.csproj | 2 + 8 files changed, 338 insertions(+), 9 deletions(-) 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. --> + +