b3021e7c5f
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
200 lines
8.8 KiB
C#
200 lines
8.8 KiB
C#
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
|
|
|
/// <summary>
|
|
/// M3.1: parse + compile gate tests. The "representative real script" corpus is
|
|
/// the PRIMARY guard that <see cref="ScriptCompileSurface"/> faithfully mirrors
|
|
/// the runtime <c>ScriptGlobals</c> surface — if a member or signature drifts,
|
|
/// the corpus stops binding and this test fails.
|
|
/// </summary>
|
|
public class RoslynScriptCompilerTests
|
|
{
|
|
[Fact]
|
|
public void ParseDiagnostics_NonEmpty_ForSyntaxError()
|
|
{
|
|
Assert.NotEmpty(RoslynScriptCompiler.ParseDiagnostics("var x = ;"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseDiagnostics_Empty_ForValidSyntax()
|
|
{
|
|
Assert.Empty(RoslynScriptCompiler.ParseDiagnostics("var x = 1;"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_NonEmpty_ForUndefinedSymbol()
|
|
{
|
|
var code = "var x = NoSuchThing.Foo();";
|
|
Assert.NotEmpty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_Empty_ForRepresentativeRealScript()
|
|
{
|
|
const string code = """
|
|
var temp = Attributes["Temperature"];
|
|
Attributes["Setpoint"] = 42;
|
|
var r = await ExternalSystem.Call("erp", "sync");
|
|
var op = await Database.CachedWrite("hist", "INSERT ...");
|
|
await Notify.To("ops").Send("subj", "msg");
|
|
var shared = await Scripts.CallShared("Helper");
|
|
var child = Children["Pump"].Attributes["Speed"];
|
|
|
|
// Widen coverage across the rest of the surface.
|
|
var attr = await Instance.GetAttribute("Temperature");
|
|
await Instance.SetAttribute("Setpoint", "43");
|
|
var track = await Instance.Tracking.Status(op);
|
|
var parentSpeed = Parent?.Attributes["Speed"];
|
|
var alarmName = Alarm?.Name;
|
|
var p = Parameters;
|
|
var ct = CancellationToken;
|
|
var status = await Notify.Status("notif-id");
|
|
var cachedCall = await ExternalSystem.CachedCall("erp", "ping");
|
|
var resolved = Attributes.Resolve("Temperature");
|
|
var conn = await Database.Connection("hist");
|
|
var scope = Scope;
|
|
var currentAlarms = await Alarms.CurrentAsync();
|
|
var viaInstance = await Instance.Alarms.CurrentAsync();
|
|
var mesReportable = await Alarms.MesReportableAsync();
|
|
var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList();
|
|
var recipeText = await Files.ReadTextAsync(@"C:\recipes\r.txt");
|
|
var recipeLines = await Instance.Files.ReadLinesAsync(@"C:\recipes\r.txt");
|
|
var recipeThere = await Files.ExistsAsync(@"C:\recipes\r.txt");
|
|
""";
|
|
|
|
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
|
Assert.Empty(diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_Empty_ForARecipeFileParsingScript()
|
|
{
|
|
// Script file access (design 2026-09-28): the Files global + catching the Commons
|
|
// ScriptFileAccessException by name must bind at design time — scripts cannot name the
|
|
// System.IO exception types.
|
|
const string code = """
|
|
var path = (string)Parameters["DownloadPath"];
|
|
try {
|
|
foreach (var line in await Files.ReadLinesAsync(path, CancellationToken)) {
|
|
var parts = line.Split(',', 2);
|
|
if (parts.Length == 2 && double.TryParse(parts[1], out var d)) { }
|
|
}
|
|
return true;
|
|
} catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) {
|
|
return false;
|
|
}
|
|
""";
|
|
|
|
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_Empty_ForTheMesAlarmStatusScriptShape()
|
|
{
|
|
// MES alarm-status API §5.3: the CvdReactor.SimpleAlarmStatus body must bind at
|
|
// design time. Every ScriptAlarm field the endpoint projects is read here, so a
|
|
// rename on the record breaks this test rather than the deployed script.
|
|
const string code = """
|
|
const int MesBandMin = 900;
|
|
const int MesBandMax = 999;
|
|
|
|
var raw = (Parameters["SAPID"] as string) ?? "";
|
|
var code = Parameters["MachineCode"]?.ToString() ?? "";
|
|
|
|
var core = raw.EndsWith("_LT") ? raw.Substring(0, raw.Length - 3) : raw;
|
|
var side = core.EndsWith("_A") ? "Left" : core.EndsWith("_B") ? "Right" : null;
|
|
|
|
System.Func<string, bool> InScope = src =>
|
|
string.IsNullOrEmpty(src)
|
|
|| side == null
|
|
|| src.StartsWith(side, System.StringComparison.OrdinalIgnoreCase)
|
|
|| src.StartsWith("Reactor", System.StringComparison.OrdinalIgnoreCase);
|
|
|
|
var alarms = await Alarms.CurrentAsync();
|
|
var infos = alarms
|
|
.Where(a => a.Active && !a.IsConfiguredPlaceholder)
|
|
.Where(a => InScope(a.NativeSourceCanonicalName))
|
|
.Where(a => a.Severity >= MesBandMin && a.Severity <= MesBandMax)
|
|
.Select(a => new {
|
|
Name = a.Name,
|
|
HierarchicalName = code + "." + a.Name,
|
|
Description = string.IsNullOrEmpty(a.Message) ? a.AlarmTypeName : a.Message,
|
|
IsFlaggedForMES = a.Severity >= MesBandMin && a.Severity <= MesBandMax,
|
|
Severity = a.Severity,
|
|
StatusCode = a.Acknowledged ? "Triggered.Acked" : "Triggered",
|
|
TriggeredDT = (a.OriginalRaiseTime ?? a.Timestamp),
|
|
AckDT = a.AckTime,
|
|
AckComment = a.OperatorComment,
|
|
}).ToList();
|
|
|
|
// The pre-filtered accessor + the configured flag are the supported shape for
|
|
// "which alarms does MES care about?" — both must bind at design time.
|
|
var r = await Alarms.MesReportableAsync();
|
|
var f = r.Where(a => a.IsMesReportable).ToList();
|
|
|
|
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos, Flagged = f.Count };
|
|
""";
|
|
|
|
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_Empty_ForTriggerExpression()
|
|
{
|
|
const string expr =
|
|
"Attributes[\"Temp\"] != null && (int)(Children[\"P\"].Attributes[\"S\"] ?? 0) > 5";
|
|
|
|
var diagnostics = RoslynScriptCompiler.Compile(expr, typeof(TriggerCompileSurface));
|
|
Assert.Empty(diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_Empty_ForWaitAsyncAndWaitForAsync()
|
|
{
|
|
// Covers all four overloads: value + predicate for both WaitAsync and
|
|
// WaitForAsync, on both root scope and composed/child scope.
|
|
const string code = """
|
|
// Root scope — value overload
|
|
var matched = await Attributes.WaitAsync("Flag", true, System.TimeSpan.FromSeconds(5));
|
|
// Root scope — predicate overload with requireGoodQuality
|
|
var matched2 = await Attributes.WaitAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
|
|
// Root scope — WaitForAsync value overload
|
|
var r = await Attributes.WaitForAsync("Flag", true, System.TimeSpan.FromSeconds(5));
|
|
// Root scope — WaitForAsync predicate overload with requireGoodQuality
|
|
var r2 = await Attributes.WaitForAsync("Flag", v => v != null, System.TimeSpan.FromSeconds(5), true);
|
|
// Composed/child scope — value overload
|
|
var childMatched = await Children["LeftMESReceiver"].Attributes.WaitAsync("MoveInCompleteFlag", true, System.TimeSpan.FromSeconds(5));
|
|
""";
|
|
|
|
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
|
Assert.Empty(diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompilesCleanly_ForTheLogGlobal()
|
|
{
|
|
// Flow Debugger step logs (design 2026-09-09 D4): `Log` is a script-reachable
|
|
// global on the runtime ScriptGlobals AND on ScriptRuntimeContext, so both the
|
|
// top-level `Log` and `Instance.Log` must bind at design time — otherwise a
|
|
// script that logs would pass the editor but fail the deploy gate.
|
|
const string code = """
|
|
Log.Info("a");
|
|
Log.Warn("b", new System.Exception("x"));
|
|
Log.Error("c");
|
|
Log.Debug("d");
|
|
Instance.Log.Info("e");
|
|
""";
|
|
|
|
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
|
Assert.Empty(diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
|
|
{
|
|
Assert.Empty(RoslynScriptCompiler.Compile("return 1 + 1;"));
|
|
Assert.Empty(RoslynScriptCompiler.Compile("return 2 + 2;"));
|
|
}
|
|
}
|