fix(adminui): ctx-receiver guard + truthful SetVirtualTag hover in script-editor completions

This commit is contained in:
Joseph Doherty
2026-06-18 02:39:12 -04:00
parent 74aad3bc87
commit ac3450d5f4
2 changed files with 70 additions and 3 deletions
@@ -161,7 +161,7 @@ public sealed class ScriptAnalysisService
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
var token = root.FindToken(Math.Max(0, position - 1));
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix))
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
{
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
@@ -210,9 +210,10 @@ public sealed class ScriptAnalysisService
.Select(ToCompletionItem).Take(200).ToList();
}
private static bool TryGetTagPathLiteral(SyntaxToken token, out string prefix)
private static bool TryGetTagPathLiteral(SyntaxToken token, out string prefix, out bool isSetVirtualTag)
{
prefix = "";
isSetVirtualTag = false;
var literal = token.Parent as LiteralExpressionSyntax
?? token.GetPreviousToken().Parent as LiteralExpressionSyntax;
if (literal is null || !literal.IsKind(SyntaxKind.StringLiteralExpression)) return false;
@@ -220,11 +221,16 @@ public sealed class ScriptAnalysisService
if (arg.Parent is not ArgumentListSyntax argList) return false;
if (argList.Parent is not InvocationExpressionSyntax inv) return false;
if (inv.Expression is not MemberAccessExpressionSyntax ma) return false;
// Receiver guard: only ctx.GetTag(...) / ctx.SetVirtualTag(...) are real tag-path calls. Mirrors the
// runtime harvest (EquipmentScriptPaths.GetTagRefRegex is syntactically `ctx`-anchored), so the editor
// offers tag completions/hover for exactly what Phase7Composer harvests — not an unrelated x.GetTag(...).
if (ma.Expression is not IdentifierNameSyntax { Identifier.ValueText: "ctx" }) return false;
var method = ma.Name.Identifier.ValueText;
if (method is not ("GetTag" or "SetVirtualTag")) return false;
// only the FIRST argument is the path
if (argList.Arguments.Count > 0 && argList.Arguments[0] != arg) return false;
prefix = literal.Token.ValueText ?? "";
isSetVirtualTag = method == "SetVirtualTag";
return true;
}
@@ -252,7 +258,7 @@ public sealed class ScriptAnalysisService
// Tag-path hover takes priority over C# symbol resolution: when the caret sits on a
// ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info
// (or note it's not a known configured tag path) instead of the string-literal symbol.
if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath) && !string.IsNullOrEmpty(tagPath))
if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath, out var isSetVirtualTag) && !string.IsNullOrEmpty(tagPath))
{
static string Code(string s) => s.Replace("`", "\\`");
// Equipment-relative literal (contains the {{equip}} token): it cannot resolve to a
@@ -270,6 +276,11 @@ public sealed class ScriptAnalysisService
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
+ (info.DriverInstanceId is null ? "" : $" · Driver `{Code(info.DriverInstanceId)}`");
// Truthfulness: ctx.SetVirtualTag(...) is a no-op in the live single-tag evaluator
// (RoslynVirtualTagEvaluator drops cross-tag writes; the cascade VirtualTagEngine is dormant).
if (isSetVirtualTag)
tagMd += "\n\n⚠ Cross-tag `SetVirtualTag` writes are currently dropped in single-tag mode " +
"(the cascade engine is not wired into the host); this write will not take effect at runtime.";
return new HoverResponse(tagMd);
}
@@ -0,0 +1,56 @@
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
public sealed class CtxCompletionGuardTests
{
private sealed class FakeCatalog : IScriptTagCatalog
{
public Task<IReadOnlyList<string>> GetPathsAsync(string? f, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" });
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null));
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
}
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
[Fact] public async Task Completion_on_ctx_GetTag_literal_offers_catalog_paths()
{
// "return ctx.GetTag(" + '""' + ')' — col 20 lands on the closing quote of the empty string literal.
var res = await Svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\")", 1, 20));
res.Items.ShouldContain(i => i.InsertText == "Line1.Speed");
}
[Fact] public async Task Completion_on_non_ctx_receiver_does_NOT_offer_catalog_paths()
{
// Same shape but receiver is "foo" — must NOT offer catalog paths.
var res = await Svc.CompleteAsync(new CompletionsRequest("return foo.GetTag(\"\")", 1, 20));
res.Items.ShouldNotContain(i => i.InsertText == "Line1.Speed");
}
[Fact] public async Task Hover_on_ctx_SetVirtualTag_literal_warns_write_is_dropped()
{
var md = (await Svc.Hover(new HoverRequest("return ctx.SetVirtualTag(\"V\", 1);", 1, 27))).Markdown;
md.ShouldNotBeNull();
md!.ShouldContain("single-tag mode");
}
[Fact] public async Task Hover_on_ctx_GetTag_literal_has_no_dropped_write_note()
{
var md = (await Svc.Hover(new HoverRequest("return ctx.GetTag(\"V\").Value;", 1, 20))).Markdown;
md.ShouldNotBeNull();
md!.ShouldNotContain("single-tag mode");
}
[Fact] public async Task Hover_on_non_ctx_receiver_literal_is_not_treated_as_a_tag_path()
{
var md = (await Svc.Hover(new HoverRequest("return bar.GetTag(\"V\");", 1, 20))).Markdown;
(md is null || !md.Contains("Tag path")).ShouldBeTrue();
}
}