Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01509a045f | |||
| 437fe154e7 | |||
| 19870d1f8f |
@@ -0,0 +1,134 @@
|
||||
# WhileTrue Trigger Mode for Conditional & Expression Script Triggers — Design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Implemented
|
||||
|
||||
## Context
|
||||
|
||||
Template script triggers of type `Conditional` and `Expression` currently fire
|
||||
*once* when their condition first holds:
|
||||
|
||||
- **Expression** is edge-triggered — the script runs once per `false→true`
|
||||
transition of the boolean expression.
|
||||
- **Conditional** runs the script on each change of the monitored attribute for
|
||||
which the `{operator, threshold}` comparison is true.
|
||||
|
||||
There is no way to keep a script running *while* a condition remains true. This
|
||||
design adds a second mode, **WhileTrue**, alongside the existing behavior
|
||||
(referred to as **OnTrue**).
|
||||
|
||||
### Decisions taken during brainstorming
|
||||
|
||||
- **WhileTrue is a timer re-fire cadence.** On the `false→true` edge the script
|
||||
fires immediately; while the condition stays true it re-fires on a periodic
|
||||
timer; on the `true→false` edge the timer stops. It re-fires even if no
|
||||
attribute updates arrive (e.g. a static condition).
|
||||
- **The re-fire interval is the script's existing `MinTimeBetweenRuns`** — no
|
||||
new configuration field. One value serves as both the WhileTrue cadence and
|
||||
the general per-script throttle.
|
||||
- **OnTrue is unchanged.** A trigger config with no `mode` field parses as
|
||||
`OnTrue`, so every existing deployed template behaves identically.
|
||||
- **Alarms are out of scope** — alarm triggers are already level-based ("active
|
||||
while true").
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Trigger model & storage
|
||||
|
||||
`Conditional` and `Expression` trigger `TriggerConfiguration` JSON gains an
|
||||
optional `mode` field:
|
||||
|
||||
| Trigger | JSON shape |
|
||||
|-------------|---------------------------------------------------------|
|
||||
| Conditional | `{ attributeName, operator, threshold, mode? }` |
|
||||
| Expression | `{ expression, mode? }` |
|
||||
|
||||
`mode` is `"OnTrue"` (default; absent ⇒ `OnTrue`) or `"WhileTrue"`. Entity
|
||||
types are unchanged — `TemplateScript.TriggerConfiguration` stays `string?`.
|
||||
|
||||
### 2. Runtime evaluation (`ScriptActor`)
|
||||
|
||||
The internal trigger-config records gain a `Mode`:
|
||||
|
||||
```csharp
|
||||
internal enum TriggerMode { OnTrue, WhileTrue }
|
||||
internal record ConditionalTriggerConfig(
|
||||
string AttributeName, string Operator, double Threshold, TriggerMode Mode) : ScriptTriggerConfig;
|
||||
internal record ExpressionTriggerConfig(string Expression, TriggerMode Mode) : ScriptTriggerConfig;
|
||||
```
|
||||
|
||||
**OnTrue** — current behavior, unchanged:
|
||||
- Conditional: fire on each monitored-attribute change for which the comparison
|
||||
is true.
|
||||
- Expression: fire once per `false→true` transition.
|
||||
|
||||
**WhileTrue** — the actor tracks the condition's current truth state:
|
||||
- **`false→true` edge:** fire once immediately (via `TrySpawnExecution`, which
|
||||
still respects `MinTimeBetweenRuns` against any prior run), then start a
|
||||
periodic Akka timer with both initial delay and interval set to
|
||||
`MinTimeBetweenRuns`.
|
||||
- **Each timer tick:** spawn an execution **directly** — the timer interval is
|
||||
itself the cadence, so ticks bypass the `MinTimeBetweenRuns` skip-check (which
|
||||
could otherwise drop a tick to sub-millisecond timing jitter).
|
||||
- **`true→false` edge:** cancel the timer.
|
||||
- The state transitions naturally re-arm: a later `true` after a `false` starts
|
||||
a fresh cycle.
|
||||
|
||||
Truth-state sources:
|
||||
- Conditional depends only on the monitored attribute, so it is re-evaluated on
|
||||
each change of that attribute (unchanged gate).
|
||||
- Expression is re-evaluated on every attribute change (unchanged).
|
||||
|
||||
A throwing/non-bool expression is treated as `false` (existing behavior); for
|
||||
WhileTrue that cleanly stops the timer.
|
||||
|
||||
**Edge case — WhileTrue with no `MinTimeBetweenRuns`:** a periodic timer has no
|
||||
interval to use, so the trigger degrades to a single edge fire and logs a
|
||||
warning. Deployment-time validation of this combination is a deliberate
|
||||
non-goal of this change.
|
||||
|
||||
New surface in `ScriptActor`: a `TriggerMode` on the two config records, one
|
||||
`WhileTrueTick` internal message, and start/stop timer helpers (the actor
|
||||
already implements `IWithTimers` for Interval triggers). Timer key
|
||||
`"whiletrue-trigger"`.
|
||||
|
||||
### 3. Editors & codec
|
||||
|
||||
- **`ScriptTriggerConfigCodec`:** `ScriptTriggerModel` gains a `Mode`
|
||||
(`ScriptTriggerMode` enum, default `OnTrue`). `Parse` reads `mode` for the
|
||||
Conditional and Expression kinds; `Serialize` writes it for those kinds.
|
||||
Absent/unrecognized `mode` ⇒ `OnTrue`.
|
||||
- **`ScriptTriggerEditor.razor`:** the Conditional and Expression panels gain a
|
||||
mode `<select>` — "When the condition becomes true — once" (`OnTrue`) vs
|
||||
"Repeatedly while true" (`WhileTrue`) — with a hint that WhileTrue re-fires at
|
||||
the script's *Min time between runs* interval. `BuildHint()` is updated to
|
||||
describe the selected mode.
|
||||
|
||||
### 4. Error handling
|
||||
|
||||
- Expression throws / returns non-bool → treated as `false` (logged as a script
|
||||
error, as today); for WhileTrue this stops the re-fire timer.
|
||||
- Malformed trigger JSON → trigger parses as inert (existing behavior).
|
||||
- WhileTrue with no `MinTimeBetweenRuns` → single fire + warning (see above).
|
||||
|
||||
### 5. Testing & verification
|
||||
|
||||
- **Runtime (`ScriptActorTests`)** — a script body that calls
|
||||
`Instance.SetAttribute(...)` is observable as a `SetStaticAttributeCommand` on
|
||||
the instance-actor `TestProbe`; with a short `MinTimeBetweenRuns`:
|
||||
- WhileTrue conditional & expression: fire on the `false→true` edge, re-fire
|
||||
on the timer, stop on `true→false`, re-arm on a later `true`.
|
||||
- OnTrue regression: conditional & expression fire exactly as before.
|
||||
- WhileTrue with no `MinTimeBetweenRuns`: a single fire, no repeats.
|
||||
- **Codec (`ScriptTriggerConfigCodecTests`, new)** — `mode` round-trips for
|
||||
Conditional and Expression; absent `mode` ⇒ `OnTrue`; `WhileTrue` serializes.
|
||||
|
||||
## Affected files
|
||||
|
||||
- `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs`
|
||||
- `src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs`
|
||||
- `src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor`
|
||||
- `tests/ScadaLink.SiteRuntime.Tests/Actors/ScriptActorTests.cs`
|
||||
- `tests/ScadaLink.CentralUI.Tests/Shared/ScriptTriggerConfigCodecTests.cs` (new)
|
||||
- Docs: `docs/requirements/Component-SiteRuntime.md`,
|
||||
`docs/requirements/Component-TemplateEngine.md` (note the new mode).
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
private string _scriptCode = string.Empty;
|
||||
private string? _scriptTriggerType;
|
||||
private string? _scriptTriggerConfig;
|
||||
private string? _scriptMinTimeValue;
|
||||
private string _scriptMinTimeUnit = "sec";
|
||||
private string? _scriptParameters;
|
||||
private string? _scriptReturn;
|
||||
private bool _scriptIsLocked;
|
||||
@@ -880,6 +882,47 @@
|
||||
Changed="@OnScriptTriggerChanged"
|
||||
AvailableAttributes="@BuildAlarmAttributeChoices()" />
|
||||
</div>
|
||||
@if (ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(_scriptTriggerType))
|
||||
{
|
||||
<div class="col-12">
|
||||
<label class="form-label">Min time between runs</label>
|
||||
<div class="row g-2" style="max-width: 420px;">
|
||||
<div class="col-7">
|
||||
<input type="number" min="1" step="1" class="form-control"
|
||||
placeholder="(optional)"
|
||||
@bind="_scriptMinTimeValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<select class="form-select" @bind="_scriptMinTimeUnit">
|
||||
<option value="ms">milliseconds</option>
|
||||
<option value="sec">seconds</option>
|
||||
<option value="min">minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (ScriptTriggerIsWhileTrue())
|
||||
{
|
||||
<div class="form-text">
|
||||
This is the re-fire interval for the
|
||||
<strong>WhileTrue</strong> trigger above.
|
||||
</div>
|
||||
@if (DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit) is null)
|
||||
{
|
||||
<div class="alert alert-warning py-1 px-2 small mt-1 mb-0">
|
||||
The WhileTrue trigger has no interval set — the script
|
||||
will fire only once. Set a value here to make it re-fire.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-text">
|
||||
Optional throttle — skips trigger invocations that fire
|
||||
sooner than this.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
|
||||
@@ -1461,6 +1504,19 @@
|
||||
_scriptTriggerConfig = v.Config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the current script trigger is a WhileTrue Conditional/Expression
|
||||
/// trigger — the case where the "Min time between runs" interval is required
|
||||
/// (it is the re-fire cadence).
|
||||
/// </summary>
|
||||
private bool ScriptTriggerIsWhileTrue()
|
||||
{
|
||||
var kind = ScriptTriggerConfigCodec.ParseKind(_scriptTriggerType);
|
||||
return kind is ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression
|
||||
&& ScriptTriggerConfigCodec.Parse(_scriptTriggerConfig, kind).Mode
|
||||
== ScriptTriggerMode.WhileTrue;
|
||||
}
|
||||
|
||||
private void BeginAddScript()
|
||||
{
|
||||
_showScriptForm = true;
|
||||
@@ -1470,6 +1526,7 @@
|
||||
_scriptCode = string.Empty;
|
||||
_scriptTriggerType = null;
|
||||
_scriptTriggerConfig = null;
|
||||
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(null);
|
||||
_scriptParameters = null;
|
||||
_scriptReturn = null;
|
||||
_scriptIsLocked = false;
|
||||
@@ -1486,6 +1543,7 @@
|
||||
_scriptCode = script.Code;
|
||||
_scriptTriggerType = script.TriggerType;
|
||||
_scriptTriggerConfig = script.TriggerConfiguration;
|
||||
(_scriptMinTimeValue, _scriptMinTimeUnit) = DurationInput.Split(script.MinTimeBetweenRuns);
|
||||
_scriptParameters = script.ParameterDefinitions;
|
||||
_scriptReturn = script.ReturnDefinition;
|
||||
_scriptIsLocked = script.IsLocked;
|
||||
@@ -1581,7 +1639,7 @@
|
||||
ParameterDefinitions = _scriptParameters,
|
||||
ReturnDefinition = _scriptReturn,
|
||||
IsLocked = _scriptIsLocked,
|
||||
MinTimeBetweenRuns = existing.MinTimeBetweenRuns,
|
||||
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit),
|
||||
IsInherited = existing.IsInherited,
|
||||
LockedInDerived = existing.LockedInDerived,
|
||||
};
|
||||
@@ -1606,7 +1664,8 @@
|
||||
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
|
||||
ParameterDefinitions = _scriptParameters,
|
||||
ReturnDefinition = _scriptReturn,
|
||||
IsLocked = _scriptIsLocked
|
||||
IsLocked = _scriptIsLocked,
|
||||
MinTimeBetweenRuns = DurationInput.Compose(_scriptMinTimeValue, _scriptMinTimeUnit)
|
||||
};
|
||||
|
||||
var addResult = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="TimeSpan"/> to and from the number+unit pair behind a
|
||||
/// duration input (milliseconds / seconds / minutes). A blank or non-positive
|
||||
/// number represents "unset" (a <c>null</c> duration).
|
||||
/// </summary>
|
||||
internal static class DurationInput
|
||||
{
|
||||
/// <summary>The unit tokens a duration input offers, smallest first.</summary>
|
||||
internal static readonly string[] Units = { "ms", "sec", "min" };
|
||||
|
||||
/// <summary>
|
||||
/// Splits a duration into the largest whole unit that represents it exactly.
|
||||
/// A null or non-positive duration yields a blank value and the default
|
||||
/// <c>sec</c> unit.
|
||||
/// </summary>
|
||||
internal static (string? Value, string Unit) Split(TimeSpan? duration)
|
||||
{
|
||||
if (duration is not { } d || d <= TimeSpan.Zero) return (null, "sec");
|
||||
|
||||
var ms = (long)d.TotalMilliseconds;
|
||||
if (ms % 60000 == 0) return ((ms / 60000).ToString(CultureInfo.InvariantCulture), "min");
|
||||
if (ms % 1000 == 0) return ((ms / 1000).ToString(CultureInfo.InvariantCulture), "sec");
|
||||
return (ms.ToString(CultureInfo.InvariantCulture), "ms");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes a number+unit pair into a duration. A blank, unparseable, or
|
||||
/// non-positive value yields <c>null</c> (unset).
|
||||
/// </summary>
|
||||
internal static TimeSpan? Compose(string? value, string unit)
|
||||
{
|
||||
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|
||||
|| n <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var factorMs = unit switch
|
||||
{
|
||||
"min" => 60000L,
|
||||
"ms" => 1L,
|
||||
_ => 1000L,
|
||||
};
|
||||
return TimeSpan.FromMilliseconds(n * factorMs);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -68,6 +81,17 @@ internal static class ScriptTriggerConfigCodec
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a trigger type honours the script's <c>MinTimeBetweenRuns</c>.
|
||||
/// True for the auto-firing triggers it throttles — ValueChange, Conditional,
|
||||
/// Expression. False for Interval (its own period is the cadence), Call
|
||||
/// (invoked explicitly, never throttled), and None/Unknown.
|
||||
/// </summary>
|
||||
internal static bool SupportsMinTimeBetweenRuns(string? triggerType) =>
|
||||
ParseKind(triggerType) is ScriptTriggerKind.ValueChange
|
||||
or ScriptTriggerKind.Conditional
|
||||
or ScriptTriggerKind.Expression;
|
||||
|
||||
/// <summary>Canonical <c>TriggerType</c> string for a kind; null for None/Unknown.</summary>
|
||||
internal static string? KindToString(ScriptTriggerKind kind) => kind switch
|
||||
{
|
||||
@@ -109,10 +133,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 +178,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 +193,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 ">".</summary>
|
||||
internal static string NormalizeOperator(string? raw)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="DurationInput"/>, the number+unit codec behind the
|
||||
/// script form's "Min time between runs" field.
|
||||
/// </summary>
|
||||
public class DurationInputTests
|
||||
{
|
||||
// ── Split: TimeSpan -> (value, unit) ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Split_Null_ReturnsBlankWithSecondsUnit()
|
||||
{
|
||||
var (value, unit) = DurationInput.Split(null);
|
||||
|
||||
Assert.Null(value);
|
||||
Assert.Equal("sec", unit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_Zero_ReturnsBlank()
|
||||
{
|
||||
var (value, _) = DurationInput.Split(TimeSpan.Zero);
|
||||
|
||||
Assert.Null(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_WholeMinutes_UsesMinuteUnit()
|
||||
{
|
||||
var (value, unit) = DurationInput.Split(TimeSpan.FromMinutes(5));
|
||||
|
||||
Assert.Equal("5", value);
|
||||
Assert.Equal("min", unit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_WholeSeconds_UsesSecondUnit()
|
||||
{
|
||||
var (value, unit) = DurationInput.Split(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal("30", value);
|
||||
Assert.Equal("sec", unit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_SubSecond_UsesMillisecondUnit()
|
||||
{
|
||||
var (value, unit) = DurationInput.Split(TimeSpan.FromMilliseconds(250));
|
||||
|
||||
Assert.Equal("250", value);
|
||||
Assert.Equal("ms", unit);
|
||||
}
|
||||
|
||||
// ── Compose: (value, unit) -> TimeSpan? ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compose_Blank_ReturnsNull() =>
|
||||
Assert.Null(DurationInput.Compose(null, "sec"));
|
||||
|
||||
[Fact]
|
||||
public void Compose_Zero_ReturnsNull() =>
|
||||
Assert.Null(DurationInput.Compose("0", "sec"));
|
||||
|
||||
[Fact]
|
||||
public void Compose_Negative_ReturnsNull() =>
|
||||
Assert.Null(DurationInput.Compose("-5", "sec"));
|
||||
|
||||
[Fact]
|
||||
public void Compose_SecondsValue_BuildsDuration() =>
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec"));
|
||||
|
||||
[Fact]
|
||||
public void Compose_MinutesValue_BuildsDuration() =>
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min"));
|
||||
|
||||
[Fact]
|
||||
public void Compose_MillisecondsValue_BuildsDuration() =>
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms"));
|
||||
|
||||
// ── Round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(250)]
|
||||
[InlineData(30000)]
|
||||
[InlineData(300000)]
|
||||
public void RoundTrip_PreservesDuration(long milliseconds)
|
||||
{
|
||||
var original = TimeSpan.FromMilliseconds(milliseconds);
|
||||
|
||||
var (value, unit) = DurationInput.Split(original);
|
||||
var reparsed = DurationInput.Compose(value, unit);
|
||||
|
||||
Assert.Equal(original, reparsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// ── SupportsMinTimeBetweenRuns ─────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("ValueChange")]
|
||||
[InlineData("Conditional")]
|
||||
[InlineData("Expression")]
|
||||
public void SupportsMinTimeBetweenRuns_TrueForAutoTriggersThatThrottle(string triggerType)
|
||||
{
|
||||
Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Interval")] // has its own period control
|
||||
[InlineData("Call")] // invoked explicitly — no throttle applies
|
||||
[InlineData(null)] // None — never runs automatically
|
||||
[InlineData("Bogus")] // Unknown trigger type
|
||||
public void SupportsMinTimeBetweenRuns_FalseForIntervalCallNoneAndUnknown(string? triggerType)
|
||||
{
|
||||
Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void RoundTrip_Expression_PreservesMode(bool whileTrue)
|
||||
{
|
||||
var mode = whileTrue ? ScriptTriggerMode.WhileTrue : ScriptTriggerMode.OnTrue;
|
||||
var original = new ScriptTriggerModel
|
||||
{
|
||||
Expression = "Attributes[\"T\"] > 1",
|
||||
Mode = mode
|
||||
};
|
||||
|
||||
var json = ScriptTriggerConfigCodec.Serialize(original, ScriptTriggerKind.Expression);
|
||||
var reparsed = ScriptTriggerConfigCodec.Parse(json, ScriptTriggerKind.Expression);
|
||||
|
||||
Assert.Equal(mode, reparsed.Mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.CentralUI.Components.Shared;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Component tests for the OnTrue/WhileTrue mode selector that
|
||||
/// <see cref="ScriptTriggerEditor"/> exposes for Conditional and Expression
|
||||
/// triggers.
|
||||
/// </summary>
|
||||
public class ScriptTriggerEditorTests : BunitContext
|
||||
{
|
||||
private const string ConditionalConfig =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50}";
|
||||
|
||||
private const string ConditionalWhileTrueConfig =
|
||||
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}";
|
||||
|
||||
[Fact]
|
||||
public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
cut.Find("#script-trigger-mode").Change("WhileTrue");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
// Change the threshold to force an emit without touching the mode.
|
||||
cut.Find("input[type=number]").Input("75");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit()
|
||||
{
|
||||
ScriptTriggerValue? captured = null;
|
||||
var cut = Render<ScriptTriggerEditor>(ps => ps
|
||||
.Add(p => p.TriggerType, "Conditional")
|
||||
.Add(p => p.TriggerConfig, ConditionalWhileTrueConfig)
|
||||
.Add(p => p.Changed,
|
||||
EventCallback.Factory.Create<ScriptTriggerValue>(this, v => captured = v)));
|
||||
|
||||
// Editing the threshold must not silently drop the loaded WhileTrue mode.
|
||||
cut.Find("input[type=number]").Input("75");
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
@@ -237,4 +239,200 @@ public class ScriptActorTests : TestKit, IDisposable
|
||||
var result2 = ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||
Assert.False(result2.Success); // Still fails, but the actor is still alive
|
||||
}
|
||||
|
||||
// ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
|
||||
//
|
||||
// A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
|
||||
// Instance Actor receives as a SetStaticAttributeCommand. The probe stands
|
||||
// in for the Instance Actor: an auto-pilot replies so each execution
|
||||
// completes promptly (freeing the script-execution scheduler), while every
|
||||
// command remains observable via ExpectMsg — one command per script firing.
|
||||
|
||||
private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";
|
||||
|
||||
/// <summary>Builds a ScriptActor whose script fires one observable command per run.</summary>
|
||||
private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
|
||||
string name,
|
||||
string triggerType,
|
||||
string triggerConfig,
|
||||
TimeSpan? minTimeBetweenRuns,
|
||||
Script<object?>? triggerExpression = null)
|
||||
{
|
||||
var compiled = CompileScript(FiringScriptCode);
|
||||
var scriptConfig = new ResolvedScript
|
||||
{
|
||||
CanonicalName = name,
|
||||
Code = FiringScriptCode,
|
||||
TriggerType = triggerType,
|
||||
TriggerConfiguration = triggerConfig,
|
||||
MinTimeBetweenRuns = minTimeBetweenRuns
|
||||
};
|
||||
|
||||
var instance = CreateTestProbe();
|
||||
instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
|
||||
{
|
||||
if (message is SetStaticAttributeCommand cmd)
|
||||
{
|
||||
sender.Tell(new SetStaticAttributeResponse(
|
||||
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
|
||||
true, null, DateTimeOffset.UtcNow));
|
||||
}
|
||||
return AutoPilot.KeepRunning;
|
||||
}));
|
||||
|
||||
var actor = ActorOf(Props.Create(() => new ScriptActor(
|
||||
name,
|
||||
"TestInstance",
|
||||
instance.Ref,
|
||||
compiled,
|
||||
scriptConfig,
|
||||
_sharedLibrary,
|
||||
_options,
|
||||
NullLogger<ScriptActor>.Instance,
|
||||
triggerExpression,
|
||||
null,
|
||||
null,
|
||||
null)));
|
||||
|
||||
return (actor, instance);
|
||||
}
|
||||
|
||||
private AttributeValueChanged Change(string attribute, object? value) =>
|
||||
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
|
||||
|
||||
private Script<object?> CompileTriggerExpression(string expression) =>
|
||||
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
|
||||
{
|
||||
// WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondWhile",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Temp 100 > 50 -> false->true edge: fire immediately.
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
|
||||
// Then the timer re-fires while the condition still holds.
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
|
||||
{
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondStop",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // at least one tick
|
||||
|
||||
// Temp 10 -> condition false: the re-fire timer stops.
|
||||
actor.Tell(Change("Temp", "10"));
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
|
||||
{
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondReArm",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300));
|
||||
|
||||
actor.Tell(Change("Temp", "100")); // true edge -> fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
actor.Tell(Change("Temp", "10")); // false -> stop
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(Change("Temp", "100")); // false->true again: re-arm + fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
|
||||
{
|
||||
// No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondNoInterval",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
|
||||
minTimeBetweenRuns: null);
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
|
||||
{
|
||||
// Regression: OnTrue (the existing behavior) fires per matching change
|
||||
// and never re-fires on a timer of its own.
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"CondOnTrue",
|
||||
"Conditional",
|
||||
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
|
||||
minTimeBetweenRuns: null);
|
||||
|
||||
actor.Tell(Change("Temp", "100"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
actor.Tell(Change("Temp", "101"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
|
||||
{
|
||||
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"ExprWhile",
|
||||
"Expression",
|
||||
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
triggerExpr);
|
||||
|
||||
actor.Tell(Change("Active", "yes"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // tick 1
|
||||
|
||||
actor.Tell(Change("Active", "no"));
|
||||
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
|
||||
{
|
||||
// Regression: OnTrue expression triggers stay edge-triggered.
|
||||
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
|
||||
var (actor, instance) = CreateTriggeredActor(
|
||||
"ExprOnTrue",
|
||||
"Expression",
|
||||
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
|
||||
minTimeBetweenRuns: null,
|
||||
triggerExpr);
|
||||
|
||||
actor.Tell(Change("Active", "yes"));
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2)); // edge fire
|
||||
actor.Tell(Change("Active", "yes")); // still true, no edge
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
actor.Tell(Change("Active", "no")); // -> false
|
||||
actor.Tell(Change("Active", "yes")); // false->true edge again
|
||||
instance.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user