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:
@@ -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"},
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="MxValue"/> union to a display-only string using
|
||||
/// <see cref="MxValueExtensions.ToClrValue"/> and invariant culture formatting,
|
||||
/// so numeric values always use '.' as the decimal separator. Null or unset
|
||||
/// values produce an empty string.
|
||||
/// </summary>
|
||||
internal static string MxValueToString(MxValue? mxVal)
|
||||
{
|
||||
if (mxVal is null) return "";
|
||||
var clr = mxVal.ToClrValue();
|
||||
return clr is null ? "" : Convert.ToString(clr, CultureInfo.InvariantCulture) ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
|
||||
/// <param name="body">The gateway alarm transition event proto message to map.</param>
|
||||
/// <returns>The protocol-neutral <see cref="NativeAlarmTransition"/>.</returns>
|
||||
@@ -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));
|
||||
|
||||
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
|
||||
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.SnapshotComplete</c>.</returns>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -65,4 +65,40 @@ public static class OpcUaAlarmMapper
|
||||
null or "Unshelved" => AlarmShelveState.Unshelved,
|
||||
_ => AlarmShelveState.OneShotShelved
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Picks a representative display-only limit value from the four standard
|
||||
/// <c>LimitAlarmType</c> set-point fields (HighHighLimit, HighLimit, LowLimit,
|
||||
/// LowLowLimit) returned by the OPC UA event SelectClause.
|
||||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>AlarmTypeName</c> or <c>ConditionName</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="highHighRaw">Raw HighHighLimit field value (null when absent).</param>
|
||||
/// <param name="highRaw">Raw HighLimit field value (null when absent).</param>
|
||||
/// <param name="lowRaw">Raw LowLimit field value (null when absent).</param>
|
||||
/// <param name="lowLowRaw">Raw LowLowLimit field value (null when absent).</param>
|
||||
/// <returns>
|
||||
/// 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).
|
||||
/// </returns>
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user