Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd1518f4f4 | |||
| b949dc4183 | |||
| 3cc174c3cd | |||
| d030153378 | |||
| d63d412461 | |||
| 0a535cd4a5 | |||
| 5065384305 | |||
| bf3f572ad9 | |||
| 3499d76f14 | |||
| 78b10d00d8 | |||
| 41c3fa3d84 | |||
| 9e21b47080 | |||
| f789ab4a91 | |||
| 199cdbe798 | |||
| 8050a1996f | |||
| c94d3b7570 |
@@ -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)
|
||||
@@ -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"] > 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"] > 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user