Files
scadalink-design/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs
Joseph Doherty 0b24b4537d feat(ui/scripts): editor support for self/child/parent accessors
Phases 3+4 of the script-scope rollout. Wires the runtime accessors
landed in efba01d through to Monaco completion, diagnostics, and
hover.

New analyzer surface in ScriptAnalysisService:

  String-literal completion contexts (added to TryStringLiteralCompletions):
    Attributes["..."]                       -> SelfAttributes
    Children["..."]                         -> composition names
    Children["X"].Attributes["..."]         -> child template's attributes
    Children["X"].CallScript("...")         -> child template's scripts
    Parent.Attributes["..."]                -> parent template's attributes
    Parent.CallScript("...")                -> parent template's scripts

  Diagnostics:
    SCADA006   Attribute "Typo" is not declared on {this template,
               child composition 'X', the parent}.  (warning)
    SCADA007   Composition "Unknown" is not declared on this template.
               (warning)

  CallShared / CallScript snippet-expansion now routes through the
  child / parent shape catalogs when invoked on Children["X"] /
  Parent — picking a child script accepts `Sample", ${1:count})`.

Contract additions:
  - AttributeShape (Name, Type) record
  - CompositionContext (Name, Attributes, Scripts) record
  - SelfAttributes / Children / Parent fields on DiagnoseRequest,
    CompletionsRequest, HoverRequest, SignatureHelpRequest

ScriptHost (analyzer-side globals) gains stub AttributeBag /
ChildrenBag / CompositionBag types so Roslyn doesn't emit CS0103 on
Attributes / Children / Parent. The stubs are never invoked — only
their signatures are read by the analyzer's compilation pass.

MonacoEditor.razor exposes SelfAttributes / Children / Parent
parameters; GetContext returns them; monaco-init.js forwards all
three on completion / hover / signature-help / diagnostics requests.

TemplateEdit fetches each composition's resolved child template
shape via GetTemplateWithChildrenAsync, and queries GetAllTemplatesAsync
for any single parent that composes the open template. Multi-parent
or no-parent → Parent is suppressed.

11 new xUnit tests on the new completion / diagnostic paths. Total:
149 -> 159.

Browser-verified via curl:
  - Children["..."] suggests composition names
  - Attributes["..."] suggests attributes with type detail
  - Attributes["Typo"] squiggles SCADA006
  - Children["Unknown"] squiggles SCADA007
  - No spurious CS0103 on the new accessors

Hover, signature help, and inlay hints for the new accessors keep
working because they reuse the same dispatch logic.
2026-05-12 05:53:13 -04:00

522 lines
19 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");
}
// ── 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);
}
}