Files
scadalink-design/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
Joseph Doherty 0528c65cba feat(ui/scripts): format, inlay hints, problems panel, type diagnostic
Three more editor features rolled in:

1. Roslyn Format command.
   New POST /api/script-analysis/format runs Formatter.Format() from
   Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script
   tree. monaco-init.js registers a DocumentFormattingEditProvider
   so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work.

2. Inlay hints with parameter names.
   New POST /api/script-analysis/inlay-hints walks CallShared /
   CallScript invocations and emits InlayHint records positioned at
   each argument with the matching parameter's name (e.g. "name:").
   Ghost text appears via Monaco's InlayHintsProvider.

3. SCADA005 argument-type diagnostic.
   Literal type vs. declared parameter type check on every
   CallShared/CallScript argument. Float accepts Integer literals;
   Object/List accept anything; null only matches reference-ish
   types. Legacy lowercase types ("string" etc) from the DB are
   normalized to the canonical set before comparison so existing
   data doesn't false-negative. Non-literal args (variables,
   expressions) are skipped — out of scope for a cheap pass.

4. Parameters["name"] hover.
   Hover endpoint now also resolves Parameters["X"] element-access
   keys against the form's DeclaredParameterShapes and returns
   "parameter `name: String`"-style markdown. MonacoEditor surfaces
   the new DeclaredParameterShapes parameter; ScriptParameterNames
   gets a ParseShapes companion.

5. Problems panel.
   Bootstrap card under the editor listing every marker with
   severity badge, line number, message, and SCADA / CS code. Click
   a row to scroll the editor to that line and focus. JS now
   invokes OnMarkersChanged on the .NET side whenever
   setModelMarkers fires, so the panel stays in sync with the
   editor.

6. Editor toolbar.
   Small top-right strip on each editor with Format / Wrap /
   Minimap / Theme toggles. New MonacoBlazor.format,
   setEditorOption, and revealLine JS APIs back the buttons and the
   problems-panel scroll-to-line.

Contracts:
  - FormatRequest / FormatResponse
  - InlayHintsRequest / InlayHintsResponse / InlayHint
  - HoverRequest.DeclaredParameters
  - MonacoEditor.DeclaredParameterShapes parameter
  - MonacoEditor.MarkersChanged callback
  - ScadaContext.DeclaredParameterShapes

10 new xUnit tests covering format, inlay hints, SCADA005 (string-
expects-integer, integer-expects-string, float-accepts-integer,
object-accepts-anything, non-literal-skipped), and Parameters key
hover. Total: 139 -> 149.

Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in
Formatter and AdhocWorkspace.

Browser-verified: typing `CallShared("Greet", 42)` now shows the
"name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"]
shows SCADA003 as before; the toolbar buttons all work.
2026-05-12 05:28:13 -04:00

400 lines
15 KiB
C#

using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using ScadaLink.CentralUI.ScriptAnalysis;
namespace ScadaLink.CentralUI.Tests.ScriptAnalysis;
public class ScriptAnalysisServiceTests
{
private readonly ISharedScriptCatalog _catalog = Substitute.For<ISharedScriptCatalog>();
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 });
private readonly ScriptAnalysisService _svc;
private static ScriptShape Shape(string name, params ParameterShape[] ps) =>
new(name, ps, null);
private static ParameterShape Param(string name, string type = "String", bool required = true) =>
new(name, type, required);
public ScriptAnalysisServiceTests()
{
_catalog.GetShapesAsync().Returns(Array.Empty<ScriptShape>());
_svc = new ScriptAnalysisService(_catalog, _cache);
}
// ── Diagnose ──────────────────────────────────────────────────────────
[Fact]
public void EmptyCode_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest(""));
Assert.Empty(resp.Markers);
}
[Fact]
public void CleanScript_NoMarkers()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1 + 2; return x;"));
Assert.Empty(resp.Markers);
}
[Fact]
public void MissingSemicolon_ReportsRoslynDiagnostic()
{
var resp = _svc.Diagnose(new DiagnoseRequest("var x = 1\n"));
Assert.Contains(resp.Markers, m => m.Code.StartsWith("CS"));
}
[Fact]
public void ForbiddenUsingDirective_RaisesSCADA001()
{
var resp = _svc.Diagnose(new DiagnoseRequest("using System.IO;"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains("System.IO"));
}
[Theory]
[InlineData("System.Diagnostics")]
[InlineData("System.Reflection")]
[InlineData("System.Net")]
public void ForbiddenUsing_AllBannedNamespaces(string ns)
{
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
}
[Fact]
public void ForbiddenTypeUsage_ResolvesViaSemanticModel()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"using System.IO; var s = File.ReadAllText(\"x\");"));
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
}
[Fact]
public void UserIdentifierNamedFile_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var File = \"hello\"; return File.Length;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void UserIdentifierNamedThread_DoesNotFalsePositive()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
"var Thread = 42; return Thread;"));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002");
}
[Fact]
public void DiagnosticsAreCached_SecondCallSkipsRecompile()
{
var req = new DiagnoseRequest("using System.IO;");
var first = _svc.Diagnose(req);
var second = _svc.Diagnose(req);
Assert.Same(first, second);
}
[Fact]
public void DifferentCode_GetsDifferentCacheEntries()
{
var a = _svc.Diagnose(new DiagnoseRequest("var x = 1;"));
var b = _svc.Diagnose(new DiagnoseRequest("var y = 2;"));
Assert.NotSame(a, b);
}
[Fact]
public void UnknownParameterKey_RaisesSCADA003()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"typo\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'"));
}
[Fact]
public void DeclaredParameterKey_NoMarker()
{
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = Parameters[\"name\"];",
DeclaredParameters: new[] { "name", "temperature" }));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003");
}
[Fact]
public void ArgumentCountTooFew_RaisesSCADA004()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("expects 2"));
}
[Fact]
public void ArgumentCountTooMany_RaisesSCADA004()
{
var siblings = new[] { Shape("Ping") };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Ping\", 1, 2);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("got 2"));
}
[Fact]
public void ArgumentCountCorrect_NoMarker()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA004");
}
[Fact]
public void OptionalParameter_AcceptsBothOmittedAndPresent()
{
var siblings = new[]
{
Shape("Calc", Param("x"), Param("y", required: false))
};
// Required only (1) — OK.
var with1 = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.DoesNotContain(with1.Markers, m => m.Code == "SCADA004");
// Both passed (2) — OK.
var with2 = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.DoesNotContain(with2.Markers, m => m.Code == "SCADA004");
}
// ── Completions ───────────────────────────────────────────────────────
[Fact]
public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames()
{
var req = new CompletionsRequest(
CodeText: "var x = Parameters[\"",
Line: 1,
Column: 21,
DeclaredParameters: new[] { "name", "temperature" });
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "name" && i.Detail == "declared parameter");
Assert.Contains(resp.Items, i => i.Label == "temperature");
}
[Fact]
public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet()
{
var siblings = new[] { Shape("SiblingA", Param("x")) };
var req = new CompletionsRequest(
CodeText: "var x = CallScript(\"",
Line: 1,
Column: 21,
SiblingScripts: siblings);
var resp = await _svc.CompleteAsync(req);
var item = Assert.Single(resp.Items, i => i.Label == "SiblingA");
Assert.Equal(4, item.InsertTextRules);
Assert.Contains("${1:x}", item.InsertText);
Assert.Contains("sibling script", item.Detail);
}
[Fact]
public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes()
{
_catalog.GetShapesAsync().Returns(new[]
{
Shape("GetWeather"),
Shape("Greet", Param("name"))
});
var req = new CompletionsRequest(
CodeText: "var x = CallShared(\"",
Line: 1,
Column: 21);
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "GetWeather");
var greet = Assert.Single(resp.Items, i => i.Label == "Greet");
Assert.Contains("${1:name}", greet.InsertText);
}
[Fact]
public async Task GeneralCompletion_ReturnsInScopeSymbols()
{
var req = new CompletionsRequest("var x = ", 1, 9);
var resp = await _svc.CompleteAsync(req);
Assert.Contains(resp.Items, i => i.Label == "Parameters");
Assert.Contains(resp.Items, i => i.Label == "CallShared");
}
// ── Hover ─────────────────────────────────────────────────────────────
[Fact]
public void Hover_OnSiblingName_ReturnsSignature()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
var resp = _svc.Hover(new HoverRequest(
CodeText: "var r = CallScript(\"Calc\", 1, 2);",
Line: 1,
Column: 23,
SiblingScripts: siblings));
Assert.NotNull(resp.Markdown);
Assert.Contains("Calc", resp.Markdown);
Assert.Contains("x: Integer", resp.Markdown);
Assert.Contains("y: Float", resp.Markdown);
}
[Fact]
public void Hover_OnUnrelatedToken_ReturnsNull()
{
var resp = _svc.Hover(new HoverRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Markdown);
}
// ── Signature help ────────────────────────────────────────────────────
[Fact]
public void SignatureHelp_InsideCallScript_ReturnsParameterStrip()
{
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
var resp = _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = CallScript(\"Calc\", 1, ",
Line: 1,
Column: 31,
SiblingScripts: siblings));
Assert.NotNull(resp.Label);
Assert.Equal(2, resp.Parameters!.Count);
Assert.Equal("x: Integer", resp.Parameters[0].Label);
Assert.Equal("y: Float", resp.Parameters[1].Label);
Assert.Equal(1, resp.ActiveParameter);
}
[Fact]
public void SignatureHelp_OutsideCall_ReturnsNull()
{
var resp = _svc.SignatureHelp(new SignatureHelpRequest(
CodeText: "var r = 1 + 2;",
Line: 1,
Column: 5));
Assert.Null(resp.Label);
}
// ── Format ────────────────────────────────────────────────────────────
[Fact]
public void Format_ScrambledCode_ReturnsPrettyPrinted()
{
var resp = _svc.Format(new FormatRequest("if(x){return 1;}else{return 2;}"));
// Roslyn's default formatter adds spaces around keywords/braces.
Assert.Contains("if (x)", resp.Code);
Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code);
}
[Fact]
public void Format_EmptyCode_ReturnsEmpty()
{
Assert.Equal("", _svc.Format(new FormatRequest("")).Code);
}
// ── Inlay hints ───────────────────────────────────────────────────────
[Fact]
public void InlayHints_OnCallScript_EmitsParameterLabels()
{
var siblings = new[] { Shape("Calc", Param("x"), Param("y")) };
var resp = _svc.InlayHints(new InlayHintsRequest(
Code: "var r = CallScript(\"Calc\", 1, 2);",
SiblingScripts: siblings));
Assert.Equal(2, resp.Hints.Count);
Assert.Equal("x:", resp.Hints[0].Label);
Assert.Equal("y:", resp.Hints[1].Label);
}
[Fact]
public void InlayHints_OnUnknownSibling_Skipped()
{
var resp = _svc.InlayHints(new InlayHintsRequest(
Code: "var r = CallScript(\"NotKnown\", 1, 2);",
SiblingScripts: Array.Empty<ScriptShape>()));
Assert.Empty(resp.Hints);
}
// ── Argument-type diagnostic (SCADA005) ───────────────────────────────
[Fact]
public void ArgumentTypeMismatch_StringExpectedIntegerGiven()
{
var siblings = new[] { Shape("Greet", Param("name", "String")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Greet\", 42);",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA005" && m.Message.Contains("String"));
}
[Fact]
public void ArgumentTypeMismatch_IntegerExpectedStringGiven()
{
var siblings = new[] { Shape("Calc", Param("n", "Integer")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", \"oops\");",
SiblingScripts: siblings));
Assert.Contains(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_FloatAcceptsInteger()
{
var siblings = new[] { Shape("Calc", Param("ratio", "Float")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Calc\", 1);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_ObjectAcceptsAnyLiteral()
{
var siblings = new[] { Shape("Log", Param("v", "Object")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var r = CallScript(\"Log\", 1); CallScript(\"Log\", \"x\"); CallScript(\"Log\", true);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
[Fact]
public void ArgumentType_NonLiteralExpression_SkipsCheck()
{
var siblings = new[] { Shape("Calc", Param("n", "Integer")) };
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "var x = \"hi\"; var r = CallScript(\"Calc\", x);",
SiblingScripts: siblings));
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005");
}
// ── Hover on Parameters["name"] ───────────────────────────────────────
[Fact]
public void Hover_OnParametersKey_ShowsDeclaredType()
{
var resp = _svc.Hover(new HoverRequest(
CodeText: "var x = Parameters[\"name\"];",
Line: 1,
Column: 22,
DeclaredParameters: new[] { new ParameterShape("name", "String", true) }));
Assert.NotNull(resp.Markdown);
Assert.Contains("name", resp.Markdown);
Assert.Contains("String", resp.Markdown);
}
}