513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
210 lines
9.6 KiB
C#
210 lines
9.6 KiB
C#
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
{
|
|
/// <summary>Verifies that parse diagnostics non empty for syntax error.</summary>
|
|
[Fact]
|
|
public void ParseDiagnostics_NonEmpty_ForSyntaxError()
|
|
{
|
|
Assert.NotEmpty(RoslynScriptCompiler.ParseDiagnostics("var x = ;"));
|
|
}
|
|
|
|
/// <summary>Verifies that parse diagnostics empty for valid syntax.</summary>
|
|
[Fact]
|
|
public void ParseDiagnostics_Empty_ForValidSyntax()
|
|
{
|
|
Assert.Empty(RoslynScriptCompiler.ParseDiagnostics("var x = 1;"));
|
|
}
|
|
|
|
/// <summary>Verifies that compile non empty for undefined symbol.</summary>
|
|
[Fact]
|
|
public void Compile_NonEmpty_ForUndefinedSymbol()
|
|
{
|
|
var code = "var x = NoSuchThing.Foo();";
|
|
Assert.NotEmpty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
|
}
|
|
|
|
/// <summary>Verifies that compile empty for representative real script.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that compile empty for a recipe file parsing script.</summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that compile empty for the mes alarm status script shape.</summary>
|
|
[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)));
|
|
}
|
|
|
|
/// <summary>Verifies that compile empty for trigger expression.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that compile empty for wait async and wait for async.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that compiles cleanly for the log global.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that compile twice with shared resolver still compiles cleanly.</summary>
|
|
[Fact]
|
|
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
|
|
{
|
|
Assert.Empty(RoslynScriptCompiler.Compile("return 1 + 1;"));
|
|
Assert.Empty(RoslynScriptCompiler.Compile("return 2 + 2;"));
|
|
}
|
|
}
|