Six tests asserted DoesNotContain(SCADA004/SCADA005) or an empty InlayHints result — all pass for the wrong reason now that those diagnostics and the positional InlayHints were removed in the analyzer realignment. They also used the obsolete top-level CallScript syntax. Removed.
409 lines
16 KiB
C#
409 lines
16 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 IServiceProvider _services = Substitute.For<IServiceProvider>();
|
|
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, _services);
|
|
}
|
|
|
|
// ── 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");
|
|
}
|
|
|
|
// ── 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 = Instance.CallScript(\"",
|
|
Line: 1,
|
|
Column: 30,
|
|
SiblingScripts: siblings);
|
|
|
|
var resp = await _svc.CompleteAsync(req);
|
|
|
|
var item = Assert.Single(resp.Items, i => i.Label == "SiblingA");
|
|
Assert.Equal(4, item.InsertTextRules);
|
|
// The runtime call API takes args as an anonymous object — the snippet
|
|
// emits one member per declared parameter.
|
|
Assert.Contains("new { x = ${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 = Scripts.CallShared(\"",
|
|
Line: 1,
|
|
Column: 29);
|
|
|
|
var resp = await _svc.CompleteAsync(req);
|
|
|
|
// No-parameter shape: snippet just closes the call.
|
|
var weather = Assert.Single(resp.Items, i => i.Label == "GetWeather");
|
|
Assert.Equal("GetWeather\")", weather.InsertText);
|
|
// Parameterized shape: anonymous-object member per parameter.
|
|
var greet = Assert.Single(resp.Items, i => i.Label == "Greet");
|
|
Assert.Contains("new { name = ${1:name} }", greet.InsertText);
|
|
Assert.Contains("shared script", greet.Detail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GeneralCompletion_ReturnsInScopeSymbols()
|
|
{
|
|
var req = new CompletionsRequest("var x = ", 1, 9);
|
|
var resp = await _svc.CompleteAsync(req);
|
|
// SandboxScriptHost globals are surfaced as in-scope symbols. The
|
|
// runtime call API is member-access — Scripts.CallShared / Instance.*
|
|
// — so the top-level globals are Parameters, Scripts, and Instance.
|
|
Assert.Contains(resp.Items, i => i.Label == "Parameters");
|
|
Assert.Contains(resp.Items, i => i.Label == "Scripts");
|
|
Assert.Contains(resp.Items, i => i.Label == "Instance");
|
|
}
|
|
|
|
// ── Hover ─────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Hover_OnSiblingName_ReturnsSignature()
|
|
{
|
|
var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) };
|
|
// Cursor inside the "Calc" name literal of Instance.CallScript("Calc", ...).
|
|
var resp = _svc.Hover(new HoverRequest(
|
|
CodeText: "var r = Instance.CallScript(\"Calc\", 1, 2);",
|
|
Line: 1,
|
|
Column: 32,
|
|
SiblingScripts: siblings));
|
|
Assert.NotNull(resp.Markdown);
|
|
Assert.Contains("sibling script", resp.Markdown);
|
|
Assert.Contains("Calc(x: Integer, y: Float): void", 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 = Instance.CallScript(\"Calc\", 1, ",
|
|
Line: 1,
|
|
Column: 40,
|
|
SiblingScripts: siblings));
|
|
Assert.Equal("Instance.CallScript(\"Calc\", x: Integer, y: Float)", 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);
|
|
}
|
|
|
|
// ── Self / Children / Parent attribute completions ────────────────────
|
|
|
|
private static AttributeShape Attr(string name, string type = "String") => new(name, type);
|
|
private static CompositionContext Comp(string name, AttributeShape[]? attrs = null, ScriptShape[]? scripts = null)
|
|
=> new(name, attrs ?? Array.Empty<AttributeShape>(), scripts ?? Array.Empty<ScriptShape>());
|
|
|
|
[Fact]
|
|
public async Task SelfAttribute_Literal_ReturnsSelfAttributeNames()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Attributes[\"",
|
|
Line: 1,
|
|
Column: 21,
|
|
SelfAttributes: new[] { Attr("Temperature"), Attr("Setpoint", "Float") });
|
|
var resp = await _svc.CompleteAsync(req);
|
|
Assert.Contains(resp.Items, i => i.Label == "Temperature");
|
|
Assert.Contains(resp.Items, i => i.Label == "Setpoint" && i.Detail.Contains("Float"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChildAttribute_Literal_ReturnsChildAttributeNames()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Children[\"TempSensor\"].Attributes[\"",
|
|
Line: 1,
|
|
Column: 44,
|
|
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature"), Attr("Humidity") }) });
|
|
var resp = await _svc.CompleteAsync(req);
|
|
Assert.Contains(resp.Items, i => i.Label == "Temperature");
|
|
Assert.Contains(resp.Items, i => i.Label == "Humidity");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParentAttribute_Literal_ReturnsParentAttributeNames()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Parent.Attributes[\"",
|
|
Line: 1,
|
|
Column: 28,
|
|
Parent: Comp("Motor", attrs: new[] { Attr("SpeedRPM") }));
|
|
var resp = await _svc.CompleteAsync(req);
|
|
Assert.Contains(resp.Items, i => i.Label == "SpeedRPM");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChildrenLiteral_ReturnsCompositionNames()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Children[\"",
|
|
Line: 1,
|
|
Column: 19,
|
|
Children: new[] { Comp("TempSensor"), Comp("PressureSensor") });
|
|
var resp = await _svc.CompleteAsync(req);
|
|
Assert.Contains(resp.Items, i => i.Label == "TempSensor" && i.Detail == "composition");
|
|
Assert.Contains(resp.Items, i => i.Label == "PressureSensor");
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownSelfAttribute_RaisesSCADA006()
|
|
{
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
Code: "var x = Attributes[\"Typo\"];",
|
|
SelfAttributes: new[] { Attr("Temperature") }));
|
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("Typo"));
|
|
}
|
|
|
|
[Fact]
|
|
public void KnownSelfAttribute_NoMarker()
|
|
{
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
Code: "var x = Attributes[\"Temperature\"];",
|
|
SelfAttributes: new[] { Attr("Temperature") }));
|
|
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA006");
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownChildAttribute_RaisesSCADA006()
|
|
{
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
Code: "var x = Children[\"TempSensor\"].Attributes[\"Typo\"];",
|
|
Children: new[] { Comp("TempSensor", attrs: new[] { Attr("Temperature") }) }));
|
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA006" && m.Message.Contains("TempSensor"));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownComposition_RaisesSCADA007()
|
|
{
|
|
var resp = _svc.Diagnose(new DiagnoseRequest(
|
|
Code: "var x = Children[\"Unknown\"].Attributes[\"X\"];",
|
|
Children: new[] { Comp("TempSensor") }));
|
|
Assert.Contains(resp.Markers, m => m.Code == "SCADA007" && m.Message.Contains("Unknown"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChildrenCallScript_ReturnsChildScripts()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Children[\"TempSensor\"].CallScript(\"",
|
|
Line: 1,
|
|
Column: 44,
|
|
Children: new[]
|
|
{
|
|
Comp("TempSensor", scripts: new[] { Shape("Sample", Param("count", "Integer")) })
|
|
});
|
|
var resp = await _svc.CompleteAsync(req);
|
|
var sample = Assert.Single(resp.Items, i => i.Label == "Sample");
|
|
Assert.Contains("script on TempSensor", sample.Detail);
|
|
Assert.Contains("${1:count}", sample.InsertText);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ParentCallScript_ReturnsParentScripts()
|
|
{
|
|
var req = new CompletionsRequest(
|
|
CodeText: "var x = Parent.CallScript(\"",
|
|
Line: 1,
|
|
Column: 28,
|
|
Parent: Comp("Motor", scripts: new[] { Shape("Trip") }));
|
|
var resp = await _svc.CompleteAsync(req);
|
|
Assert.Contains(resp.Items, i => i.Label == "Trip" && i.Detail.Contains("parent script"));
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|