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.
This commit is contained in:
Joseph Doherty
2026-05-12 05:53:13 -04:00
parent efba01d10a
commit 0b24b4537d
7 changed files with 506 additions and 18 deletions

View File

@@ -382,6 +382,128 @@ public class ScriptAnalysisServiceTests
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]