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:
@@ -0,0 +1,137 @@
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip coverage for the WhileTrue/OnTrue <c>mode</c> field on the
|
||||
/// Conditional and Expression script triggers.
|
||||
/// </summary>
|
||||
public class ScriptTriggerConfigCodecTests
|
||||
{
|
||||
// ── Parse: mode field ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_Conditional_WithoutMode_DefaultsToOnTrue()
|
||||
{
|
||||
const string json = @"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80}";
|
||||
|
||||
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
|
||||
|
||||
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Conditional_WhileTrue_IsRead()
|
||||
{
|
||||
const string json =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""WhileTrue""}";
|
||||
|
||||
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
|
||||
|
||||
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Expression_WithoutMode_DefaultsToOnTrue()
|
||||
{
|
||||
const string json = @"{""expression"":""Attributes[\""T\""] > 1""}";
|
||||
|
||||
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
|
||||
|
||||
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Expression_WhileTrue_IsRead()
|
||||
{
|
||||
const string json =
|
||||
@"{""expression"":""Attributes[\""T\""] > 1"",""mode"":""WhileTrue""}";
|
||||
|
||||
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
|
||||
|
||||
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnrecognizedMode_DefaultsToOnTrue()
|
||||
{
|
||||
const string json =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":80,""mode"":""Sometimes""}";
|
||||
|
||||
var model = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
|
||||
|
||||
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
|
||||
}
|
||||
|
||||
// ── Serialize: mode field ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Conditional_WhileTrue_WritesMode()
|
||||
{
|
||||
var model = new ScriptTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Operator = ">",
|
||||
Threshold = 80,
|
||||
Mode = ScriptTriggerMode.WhileTrue
|
||||
};
|
||||
|
||||
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Conditional);
|
||||
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_Expression_WhileTrue_WritesMode()
|
||||
{
|
||||
var model = new ScriptTriggerModel
|
||||
{
|
||||
Expression = "Attributes[\"T\"] > 1",
|
||||
Mode = ScriptTriggerMode.WhileTrue
|
||||
};
|
||||
|
||||
var json = ScriptTriggerConfigCodec.Serialize(model, ScriptTriggerKind.Expression);
|
||||
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", json);
|
||||
}
|
||||
|
||||
// ── Round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void RoundTrip_Conditional_PreservesMode(bool whileTrue)
|
||||
{
|
||||
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
|
||||
var original = new ScriptTriggerModel
|
||||
{
|
||||
AttributeName = "Temp",
|
||||
Operator = ">=",
|
||||
Threshold = 12.5,
|
||||
Mode = mode
|
||||
};
|
||||
|
||||
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Conditional);
|
||||
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Conditional);
|
||||
|
||||
Assert.Equal(mode, reparsed.Mode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void RoundTrip_Expression_PreservesMode(bool whileTrue)
|
||||
{
|
||||
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
|
||||
var original = new ScriptTriggerModel
|
||||
{
|
||||
Expression = "Attributes[\"T\"] > 1",
|
||||
Mode = mode
|
||||
};
|
||||
|
||||
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Expression);
|
||||
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
|
||||
|
||||
Assert.Equal(mode, reparsed.Mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Component tests for the OnTrue/WhileTrue mode selector that
|
||||
/// <see cref="ScriptTriggerEditor"/> exposes for Conditional and Expression
|
||||
/// triggers.
|
||||
/// </summary>
|
||||
public class ScriptTriggerEditorTests : BunitContext
|
||||
{
|
||||
private const string ConditionalConfig =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50}";
|
||||
|
||||
private const string ConditionalWhileTrueConfig =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}";
|
||||
|
||||
[Fact]
|
||||
public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
cut.Find("#script-trigger-mode").Change("WhileTrue");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
// Change the threshold to force an emit without touching the mode.
|
||||
cut.Find("input[type=number]").Input("75");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalWhileTrueConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
// Editing the threshold must not silently drop the loaded WhileTrue mode.
|
||||
cut.Find("input[type=number]").Input("75");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user