Now that the form holds parameter + return shapes for declared
parameters, sibling scripts (template Scripts tab), and shared
scripts (via SharedScriptCatalog), the editor leverages them four
ways:
1. Snippet expansion on accept.
Picking a CallShared or CallScript completion inserts the full
call template with tabstops, e.g. `Greet", ${1:name})`. The JS
provider extends the completion range over Monaco's auto-closed
`")` so the snippet replaces the closing pair cleanly. Items
carry insertTextRules=4 (InsertAsSnippet) and a command to
immediately trigger parameter hints after acceptance.
2. Hover info.
Hovering the script name token inside CallShared("X") or
CallScript("Y") shows a markdown tooltip with the call signature
and return type. New endpoint POST /api/script-analysis/hover.
3. Signature help.
Inside CallShared(...) / CallScript(...) Monaco shows the
parameter strip with the active parameter highlighted. The
service walks up from the cursor to the nearest enclosing
InvocationExpression and resolves which argument index the
cursor is on. New endpoint POST /api/script-analysis/signature-help.
4. Argument-count diagnostic (SCADA004) and unknown-Parameters-key
diagnostic (SCADA003). The Diagnose pipeline now consults the
declared parameters and sibling/shared shapes to flag:
- Parameters["typo"] when "typo" isn't on the form (warn)
- CallScript("Calc", 1) when Calc declares 2 required args (err)
- CallShared("Greet", 1, 2, 3) when Greet declares 1 arg (err)
Optional parameters relax the required-count bound.
Contract changes:
- ScriptShape / ParameterShape records
- ISharedScriptCatalog.GetShapesAsync (replaces GetNamesAsync)
- new HoverRequest/Response, SignatureHelpRequest/Response
- CompletionsRequest.SiblingScripts: string[] -> ScriptShape[]
- DiagnoseRequest gains DeclaredParameters + SiblingScripts
- CompletionItem gains InsertTextRules (Monaco snippet rule)
Form wiring:
- TemplateEdit passes ScriptShapeParser.Parse(...) per sibling
- MonacoEditor surfaces SiblingScripts: IReadOnlyList<ScriptShape>
- GetContext returns shapes to JS on each completion/hover/sig
request
10 new ScriptAnalysisServiceTests covering all four features plus
optional-parameter edge cases. Existing tests updated for the
contract changes. Total: 113 -> 139.
Browser-verified via direct curl + Monaco marker readback:
- SCADA003 squiggle on Parameters["typo"]
- Snippet item Greet", ${1:name}) with insertTextRules=4
- Hover markdown shape signature
- Signature help parameter strip
293 lines
10 KiB
C#
293 lines
10 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);
|
|
}
|
|
}
|