16 Commits

Author SHA1 Message Date
Joseph Doherty fd1518f4f4 test(central-ui): remove vacuous tests for removed analyzer diagnostics
Six tests asserted DoesNotContain(SCADA004/SCADA005) or an empty InlayHints
result — all pass for the wrong reason now that those diagnostics and the
positional InlayHints were removed in the analyzer realignment. They also
used the obsolete top-level CallScript syntax. Removed.
2026-05-16 15:06:30 -04:00
Joseph Doherty b949dc4183 test(central-ui): realign analyzer tests with the reworked script-call API 2026-05-16 15:04:06 -04:00
Joseph Doherty 3cc174c3cd test(central-ui): fix the CentralUI.Tests build
Two stale references blocked compilation: the DataConnection page tests
still pointed at Components.Pages.Admin (the pages moved to .Design), and
ScriptAnalysisServiceTests constructed ScriptAnalysisService without the
IServiceProvider parameter. The project now compiles.
2026-05-16 14:44:30 -04:00
Joseph Doherty d030153378 test(site-runtime): fix stale SetStaticAttribute tests
HandleSetStaticAttribute was made fire-and-forget (commit 2951507) — it no
longer replies with SetStaticAttributeResponse — but three InstanceActor
tests still ExpectMsg<SetStaticAttributeResponse> and timed out. Verify the
mutation via the GetAttributeRequest round-trip instead, which the FIFO
mailbox makes a sound sync point. Test intent (in-memory update, SQLite
persistence, serialized ordering) is unchanged.
2026-05-16 14:33:09 -04:00
Joseph Doherty d63d412461 test(triggers): expect AlarmTriggerType.Expression in the enum membership test 2026-05-16 06:42:17 -04:00
Joseph Doherty 0a535cd4a5 fix(triggers): don't false-flag Children/Parent attribute refs in expression validation 2026-05-16 06:08:06 -04:00
Joseph Doherty 5065384305 fix(triggers): use explicit ValidationCategory + tighten expression syntax validation 2026-05-16 05:57:39 -04:00
Joseph Doherty bf3f572ad9 feat(triggers): validate expression triggers pre-deployment 2026-05-16 05:52:25 -04:00
Joseph Doherty 3499d76f14 feat(ui/triggers): expression trigger panel in the script & alarm editors 2026-05-16 05:46:27 -04:00
Joseph Doherty 78b10d00d8 fix(triggers): bound expression evaluation, align AlarmActor error handling, dedupe config parsing 2026-05-16 05:43:18 -04:00
Joseph Doherty 41c3fa3d84 fix(triggers): seed the trigger-expression attribute snapshot at actor startup 2026-05-16 05:38:50 -04:00
Joseph Doherty 9e21b47080 feat(triggers): runtime expression trigger evaluation for scripts and alarms 2026-05-16 05:35:02 -04:00
Joseph Doherty f789ab4a91 docs(triggers): list the Expression config shape in the codec summaries 2026-05-16 05:30:12 -04:00
Joseph Doherty 199cdbe798 feat(triggers): add Expression to the script & alarm trigger codecs 2026-05-16 05:27:33 -04:00
Joseph Doherty 8050a1996f docs(plans): implementation plan for expression triggers 2026-05-16 05:25:10 -04:00
Joseph Doherty c94d3b7570 docs(plans): design for expression-based script & alarm triggers
Captures the brainstormed design for a new Expression trigger: a read-only
boolean C# expression evaluated on attribute updates, edge-triggered for
scripts and level-based for alarms, compiled against a restricted read-only
globals type.
2026-05-16 05:21:57 -04:00
22 changed files with 1208 additions and 193 deletions
@@ -0,0 +1,114 @@
# Expression Trigger for Template Scripts and Alarms — Design
**Date:** 2026-05-16
**Status:** Approved (brainstorming) — implementation plan to follow
## Context
Template scripts and template alarms can only be triggered by single-attribute
conditions. Scripts support `Interval`, `ValueChange`, `Conditional`
(`{attributeName, operator, threshold}` — one attribute, numeric compare),
and `Call`. Alarms support `ValueMatch`, `RangeViolation`, `RateOfChange`, and
`HiLo` — all single-attribute. There is no way to trigger on a relationship
between *multiple* attributes (e.g. "speed is high *and* mode is Run").
This design adds an **Expression trigger**: a user-supplied read-only boolean
C# expression, evaluated whenever an instance attribute updates, that fires the
script / activates the alarm when it returns true. It generalizes the existing
single-attribute `Conditional` trigger.
### Decisions taken during brainstorming
- The trigger is a **read-only boolean expression** — no `External`/`Database`/
`Notify`/`CallScript` side effects. It must be cheap and safe to run on every
attribute update.
- **Scripts fire edge-triggered** — once per `false→true` transition.
- **Alarms are level-based** — active while the expression is true, clear when
false (consistent with all existing alarm trigger types).
- **Evaluation approach B** — compile against a *restricted read-only globals
type*, so read-only is enforced, not merely conventional. Reuses the existing
Roslyn compilation pipeline.
## Design
### 1. Trigger model & storage
- **Scripts:** `TemplateScript.TriggerType` (`string?`) gains the value
`"Expression"`. `TriggerConfiguration` JSON is `{ "expression": "<C#>" }`.
- **Alarms:** `AlarmTriggerType` enum gains a member `Expression`.
`TriggerConfiguration` JSON is the same `{ "expression": "<C#>" }`.
- The expression is a bare C# boolean expression (no `return` keyword — Roslyn
scripting returns the trailing expression's value), e.g.
`Attributes["Speed"] > 1000 && (string)Attributes["Mode"] == "Run"`.
- Entity types unchanged: both `TriggerConfiguration` fields stay `string?`.
Adding the `AlarmTriggerType` member touches three switch sites:
`AlarmActor.ParseEvalConfig`, `AlarmActor.HandleAttributeValueChanged`,
`AlarmTriggerConfigCodec`.
### 2. Runtime evaluation
- **`TriggerExpressionGlobals`** (new, `ScadaLink.SiteRuntime`) — a read-only
globals type exposing only `Attributes["X"]`, `Children["C"].Attributes["X"]`,
and `Parent.Attributes["X"]`, backed by an in-memory snapshot dictionary. No
side-effecting APIs. A missing attribute reads as `null` (never throws).
- The expression is compiled once via the existing Roslyn pipeline (same
forbidden-API trust checks) against `TriggerExpressionGlobals`; the compiled
delegate is cached on the actor.
- **Attribute snapshot:** `ScriptActor` and `AlarmActor` already receive every
`AttributeValueChanged`. Each keeps a local `Dictionary<string,object?>`
snapshot — seeded from the instance's initial attribute set at startup, then
updated on each change. The expression evaluates against the snapshot — no
`Ask` back to the `InstanceActor`; cheap and re-entrancy-free.
- **On each `AttributeValueChanged`:** update snapshot → run cached expression
`bool`.
- **Script (edge):** track the previous result; on `false→true`, run the
script (spawn `ScriptExecutionActor`, as the other triggers do).
- **Alarm (level):** the `bool` feeds the existing binary Normal↔Active state
machine — raise on `→Active`, clear on `→Normal`.
- Cost per attribute update: one cached-delegate call + one bool compare.
### 3. Editors & analysis
- **`ScriptTriggerEditor`:** add `Expression` to `ScriptTriggerKind` and
`ScriptTriggerConfigCodec` (round-trips `{ expression }`).
- **`AlarmTriggerEditor`:** add an `Expression` case to its trigger `@switch`.
- Both render the same **expression panel**: a compact `MonacoEditor`
(~120 px) with C# syntax, `Attributes["..."]` completion driven by the
template's attribute metadata (self / children / parent), and live compile
diagnostics. A one-line hint summarizes what fires.
- **Analysis:** reuse the existing `Template` analysis kind — completion and
diagnostics work with no new analyzer code. Editor completion is slightly
permissive (also shows `Instance`/`External`), but the runtime's restricted
`TriggerExpressionGlobals` is what enforces read-only. A dedicated strict
analysis kind is a possible later refinement, out of scope here.
### 4. Error handling & validation
- **Pre-deployment:** extend `ValidationService` to compile-check expression
triggers (against `TriggerExpressionGlobals`); compile errors block
deployment and surface like other validation errors. Unknown
`Attributes["..."]` keys are flagged as the existing trigger-reference
validation does.
- **Runtime — expression throws:** caught; treated as `false` for that update;
a script-error event is written to the site event log. The actor never
crashes.
- **Non-bool result:** treated as `false` and logged.
- **Missing attribute:** reads as `null` (handled in `TriggerExpressionGlobals`).
- **Blank expression:** the trigger is inert; validation emits a warning.
### 5. Testing & verification
- **Unit:** codec round-trip for script and alarm `{ expression }`; expression
compile (valid + invalid).
- **Runtime:** deploy an instance with an expression-triggered script and
alarm; drive attribute updates (bound Test Run / CLI); confirm the script
fires only on `false→true` and the alarm raises/clears with the expression.
- **UI:** the expression panel in both editors; save → reopen round-trip.
## Implementation tasks
- #25 — Implement expression trigger model + codecs
- #26 — Implement runtime expression evaluation (blocked by #25)
- #27 — Add expression panel to the trigger editors (blocked by #25)
- #28 — Validate expression triggers pre-deployment (blocked by #25, #26)
+220
View File
@@ -0,0 +1,220 @@
# Expression Trigger Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add an "Expression" trigger to template scripts and alarms — a read-only boolean C# expression evaluated on attribute updates that fires the script (edge) or activates the alarm (level).
**Architecture:** A new restricted read-only globals type (`TriggerExpressionGlobals`) backed by an in-memory attribute snapshot; the expression is compiled once via the existing Roslyn pipeline and cached on `ScriptActor`/`AlarmActor`, which already receive every `AttributeValueChanged`. The CentralUI trigger editors gain an Expression panel. See the approved design: `docs/plans/2026-05-16-expression-trigger-design.md`.
**Tech Stack:** C#/.NET, Akka.NET (site runtime actors), Roslyn C# scripting, Blazor Server (CentralUI), Docker cluster.
**Verification note:** This repo has no CentralUI/Commons unit-test project; pure-logic correctness is verified by `dotnet build` + the editor round-trip, and runtime behavior by `bash docker/deploy.sh` + a browser/CLI walkthrough (the established pattern in this codebase). Steps below follow that.
---
### Task 1: Trigger model + codecs
**Files:**
- Modify: `src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs`
- Modify: `src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs`
- Modify: `src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs`
**Step 1: Add the `Expression` alarm trigger type.**
In `AlarmTriggerType.cs`, add `Expression` as the last enum member (append — do not reorder; the enum is persisted by value):
```csharp
public enum AlarmTriggerType
{
ValueMatch,
RangeViolation,
RateOfChange,
HiLo,
Expression
}
```
**Step 2: Extend `ScriptTriggerConfigCodec`.**
- Add `Expression` to `ScriptTriggerKind` (before `Unknown`).
- `ParseKind`: map `"expression"``ScriptTriggerKind.Expression`.
- `KindToString`: `Expression``"Expression"`.
- Add `string? Expression` to `ScriptTriggerModel`.
- `Parse`: for `Expression`, read `model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;`
- `Serialize`: for `Expression`, write `w.WriteString("expression", model.Expression ?? "");`
**Step 3: Extend `AlarmTriggerConfigCodec`.**
- Add `string? Expression` to `AlarmTriggerModel`.
- `Parse`: `case AlarmTriggerType.Expression:``model.Expression = TryReadString(root, "expression");`
- `Serialize`: `case AlarmTriggerType.Expression:``w.WriteString("expression", model.Expression ?? "");` (note: this codec always writes `attributeName` first — for Expression that key is unused; leave it written empty, harmless, or guard it. Prefer: skip the `attributeName` write when `type == Expression`.)
**Step 4: Build.**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj -nologo`
Expected: `Build succeeded`.
**Step 5: Commit.**
```bash
git add src/ScadaLink.Commons/Types/Enums/AlarmTriggerType.cs src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerConfigCodec.cs src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerConfigCodec.cs
git commit -m "feat(triggers): add Expression to the script & alarm trigger codecs"
```
---
### Task 2: Runtime expression evaluation
**Files:**
- Create: `src/ScadaLink.SiteRuntime/Scripts/TriggerExpressionGlobals.cs`
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptCompilationService.cs`
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs`
- Modify: `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs`
**Step 1: Create `TriggerExpressionGlobals`.**
A read-only globals type backed by a snapshot dictionary. Exposes only attribute reads — no `Instance`/`Scripts`/`ExternalSystem`/`Database`/`Notify`. Mirror the shape of `ScopeAccessors` but read straight from the dict (no actor Ask). Missing key → `null`.
```csharp
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// Read-only globals a trigger expression is compiled against. Exposes only
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask.
/// </summary>
public sealed class TriggerExpressionGlobals
{
private readonly IReadOnlyDictionary<string, object?> _snapshot;
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot) => _snapshot = snapshot;
public ReadOnlyAttributes Attributes => new(_snapshot, "");
public ReadOnlyChildren Children => new(_snapshot);
public ReadOnlyComposition? Parent { get; init; } // set by caller for derived/composed scopes; null at root
public sealed class ReadOnlyAttributes
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _prefix;
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix) { _s = s; _prefix = prefix; }
public object? this[string key] =>
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
}
public sealed class ReadOnlyComposition
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _path;
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path) { _s = s; _path = path; }
public ReadOnlyAttributes Attributes => new(_s, _path);
}
public sealed class ReadOnlyChildren
{
private readonly IReadOnlyDictionary<string, object?> _s;
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
}
}
```
Note: confirm against `ScopeAccessors.cs` whether canonical attribute keys are dotted (`TempSensor.Reading`) — they are; the prefix logic matches `AttributeAccessor.Resolve`.
**Step 2: Add expression compilation to `ScriptCompilationService`.**
Add a method that compiles a bare C# boolean expression against `TriggerExpressionGlobals`, reusing the existing `ScriptOptions` (references/imports) and the forbidden-API trust check. Return `Script<object?>` (Roslyn scripting returns the trailing expression's value).
```csharp
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
{
// same ScriptOptions as Compile(), globalsType: typeof(TriggerExpressionGlobals)
// run the same forbidden-API validation
}
```
Read the existing `Compile` (lines ~94-148) and factor the shared option-building + validation rather than duplicating.
**Step 3: ScriptActor — `ExpressionTriggerConfig` + edge evaluation.**
- Add a trigger config record `ExpressionTriggerConfig(string Expression)` alongside `IntervalTriggerConfig`/etc.
- `ParseTriggerConfig` (~line 262): add `"expression" => ParseExpressionTrigger(triggerConfigJson)` reading `{ "expression": "..." }`.
- On actor start (where the trigger is parsed/registered): if the config is `ExpressionTriggerConfig`, compile via `CompileTriggerExpression`, cache the `Script<object?>`, init `bool _lastExpressionResult = false`.
- Maintain `Dictionary<string,object?> _attributeSnapshot` — update it in the `AttributeValueChanged` handler (~lines 148-168) for **every** change, before trigger logic.
- In that handler, for `ExpressionTriggerConfig`: build `new TriggerExpressionGlobals(_attributeSnapshot)`, run the cached script (`RunAsync(globals)`), coerce `ReturnValue` to bool; if `result && !_lastExpressionResult` → run the script (same path `Conditional`/`ValueChange` use to spawn `ScriptExecutionActor`); set `_lastExpressionResult = result`.
- Wrap the evaluation in try/catch — on throw, treat as `false` and log a site-event-log script error; do not crash.
**Step 4: AlarmActor — `Expression` eval config + level evaluation.**
- `ParseEvalConfig` (~lines 413-484): add `case AlarmTriggerType.Expression:` building an `ExpressionEvalConfig` that holds the compiled `Script<object?>` (compile here via `CompileTriggerExpression`).
- `HandleAttributeValueChanged` (~lines 127-189): maintain the same `_attributeSnapshot`; for the `Expression` case (switch ~lines 141-147) evaluate the compiled expression against `TriggerExpressionGlobals` → bool; feed that bool into the existing **binary** Normal↔Active path (the same one `ValueMatch`/`RangeViolation` use — raise on `→Active`, clear on `→Normal`). Not HiLo.
- Same try/catch → `false` + log on throw.
**Step 5: Build.**
Run: `dotnet build src/ScadaLink.Host/ScadaLink.Host.csproj -nologo`
Expected: `Build succeeded`.
**Step 6: Commit.**
```bash
git add src/ScadaLink.SiteRuntime/
git commit -m "feat(triggers): runtime expression trigger evaluation for scripts and alarms"
```
---
### Task 3: Trigger editor panels (CentralUI)
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Shared/ScriptTriggerEditor.razor`
- Modify: `src/ScadaLink.CentralUI/Components/Shared/AlarmTriggerEditor.razor`
- Reference: `src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor`, `src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor` (how the script Code editor is fed `SelfAttributes`/`Children`/`Parent`)
**Step 1: `ScriptTriggerEditor` — Expression panel.**
- The codec already has the `Expression` kind (Task 1). Add `<option value="Expression">Expression — run when a boolean expression becomes true</option>` to the type `<select>`.
- Add a `case ScriptTriggerKind.Expression:` to the `@switch` rendering `RenderExpression()`.
- `RenderExpression()` hosts a compact `MonacoEditor` (`Height="120px"`, `Language="csharp"`, `ScriptKind=Template`) bound to `_model.Expression`; `ValueChanged` → update model + `Emit()`. Feed it the template attribute metadata for completion (see Step 3).
- Hint: "Runs once each time this expression becomes true."
**Step 2: `AlarmTriggerEditor` — Expression panel.**
- Add `case AlarmTriggerType.Expression: @RenderExpression(); break;` to the trigger `@switch` (~line 72).
- Same compact `MonacoEditor` bound to `_model.Expression`; `Emit()` on change.
- Hint: "Alarm is active while this expression is true."
**Step 3: Feed attribute metadata for completion.**
Both editors already receive `AvailableAttributes` (`IReadOnlyList<AlarmAttributeChoice>`). `MonacoEditor` wants `SelfAttributes` (`AttributeShape[]`) / `Children` / `Parent`. Add a small mapper from `AlarmAttributeChoice` → the Monaco metadata (Direct/Inherited → `SelfAttributes`; Composed → `Children` contexts). Keep it minimal — at least pass `SelfAttributes` so `Attributes["..."]` completion works.
**Step 4: Build.**
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj -nologo`
Expected: `Build succeeded`.
**Step 5: Commit.**
```bash
git add src/ScadaLink.CentralUI/Components/Shared/
git commit -m "feat(ui/triggers): expression trigger panel in the script & alarm editors"
```
---
### Task 4: Pre-deployment validation
**Files:**
- Modify: `src/ScadaLink.TemplateEngine/.../ValidationService.cs` (the file with `ValidateScriptTriggerReferences` / `ExtractAttributeNameFromTriggerConfig`)
**Step 1: Compile-check expression triggers.**
In the validation pass, for any script/alarm whose trigger type is `Expression`, extract `expression` from `TriggerConfiguration` and compile-check it. The TemplateEngine project may not reference the SiteRuntime compiler — if so, do a Roslyn syntax/compile check using the same approach, or surface a clear "expression empty / invalid" check at minimum. Confirm the reference graph during execution; prefer reusing `CompileTriggerExpression` if reachable.
**Step 2: Flag unknown attribute references (best-effort).**
Expression text references `Attributes["X"]`; extend the existing attribute-reference validation to scan the expression for `Attributes["..."]` literals and flag keys absent from the flattened config — mirroring `ExtractAttributeNameFromTriggerConfig` for the structured triggers.
**Step 3: Build + commit.**
```bash
dotnet build src/ScadaLink.Host/ScadaLink.Host.csproj -nologo
git add src/ScadaLink.TemplateEngine/
git commit -m "feat(triggers): validate expression triggers pre-deployment"
```
---
### Task 5: Build, deploy, verify
**Step 1:** `bash docker/deploy.sh` and wait for `http://localhost:9000/health/ready`.
**Step 2 — UI:** Log in (`multi-role`/`password`), open a template → Scripts → Add Script. Select trigger type **Expression**; confirm the Monaco expression box renders with attribute completion. Save `Attributes["TestDouble"] > 50` → reopen → confirm round-trip. Repeat on the alarm editor.
**Step 3 — runtime (script, edge):** Deploy an instance; set an attribute so the expression is false, then true → confirm the script runs once on the transition and does **not** re-run while it stays true; flip false then true again → runs again.
**Step 4 — runtime (alarm, level):** Expression-triggered alarm raises when the expression becomes true and clears when it becomes false (check the alarm state / Debug View).
**Step 5:** `git push`.
---
## Notes for the executor
- Append the `AlarmTriggerType.Expression` enum member **last** — the enum is persisted by integer value.
- The trigger expression is a *bare expression* (no `return`) — Roslyn scripting returns the trailing expression's value.
- Keep the evaluation try/catch tight; a throwing expression must never crash `ScriptActor`/`AlarmActor`.
- `_attributeSnapshot` must be updated for **every** `AttributeValueChanged`, not just attributes the expression names.
@@ -0,0 +1,11 @@
{
"planPath": "docs/plans/2026-05-16-expression-trigger.md",
"tasks": [
{"id": 25, "subject": "Task 1: Trigger model + codecs", "status": "pending"},
{"id": 26, "subject": "Task 2: Runtime expression evaluation", "status": "pending", "blockedBy": [25]},
{"id": 27, "subject": "Task 3: Trigger editor panels", "status": "pending", "blockedBy": [25]},
{"id": 28, "subject": "Task 4: Pre-deployment validation", "status": "pending", "blockedBy": [25, 26]},
{"id": 29, "subject": "Task 5: Build, deploy, verify", "status": "pending", "blockedBy": [25, 26, 27, 28]}
],
"lastUpdated": "2026-05-16"
}
@@ -15,6 +15,7 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// RateOfChange { attributeName, thresholdPerSecond, windowSeconds, direction }
/// HiLo { attributeName, loLo, lo, hi, hiHi,
/// loLoPriority, loPriority, hiPriority, hiHiPriority }
/// Expression { expression }
///
/// All HiLo setpoints and per-setpoint priorities are optional — any subset
/// is valid (e.g., only Hi/HiHi configured for over-temperature protection).
@@ -93,6 +94,10 @@ internal static class AlarmTriggerConfigCodec
model.HiMessage = TryReadString(root, "hiMessage");
model.HiHiMessage = TryReadString(root, "hiHiMessage");
break;
case AlarmTriggerType.Expression:
model.Expression = TryReadString(root, "expression");
break;
}
}
catch (JsonException)
@@ -105,8 +110,10 @@ internal static class AlarmTriggerConfigCodec
/// <summary>
/// Serializes the model to the JSON shape AlarmActor.ParseEvalConfig
/// expects. Always writes <c>attributeName</c> (canonical key) and only
/// the keys relevant to the current trigger type.
/// expects. Writes <c>attributeName</c> (canonical key) for the
/// attribute-bound trigger types and only the keys relevant to the
/// current trigger type. <c>Expression</c> is not bound to a single
/// attribute, so <c>attributeName</c> is omitted for it.
/// </summary>
internal static string Serialize(AlarmTriggerModel model, AlarmTriggerType type)
{
@@ -114,7 +121,8 @@ internal static class AlarmTriggerConfigCodec
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartObject();
w.WriteString("attributeName", model.AttributeName ?? "");
if (type != AlarmTriggerType.Expression)
w.WriteString("attributeName", model.AttributeName ?? "");
switch (type)
{
@@ -155,6 +163,10 @@ internal static class AlarmTriggerConfigCodec
if (!string.IsNullOrEmpty(model.HiMessage)) w.WriteString("hiMessage", model.HiMessage);
if (!string.IsNullOrEmpty(model.HiHiMessage)) w.WriteString("hiHiMessage", model.HiHiMessage);
break;
case AlarmTriggerType.Expression:
w.WriteString("expression", model.Expression ?? "");
break;
}
w.WriteEndObject();
@@ -241,4 +253,7 @@ internal sealed class AlarmTriggerModel
public string? LoMessage { get; set; }
public string? HiMessage { get; set; }
public string? HiHiMessage { get; set; }
// Expression — boolean C# expression evaluated on attribute updates.
public string? Expression { get; set; }
}
@@ -12,6 +12,10 @@
<div class="border rounded bg-white p-3">
@* ── Monitored attribute ───────────────────────────────────────────── *@
@* Expression triggers reference attributes inside the C# expression itself,
so they do not use the single-attribute picker. *@
@if (TriggerType != AlarmTriggerType.Expression)
{
<div class="mb-3">
<label for="alarm-attr-select" class="form-label small text-uppercase text-muted fw-semibold mb-1">
Monitored attribute
@@ -67,6 +71,7 @@
</div>
}
</div>
}
@* ── Type-specific block ───────────────────────────────────────────── *@
@switch (TriggerType)
@@ -83,6 +88,9 @@
case AlarmTriggerType.HiLo:
@RenderHiLo();
break;
case AlarmTriggerType.Expression:
@RenderExpression();
break;
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@@ -559,6 +567,30 @@
await Emit();
}
// ── Expression ─────────────────────────────────────────────────────────
private RenderFragment RenderExpression() => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// ── Hint text ──────────────────────────────────────────────────────────
private string BuildHint()
@@ -582,6 +614,9 @@
AlarmTriggerType.HiLo => BuildHiLoHint(attr),
AlarmTriggerType.Expression =>
"Alarm is active while this expression is true.",
_ => string.Empty
};
}
@@ -10,7 +10,7 @@ namespace ScadaLink.CentralUI.Components.Shared;
/// trigger; <see cref="Unknown"/> is a stored trigger-type string the runtime
/// does not recognize (preserved as-is by the editor).
/// </summary>
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Unknown }
internal enum ScriptTriggerKind { None, Interval, ValueChange, Conditional, Call, Expression, Unknown }
/// <summary>A script's trigger as the editor emits it: a type string + config JSON.</summary>
public sealed record ScriptTriggerValue(string? TriggerType, string? Config);
@@ -29,6 +29,9 @@ internal sealed class ScriptTriggerModel
/// <summary>Comparison threshold (Conditional).</summary>
public double? Threshold { get; set; }
/// <summary>Boolean C# expression (Expression).</summary>
public string? Expression { get; set; }
}
/// <summary>
@@ -40,6 +43,7 @@ internal sealed class ScriptTriggerModel
/// ValueChange { attributeName }
/// Conditional { attributeName, operator, threshold }
/// Call { }
/// Expression { expression }
///
/// Parsing also accepts the legacy aliases <c>attribute</c> and <c>value</c> so
/// older configs survive a round-trip through the editor.
@@ -59,6 +63,7 @@ internal static class ScriptTriggerConfigCodec
"valuechange" => ScriptTriggerKind.ValueChange,
"conditional" => ScriptTriggerKind.Conditional,
"call" => ScriptTriggerKind.Call,
"expression" => ScriptTriggerKind.Expression,
_ => ScriptTriggerKind.Unknown
};
}
@@ -70,6 +75,7 @@ internal static class ScriptTriggerConfigCodec
ScriptTriggerKind.ValueChange => "ValueChange",
ScriptTriggerKind.Conditional => "Conditional",
ScriptTriggerKind.Call => "Call",
ScriptTriggerKind.Expression => "Expression",
_ => null
};
@@ -104,6 +110,10 @@ internal static class ScriptTriggerConfigCodec
model.Operator = NormalizeOperator(op);
model.Threshold = TryReadDouble(root, "threshold") ?? TryReadDouble(root, "value");
break;
case ScriptTriggerKind.Expression:
model.Expression = root.TryGetProperty("expression", out var e) ? e.GetString() : null;
break;
}
}
catch (JsonException)
@@ -144,6 +154,10 @@ internal static class ScriptTriggerConfigCodec
w.WriteNumber("threshold", model.Threshold.Value);
break;
case ScriptTriggerKind.Expression:
w.WriteString("expression", model.Expression ?? "");
break;
// Call → empty object.
}
w.WriteEndObject();
@@ -23,6 +23,7 @@
<option value="Interval">Interval — run on a fixed timer</option>
<option value="ValueChange">Value change — run when an attribute changes</option>
<option value="Conditional">Conditional — run when a condition is met</option>
<option value="Expression">Expression — run when a boolean expression becomes true</option>
<option value="Call">Call — run only when invoked by another script</option>
@if (_kind == ScriptTriggerKind.Unknown)
{
@@ -45,6 +46,9 @@
case ScriptTriggerKind.Conditional:
@RenderConditional();
break;
case ScriptTriggerKind.Expression:
@RenderExpression();
break;
case ScriptTriggerKind.Call:
<div class="small text-muted">
No automatic trigger — this script runs only when another script
@@ -62,7 +66,8 @@
}
@* ── Hint ──────────────────────────────────────────────────────────── *@
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange or ScriptTriggerKind.Conditional)
@if (_kind is ScriptTriggerKind.Interval or ScriptTriggerKind.ValueChange
or ScriptTriggerKind.Conditional or ScriptTriggerKind.Expression)
{
<div class="mt-3 pt-2 border-top small text-muted">@BuildHint()</div>
}
@@ -244,6 +249,30 @@
await Emit();
}
// ── Expression ─────────────────────────────────────────────────────────
private RenderFragment RenderExpression() => __builder =>
{
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">Trigger expression</label>
<MonacoEditor Height="120px"
Language="csharp"
ScriptKind="ScriptAnalysis.ScriptKind.Template"
ShowToolbar="false"
Value="@(_model.Expression ?? string.Empty)"
ValueChanged="OnExpressionChanged"
SelfAttributes="@TriggerAttributeMapper.SelfAttributes(AvailableAttributes)"
Children="@TriggerAttributeMapper.Children(AvailableAttributes)" />
<div class="form-text">
A boolean C# expression — e.g. <code>Attributes["Temperature"] &gt; 80</code>.
</div>
};
private async Task OnExpressionChanged(string value)
{
_model.Expression = value;
await Emit();
}
// ── Attribute picker (ValueChange + Conditional) ───────────────────────
private RenderFragment RenderAttributePicker(string label) => __builder =>
@@ -315,6 +344,9 @@
? $"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.",
_ => string.Empty
};
}
@@ -0,0 +1,49 @@
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Maps the trigger editors' flattened <see cref="AlarmAttributeChoice"/> list
/// into the metadata the <see cref="MonacoEditor"/> uses to drive C# completion
/// inside an expression trigger:
/// <list type="bullet">
/// <item>Direct + Inherited choices become <see cref="AttributeShape"/>s,
/// surfaced under <c>Attributes["..."]</c>.</item>
/// <item>Composed choices — whose canonical name is dotted, e.g.
/// <c>CoolingTank.Temp</c> — are grouped by their composition-instance prefix
/// into <see cref="CompositionContext"/>s, surfaced under
/// <c>Children["..."].Attributes["..."]</c>.</item>
/// </list>
/// </summary>
public static class TriggerAttributeMapper
{
/// <summary>Direct and inherited attributes, exposed as <c>Attributes["..."]</c>.</summary>
public static IReadOnlyList<AttributeShape> SelfAttributes(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source is "Direct" or "Inherited")
.Select(c => new AttributeShape(c.CanonicalName, c.DataType))
.ToList();
/// <summary>
/// Composed attributes grouped by composition-instance name, exposed as
/// <c>Children["X"].Attributes["Y"]</c>. Entries without a dotted prefix
/// are skipped (no child scope to attach them to).
/// </summary>
public static IReadOnlyList<CompositionContext> Children(
IReadOnlyList<AlarmAttributeChoice> choices) =>
choices
.Where(c => c.Source == "Composed" && c.CanonicalName.Contains('.'))
.Select(c => new
{
Child = c.CanonicalName[..c.CanonicalName.IndexOf('.')],
Member = c.CanonicalName[(c.CanonicalName.IndexOf('.') + 1)..],
c.DataType
})
.GroupBy(x => x.Child, StringComparer.Ordinal)
.Select(g => new CompositionContext(
g.Key,
g.Select(x => new AttributeShape(x.Member, x.DataType)).ToList(),
Array.Empty<ScriptShape>()))
.ToList();
}
@@ -11,5 +11,11 @@ public enum AlarmTriggerType
/// may carry its own priority; transitions between levels emit a fresh
/// AlarmStateChanged with the corresponding <see cref="AlarmLevel"/>.
/// </summary>
HiLo
HiLo,
/// <summary>
/// Read-only boolean C# expression evaluated on attribute updates. The
/// trigger fires when the expression evaluates to <c>true</c>.
/// </summary>
Expression
}
+87 -2
View File
@@ -50,6 +50,12 @@ public class AlarmActor : ReceiveActor
private readonly string? _onTriggerScriptName;
private readonly Script<object?>? _onTriggerCompiledScript;
// Expression trigger: compiled expression + the attribute snapshot it
// evaluates against. This field is the single home for the compiled
// expression on the hot path.
private readonly Script<object?>? _compiledTriggerExpression;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
// Rate of change tracking
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
private readonly TimeSpan _rateOfChangeWindowDuration;
@@ -65,6 +71,8 @@ public class AlarmActor : ReceiveActor
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null)
{
_alarmName = alarmName;
@@ -77,6 +85,16 @@ public class AlarmActor : ReceiveActor
_priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript;
_compiledTriggerExpression = compiledTriggerExpression;
// Seed the trigger-expression attribute snapshot from the instance's
// initial attribute set so static attributes (which never re-emit an
// AttributeValueChanged after deploy) evaluate correctly at startup.
if (initialAttributes != null)
{
foreach (var kvp in initialAttributes)
_attributeSnapshot[kvp.Key] = kvp.Value;
}
// Parse trigger type
_triggerType = Enum.TryParse<AlarmTriggerType>(alarmConfig.TriggerType, true, out var tt)
@@ -126,9 +144,18 @@ public class AlarmActor : ReceiveActor
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// Only evaluate if this change is for an attribute we're monitoring
if (!IsMonitoredAttribute(changed.AttributeName))
// Expression triggers evaluate against a snapshot of every attribute,
// not a single monitored attribute. Keep the snapshot current for every
// change before the IsMonitoredAttribute gate (which does not apply).
if (_triggerType == AlarmTriggerType.Expression)
{
_attributeSnapshot[changed.AttributeName] = changed.Value;
}
else if (!IsMonitoredAttribute(changed.AttributeName))
{
// Only evaluate if this change is for an attribute we're monitoring
return;
}
try
{
@@ -143,6 +170,7 @@ public class AlarmActor : ReceiveActor
AlarmTriggerType.ValueMatch => EvaluateValueMatch(changed.Value),
AlarmTriggerType.RangeViolation => EvaluateRangeViolation(changed.Value),
AlarmTriggerType.RateOfChange => EvaluateRateOfChange(changed.Value, changed.Timestamp),
AlarmTriggerType.Expression => EvaluateExpression(),
_ => false
};
@@ -337,6 +365,44 @@ public class AlarmActor : ReceiveActor
}
}
/// <summary>
/// Evaluates the compiled trigger expression against the current attribute
/// snapshot, returning the resulting bool. This bool feeds the existing
/// binary Normal↔Active state path — the alarm is active while true. A
/// throwing, non-bool, or timed-out expression is treated as false (logged
/// as an alarm error) so that the state machine still runs — an Active
/// alarm correctly clears if the expression starts throwing.
/// </summary>
private bool EvaluateExpression()
{
if (_compiledTriggerExpression == null) return false;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
return state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
_healthCollector?.IncrementAlarmError();
_logger.LogError(ex,
"Alarm {Alarm} trigger expression evaluation failed on {Instance}; treated as false",
_alarmName, _instanceName);
return false;
}
}
/// <summary>
/// HiLo level evaluator: returns the most-severe matching band for the
/// given value. Severity order checked from highest to lowest so that a
@@ -473,6 +539,14 @@ public class AlarmActor : ReceiveActor
HiMessage: TryReadString(root, "hiMessage"),
HiHiMessage: TryReadString(root, "hiHiMessage")),
// Expression triggers have no single monitored attribute; they
// evaluate the compiled expression (passed into the actor and
// cached in _compiledTriggerExpression) over the full attribute
// snapshot. MonitoredAttributeName is unused.
AlarmTriggerType.Expression => new ExpressionEvalConfig(
"",
TriggerExpressionGlobals.ExtractExpression(triggerConfigJson) ?? ""),
_ => new ValueMatchEvalConfig(attr, null)
};
}
@@ -535,6 +609,17 @@ internal record RateOfChangeEvalConfig(
TimeSpan WindowDuration,
RateOfChangeDirection Direction) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary>
/// Expression evaluation config: a read-only boolean C# expression evaluated
/// over the full attribute snapshot. Has no single monitored attribute
/// (<see cref="AlarmEvalConfig.MonitoredAttributeName"/> is empty). The
/// compiled expression itself lives on the actor's <c>_compiledTriggerExpression</c>
/// field, the single source for the hot path.
/// </summary>
internal record ExpressionEvalConfig(
string MonitoredAttributeName,
string Expression) : AlarmEvalConfig(MonitoredAttributeName);
/// <summary>
/// HiLo evaluation config: any subset of the four setpoints may be set; null
/// means "don't evaluate that band". Per-setpoint priorities override the
@@ -1,4 +1,5 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.DataConnection;
using ScadaLink.Commons.Messages.DebugView;
@@ -515,6 +516,10 @@ public class InstanceActor : ReceiveActor
continue;
}
// Compile the trigger expression for Expression-triggered scripts.
var triggerExpression = CompileTriggerExpression(
script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");
var props = Props.Create(() => new ScriptActor(
script.CanonicalName,
_instanceUniqueName,
@@ -524,6 +529,8 @@ public class InstanceActor : ReceiveActor
_sharedScriptLibrary,
_options,
_logger,
triggerExpression,
_attributes,
_healthCollector,
_serviceProvider));
@@ -534,7 +541,7 @@ public class InstanceActor : ReceiveActor
// Create Alarm Actors
foreach (var alarm in _configuration.Alarms)
{
Microsoft.CodeAnalysis.Scripting.Script<object?>? onTriggerScript = null;
Script<object?>? onTriggerScript = null;
// Compile on-trigger script if defined
if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
@@ -559,6 +566,10 @@ public class InstanceActor : ReceiveActor
}
}
// Compile the trigger expression for Expression-triggered alarms.
var triggerExpression = CompileTriggerExpression(
alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");
var props = Props.Create(() => new AlarmActor(
alarm.CanonicalName,
_instanceUniqueName,
@@ -568,6 +579,8 @@ public class InstanceActor : ReceiveActor
_sharedScriptLibrary,
_options,
_logger,
triggerExpression,
_attributes,
_healthCollector));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
@@ -581,6 +594,32 @@ public class InstanceActor : ReceiveActor
_instanceUniqueName, _scriptActors.Count, _alarmActors.Count);
}
/// <summary>
/// Compiles the boolean trigger expression for an Expression-triggered
/// script or alarm. Returns null for non-Expression triggers, a blank
/// expression, or a compilation failure (logged) — in which case the
/// trigger is inert and the actor still starts.
/// </summary>
private Script<object?>? CompileTriggerExpression(
string? triggerType, string? triggerConfigJson, string compileName)
{
if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
return null;
var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson);
if (expression == null)
return null;
var result = _compilationService.CompileTriggerExpression(compileName, expression);
if (result.IsSuccess)
return result.CompiledScript;
_logger.LogError(
"Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
compileName, _instanceUniqueName, string.Join("; ", result.Errors));
return null;
}
/// <summary>
/// Read-only access to current attribute count (for testing/diagnostics).
/// </summary>
@@ -1,10 +1,12 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.HealthMonitoring;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime.Scripts;
using System.Text.Json;
@@ -40,6 +42,12 @@ public class ScriptActor : ReceiveActor, IWithTimers
private int _executionCounter;
private readonly Commons.Types.Scripts.ScriptScope _scope;
// Expression trigger state: compiled expression, edge-tracking, and the
// attribute snapshot the expression evaluates against.
private readonly Script<object?>? _compiledTriggerExpression;
private bool _lastExpressionResult;
private readonly Dictionary<string, object?> _attributeSnapshot = new();
public ITimerScheduler Timers { get; set; } = null!;
public ScriptActor(
@@ -51,6 +59,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
SharedScriptLibrary sharedScriptLibrary,
SiteRuntimeOptions options,
ILogger logger,
Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
{
@@ -65,6 +75,16 @@ public class ScriptActor : ReceiveActor, IWithTimers
_serviceProvider = serviceProvider;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_scope = scriptConfig.Scope;
_compiledTriggerExpression = compiledTriggerExpression;
// Seed the trigger-expression attribute snapshot from the instance's
// initial attribute set so static attributes (which never re-emit an
// AttributeValueChanged after deploy) evaluate correctly at startup.
if (initialAttributes != null)
{
foreach (var kvp in initialAttributes)
_attributeSnapshot[kvp.Key] = kvp.Value;
}
// Parse trigger configuration
_triggerConfig = ParseTriggerConfig(scriptConfig.TriggerType, scriptConfig.TriggerConfiguration);
@@ -143,10 +163,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Handles attribute value changes — triggers script if configured for value-change or conditional.
/// Handles attribute value changes — triggers script if configured for
/// value-change, conditional, or expression. The attribute snapshot is
/// updated for every change before any trigger logic runs.
/// </summary>
private void HandleAttributeValueChanged(AttributeValueChanged changed)
{
// Keep the snapshot current for every change, regardless of trigger type.
_attributeSnapshot[changed.AttributeName] = changed.Value;
if (_triggerConfig is ValueChangeTriggerConfig valueTrigger)
{
if (valueTrigger.AttributeName == changed.AttributeName)
@@ -165,6 +190,65 @@ public class ScriptActor : ReceiveActor, IWithTimers
}
}
}
else if (_triggerConfig is ExpressionTriggerConfig)
{
EvaluateExpressionTrigger();
}
}
/// <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.
/// </summary>
private void EvaluateExpressionTrigger()
{
if (_compiledTriggerExpression == null) return;
bool result;
try
{
var globals = new TriggerExpressionGlobals(_attributeSnapshot);
// Bound evaluation with a short timeout. The CancellationToken
// covers cooperative/async cases; a pathological CPU-bound
// expression is not fully interruptible. Acceptable because
// trigger expressions are authored by trusted Design-role users
// and are compile-checked pre-deployment.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var state = _compiledTriggerExpression
.RunAsync(globals, cancellationToken: cts.Token)
.GetAwaiter().GetResult();
result = state.ReturnValue is bool b && b;
}
catch (Exception ex)
{
// OperationCanceledException (timeout) falls through here too,
// and is correctly treated as false.
LogExpressionError(ex);
result = false;
}
if (result && !_lastExpressionResult)
{
TrySpawnExecution(null);
}
_lastExpressionResult = result;
}
/// <summary>
/// Records a trigger-expression evaluation failure to the site event log,
/// mirroring how ScriptExecutionActor reports script errors.
/// </summary>
private void LogExpressionError(Exception ex)
{
_healthCollector?.IncrementScriptError();
var errorMsg = $"Trigger expression for script '{_scriptName}' on instance '{_instanceName}' failed: {ex.Message}";
_logger.LogError(ex, "Trigger expression evaluation failed: {Script} on {Instance}", _scriptName, _instanceName);
_ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
"script", "Error", _instanceName, $"ScriptActor:{_scriptName}", errorMsg, ex.ToString());
}
/// <summary>
@@ -264,11 +348,18 @@ public class ScriptActor : ReceiveActor, IWithTimers
"interval" => ParseIntervalTrigger(triggerConfigJson),
"valuechange" => ParseValueChangeTrigger(triggerConfigJson),
"conditional" => ParseConditionalTrigger(triggerConfigJson),
"expression" => ParseExpressionTrigger(triggerConfigJson),
"call" => null, // No automatic trigger — invoked only via Instance.CallScript()
_ => null
};
}
private static ExpressionTriggerConfig? ParseExpressionTrigger(string? json)
{
var expr = TriggerExpressionGlobals.ExtractExpression(json);
return expr == null ? null : new ExpressionTriggerConfig(expr);
}
private static IntervalTriggerConfig? ParseIntervalTrigger(string? json)
{
if (string.IsNullOrEmpty(json)) return null;
@@ -323,4 +414,5 @@ public class ScriptActor : ReceiveActor, IWithTimers
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 abstract record ScriptTriggerConfig;
@@ -87,11 +87,45 @@ public class ScriptCompilationService
return violations;
}
/// <summary>
/// Shared Roslyn scripting options (references + imports) used by both full
/// script compilation and trigger-expression compilation.
/// </summary>
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
/// <summary>
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
/// and parameters dictionary, and returns an object? result.
/// </summary>
public ScriptCompilationResult Compile(string scriptName, string code)
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
/// <summary>
/// Compiles a bare C# boolean trigger expression against the restricted
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
/// options and forbidden-API trust validation as <see cref="Compile"/>.
/// </summary>
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
/// <summary>
/// Shared compilation path: validates the trust model, builds the script
/// against the given globals type, and returns the compiled result.
/// </summary>
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
{
// Validate trust model
var violations = ValidateTrustModel(code);
@@ -99,29 +133,16 @@ public class ScriptCompilationService
{
_logger.LogWarning(
"Script {Script} failed trust validation: {Violations}",
scriptName, string.Join("; ", violations));
name, string.Join("; ", violations));
return ScriptCompilationResult.Failed(violations);
}
try
{
var scriptOptions = ScriptOptions.Default
.WithReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(Math).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.DynamicJsonElement).Assembly)
.WithImports(
"System",
"System.Collections.Generic",
"System.Linq",
"System.Threading.Tasks");
var script = CSharpScript.Create<object?>(
code,
scriptOptions,
globalsType: typeof(ScriptGlobals));
BuildScriptOptions(),
globalsType: globalsType);
var diagnostics = script.Compile();
var errors = diagnostics
@@ -133,16 +154,16 @@ public class ScriptCompilationService
{
_logger.LogWarning(
"Script {Script} compilation failed: {Errors}",
scriptName, string.Join("; ", errors));
name, string.Join("; ", errors));
return ScriptCompilationResult.Failed(errors);
}
_logger.LogDebug("Script {Script} compiled successfully", scriptName);
_logger.LogDebug("Script {Script} compiled successfully", name);
return ScriptCompilationResult.Succeeded(script);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error compiling script {Script}", scriptName);
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
}
}
@@ -0,0 +1,99 @@
using System.Text.Json;
namespace ScadaLink.SiteRuntime.Scripts;
/// <summary>
/// Read-only globals a trigger expression is compiled against. Exposes only
/// attribute reads, backed by an in-memory snapshot — no I/O, no actor Ask,
/// no side-effecting APIs. A missing attribute key reads as <c>null</c> and
/// never throws.
///
/// Canonical attribute keys are dotted (e.g. "TempSensor.Reading"); the prefix
/// logic here mirrors <see cref="AttributeAccessor.Resolve"/>.
/// </summary>
public sealed class TriggerExpressionGlobals
{
/// <summary>
/// Extracts the <c>"expression"</c> field from an Expression-trigger config
/// JSON document. Returns <c>null</c> for a missing, blank, or malformed
/// config — the single parsing idiom shared by InstanceActor, ScriptActor,
/// and AlarmActor.
/// </summary>
public static string? ExtractExpression(string? triggerConfigJson)
{
if (string.IsNullOrEmpty(triggerConfigJson)) return null;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
var expr = doc.RootElement.TryGetProperty("expression", out var e)
? e.GetString()
: null;
return string.IsNullOrWhiteSpace(expr) ? null : expr;
}
catch (JsonException)
{
return null;
}
}
private readonly IReadOnlyDictionary<string, object?> _snapshot;
public TriggerExpressionGlobals(IReadOnlyDictionary<string, object?> snapshot)
=> _snapshot = snapshot;
/// <summary>Attributes in the expression's own scope (root prefix).</summary>
public ReadOnlyAttributes Attributes => new(_snapshot, "");
/// <summary>Indexed access to child compositions' attributes.</summary>
public ReadOnlyChildren Children => new(_snapshot);
/// <summary>
/// Parent composition (null at root). Set by the caller for derived/composed
/// scopes; the runtime actors evaluate at root scope, so this stays null.
/// </summary>
public ReadOnlyComposition? Parent { get; init; }
/// <summary>
/// Read-only attribute view anchored at a canonical-name prefix. Indexing
/// resolves to the canonical key ("" → key, "TempSensor" → "TempSensor.key").
/// </summary>
public sealed class ReadOnlyAttributes
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _prefix;
public ReadOnlyAttributes(IReadOnlyDictionary<string, object?> s, string prefix)
{
_s = s;
_prefix = prefix;
}
public object? this[string key] =>
_s.TryGetValue(_prefix.Length == 0 ? key : _prefix + "." + key, out var v) ? v : null;
}
/// <summary>A read-only view of one composition at a canonical-name path.</summary>
public sealed class ReadOnlyComposition
{
private readonly IReadOnlyDictionary<string, object?> _s;
private readonly string _path;
public ReadOnlyComposition(IReadOnlyDictionary<string, object?> s, string path)
{
_s = s;
_path = path;
}
public ReadOnlyAttributes Attributes => new(_s, _path);
}
/// <summary>Dictionary-style accessor for child compositions.</summary>
public sealed class ReadOnlyChildren
{
private readonly IReadOnlyDictionary<string, object?> _s;
public ReadOnlyChildren(IReadOnlyDictionary<string, object?> s) => _s = s;
public ReadOnlyComposition this[string compositionName] => new(_s, compositionName);
}
}
@@ -12,8 +12,13 @@ namespace ScadaLink.TemplateEngine.Validation;
/// </summary>
public class ScriptCompiler
{
// Forbidden namespace patterns - scripts must not use these
private static readonly string[] ForbiddenPatterns =
/// <summary>
/// Forbidden namespace patterns — scripts (and trigger expressions, via
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
/// under the same trust model as scripts, so the list is shared from here rather
/// than duplicated.
/// </summary>
internal static readonly string[] ForbiddenPatterns =
[
"System.IO.",
"System.Diagnostics.Process",
@@ -13,8 +13,9 @@ namespace ScadaLink.TemplateEngine.Validation;
/// 3. Script compilation (via ScriptCompiler)
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Connection binding completeness (all data-sourced attributes must have a binding)
/// 7. Does NOT verify tag path resolution on devices
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness (all data-sourced attributes must have a binding)
/// 8. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
@@ -48,6 +49,7 @@ public class ValidationService
ValidateScriptCompilation(configuration),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration),
_semanticValidator.Validate(configuration, sharedScripts)
};
@@ -178,6 +180,293 @@ public class ValidationService
: ValidationResult.Success();
}
/// <summary>
/// Validates Expression-trigger scripts and alarms before deployment.
///
/// For every script/alarm whose trigger type is "Expression" this performs three
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
/// <list type="bullet">
/// <item>Blank expression → warning (the trigger will never fire).</item>
/// <item>Syntax check → error if the expression uses a forbidden API or has
/// unbalanced brackets/quotes. The TemplateEngine project does not reference a
/// Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
/// string-based syntax check rather than a full compile.</item>
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
/// whose key is absent from the flattened configuration, mirroring
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
/// </list>
/// </summary>
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (!IsExpressionTrigger(script.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.ScriptTriggerReference, "script",
script.CanonicalName, script.TriggerConfiguration,
attributeNames, errors, warnings);
}
foreach (var alarm in configuration.Alarms)
{
if (!IsExpressionTrigger(alarm.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.AlarmTriggerReference, "alarm",
alarm.CanonicalName, alarm.TriggerConfiguration,
attributeNames, errors, warnings);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static bool IsExpressionTrigger(string? triggerType) =>
string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Runs the blank / syntax / attribute-reference checks for a single
/// Expression-trigger entity and appends any findings to the shared lists.
/// </summary>
/// <param name="category">
/// The <see cref="ValidationCategory"/> to file every finding under
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
/// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
/// category is used for blank, syntax, and attribute-reference findings so an
/// alarm's syntax error is not miscategorised as script compilation.
/// </param>
/// <param name="entityLabel">
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
/// message text only.
/// </param>
private static void CheckExpressionTrigger(
ValidationCategory category,
string entityLabel,
string entityName,
string? triggerConfigJson,
HashSet<string> attributeNames,
List<ValidationEntry> errors,
List<ValidationEntry> warnings)
{
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
if (string.IsNullOrWhiteSpace(expression))
{
warnings.Add(ValidationEntry.Warning(category,
$"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
entityName));
return;
}
var syntaxError = CheckExpressionSyntax(expression);
if (syntaxError != null)
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
entityName));
}
foreach (var attrName in ExtractAttributeReferences(expression))
{
if (!attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
entityName));
}
}
}
/// <summary>
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
/// </summary>
internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
{
if (string.IsNullOrWhiteSpace(triggerConfigJson))
return null;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("expression", out var prop)
&& prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
}
catch (JsonException)
{
// Not valid JSON — treated as a blank expression by the caller.
}
return null;
}
/// <summary>
/// Lightweight string-based syntax check for a trigger expression. Mirrors the
/// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
/// Roslyn compiler reference): rejects forbidden APIs and unbalanced
/// brackets/quotes. Returns an error message, or <c>null</c> when the expression
/// looks well-formed.
/// </summary>
internal static string? CheckExpressionSyntax(string expression)
{
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
{
if (expression.Contains(pattern, StringComparison.Ordinal))
{
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
}
}
var parenDepth = 0;
var bracketDepth = 0;
var braceDepth = 0;
var inString = false;
var inChar = false;
var inLineComment = false;
var inBlockComment = false;
for (int i = 0; i < expression.Length; i++)
{
var c = expression[i];
var next = i + 1 < expression.Length ? expression[i + 1] : '\0';
if (inLineComment)
{
if (c == '\n') inLineComment = false;
continue;
}
if (inBlockComment)
{
if (c == '*' && next == '/')
{
inBlockComment = false;
i++;
}
continue;
}
if (inString)
{
if (c == '\\') { i++; continue; }
if (c == '"') inString = false;
continue;
}
if (inChar)
{
if (c == '\\') { i++; continue; }
if (c == '\'') inChar = false;
continue;
}
if (c == '/' && next == '/')
{
inLineComment = true;
i++;
continue;
}
if (c == '/' && next == '*')
{
inBlockComment = true;
i++;
continue;
}
switch (c)
{
case '"': inString = true; break;
case '\'': inChar = true; break;
case '(': parenDepth++; break;
case ')':
parenDepth--;
if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
break;
case '[': bracketDepth++; break;
case ']':
bracketDepth--;
if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
break;
case '{': braceDepth++; break;
case '}':
braceDepth--;
if (braceDepth < 0) return "mismatched braces (unexpected '}').";
break;
}
}
if (inBlockComment) return "unterminated block comment.";
if (inString) return "unterminated string literal.";
if (inChar) return "unterminated character literal.";
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";
return null;
}
/// <summary>
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
/// Best-effort: only matches double-quoted literals (the form the editor emits)
/// and skips keys built dynamically.
/// </summary>
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
const string marker = "Attributes[";
var index = 0;
while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
{
// Only treat this as a self-attribute reference when it is not a member
// access. A bare `Attributes["X"]` resolves against the flattened
// configuration; `Children["Pump"].Attributes["X"]` and
// `Parent.Attributes["X"]` are member accesses (preceded by '.') whose
// dotted/composed canonical names cannot be checked against the flat
// self-attribute set — skip them rather than emit a false positive.
if (index > 0 && expression[index - 1] == '.')
{
index += marker.Length;
continue;
}
var cursor = index + marker.Length;
// Skip whitespace between '[' and the literal.
while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
cursor++;
if (cursor < expression.Length && expression[cursor] == '"')
{
var keyStart = cursor + 1;
var keyEnd = keyStart;
while (keyEnd < expression.Length && expression[keyEnd] != '"')
{
if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
keyEnd++;
}
if (keyEnd < expression.Length)
{
var key = expression.Substring(keyStart, keyEnd - keyStart);
if (key.Length > 0 && seen.Add(key))
yield return key;
}
}
index += marker.Length;
}
}
/// <summary>
/// Validates that all data-sourced attributes have connection bindings.
/// </summary>
@@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using DataConnectionForm = ScadaLink.CentralUI.Components.Pages.Admin.DataConnectionForm;
using DataConnectionForm = ScadaLink.CentralUI.Components.Pages.Design.DataConnectionForm;
namespace ScadaLink.CentralUI.Tests;
@@ -6,7 +6,7 @@ using NSubstitute;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using DataConnectionsPage = ScadaLink.CentralUI.Components.Pages.Admin.DataConnections;
using DataConnectionsPage = ScadaLink.CentralUI.Components.Pages.Design.DataConnections;
namespace ScadaLink.CentralUI.Tests;
@@ -147,16 +147,18 @@ public class DataConnectionsPageTests : BunitContext
}
[Fact]
public void LegacyDataConnectionsRoute_IsDeclaredOnListPage()
public void DataConnectionsRoutes_AreDeclaredOnListPage()
{
// Old bookmarks to /admin/data-connections must still resolve.
// The page moved from Admin to Design; both the canonical
// /design/connections route and the /design/data-connections alias
// must resolve to the list page.
var routes = typeof(DataConnectionsPage).GetCustomAttributes(
typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
.Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
.Select(a => a.Template)
.ToList();
Assert.Contains("/admin/connections", routes);
Assert.Contains("/admin/data-connections", routes);
Assert.Contains("/design/connections", routes);
Assert.Contains("/design/data-connections", routes);
}
}
@@ -8,6 +8,7 @@ public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly IServiceProvider _services = Substitute.For<IServiceProvider>();
private readonly ScriptAnalysisService _svc;
private static ScriptShape Shape(string name, params ParameterShape[] ps) =>
@@ -19,7 +20,7 @@ public class ScriptAnalysisServiceTests
public ScriptAnalysisServiceTests()
{
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
_svc = new ScriptAnalysisService(_catalog, _cache);
_svc = new ScriptAnalysisService(_catalog, _cache, _services);
}
// ── Diagnose ──────────────────────────────────────────────────────────
@@ -121,55 +122,6 @@ public class ScriptAnalysisServiceTests
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003");
}
[Fact]
public void ArgumentCountTooFew_RaisesSCADA004()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("expects 2"));
}
[Fact]
public void ArgumentCountTooMany_RaisesSCADA004()
{
var siblings = new[] { Shape("Ping") };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Ping\", 1, 2);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("got 2"));
}
[Fact]
public void ArgumentCountCorrect_NoMarker()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA004");
}
[Fact]
public void OptionalParameter_AcceptsBothOmittedAndPresent()
{
var siblings = new[]
{
Shape("Calc", Param("x"), Param("y", required: false))
};
// Required only (1) — OK.
var with1 = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.DoesNotContain(with1.Markers, m => m.Code == "SCADA004");
// Both passed (2) — OK.
var with2 = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.DoesNotContain(with2.Markers, m => m.Code == "SCADA004");
}
// ── Completions ───────────────────────────────────────────────────────
[Fact]
@@ -192,16 +144,18 @@ public class ScriptAnalysisServiceTests
{
var siblings = new[] { Shape("SiblingA", Param("x")) };
var req = new CompletionsRequest(
CodeText: "var x = CallScript(\"",
CodeText: "var x = Instance.CallScript(\"",
Line: 1,
Column: 21,
Column: 30,
SiblingScripts: siblings);
var resp = await _svc.CompleteAsync(req);
var item = Assert.Single(resp.Items, i => i.Label == "SiblingA");
Assert.Equal(4, item.InsertTextRules);
Assert.Contains("${1:x}", item.InsertText);
// The runtime call API takes args as an anonymous object — the snippet
// emits one member per declared parameter.
Assert.Contains("new { x = ${1:x} }", item.InsertText);
Assert.Contains("sibling script", item.Detail);
}
@@ -215,15 +169,19 @@ public class ScriptAnalysisServiceTests
});
var req = new CompletionsRequest(
CodeText: "var x = CallShared(\"",
CodeText: "var x = Scripts.CallShared(\"",
Line: 1,
Column: 21);
Column: 29);
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "GetWeather");
// No-parameter shape: snippet just closes the call.
var weather = Assert.Single(resp.Items, i => i.Label == "GetWeather");
Assert.Equal("GetWeather\")", weather.InsertText);
// Parameterized shape: anonymous-object member per parameter.
var greet = Assert.Single(resp.Items, i => i.Label == "Greet");
Assert.Contains("${1:name}", greet.InsertText);
Assert.Contains("new { name = ${1:name} }", greet.InsertText);
Assert.Contains("shared script", greet.Detail);
}
[Fact]
@@ -231,8 +189,12 @@ public class ScriptAnalysisServiceTests
{
var req = new CompletionsRequest("var x = ", 1, 9);
var resp = await _svc.CompleteAsync(req);
// SandboxScriptHost globals are surfaced as in-scope symbols. The
// runtime call API is member-access — Scripts.CallShared / Instance.*
// — so the top-level globals are Parameters, Scripts, and Instance.
Assert.Contains(resp.Items, i => i.Label == "Parameters");
Assert.Contains(resp.Items, i => i.Label == "CallShared");
Assert.Contains(resp.Items, i => i.Label == "Scripts");
Assert.Contains(resp.Items, i => i.Label == "Instance");
}
// ── Hover ─────────────────────────────────────────────────────────────
@@ -241,15 +203,15 @@ public class ScriptAnalysisServiceTests
public void Hover_OnSiblingName_ReturnsSignature()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
// Cursor inside the "Calc" name literal of Instance.CallScript("Calc", ...).
var resp = _svc.Hover(new HoverRequest(
CodeText: "var r = CallScript(\"Calc\", 1, 2);",
CodeText: "var r = Instance.CallScript(\"Calc\", 1, 2);",
Line: 1,
Column: 23,
Column: 32,
SiblingScripts: siblings));
Assert.NotNull(resp.Markdown);
Assert.Contains("Calc", resp.Markdown);
Assert.Contains("x: Integer", resp.Markdown);
Assert.Contains("y: Float", resp.Markdown);
Assert.Contains("sibling script", resp.Markdown);
Assert.Contains("Calc(x: Integer, y: Float): void", resp.Markdown);
}
[Fact]
@@ -269,11 +231,11 @@ public class ScriptAnalysisServiceTests
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
var resp = _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = CallScript(\"Calc\", 1, ",
CodeText: "var r = Instance.CallScript(\"Calc\", 1, ",
Line: 1,
Column: 31,
Column: 40,
SiblingScripts: siblings));
Assert.NotNull(resp.Label);
Assert.Equal("Instance.CallScript(\"Calc\", x: Integer, y: Float)", resp.Label);
Assert.Equal(2, resp.Parameters!.Count);
Assert.Equal("x: Integer", resp.Parameters[0].Label);
Assert.Equal("y: Float", resp.Parameters[1].Label);
@@ -307,81 +269,6 @@ public class ScriptAnalysisServiceTests
Assert.Equal("", _svc.Format(new FormatRequest("")).Code);
}
// ── Inlay hints ───────────────────────────────────────────────────────
[Fact]
public void InlayHints_OnCallScript_EmitsParameterLabels()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.InlayHints(new InlayHintsRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.Equal(2, resp.Hints.Count);
Assert.Equal("x:", resp.Hints[0].Label);
Assert.Equal("y:", resp.Hints[1].Label);
}
[Fact]
public void InlayHints_OnUnknownSibling_Skipped()
{
var resp = _svc.InlayHints(new InlayHintsRequest(
Code: "var r = CallScript(\"NotKnown\", 1, 2);",
SiblingScripts: Array.Empty<ScriptShape>()));
Assert.Empty(resp.Hints);
}
// ── Argument-type diagnostic (SCADA005) ───────────────────────────────
[Fact]
public void ArgumentTypeMismatch_StringExpectedIntegerGiven()
{
var siblings = new[] { Shape("Greet", Param("name", "String")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Greet\", 42);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA005" && m.Message.Contains("String"));
}
[Fact]
public void ArgumentTypeMismatch_IntegerExpectedStringGiven()
{
var siblings = new[] { Shape("Calc", Param("n", "Integer")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", \"oops\");",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_FloatAcceptsInteger()
{
var siblings = new[] { Shape("Calc", Param("ratio", "Float")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_ObjectAcceptsAnyLiteral()
{
var siblings = new[] { Shape("Log", Param("v", "Object")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Log\", 1); CallScript(\"Log\", \"x\"); CallScript(\"Log\", true);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_NonLiteralExpression_SkipsCheck()
{
var siblings = new[] { Shape("Calc", Param("n", "Integer")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = \"hi\"; var r = CallScript(\"Calc\", x);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
// ── Self / Children / Parent attribute completions ────────────────────
private static AttributeShape Attr(string name, string type = "String") => new(name, type);
@@ -10,7 +10,7 @@ public class EnumTests
[InlineData(typeof(DeploymentStatus), new[] { "Pending", "InProgress", "Success", "Failed" })]
[InlineData(typeof(AlarmState), new[] { "Active", "Normal" })]
[InlineData(typeof(AlarmLevel), new[] { "None", "Low", "LowLow", "High", "HighHigh" })]
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo" })]
[InlineData(typeof(AlarmTriggerType), new[] { "ValueMatch", "RangeViolation", "RateOfChange", "HiLo", "Expression" })]
[InlineData(typeof(ConnectionHealth), new[] { "Connected", "Disconnected", "Connecting", "Error" })]
public void Enum_ShouldHaveExpectedValues(Type enumType, string[] expectedNames)
{
@@ -123,11 +123,9 @@ public class InstanceActorIntegrationTests : TestKit, IDisposable
$"corr-{i}", "Pump1", "Temperature", $"{i}", DateTimeOffset.UtcNow));
}
// Wait for all to process
for (int i = 0; i < 50; i++)
{
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10));
}
// SetStaticAttributeCommand is fire-and-forget; the GetAttributeRequest
// round-trip below is the sync point — the FIFO mailbox guarantees all
// 50 sets are processed before the get is.
// The last value should be the final one
actor.Tell(new GetAttributeRequest(
@@ -113,13 +113,12 @@ public class InstanceActorTests : TestKit, IDisposable
var actor = CreateInstanceActor("Pump1", config);
// Set a static attribute -- response comes async via PipeTo
// SetStaticAttributeCommand is fire-and-forget (no reply); the
// GetAttributeRequest round-trip below confirms it was applied — the
// actor mailbox is FIFO, so the set is processed before the get.
actor.Tell(new SetStaticAttributeCommand(
"corr-3", "Pump1", "Temperature", "100.0", DateTimeOffset.UtcNow));
var setResponse = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(setResponse.Success);
// Verify the value changed in memory
actor.Tell(new GetAttributeRequest(
"corr-4", "Pump1", "Temperature", DateTimeOffset.UtcNow));
@@ -146,9 +145,12 @@ public class InstanceActorTests : TestKit, IDisposable
actor.Tell(new SetStaticAttributeCommand(
"corr-persist", "PumpPersist1", "Temperature", "100.0", DateTimeOffset.UtcNow));
ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
// Give async persistence time to complete
// SetStaticAttributeCommand is fire-and-forget; round-trip a
// GetAttributeRequest to confirm the command was processed (FIFO
// mailbox), then wait for the async SQLite persist to complete.
actor.Tell(new GetAttributeRequest(
"corr-persist-get", "PumpPersist1", "Temperature", DateTimeOffset.UtcNow));
ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(500);
// Verify it persisted to SQLite