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());