feat(adminui): native-alarm HistorizeToAveva opt-out
This commit is contained in:
@@ -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