feat(adminui): native-alarm HistorizeToAveva opt-out
This commit is contained in:
@@ -136,6 +136,27 @@
|
||||
<ValidationMessage For="@(() => _form.TagConfig)" />
|
||||
</div>
|
||||
|
||||
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
|
||||
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
|
||||
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
|
||||
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
|
||||
@if (HasNativeAlarm)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Native alarm</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
|
||||
checked="@AlarmHistorizeToAveva"
|
||||
@onchange="OnAlarmHistorizeChanged" />
|
||||
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
|
||||
(the live alerts feed is unaffected). Checked is the default.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
@@ -234,6 +255,27 @@
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
};
|
||||
|
||||
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
|
||||
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
|
||||
private bool HasNativeAlarm => NativeAlarmModel.FromJson(_form.TagConfig).IsAlarm;
|
||||
|
||||
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
|
||||
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
|
||||
// explicit false leaves it unchecked.
|
||||
private bool AlarmHistorizeToAveva => NativeAlarmModel.FromJson(_form.TagConfig).HistorizeToAveva != false;
|
||||
|
||||
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
|
||||
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
|
||||
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
|
||||
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
|
||||
{
|
||||
var model = NativeAlarmModel.FromJson(_form.TagConfig);
|
||||
if (!model.IsAlarm) { return; }
|
||||
var isChecked = e.Value is bool b && b;
|
||||
model.HistorizeToAveva = isChecked ? null : false;
|
||||
_form.TagConfig = model.ToJson();
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Typed working model for the optional native-alarm <c>alarm</c> sub-object inside a tag's
|
||||
/// <c>TagConfig</c> JSON. A tag whose <c>TagConfig</c> carries an <c>alarm</c> object is materialised
|
||||
/// as an OPC UA Part 9 condition (rather than a value variable); the fields here mirror what the
|
||||
/// server's <c>Phase7Composer.ExtractTagAlarm</c> / <c>DeploymentArtifact.ExtractTagAlarm</c> parse.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="HistorizeToAveva"/> is the per-condition opt-out of the DURABLE AVEVA historian write
|
||||
/// (<c>bool?</c>; absent ⇒ <c>null</c> ⇒ historize). The server's <c>HistorianAdapterActor</c> gates
|
||||
/// the sink write on <c>historizeToAveva is not false</c>, so <c>null</c> and <c>true</c> both
|
||||
/// historize and only an explicit <c>false</c> suppresses the durable row — the live <c>/alerts</c>
|
||||
/// fan-out is unaffected. This mirrors the scripted-alarm <c>HistorizeToAveva</c> opt-out posture.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="FromJson"/> / <see cref="ToJson"/> operate over the WHOLE <c>TagConfig</c> JSON (root +
|
||||
/// nested <c>alarm</c>), preserving every unrecognised key at both levels so a load→save can't drop a
|
||||
/// field this editor doesn't expose.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmModel
|
||||
{
|
||||
/// <summary>True iff the TagConfig carried an <c>alarm</c> object (the tag is an alarm condition).</summary>
|
||||
public bool IsAlarm { get; set; }
|
||||
|
||||
/// <summary>OPC UA Part 9 condition subtype (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition).</summary>
|
||||
public string AlarmType { get; set; } = "AlarmCondition";
|
||||
|
||||
/// <summary>1..1000 OPC UA severity.</summary>
|
||||
public int Severity { get; set; } = 500;
|
||||
|
||||
/// <summary>Per-condition opt-out of the durable AVEVA historian write. <c>null</c> ⇒ absent ⇒
|
||||
/// historize (default-on at the server gate); <c>true</c> ⇒ historize; <c>false</c> ⇒ suppress the
|
||||
/// durable row (live alerts fan-out unaffected).</summary>
|
||||
public bool? HistorizeToAveva { get; set; }
|
||||
|
||||
private JsonObject _root = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string. When no <c>alarm</c> object is present the
|
||||
/// model is flagged non-alarm and the alarm fields keep their defaults. Retains every original key
|
||||
/// at the root and inside <c>alarm</c> so a load→save preserves fields this editor doesn't expose.</summary>
|
||||
public static NativeAlarmModel FromJson(string? json)
|
||||
{
|
||||
var root = TagConfigJson.ParseOrNew(json);
|
||||
var model = new NativeAlarmModel { _root = root };
|
||||
|
||||
if (root.TryGetPropertyValue("alarm", out var aNode) && aNode is JsonObject alarm)
|
||||
{
|
||||
model.IsAlarm = true;
|
||||
model.AlarmType = TagConfigJson.GetString(alarm, "alarmType") is { } t && t.Length > 0
|
||||
? t : "AlarmCondition";
|
||||
model.Severity = TagConfigJson.GetInt(alarm, "severity", 500);
|
||||
model.HistorizeToAveva = ReadNullableBool(alarm, "historizeToAveva");
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string. When <see cref="IsAlarm"/> is set,
|
||||
/// writes the alarm fields over the preserved <c>alarm</c> sub-object (creating it if absent); a
|
||||
/// <c>null</c> <see cref="HistorizeToAveva"/> REMOVES the key so the absent ⇒ historize default holds.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
if (IsAlarm)
|
||||
{
|
||||
var alarm = _root.TryGetPropertyValue("alarm", out var n) && n is JsonObject existing
|
||||
? existing
|
||||
: new JsonObject();
|
||||
|
||||
TagConfigJson.Set(alarm, "alarmType", AlarmType);
|
||||
TagConfigJson.Set(alarm, "severity", Severity);
|
||||
// null REMOVES the key (absent ⇒ historize default-on at the server gate); a concrete
|
||||
// true/false is written through. JsonValue.Create(bool) yields a JSON boolean literal.
|
||||
if (HistorizeToAveva is { } h)
|
||||
{
|
||||
alarm["historizeToAveva"] = JsonValue.Create(h);
|
||||
}
|
||||
else
|
||||
{
|
||||
alarm.Remove("historizeToAveva");
|
||||
}
|
||||
|
||||
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
|
||||
_root["alarm"] = alarm;
|
||||
}
|
||||
|
||||
return TagConfigJson.Serialize(_root);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate() => null;
|
||||
|
||||
/// <summary>Reads a nullable bool: <c>null</c> when absent/null/non-bool; otherwise the bool value.</summary>
|
||||
private static bool? ReadNullableBool(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b)
|
||||
? b
|
||||
: null;
|
||||
}
|
||||
@@ -99,8 +99,13 @@ public sealed record EquipmentTagPlan(
|
||||
|
||||
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
|
||||
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
|
||||
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.</summary>
|
||||
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
|
||||
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.
|
||||
/// <see cref="HistorizeToAveva"/> is the per-condition opt-out of the durable AVEVA historian write
|
||||
/// (<c>bool?</c>; absent ⇒ <c>null</c> ⇒ historize). It is threaded onto each native
|
||||
/// <c>AlarmTransitionEvent</c> so the runtime's <c>HistorianAdapterActor</c> gate
|
||||
/// (<c>historizeToAveva is not false</c>) suppresses the durable row only on an explicit <c>false</c> —
|
||||
/// the same posture as the scripted-alarm opt-out; the live <c>/alerts</c> fan-out is unaffected.</summary>
|
||||
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool? HistorizeToAveva = null);
|
||||
|
||||
/// <summary>
|
||||
/// One Equipment-namespace VirtualTag from a <see cref="VirtualTag"/> row (joined to its
|
||||
@@ -493,7 +498,13 @@ public static class Phase7Composer
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
return new EquipmentTagAlarmInfo(type, sev);
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): only an explicit false suppresses the
|
||||
// durable AVEVA write at the HistorianAdapterActor gate; a non-bool node ⇒ null (default-on).
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
@@ -679,7 +679,13 @@ public static class DeploymentArtifact
|
||||
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
|
||||
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
|
||||
&& sEl.TryGetInt32(out var sv) ? sv : 500;
|
||||
return new EquipmentTagAlarmInfo(type, sev);
|
||||
// historizeToAveva (bool?, absent ⇒ null ⇒ historize): byte-parity with
|
||||
// Phase7Composer.ExtractTagAlarm — only an explicit false suppresses the durable AVEVA write.
|
||||
bool? historize = a.TryGetProperty("historizeToAveva", out var hEl)
|
||||
&& hEl.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? hEl.GetBoolean()
|
||||
: null;
|
||||
return new EquipmentTagAlarmInfo(type, sev, historize);
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
@@ -139,9 +139,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string FullName)> _driverRefByAlarmNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type) for building the
|
||||
/// AlarmTransitionEvent fan-out. Built in the same PushDesiredSubscriptions alarm branch.</summary>
|
||||
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType)> _alarmMetaByNodeId =
|
||||
/// <summary>Condition NodeId → (EquipmentId, tag Name, OPC UA alarm type, HistorizeToAveva) for building
|
||||
/// the AlarmTransitionEvent fan-out. Built in the same PushDesiredSubscriptions alarm branch.
|
||||
/// HistorizeToAveva (bool?, null ⇒ historize) is the native per-condition opt-out parsed from
|
||||
/// <c>TagConfig.alarm.historizeToAveva</c>; it is threaded onto the transition so the
|
||||
/// HistorianAdapterActor's <c>is not false</c> gate suppresses the durable AVEVA row only on an
|
||||
/// explicit false (mirroring the scripted-alarm opt-out).</summary>
|
||||
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva)> _alarmMetaByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
|
||||
@@ -589,7 +593,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue;
|
||||
|
||||
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition");
|
||||
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null);
|
||||
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
|
||||
AlarmId: nodeId,
|
||||
EquipmentPath: meta.EquipmentId,
|
||||
@@ -605,9 +609,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
TimestampUtc: msg.Args.SourceTimestampUtc,
|
||||
AlarmTypeName: meta.AlarmType,
|
||||
Comment: msg.Args.OperatorComment,
|
||||
// Native alarms always historize (no per-condition opt-out surface yet — that is a
|
||||
// scripted-alarm plan flag); pass a concrete true so the historian's null-default isn't relied on.
|
||||
HistorizeToAveva: true)));
|
||||
// Per-condition opt-out parsed from TagConfig.alarm.historizeToAveva (bool?, null ⇒ absent ⇒
|
||||
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
|
||||
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
|
||||
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
|
||||
HistorizeToAveva: meta.HistorizeToAveva)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,7 +1026,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
|
||||
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
|
||||
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
|
||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType);
|
||||
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
|
||||
continue;
|
||||
}
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for the native-alarm sub-model's <c>historizeToAveva</c> opt-out
|
||||
/// (the <c>TagConfig.alarm.historizeToAveva</c> bool?). Absent ⇒ null ⇒ the server's
|
||||
/// <c>HistorianAdapterActor</c> "is not false" gate still historizes (default-on); explicit
|
||||
/// <c>false</c> suppresses the durable AVEVA write. Mirrors the scripted-alarm opt-out posture.
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmHistorizeModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromJson_no_alarm_object_means_not_an_alarm_tag()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson("""{"FullName":"Temp.HiHi"}""");
|
||||
|
||||
m.IsAlarm.ShouldBeFalse();
|
||||
m.HistorizeToAveva.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_non_alarm_for_empty_input(string? json)
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(json);
|
||||
|
||||
m.IsAlarm.ShouldBeFalse();
|
||||
m.HistorizeToAveva.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_alarm_without_historizeToAveva_is_null_default_on()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"FullName":"Temp.HiHi","alarm":{"alarmType":"OffNormalAlarm","severity":700}}""");
|
||||
|
||||
m.IsAlarm.ShouldBeTrue();
|
||||
m.AlarmType.ShouldBe("OffNormalAlarm");
|
||||
m.Severity.ShouldBe(700);
|
||||
// Absent ⇒ null ⇒ historize (default-on at the gate).
|
||||
m.HistorizeToAveva.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_historizeToAveva_true()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":true}}""");
|
||||
|
||||
m.HistorizeToAveva.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_reads_historizeToAveva_false_opt_out()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":false}}""");
|
||||
|
||||
m.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_true_persists_true()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"alarm":{"alarmType":"LimitAlarm","severity":500}}""");
|
||||
m.HistorizeToAveva = true;
|
||||
|
||||
var round = NativeAlarmModel.FromJson(m.ToJson());
|
||||
|
||||
round.HistorizeToAveva.ShouldBe(true);
|
||||
round.IsAlarm.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_false_persists_false()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"alarm":{"alarmType":"LimitAlarm","severity":500}}""");
|
||||
m.HistorizeToAveva = false;
|
||||
|
||||
var round = NativeAlarmModel.FromJson(m.ToJson());
|
||||
|
||||
round.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_null_omits_the_key_default_on()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"alarm":{"alarmType":"LimitAlarm","severity":500,"historizeToAveva":true}}""");
|
||||
m.HistorizeToAveva = null;
|
||||
|
||||
var json = m.ToJson();
|
||||
json.ShouldNotContain("historizeToAveva");
|
||||
|
||||
NativeAlarmModel.FromJson(json).HistorizeToAveva.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_preserves_unknown_keys_at_root_and_in_alarm()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson(
|
||||
"""{"FullName":"Temp.HiHi","alarm":{"alarmType":"OffNormalAlarm","severity":700,"customAlarmKey":"keep-me"},"customRootKey":42}""");
|
||||
m.HistorizeToAveva = false;
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("FullName");
|
||||
json.ShouldContain("Temp.HiHi");
|
||||
json.ShouldContain("customRootKey");
|
||||
json.ShouldContain("customAlarmKey");
|
||||
json.ShouldContain("keep-me");
|
||||
json.ShouldContain("historizeToAveva");
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,19 @@ public class ExtractTagAlarmTests
|
||||
info!.AlarmType.ShouldBe(type);
|
||||
info.Severity.ShouldBe(sev);
|
||||
}
|
||||
|
||||
/// <summary>historizeToAveva (bool?, absent ⇒ null ⇒ historize): an explicit true/false parses
|
||||
/// through; a missing or non-bool node yields null (the HistorianAdapterActor gate then treats it as
|
||||
/// default-on). Mirrors the scripted-alarm opt-out posture.</summary>
|
||||
[Theory]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500}}", null)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":true}}", true)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":false}}", false)]
|
||||
[InlineData("{\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":500,\"historizeToAveva\":\"oops\"}}", null)]
|
||||
public void ExtractTagAlarm_parses_historizeToAveva(string cfg, bool? expected)
|
||||
{
|
||||
var info = Phase7Composer.ExtractTagAlarm(cfg);
|
||||
info.ShouldNotBeNull();
|
||||
info!.HistorizeToAveva.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
+77
-3
@@ -146,10 +146,78 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
|
||||
evt.Message.ShouldBe("temperature high");
|
||||
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
|
||||
evt.HistorizeToAveva.ShouldBe(true);
|
||||
// This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
|
||||
// gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
|
||||
// suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
|
||||
evt.HistorizeToAveva.ShouldBeNull();
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose <c>TagConfig.alarm</c> carries
|
||||
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
|
||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate
|
||||
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
|
||||
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
|
||||
/// publishes; only the durable row is gated downstream).</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_false_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: false);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.AlarmId.ShouldBe("eq-1/temp_hi");
|
||||
// The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
|
||||
evt.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
|
||||
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
|
||||
/// deliberately opts in is recorded as such on the transition.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_true_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: true);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.HistorizeToAveva.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host
|
||||
/// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
|
||||
/// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
|
||||
@@ -292,8 +360,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name)
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name,
|
||||
bool? historizeToAveva = null)
|
||||
{
|
||||
// historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
|
||||
// exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||
object alarm = historizeToAveva is { } h
|
||||
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
|
||||
: new { alarmType = "OffNormalAlarm", severity = 700 };
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
@@ -317,7 +391,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
FullName,
|
||||
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
|
||||
alarm,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user