diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index 1ab440aa..2836ff21 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -15,8 +15,14 @@ public interface IScriptTagCatalog /// Case-insensitive StartsWith prefix; null/empty returns all (bounded). /// Cancellation token. Task> GetPathsAsync(string? filter, CancellationToken ct); + + /// Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path. + Task GetTagInfoAsync(string path, CancellationToken ct); } +/// Resolved info for one configured tag/virtual-tag path (for hover). +public sealed record ScriptTagInfo(string Path, string Kind, string DataType, string? DriverInstanceId); + /// /// Default . Returns ONLY the resolvable keys a script may pass to /// ctx.GetTag / ctx.SetVirtualTag — the driver FullName for equipment tags, @@ -69,44 +75,9 @@ public sealed class ScriptTagCatalog(IDbContextFactory d /// public async Task> GetPathsAsync(string? filter, CancellationToken ct) { - await using var db = await dbFactory.CreateDbContextAsync(ct); + var entries = await BuildEntriesAsync(ct); - // Only the resolvable keys are projected, so no UNS join is needed: the equipment-tag key is - // the FullName from TagConfig, the SystemPlatform key is FolderPath/Name, and the virtual-tag - // key is its own Name. Pull just those columns, untracked. - var tagRows = await db.Tags - .AsNoTracking() - .Select(t => new { t.EquipmentId, t.Name, t.FolderPath, t.TagConfig }) - .ToListAsync(ct); - - var vtagRows = await db.VirtualTags - .AsNoTracking() - .Select(v => new { v.Name }) - .ToListAsync(ct); - - var paths = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var t in tagRows) - { - if (t.EquipmentId is null) - { - // SystemPlatform tag — the Galaxy driver subscribes by the MXAccess dot-ref, which is - // "FolderPath.Name" when a folder is set, else just "Name". - paths.Add(string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"); - } - else - { - // Equipment driver tag — the runtime GetTag key is the driver FullName from TagConfig. - paths.Add(ExtractFullNameFromTagConfig(t.TagConfig)); - } - } - - foreach (var v in vtagRows) - { - // Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is - // unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves). - paths.Add(v.Name); - } + var paths = new HashSet(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase); IEnumerable query = paths; if (!string.IsNullOrWhiteSpace(filter)) @@ -120,6 +91,63 @@ public sealed class ScriptTagCatalog(IDbContextFactory d .ToList(); } + /// + public async Task GetTagInfoAsync(string path, CancellationToken ct) + { + var entries = await BuildEntriesAsync(ct); + + // Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal + // via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would + // not resolve at runtime — hover must report it as "not a known configured tag path". + return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal)); + } + + /// + /// Loads Tags + VirtualTags (untracked) and projects one per row — + /// the SINGLE source of the per-category resolvable-key projection shared by + /// (distinct paths, prefix-filtered) and + /// (exact Ordinal lookup). + /// + private async Task> BuildEntriesAsync(CancellationToken ct) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // Only the resolvable keys are projected, so no UNS join is needed: the equipment-tag key is + // the FullName from TagConfig, the SystemPlatform key is FolderPath/Name, and the virtual-tag + // key is its own Name. Pull just those columns, untracked. + var tagRows = await db.Tags + .AsNoTracking() + .Select(t => new { t.EquipmentId, t.Name, t.FolderPath, t.DataType, t.DriverInstanceId, t.TagConfig }) + .ToListAsync(ct); + + var vtagRows = await db.VirtualTags + .AsNoTracking() + .Select(v => new { v.Name, v.DataType }) + .ToListAsync(ct); + + var entries = new List(tagRows.Count + vtagRows.Count); + + foreach (var t in tagRows) + { + var path = t.EquipmentId is null + // SystemPlatform tag — the Galaxy driver subscribes by the MXAccess dot-ref, which is + // "FolderPath.Name" when a folder is set, else just "Name". + ? (string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}") + // Equipment driver tag — the runtime GetTag key is the driver FullName from TagConfig. + : ExtractFullNameFromTagConfig(t.TagConfig); + entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, t.DriverInstanceId)); + } + + foreach (var v in vtagRows) + { + // Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is + // unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves). + entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null)); + } + + return entries; + } + /// /// Extracts the driver-side full reference from a Tag.TagConfig JSON blob — the /// top-level FullName string every shipped driver stores. Mirrors diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index d683d4ce..0bdbfe23 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -12,7 +12,7 @@ public static class ScriptAnalysisEndpoints var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin"); group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); - group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r))); + group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r))); group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r))); group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r))); 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 ead65b1a..b9f98e54 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -227,7 +227,7 @@ public sealed class ScriptAnalysisService return new CompletionItem(symbol.Name, symbol.Name, symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), kind); } - public HoverResponse Hover(HoverRequest req) + public async Task Hover(HoverRequest req) { if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null); try @@ -236,6 +236,21 @@ public sealed class ScriptAnalysisService var (tree, _, model, preambleLength) = Analyze(code); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); var token = tree.GetRoot().FindToken(Math.Max(0, position - 1)); + + // 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)) + { + var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None); + static string Code(string s) => s.Replace("`", "\\`"); + var tagMd = info is null + ? $"**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)}`"); + return new HoverResponse(tagMd); + } + var node = token.Parent; if (node is null) return new HoverResponse(null); // Resolve the token's node; if that yields nothing, climb to the enclosing diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs index 44a5abb1..d3bd636a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs @@ -8,16 +8,26 @@ public sealed class HoverSignatureTests { private static readonly ScriptAnalysisService Svc = new(); - [Fact] public void Hover_on_GetTag_returns_member_markdown() + /// A fake catalog that resolves only "Line1.Speed" (Tag/Double/driver) and nothing else, + /// so the tag-path hover branch can be exercised without a database. + private sealed class FakeCatalog : IScriptTagCatalog + { + public Task> GetPathsAsync(string? f, CancellationToken ct) + => Task.FromResult>(System.Array.Empty()); + public Task GetTagInfoAsync(string path, CancellationToken ct) + => Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null); + } + + [Fact] public async Task Hover_on_GetTag_returns_member_markdown() { // hover over the "GetTag" identifier in ctx.GetTag("A") - var md = Svc.Hover(new HoverRequest("""return ctx.GetTag("A").Value;""", 1, 16)).Markdown; + var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("A").Value;""", 1, 16))).Markdown; md.ShouldNotBeNull(); md!.ShouldContain("GetTag"); } - [Fact] public void Hover_on_nothing_returns_null() - => Svc.Hover(new HoverRequest(" ", 1, 1)).Markdown.ShouldBeNull(); + [Fact] public async Task Hover_on_nothing_returns_null() + => (await Svc.Hover(new HoverRequest(" ", 1, 1))).Markdown.ShouldBeNull(); [Fact] public void SignatureHelp_inside_GetTag_call_shows_the_signature() { @@ -29,10 +39,10 @@ public sealed class HoverSignatureTests sh.Parameters!.ShouldContain(p => p.Label.Contains("path") || p.Label.Contains("string")); } - [Fact] public void Hover_on_a_plain_local_resolves_the_local_not_an_enclosing_member() + [Fact] public async Task Hover_on_a_plain_local_resolves_the_local_not_an_enclosing_member() { // hovering the local `x` (not a member) must resolve the LOCAL, and must NOT climb to GetTag/Value. - var md = Svc.Hover(new HoverRequest("var x = 1;\nreturn ctx.GetTag(\"A\").Value + x;", 2, 33)).Markdown; + var md = (await Svc.Hover(new HoverRequest("var x = 1;\nreturn ctx.GetTag(\"A\").Value + x;", 2, 33))).Markdown; md.ShouldNotBeNull(); md!.ShouldContain("x"); md.ShouldNotContain("GetTag"); @@ -46,6 +56,29 @@ public sealed class HoverSignatureTests sh.ActiveParameter.ShouldBe(0); // clamped; no exception } - [Fact] public void Hover_with_out_of_range_position_returns_null_without_throwing() - => Svc.Hover(new HoverRequest("return 1;", 99, 99)).Markdown.ShouldBeNull(); + [Fact] public async Task Hover_with_out_of_range_position_returns_null_without_throwing() + => (await Svc.Hover(new HoverRequest("return 1;", 99, 99))).Markdown.ShouldBeNull(); + + // ── tag-path hover (driven by the catalog) ───────────────────────────────────────────────── + + [Fact] public async Task Hover_on_known_tag_path_literal_shows_kind_type_and_path() + { + var svc = new ScriptAnalysisService(new FakeCatalog()); + // caret on the path literal inside ctx.GetTag("Line1.Speed") + var md = (await svc.Hover(new HoverRequest("""return ctx.GetTag("Line1.Speed").Value;""", 1, 24))).Markdown; + md.ShouldNotBeNull(); + md!.ShouldContain("Line1.Speed"); + md.ShouldContain("Double"); + md.ShouldContain("Tag"); + md.ShouldContain("MAIN-modbus"); + } + + [Fact] public async Task Hover_on_unknown_tag_path_literal_reports_not_known() + { + var svc = new ScriptAnalysisService(new FakeCatalog()); + // caret on the path literal inside ctx.GetTag("Unknownz") + var md = (await svc.Hover(new HoverRequest("""return ctx.GetTag("Unknownz").Value;""", 1, 22))).Markdown; + md.ShouldNotBeNull(); + md!.ShouldContain("Not a known"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs index a5dbaf91..af19b52f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs @@ -232,4 +232,59 @@ public sealed class ScriptTagCatalogTests paths.ShouldBeEmpty(); } + + /// A SystemPlatform tag resolves by its "FolderPath.Name" dot-ref to a Tag-kind info with + /// the configured DataType + DriverInstanceId. + [Fact] + public async Task GetTagInfo_systemplatform_tag_returns_tag_info() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + var info = await catalog.GetTagInfoAsync("DelmiaReceiver_001.DownloadPath", default); + + info.ShouldNotBeNull(); + info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath"); + info.Kind.ShouldBe("Tag"); + info.DataType.ShouldBe("String"); + info.DriverInstanceId.ShouldBe("DRV-GALAXY"); + } + + /// A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver. + [Fact] + public async Task GetTagInfo_virtual_tag_returns_virtual_info_with_no_driver() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + var info = await catalog.GetTagInfoAsync("Computed", default); + + info.ShouldNotBeNull(); + info!.Path.ShouldBe("Computed"); + info.Kind.ShouldBe("Virtual tag"); + info.DataType.ShouldBe("Double"); + info.DriverInstanceId.ShouldBeNull(); + } + + /// An unknown path resolves to null. + [Fact] + public async Task GetTagInfo_unknown_path_returns_null() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + (await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull(); + } + + /// The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT + /// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying. + [Fact] + public async Task GetTagInfo_case_mismatch_returns_null_ordinal() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + (await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull(); + (await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull(); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs index f3e6aded..2cf04eff 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs @@ -10,6 +10,9 @@ public sealed class TagPathCompletionTests { public Task> GetPathsAsync(string? filter, CancellationToken ct) => Task.FromResult>(new[] { "Motor.Speed", "Motor.Temp" }); + + public Task GetTagInfoAsync(string path, CancellationToken ct) + => Task.FromResult(null); } private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());