feat(runtime): F8/F9 engine evaluator seams + DPS fan-out
VirtualTagActor and ScriptedAlarmActor now route through pluggable evaluator interfaces and fan out to the cluster's live-tail topics shipped in F15.3: - IVirtualTagEvaluator + NullVirtualTagEvaluator in Commons.Engines. VirtualTagActor calls evaluator on every DependencyValueChanged, dedupes unchanged values, forwards EvaluationResult to its parent, and publishes ScriptLogEntry Warning to the script-logs DPS topic whenever the evaluator fails. - IScriptedAlarmEvaluator + NullScriptedAlarmEvaluator. ScriptedAlarmActor takes an AlarmConfig (id/name/equipment-path/severity/predicate) and publishes both an AlarmTransitionEvent (alerts topic) and a ScriptLogEntry (script-logs topic) at every transition. Manual ConditionMet/Acknowledge/Cleared still flow through the same Transition() so callers without engine bindings still drive the state machine; the legacy single-string Props() overload routes through a default AlarmConfig. The Null* defaults keep the actors safe when no engine is bound — unconfigured nodes never spuriously alarm. Production binding to Core.VirtualTags.VirtualTagEngine and Core.ScriptedAlarms is the remaining residual (F8b/F9b — split in tasks JSON). Tests: Runtime 34 -> 40 (+6): - VirtualTagActorTests x3 (evaluator drives EvaluationResult, unchanged-value dedup, failure publishes Warning ScriptLogEntry) - ScriptedAlarmActorTests x3 (engine threshold drives Activated + Cleared on alerts topic, manual Acknowledge attribution). All 6 v2 test suites green: 126 tests passing.
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
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;
|
||||
|
||||
@@ -13,7 +18,6 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
|
||||
public void Full_state_cycle_publishes_StateChanged_to_parent_at_each_transition()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
// Wrap the alarm actor under our probe as parent so StateChanged lands on the probe.
|
||||
var actor = parent.ChildActorOf(ScriptedAlarmActor.Props("alarm-1"));
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.ConditionMet("threshold"));
|
||||
@@ -41,4 +45,113 @@ public sealed class ScriptedAlarmActorTests : RuntimeActorTestBase
|
||||
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<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Active);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var transitionEvt = capture.Payloads.OfType<AlarmTransitionEvent>().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<ScriptLogEntry>().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<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.DependencyValueChanged("temp", 70, DateTime.UtcNow));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Inactive);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var kinds = capture.Payloads.OfType<AlarmTransitionEvent>().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<ScriptedAlarmActor.StateChanged>();
|
||||
|
||||
actor.Tell(new ScriptedAlarmActor.AcknowledgeAlarm("operator-jane"));
|
||||
parent.ExpectMsg<ScriptedAlarmActor.StateChanged>().State.ShouldBe(ScriptedAlarmActorState.Acknowledged);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var ackEvt = capture.Payloads.OfType<AlarmTransitionEvent>()
|
||||
.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<string, object?> 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<string> Topics { get; } = new();
|
||||
public ConcurrentBag<object> Payloads { get; } = new();
|
||||
public void Publish(string topic, object payload)
|
||||
{
|
||||
Topics.Add(topic);
|
||||
Payloads.Add(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
|
||||
@@ -15,8 +18,100 @@ public sealed class VirtualTagActorTests : RuntimeActorTestBase
|
||||
Watch(actor);
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("tag-a", 10, DateTime.UtcNow));
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("tag-b", 20, DateTime.UtcNow));
|
||||
|
||||
// No crash, no termination.
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluator_result_flows_to_parent_as_EvaluationResult()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new SumEvaluator();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "a + b", evaluator: evaluator));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 10, DateTime.UtcNow));
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("b", 32, DateTime.UtcNow));
|
||||
|
||||
// First dep: a alone -> 10. Second dep: a + b -> 42.
|
||||
var first = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
first.Value.ShouldBe(10);
|
||||
var second = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
second.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_same_value_does_not_emit_a_second_EvaluationResult()
|
||||
{
|
||||
var parent = CreateTestProbe();
|
||||
var evaluator = new ConstEvaluator(42);
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props("vt-1", "expr", evaluator: evaluator));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
|
||||
var first = parent.ExpectMsg<VirtualTagActor.EvaluationResult>();
|
||||
first.Value.ShouldBe(42);
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 2, DateTime.UtcNow));
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluator_failure_publishes_ScriptLogEntry_warning()
|
||||
{
|
||||
var capture = new CapturingPublisher();
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(VirtualTagActor.Props(
|
||||
"vt-1", "broken",
|
||||
evaluator: new FailingEvaluator("syntax error"),
|
||||
scriptId: "script-7",
|
||||
publisherFactory: () => new DPSPublisher(capture.Publish)));
|
||||
|
||||
actor.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
capture.Topics.ShouldContain("script-logs");
|
||||
var entry = (ScriptLogEntry)capture.Payloads.Single();
|
||||
entry.Level.ShouldBe("Warning");
|
||||
entry.Message.ShouldContain("syntax error");
|
||||
entry.ScriptId.ShouldBe("script-7");
|
||||
entry.VirtualTagId.ShouldBe("vt-1");
|
||||
}, duration: TimeSpan.FromMilliseconds(500));
|
||||
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
private sealed class SumEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
var sum = deps.Values.OfType<int>().Sum();
|
||||
return VirtualTagEvalResult.Ok(sum);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ConstEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
private readonly object _value;
|
||||
public ConstEvaluator(object value) { _value = value; }
|
||||
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
|
||||
=> VirtualTagEvalResult.Ok(_value);
|
||||
}
|
||||
|
||||
private sealed class FailingEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
private readonly string _reason;
|
||||
public FailingEvaluator(string reason) { _reason = reason; }
|
||||
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
|
||||
=> VirtualTagEvalResult.Failure(_reason);
|
||||
}
|
||||
|
||||
private sealed class CapturingPublisher
|
||||
{
|
||||
public ConcurrentBag<string> Topics { get; } = new();
|
||||
public ConcurrentBag<object> Payloads { get; } = new();
|
||||
public void Publish(string topic, object payload)
|
||||
{
|
||||
Topics.Add(topic);
|
||||
Payloads.Add(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user