using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// /// M3.1: parse + compile gate tests. The "representative real script" corpus is /// the PRIMARY guard that faithfully mirrors /// the runtime ScriptGlobals surface — if a member or signature drifts, /// the corpus stops binding and this test fails. /// 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 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;")); } }