feat(triggers): add WhileTrue fire mode for Conditional/Expression script triggers

Conditional and Expression script triggers gain an optional `mode` field
in their TriggerConfiguration JSON:

- OnTrue (default): unchanged edge/per-change firing. An absent mode field
  parses as OnTrue, so every existing trigger config behaves identically.
- WhileTrue: fires on the false->true edge, then re-fires on a periodic
  timer while the condition holds; stops on the true->false edge. The
  re-fire cadence is the script's MinTimeBetweenRuns; with none configured
  the trigger degrades to a single edge fire and logs a warning.

ScriptActor tracks condition truth state and manages a dedicated
"whiletrue-trigger" timer. ScriptTriggerConfigCodec and ScriptTriggerEditor
round-trip the mode and expose an OnTrue/WhileTrue selector for the two
trigger kinds. Design: docs/plans/2026-05-18-whiletrue-trigger-mode-design.md

Tests: 7 ScriptActor runtime tests (edge fire, timer re-fire, stop,
re-arm, no-MinTimeBetweenRuns degrade, OnTrue regressions) + 14 codec /
editor tests. SiteRuntime suite 206 green, CentralUI suite 295 green.
This commit is contained in:
Joseph Doherty
2026-05-18 10:44:11 -04:00
parent 19870d1f8f
commit 437fe154e7
9 changed files with 625 additions and 21 deletions

View File

@@ -1,8 +1,10 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening;
@@ -237,4 +239,200 @@ public class ScriptActorTests : TestKit, IDisposable
var result2 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
Assert.False(result2.Success); // Still fails, but the actor is still alive
}
// ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
//
// A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
// Instance Actor receives as a SetStaticAttributeCommand. The probe stands
// in for the Instance Actor: an auto-pilot replies so each execution
// completes promptly (freeing the script-execution scheduler), while every
// command remains observable via ExpectMsg — one command per script firing.
private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";
/// <summary>Builds a ScriptActor whose script fires one observable command per run.</summary>
private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
string name,
string triggerType,
string triggerConfig,
TimeSpan? minTimeBetweenRuns,
Script<object?>? triggerExpression = null)
{
var compiled = CompileScript(FiringScriptCode);
var scriptConfig = new ResolvedScript
{
CanonicalName = name,
Code = FiringScriptCode,
TriggerType = triggerType,
TriggerConfiguration = triggerConfig,
MinTimeBetweenRuns = minTimeBetweenRuns
};
var instance = CreateTestProbe();
instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
{
if (message is SetStaticAttributeCommand cmd)
{
sender.Tell(new SetStaticAttributeResponse(
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
return AutoPilot.KeepRunning;
}));
var actor = ActorOf(Props.Create(() => new ScriptActor(
name,
"TestInstance",
instance.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger<ScriptActor>.Instance,
triggerExpression,
null,
null,
null)));
return (actor, instance);
}
private AttributeValueChanged Change(string attribute, object? value) =>
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
private Script<object?> CompileTriggerExpression(string expression) =>
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{
// WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
var (actor, instance) = CreateTriggeredActor(
"CondWhile",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
// Temp 100 > 50 -> false->true edge: fire immediately.
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
// Then the timer re-fires while the condition still holds.
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
{
var (actor, instance) = CreateTriggeredActor(
"CondStop",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // at least one tick
// Temp 10 -> condition false: the re-fire timer stops.
actor.Tell(Change("Temp", "10"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
{
var (actor, instance) = CreateTriggeredActor(
"CondReArm",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100")); // true edge -> fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "10")); // false -> stop
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Temp", "100")); // false->true again: re-arm + fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
{
// No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
var (actor, instance) = CreateTriggeredActor(
"CondNoInterval",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
}
[Fact]
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
{
// Regression: OnTrue (the existing behavior) fires per matching change
// and never re-fires on a timer of its own.
var (actor, instance) = CreateTriggeredActor(
"CondOnTrue",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "101"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
}
[Fact]
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
{
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprWhile",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300),
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
actor.Tell(Change("Active", "no"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
{
// Regression: OnTrue expression triggers stay edge-triggered.
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprOnTrue",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null,
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
actor.Tell(Change("Active", "yes")); // still true, no edge
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Active", "no")); // -> false
actor.Tell(Change("Active", "yes")); // false->true edge again
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
}
}