feat(adminui): native-alarm HistorizeToAveva opt-out

This commit is contained in:
Joseph Doherty
2026-06-16 16:27:31 -04:00
parent 72d414ada7
commit 6a8020e7e7
8 changed files with 393 additions and 15 deletions
@@ -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);
}
}
@@ -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,
}),
},
},