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,7 +1,7 @@
# WhileTrue Trigger Mode for Conditional & Expression Script Triggers — Design
**Date:** 2026-05-18
**Status:** Approved (brainstorming) — implementation to follow
**Status:** Implemented
## Context

View File

@@ -144,8 +144,12 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
### Trigger Management
- **Interval**: The Script Actor manages an internal timer. When the timer fires, it spawns a Script Execution Actor.
- **Value Change**: The Script Actor subscribes to attribute change notifications from its parent Instance Actor for the specific monitored attribute. When the attribute changes, it spawns a Script Execution Actor.
- **Conditional**: The Script Actor subscribes to attribute change notifications for the monitored attribute. On each update, it evaluates the condition (equals or not-equals a value). If the condition is met, it spawns a Script Execution Actor.
- **Minimum time between runs**: If configured, the Script Actor tracks the last execution time and skips trigger invocations that fire before the minimum interval has elapsed.
- **Conditional**: The Script Actor subscribes to attribute change notifications for the monitored attribute. On each update, it evaluates the condition (compares the attribute against a threshold). Firing depends on the **fire mode** (see below).
- **Expression**: The Script Actor evaluates a compiled boolean expression against an attribute snapshot on each attribute change. Firing depends on the **fire mode** (see below).
- **Fire mode (Conditional + Expression)**:
- **OnTrue** (default): Conditional fires on each matching attribute change; Expression fires once per `false → true` transition (edge-triggered). This is the original behavior — a trigger configuration with no mode field is treated as OnTrue.
- **WhileTrue**: On the `false → true` edge the script fires once, then re-fires on a periodic timer while the condition stays true; on the `true → false` edge the timer stops. The re-fire cadence is the script's **minimum time between runs**; with none configured the trigger degrades to the single edge fire and logs a warning.
- **Minimum time between runs**: If configured, the Script Actor tracks the last execution time and skips trigger invocations that fire before the minimum interval has elapsed. For a WhileTrue trigger it doubles as the re-fire cadence.
### Concurrent Execution
- Each invocation spawns a **new Script Execution Actor** as a child.

View File

@@ -55,8 +55,8 @@ Central cluster only. Sites receive flattened output and have no awareness of te
### Script (Template-Level)
- Name, Lock Flag, C# source code.
- Trigger configuration: Interval, Value Change, Conditional, or invoked by alarm/other script.
- Optional minimum time between runs.
- Trigger configuration: Interval, Value Change, Conditional, Expression, or invoked by alarm/other script. Conditional and Expression triggers also carry a fire mode — **OnTrue** (fire as the condition becomes true) or **WhileTrue** (re-fire on a timer while it stays true).
- Optional minimum time between runs — also the re-fire cadence for a WhileTrue trigger.
- **Parameter Definition** *(optional)*: Defines input parameters (name and data type per parameter). Scripts without parameters accept no arguments.
- **Return Value Definition** *(optional)*: Defines the structure of the script's return value (field names and data types). Supports single objects and lists of objects. Scripts without a return definition return void.

View File

@@ -12,6 +12,13 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// </summary>
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> re-fires on a timer
/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
/// </summary>
internal enum ScriptTriggerMode { OnTrue, WhileTrue }
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
@@ -32,6 +39,9 @@ internal sealed class ScriptTriggerModel
/// <summary>Boolean C# expression (Expression).</summary>
public string? Expression { get; set; }
/// <summary>Fire mode (Conditional + Expression). Defaults to <see cref="ScriptTriggerMode.OnTrue"/>.</summary>
public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
}
/// <summary>
@@ -41,9 +51,12 @@ internal sealed class ScriptTriggerModel
/// Serialized config shapes:
/// Interval { intervalMs }
/// ValueChange { attributeName }
/// Conditional { attributeName, operator, threshold }
/// Conditional { attributeName, operator, threshold, mode }
/// Call { }
/// Expression { expression }
/// Expression { expression, mode }
///
/// <c>mode</c> (Conditional + Expression) is <c>OnTrue</c> or <c>WhileTrue</c>;
/// an absent or unrecognized value parses as <c>OnTrue</c>.
///
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
/// older configs survive a round-trip through the editor.
@@ -109,10 +122,12 @@ internal static class ScriptTriggerConfigCodec
var op = root.TryGetProperty("operator", out var o) ? o.GetString() : null;
model.Operator = NormalizeOperator(op);
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
model.Mode = ReadMode(root);
break;
case ScriptTriggerKind.Expression:
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
model.Mode = ReadMode(root);
break;
}
}
@@ -152,10 +167,12 @@ internal static class ScriptTriggerConfigCodec
w.WriteString("operator", model.Operator);
if (model.Threshold.HasValue)
w.WriteNumber("threshold", model.Threshold.Value);
w.WriteString("mode", model.Mode.ToString());
break;
case ScriptTriggerKind.Expression:
w.WriteString("expression", model.Expression ?? "");
w.WriteString("mode", model.Mode.ToString());
break;
// Call → empty object.
@@ -165,6 +182,18 @@ internal static class ScriptTriggerConfigCodec
return Encoding.UTF8.GetString(stream.ToArray());
}
/// <summary>
/// Reads the optional <c>mode</c> property; an absent or unrecognized value
/// (case-insensitive) yields <see cref="ScriptTriggerMode.OnTrue"/>.
/// </summary>
private static ScriptTriggerMode ReadMode(JsonElement root)
{
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
? ScriptTriggerMode.WhileTrue
: ScriptTriggerMode.OnTrue;
}
/// <summary>Returns <paramref name="raw"/> if it is a recognized operator, else "&gt;".</summary>
internal static string NormalizeOperator(string? raw)
{

View File

@@ -7,7 +7,8 @@
ScriptActor.ParseTriggerConfig consumes:
Interval { intervalMs }
ValueChange { attributeName }
Conditional { attributeName, operator, threshold }
Conditional { attributeName, operator, threshold, mode }
Expression { expression, mode }
Call { } *@
<div class="border rounded bg-white p-3">
@@ -65,6 +66,12 @@
break;
}
@* ── Fire mode (Conditional + Expression) ──────────────────────────── *@
@if (_kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
{
@RenderMode();
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
@@ -273,6 +280,37 @@
await Emit();
}
// ── Fire mode (Conditional + Expression) ───────────────────────────────
private RenderFragment RenderMode() => __builder =>
{
<div class="mt-3">
<label for="script-trigger-mode" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Fire mode
</label>
<select id="script-trigger-mode" class="form-select form-select-sm"
style="max-width: 420px;"
@bind="_model.Mode" @bind:after="OnModeChanged">
<option value="@ScriptTriggerMode.OnTrue">
Once — when the condition becomes true
</option>
<option value="@ScriptTriggerMode.WhileTrue">
Repeatedly — while the condition stays true
</option>
</select>
@if (_model.Mode == ScriptTriggerMode.WhileTrue)
{
<div class="form-text">
Re-runs on a timer while the condition holds, at the script's
<strong>Min time between runs</strong> interval — set that field below
the script editor, or the trigger fires only once.
</div>
}
</div>
};
private async Task OnModeChanged() => await Emit();
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
private RenderFragment RenderAttributePicker(string label) => __builder =>
@@ -341,11 +379,15 @@
ScriptTriggerKind.Conditional =>
_model.Threshold is { } t
? $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
? (_model.Mode == ScriptTriggerMode.WhileTrue
? $"Re-runs while {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}."
: $"Runs when {attr} changes, if {attr} {_model.Operator} {t.ToString("0.###", CultureInfo.InvariantCulture)}.")
: $"Runs when {attr} changes and meets the configured condition — set a threshold above.",
ScriptTriggerKind.Expression =>
"Runs once each time this expression becomes true.",
_model.Mode == ScriptTriggerMode.WhileTrue
? "Re-runs while this expression stays true."
: "Runs once each time this expression becomes true.",
_ => string.Empty
};

View File

@@ -20,7 +20,11 @@ namespace ScadaLink.SiteRuntime.Actors;
/// Trigger types:
/// - Interval: uses Akka timers to fire periodically
/// - ValueChange: receives attribute change notifications from Instance Actor
/// - Conditional: evaluates a condition on attribute change
/// - Conditional: evaluates a threshold comparison on attribute change
/// - Expression: evaluates a compiled boolean expression on attribute change
/// Conditional and Expression triggers carry a <see cref="TriggerMode"/>:
/// OnTrue fires as the condition becomes true; WhileTrue additionally re-fires
/// on a timer (cadence = MinTimeBetweenRuns) while the condition stays true.
///
/// Supervision strategy: Resume on exception (coordinator preserves state).
/// </summary>
@@ -48,6 +52,14 @@ public class ScriptActor : ReceiveActor, IWithTimers
private bool _lastExpressionResult;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// WhileTrue trigger state: the most recent truth value of a Conditional
// trigger's comparison, used to detect false->true / true->false edges.
// (Expression triggers reuse _lastExpressionResult for the same purpose.)
private bool _conditionState;
/// <summary>Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns).</summary>
private const string WhileTrueTimerKey = "whiletrue-trigger";
/// <summary>
/// SiteRuntime-017: the exact dictionary instance this actor was seeded from
/// at construction. The Instance Actor must pass a private snapshot here, not
@@ -108,6 +120,9 @@ public class ScriptActor : ReceiveActor, IWithTimers
// Handle interval tick
Receive<IntervalTick>(_ => TrySpawnExecution(null));
// Handle WhileTrue re-fire tick
Receive<WhileTrueTick>(_ => FireWhileTrueTick());
// Handle execution completion (for logging/metrics)
Receive<ScriptExecutionCompleted>(HandleExecutionCompleted);
}
@@ -193,9 +208,17 @@ public class ScriptActor : ReceiveActor, IWithTimers
{
if (conditional.AttributeName == changed.AttributeName)
{
// Evaluate condition
if (EvaluateCondition(conditional, changed.Value))
var conditionMet = EvaluateCondition(conditional, changed.Value);
if (conditional.Mode == TriggerMode.WhileTrue)
{
// Edge-detect against the prior truth value; the timer does
// the repeated firing while the condition stays true.
HandleWhileTrueTransition(conditionMet, _conditionState);
_conditionState = conditionMet;
}
else if (conditionMet)
{
// OnTrue: fire on each matching change (existing behavior).
TrySpawnExecution(null);
}
}
@@ -208,13 +231,16 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot and runs the script edge-triggered — once per false→true
/// transition. A throwing or non-bool expression is treated as false and
/// logged as a script error; the actor never crashes.
/// snapshot. In <see cref="TriggerMode.OnTrue"/> mode the script runs once
/// per false→true transition; in <see cref="TriggerMode.WhileTrue"/> mode it
/// fires on the edge and the re-fire timer is started/stopped with the
/// expression's truth value. A throwing or non-bool expression is treated as
/// false and logged as a script error; the actor never crashes.
/// </summary>
private void EvaluateExpressionTrigger()
{
if (_compiledTriggerExpression == null) return;
if (_triggerConfig is not ExpressionTriggerConfig exprConfig) return;
bool result;
try
@@ -239,7 +265,11 @@ public class ScriptActor : ReceiveActor, IWithTimers
result = false;
}
if (result && !_lastExpressionResult)
if (exprConfig.Mode == TriggerMode.WhileTrue)
{
HandleWhileTrueTransition(result, _lastExpressionResult);
}
else if (result && !_lastExpressionResult)
{
TrySpawnExecution(null);
}
@@ -247,6 +277,63 @@ public class ScriptActor : ReceiveActor, IWithTimers
_lastExpressionResult = result;
}
/// <summary>
/// Applies a WhileTrue trigger's condition-state transition: on the
/// false→true edge, fire once and start the re-fire timer; on the
/// true→false edge, stop the timer. While the state is unchanged, the
/// already-running timer continues to drive re-firing.
/// </summary>
private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue)
{
if (nowTrue && !wasTrue)
{
TrySpawnExecution(null);
StartWhileTrueTimer();
}
else if (!nowTrue && wasTrue)
{
StopWhileTrueTimer();
}
}
/// <summary>
/// Starts the periodic WhileTrue re-fire timer. The cadence is the script's
/// <c>MinTimeBetweenRuns</c>; with none configured the trigger cannot
/// re-fire, so it degrades to the single edge fire and logs a warning.
/// </summary>
private void StartWhileTrueTimer()
{
if (_compiledScript == null) return;
if (_minTimeBetweenRuns is not { } interval)
{
_logger.LogWarning(
"ScriptActor {Script} on {Instance}: WhileTrue trigger has no MinTimeBetweenRuns — " +
"firing once on the edge only, no re-fire timer.",
_scriptName, _instanceName);
return;
}
Timers.StartPeriodicTimer(WhileTrueTimerKey, WhileTrueTick.Instance, interval, interval);
}
/// <summary>Cancels the WhileTrue re-fire timer (a no-op if it is not running).</summary>
private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey);
/// <summary>
/// Fires the script for a WhileTrue re-fire tick. The timer interval is
/// itself the cadence, so this spawns directly — bypassing the
/// MinTimeBetweenRuns skip-check that gates change-driven spawns (which
/// could otherwise drop a tick to sub-millisecond timing jitter).
/// </summary>
private void FireWhileTrueTick()
{
if (_compiledScript == null) return;
_lastExecutionTime = DateTimeOffset.UtcNow;
SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
}
/// <summary>
/// Records a trigger-expression evaluation failure to the site event log,
/// mirroring how ScriptExecutionActor reports script errors.
@@ -368,7 +455,31 @@ public class ScriptActor : ReceiveActor, IWithTimers
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
{
var expr = TriggerExpressionGlobals.ExtractExpression(json);
return expr == null ? null : new ExpressionTriggerConfig(expr);
if (expr == null) return null;
// ExtractExpression already proved the JSON parses; read the mode too.
var mode = TriggerMode.OnTrue;
try
{
using var doc = JsonDocument.Parse(json!);
mode = ParseTriggerMode(doc.RootElement);
}
catch (JsonException) { /* keep OnTrue */ }
return new ExpressionTriggerConfig(expr, mode);
}
/// <summary>
/// Reads the optional <c>mode</c> field (Conditional + Expression triggers).
/// An absent or unrecognized value (case-insensitive) yields
/// <see cref="TriggerMode.OnTrue"/>, so pre-WhileTrue configs are unchanged.
/// </summary>
private static TriggerMode ParseTriggerMode(JsonElement root)
{
var raw = root.TryGetProperty("mode", out var m) ? m.GetString() : null;
return string.Equals(raw?.Trim(), "WhileTrue", StringComparison.OrdinalIgnoreCase)
? TriggerMode.WhileTrue
: TriggerMode.OnTrue;
}
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
@@ -404,7 +515,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
var attr = doc.RootElement.GetProperty("attributeName").GetString()!;
var op = doc.RootElement.GetProperty("operator").GetString()!;
var threshold = doc.RootElement.GetProperty("threshold").GetDouble();
return new ConditionalTriggerConfig(attr, op, threshold);
return new ConditionalTriggerConfig(
attr, op, threshold, ParseTriggerMode(doc.RootElement));
}
catch { return null; }
}
@@ -417,13 +529,26 @@ public class ScriptActor : ReceiveActor, IWithTimers
private IntervalTick() { }
}
internal sealed class WhileTrueTick
{
public static readonly WhileTrueTick Instance = new();
private WhileTrueTick() { }
}
internal record ScriptExecutionCompleted(string ScriptName, bool Success, string? Error);
}
// ── Trigger config types ──
/// <summary>
/// When a Conditional/Expression trigger fires. <see cref="OnTrue"/> fires once
/// as the condition becomes true; <see cref="WhileTrue"/> additionally re-fires
/// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false.
/// </summary>
internal enum TriggerMode { OnTrue, WhileTrue }
internal record IntervalTriggerConfig(TimeSpan Interval) : ScriptTriggerConfig;
internal record ValueChangeTriggerConfig(string AttributeName) : ScriptTriggerConfig;
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold) : ScriptTriggerConfig;
internal record ExpressionTriggerConfig(string Expression) : ScriptTriggerConfig;
internal record ConditionalTriggerConfig(string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig;
internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig;
internal abstract record ScriptTriggerConfig;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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));
}
}