diff --git a/docs/plans/2026-05-18-whiletrue-trigger-mode-design.md b/docs/plans/2026-05-18-whiletrue-trigger-mode-design.md
index 0e53370..97e680e 100644
--- a/docs/plans/2026-05-18-whiletrue-trigger-mode-design.md
+++ b/docs/plans/2026-05-18-whiletrue-trigger-mode-design.md
@@ -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
diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md
index e76d8af..0dcc079 100644
--- a/docs/requirements/Component-SiteRuntime.md
+++ b/docs/requirements/Component-SiteRuntime.md
@@ -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.
diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md
index b4d586b..bdfb196 100644
--- a/docs/requirements/Component-TemplateEngine.md
+++ b/docs/requirements/Component-TemplateEngine.md
@@ -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.
diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
index 835dad5..738e11d 100644
--- a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
+++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs
@@ -12,6 +12,13 @@ namespace ScadaLink.CentralUI.Components.Shared;
///
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
+///
+/// When a Conditional/Expression trigger fires. fires once
+/// as the condition becomes true; re-fires on a timer
+/// (cadence = the script's MinTimeBetweenRuns) while the condition stays true.
+///
+internal enum ScriptTriggerMode { OnTrue, WhileTrue }
+
/// A script's trigger as the editor emits it: a type string + config JSON.
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
@@ -32,6 +39,9 @@ internal sealed class ScriptTriggerModel
/// Boolean C# expression (Expression).
public string? Expression { get; set; }
+
+ /// Fire mode (Conditional + Expression). Defaults to .
+ public ScriptTriggerMode Mode { get; set; } = ScriptTriggerMode.OnTrue;
}
///
@@ -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 }
+///
+/// mode (Conditional + Expression) is OnTrue or WhileTrue;
+/// an absent or unrecognized value parses as OnTrue.
///
/// Parsing also accepts the legacy aliases attribute and value 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());
}
+ ///
+ /// Reads the optional mode property; an absent or unrecognized value
+ /// (case-insensitive) yields .
+ ///
+ 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;
+ }
+
/// Returns if it is a recognized operator, else ">".
internal static string NormalizeOperator(string? raw)
{
diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor
index 07f0dad..ff804d5 100644
--- a/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor
+++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor
@@ -7,7 +7,8 @@
ScriptActor.ParseTriggerConfig consumes:
Interval { intervalMs }
ValueChange { attributeName }
- Conditional { attributeName, operator, threshold }
+ Conditional { attributeName, operator, threshold, mode }
+ Expression { expression, mode }
Call { } *@
@@ -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 =>
+ {
+
+ Re-runs on a timer while the condition holds, at the script's
+ Min time between runs interval — set that field below
+ the script editor, or the trigger fires only once.
+
+ }
+
+ };
+
+ 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
};
diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
index 793fdec..9cf6928 100644
--- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
+++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs
@@ -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 :
+/// 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).
///
@@ -48,6 +52,14 @@ public class ScriptActor : ReceiveActor, IWithTimers
private bool _lastExpressionResult;
private readonly Dictionary _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;
+
+ /// Timer key for the WhileTrue re-fire timer (cadence = MinTimeBetweenRuns).
+ private const string WhileTrueTimerKey = "whiletrue-trigger";
+
///
/// 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(_ => TrySpawnExecution(null));
+ // Handle WhileTrue re-fire tick
+ Receive(_ => FireWhileTrueTick());
+
// Handle execution completion (for logging/metrics)
Receive(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
///
/// 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 mode the script runs once
+ /// per false→true transition; in 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.
///
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;
}
+ ///
+ /// 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.
+ ///
+ private void HandleWhileTrueTransition(bool nowTrue, bool wasTrue)
+ {
+ if (nowTrue && !wasTrue)
+ {
+ TrySpawnExecution(null);
+ StartWhileTrueTimer();
+ }
+ else if (!nowTrue && wasTrue)
+ {
+ StopWhileTrueTimer();
+ }
+ }
+
+ ///
+ /// Starts the periodic WhileTrue re-fire timer. The cadence is the script's
+ /// MinTimeBetweenRuns; with none configured the trigger cannot
+ /// re-fire, so it degrades to the single edge fire and logs a warning.
+ ///
+ 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);
+ }
+
+ /// Cancels the WhileTrue re-fire timer (a no-op if it is not running).
+ private void StopWhileTrueTimer() => Timers.Cancel(WhileTrueTimerKey);
+
+ ///
+ /// 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).
+ ///
+ private void FireWhileTrueTick()
+ {
+ if (_compiledScript == null) return;
+
+ _lastExecutionTime = DateTimeOffset.UtcNow;
+ SpawnExecution(null, 0, ActorRefs.NoSender!, Guid.NewGuid().ToString());
+ }
+
///
/// 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);
+ }
+
+ ///
+ /// Reads the optional mode field (Conditional + Expression triggers).
+ /// An absent or unrecognized value (case-insensitive) yields
+ /// , so pre-WhileTrue configs are unchanged.
+ ///
+ 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 ──
+///
+/// When a Conditional/Expression trigger fires. fires once
+/// as the condition becomes true; additionally re-fires
+/// on a timer (cadence = the script's MinTimeBetweenRuns) until it goes false.
+///
+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;
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs
new file mode 100644
index 0000000..5104ab5
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs
@@ -0,0 +1,137 @@
+using ScadaLink.CentralUI.Components.Shared;
+
+namespace ScadaLink.CentralUI.Tests.Shared;
+
+///
+/// Round-trip coverage for the WhileTrue/OnTrue mode field on the
+/// Conditional and Expression script triggers.
+///
+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);
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs
new file mode 100644
index 0000000..34c66cd
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs
@@ -0,0 +1,69 @@
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using ScadaLink.CentralUI.Components.Shared;
+
+namespace ScadaLink.CentralUI.Tests.Shared;
+
+///
+/// Component tests for the OnTrue/WhileTrue mode selector that
+/// exposes for Conditional and Expression
+/// triggers.
+///
+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(ps => ps
+ .Add(p => p.TriggerType, "Conditional")
+ .Add(p => p.TriggerConfig, ConditionalConfig)
+ .Add(p => p.Changed,
+ EventCallback.Factory.Create(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(ps => ps
+ .Add(p => p.TriggerType, "Conditional")
+ .Add(p => p.TriggerConfig, ConditionalConfig)
+ .Add(p => p.Changed,
+ EventCallback.Factory.Create(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(ps => ps
+ .Add(p => p.TriggerType, "Conditional")
+ .Add(p => p.TriggerConfig, ConditionalWhileTrueConfig)
+ .Add(p => p.Changed,
+ EventCallback.Factory.Create(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);
+ }
+}
diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs
index cf891cc..fae813a 100644
--- a/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs
+++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs
@@ -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(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\")";
+
+ /// Builds a ScriptActor whose script fires one observable command per run.
+ private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
+ string name,
+ string triggerType,
+ string triggerConfig,
+ TimeSpan? minTimeBetweenRuns,
+ Script