using System.Collections.Concurrent;
using Akka.Actor;
using Akka.TestKit;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
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;
public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
{
/// Verifies that full state cycle publishes StateChanged messages to parent at each transition.
[Fact]
public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
var t1 = parent.ExpectMsg();
t1.State.ShouldBe(ScriptedAlarmActorState.Active);
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("joe"));
var t2 = parent.ExpectMsg();
t2.State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
actor.Tell(new ScriptedAlarmActor.ConditionCleared());
var t3 = parent.ExpectMsg();
t3.State.ShouldBe(ScriptedAlarmActorState.Inactive);
}
/// Verifies that duplicate ConditionMet messages in Active state are ignored.
[Fact]
public void Duplicate_ConditionMet_in_Active_is_ignored()
{
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
actor.Tell(new ScriptedAlarmActor.ConditionMet("first"));
parent.ExpectMsg();
actor.Tell(new ScriptedAlarmActor.ConditionMet("second"));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// Verifies that active transition publishes AlarmTransitionEvent to the alerts topic.
[Fact]
public void Engine_active_transition_publishes_AlarmTransitionEvent_to_alerts_topic()
{
var capture = new CapturingPublisher();
var parent = CreateTestProbe();
var config = new ScriptedAlarmActor.AlarmConfig(
AlarmId: "alarm-7",
AlarmName: "High Temp",
EquipmentPath: "/site-1/line-A/oven",
Severity: 800,
Predicate: "temp > 80");
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
config,
evaluator: new ThresholdEvaluator(80),
publisherFactory: () => new DPSPublisher(capture.Publish)));
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Active);
AwaitAssert(() =>
{
var transitionEvt = capture.Payloads.OfType().SingleOrDefault();
transitionEvt.ShouldNotBeNull();
transitionEvt.AlarmId.ShouldBe("alarm-7");
transitionEvt.AlarmName.ShouldBe("High Temp");
transitionEvt.EquipmentPath.ShouldBe("/site-1/line-A/oven");
transitionEvt.Severity.ShouldBe(800);
transitionEvt.TransitionKind.ShouldBe("Activated");
transitionEvt.User.ShouldBe("system");
var log = capture.Payloads.OfType().SingleOrDefault();
log.ShouldNotBeNull();
log.AlarmId.ShouldBe("alarm-7");
}, duration: TimeSpan.FromSeconds(1));
}
/// Verifies that clear transition publishes Cleared event.
[Fact]
public void Engine_clear_transition_publishes_Cleared_event()
{
var capture = new CapturingPublisher();
var parent = CreateTestProbe();
var config = new ScriptedAlarmActor.AlarmConfig("alarm-7", "High Temp", "/p", 500, "temp > 80");
var evaluator = new ThresholdEvaluator(80);
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
config, evaluator,
publisherFactory: () => new DPSPublisher(capture.Publish)));
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 92, DateTime.UtcNow));
parent.ExpectMsg();
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Inactive);
AwaitAssert(() =>
{
var kinds = capture.Payloads.OfType().Select(e => e.TransitionKind).ToList();
kinds.ShouldContain("Activated");
kinds.ShouldContain("Cleared");
}, duration: TimeSpan.FromSeconds(1));
}
/// Verifies that manual acknowledge emits Acknowledged transition with the user.
[Fact]
public void Manual_acknowledge_emits_Acknowledged_transition_with_user()
{
var capture = new CapturingPublisher();
var parent = CreateTestProbe();
var config = new ScriptedAlarmActor.AlarmConfig("a-1", "Pump Fail", "/eq", 700, Predicate: null);
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props(
config, evaluator: null,
publisherFactory: () => new DPSPublisher(capture.Publish)));
actor.Tell(new ScriptedAlarmActor.ConditionMet("driver-fault"));
parent.ExpectMsg();
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
parent.ExpectMsg().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
AwaitAssert(() =>
{
var ackEvt = capture.Payloads.OfType()
.SingleOrDefault(e => e.TransitionKind == "Acknowledged");
ackEvt.ShouldNotBeNull();
ackEvt.User.ShouldBe("operator-jane");
}, duration: TimeSpan.FromSeconds(1));
}
/// A threshold-based alarm evaluator for testing.
private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator
{
private readonly double _threshold;
/// Initializes a new instance of the ThresholdEvaluator class.
/// The threshold value to compare against.
public ThresholdEvaluator(double threshold) { _threshold = threshold; }
///
public ScriptedAlarmEvalResult Evaluate(string id, string predicate, IReadOnlyDictionary deps)
{
if (!deps.TryGetValue("temp", out var raw) || raw is null)
return ScriptedAlarmEvalResult.Failure("missing temp");
return ScriptedAlarmEvalResult.Ok(Convert.ToDouble(raw) > _threshold);
}
}
/// A test publisher that captures published messages.
private sealed class CapturingPublisher
{
/// Gets the topics that messages were published to.
public ConcurrentBag Topics { get; } = new();
/// Gets the payloads that were published.
public ConcurrentBag