diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 97acb700..32cf54aa 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -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); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CtxCompletionGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CtxCompletionGuardTests.cs new file mode 100644 index 00000000..f2b84b19 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CtxCompletionGuardTests.cs @@ -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> GetPathsAsync(string? f, CancellationToken ct) + => Task.FromResult>(new[] { "Line1.Speed" }); + public Task GetTagInfoAsync(string path, CancellationToken ct) + => Task.FromResult(new ScriptTagInfo(path, "Virtual tag", "Double", null)); + public Task> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct) + => Task.FromResult>(System.Array.Empty()); + } + + 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(); + } +}