From 437fe154e7551d56586185ea467a431547c27b50 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 10:44:11 -0400 Subject: [PATCH] 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. --- ...026-05-18-whiletrue-trigger-mode-design.md | 2 +- docs/requirements/Component-SiteRuntime.md | 8 +- docs/requirements/Component-TemplateEngine.md | 4 +- .../Shared/ScriptTriggerConfigCodec.cs | 33 ++- .../Shared/ScriptTriggerEditor.razor | 48 ++++- .../Actors/ScriptActor.cs | 147 ++++++++++++- .../Shared/ScriptTriggerConfigCodecTests.cs | 137 ++++++++++++ .../Shared/ScriptTriggerEditorTests.cs | 69 ++++++ .../Actors/ScriptActorTests.cs | 198 ++++++++++++++++++ 9 files changed, 625 insertions(+), 21 deletions(-) create mode 100644 tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerEditorTests.cs 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 => + { +
+ + + @if (_model.Mode == ScriptTriggerMode.WhileTrue) + { +
+ 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? 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.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 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(TimeSpan.FromSeconds(2)); // edge fire + + // Then the timer re-fires while the condition still holds. + instance.ExpectMsg(TimeSpan.FromSeconds(2)); // tick 1 + instance.ExpectMsg(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(TimeSpan.FromSeconds(2)); // edge fire + instance.ExpectMsg(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(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(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(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(TimeSpan.FromSeconds(2)); + actor.Tell(Change("Temp", "101")); + instance.ExpectMsg(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(TimeSpan.FromSeconds(2)); // edge fire + instance.ExpectMsg(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(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(TimeSpan.FromSeconds(2)); + } }