Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs
T

174 lines
7.5 KiB
C#

using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
/// <summary>
/// Verifies <see cref="ScriptedAlarmHostActor"/> loads the enabled subset of
/// <see cref="EquipmentScriptedAlarmPlan"/>s into its <see cref="ScriptedAlarmEngine"/>, registers mux
/// interest for their dependency refs after the load completes, feeds live
/// <see cref="VirtualTagActor.DependencyValueChanged"/> values into the engine, and bridges the
/// engine's emissions to both an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> and an
/// <see cref="AlarmTransitionEvent"/> on the cluster <c>alerts</c> topic.
/// </summary>
public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(8);
/// <summary>Plan whose predicate compares the single tag "M.T" against 90 — enabled by default.</summary>
private static EquipmentScriptedAlarmPlan Plan(
string id = "alm-1",
string equipmentId = "Plant/Line1/M",
string name = "HighTemp",
string depRef = "M.T",
int threshold = 90,
bool enabled = true,
int severity = 800) =>
new(
ScriptedAlarmId: id,
EquipmentId: equipmentId,
Name: name,
AlarmType: "AlarmCondition",
Severity: severity,
MessageTemplate: "condition",
PredicateScriptId: $"{id}-script",
PredicateSource: $"return (int)ctx.GetTag(\"{depRef}\").Value > {threshold};",
DependencyRefs: new[] { depRef },
HistorizeToAveva: true,
Retain: true,
Enabled: enabled);
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
}
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
TestProbe publish, TestProbe mux)
{
var upstream = new DependencyMuxTagUpstreamSource();
var engine = BuildEngine(upstream);
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine));
return (host, upstream);
}
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it.</summary>
private void SubscribeToAlerts(TestProbe probe)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
new Subscribe(ScriptedAlarmHostActor.AlertsTopic, probe.Ref), probe.Ref);
probe.ExpectMsg<SubscribeAck>(Timeout);
}
/// <summary>Load + interest: applying one enabled alarm registers mux interest for its dep ref
/// AFTER the engine load completes; a disabled alarm in the same apply contributes no dep ref.</summary>
[Fact]
public void Apply_loads_enabled_alarm_and_registers_interest()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[]
{
Plan(id: "alm-1", depRef: "M.T"),
Plan(id: "alm-2", depRef: "M.X", enabled: false), // disabled — not loaded, dep absent
}));
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
reg.TagRefs.ShouldContain("M.T");
reg.TagRefs.ShouldNotContain("M.X");
}
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
[Fact]
public void Dependency_change_above_threshold_activates_alarm()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout); // load completed
var now = DateTime.UtcNow;
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, now));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.Active.ShouldBeTrue();
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(Timeout);
evt.AlarmId.ShouldBe("alm-1");
evt.TransitionKind.ShouldBe("Activated");
evt.Severity.ShouldBe(1000); // 800 → Critical bucket → 1000
evt.User.ShouldBe("system");
}
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
[Fact]
public void Dependency_change_below_threshold_clears_alarm()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
// Activate first.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow));
publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => m.Active, Timeout);
alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Activated", Timeout);
// Now clear.
host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 10, DateTime.UtcNow));
var cleared = publish.FishForMessage<OpcUaPublishActor.AlarmStateUpdate>(m => !m.Active, Timeout);
cleared.AlarmNodeId.ShouldBe("alm-1");
var evt = alerts.FishForMessage<AlarmTransitionEvent>(e => e.TransitionKind == "Cleared", Timeout);
evt.AlarmId.ShouldBe("alm-1");
}
/// <summary>Re-apply reloads: a second ApplyScriptedAlarms with a different alarm set loads the new
/// alarm — a fresh RegisterInterest reflecting the new dependency refs lands on the mux.</summary>
[Fact]
public void Reapply_reloads_with_new_dependency_refs()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux);
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-1", depRef: "M.T") }));
var first = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
first.TagRefs.ShouldContain("M.T");
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(id: "alm-9", depRef: "M.Y") }));
var second = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
second.TagRefs.ShouldContain("M.Y");
second.TagRefs.ShouldNotContain("M.T");
}
}