Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs
T
Joseph Doherty b3021e7c5f feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
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).
2026-09-28 10:36:42 -04:00

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;"));
}
}