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 { [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); } [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)); } [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)); } [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)); } [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)); } private sealed class ThresholdEvaluator : IScriptedAlarmEvaluator { private readonly double _threshold; 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); } } private sealed class CapturingPublisher { public ConcurrentBag Topics { get; } = new(); public ConcurrentBag Payloads { get; } = new(); public void Publish(string topic, object payload) { Topics.Add(topic); Payloads.Add(payload); } } }