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 diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
Assert.Empty(diagnostics);
}
[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();
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
""";
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 Compile_TwiceWithSharedResolver_StillCompilesCleanly()
{
Assert.Empty(RoslynScriptCompiler.Compile("return 1 + 1;"));
Assert.Empty(RoslynScriptCompiler.Compile("return 2 + 2;"));
}
}