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 2836ff21..669601e4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
@@ -18,6 +18,14 @@ public interface IScriptTagCatalog
/// Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path.
Task GetTagInfoAsync(string path, CancellationToken ct);
+
+ /// Distinct attribute leaf names — the substring after the first dot of each
+ /// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion,
+ /// which needs the per-equipment object prefix stripped.
+ /// Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).
+ /// Cancellation token.
+ /// Distinct leaf names.
+ Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
}
/// Resolved info for one configured tag/virtual-tag path (for hover).
@@ -33,7 +41,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
///
/// Fidelity over breadth. Verified: the live runtime resolves a ctx.GetTag("X")
/// literal against the driver FullName — the resolution chain is
-/// Phase7Composer.ExtractDependencyRefs harvesting the ctx.GetTag("…") literals
+/// Phase7Composer (via EquipmentScriptPaths.ExtractDependencyRefs) harvesting the ctx.GetTag("…") literals
/// into EquipmentVirtualTagPlan.DependencyRefs
/// (src/Server/…OpcUaServer/Phase7Composer.cs); those become
/// VirtualTagActor._dependencyRefs, registered with the
@@ -102,6 +110,23 @@ public sealed class ScriptTagCatalog(IDbContextFactory d
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
}
+ ///
+ public async Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
+ {
+ var entries = await BuildEntriesAsync(ct);
+ var leaves = new HashSet(StringComparer.Ordinal);
+ foreach (var e in entries)
+ {
+ var dot = e.Path.IndexOf('.');
+ if (dot < 0 || dot + 1 >= e.Path.Length) continue;
+ leaves.Add(e.Path.Substring(dot + 1));
+ }
+ IEnumerable q = leaves;
+ if (!string.IsNullOrWhiteSpace(filter))
+ q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
+ return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
+ }
+
///
/// Loads Tags + VirtualTags (untracked) and projects one per row —
/// the SINGLE source of the per-category resolvable-key projection shared by
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 b9f98e54..35fa8ebf 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
@@ -161,6 +162,16 @@ public sealed class ScriptAnalysisService
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix))
{
+ const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
+ if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
+ {
+ var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
+ pathPrefix.Substring(equipDot.Length), CancellationToken.None);
+ return new CompletionsResponse(leaves
+ .Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
+ .ToList());
+ }
+
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
return new CompletionsResponse(
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
@@ -242,8 +253,18 @@ public sealed class ScriptAnalysisService
// (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("`", "\\`");
+ // Equipment-relative literal (contains the {{equip}} token): it cannot resolve to a
+ // configured path as-typed (the token is only substituted at deploy), so short-circuit
+ // before GetTagInfoAsync and explain the token instead of warning "not a known path".
+ if (tagPath.Contains(EquipmentScriptPaths.EquipToken, StringComparison.Ordinal))
+ {
+ return new HoverResponse(
+ $"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
+ "The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
+ }
+
+ var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
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}**"
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs
new file mode 100644
index 00000000..537b2a37
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs
@@ -0,0 +1,67 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
+
+///
+/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the
+/// {{equip}} token shows an "equipment-relative" note (not the "not a known configured
+/// tag path" warning), and completion after {{equip}}. offers the attribute leaf names.
+///
+public sealed class EquipTokenEditorTests
+{
+ /// A fake catalog whose configured paths all share an object prefix, so the attribute
+ /// leaf names ("Source", "Other") are what {{equip}}. completion should surface.
+ private sealed class FakeCatalog : IScriptTagCatalog
+ {
+ private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
+
+ public Task> GetPathsAsync(string? filter, CancellationToken ct)
+ => Task.FromResult>(Paths);
+
+ public Task GetTagInfoAsync(string path, CancellationToken ct)
+ => Task.FromResult(null);
+
+ public Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
+ {
+ IEnumerable leaves = new[] { "Source", "Other" };
+ if (!string.IsNullOrWhiteSpace(filter))
+ leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult>(leaves.ToList());
+ }
+ }
+
+ private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
+
+ private static async Task> Items(string code, int line, int col)
+ => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
+
+ [Fact] public async Task Completion_after_equip_dot_offers_attribute_leaves()
+ {
+ // caret right after the dot of "{{equip}}." — column 30 sits between the trailing dot and
+ // the closing quote of ctx.GetTag("{{equip}}.").
+ var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
+ items.Select(i => i.Label).ShouldContain("{{equip}}.Source");
+ items.ShouldAllBe(i => i.Detail == "tag path");
+ }
+
+ [Fact] public async Task Completion_after_equip_dot_inserts_the_full_token_qualified_leaf()
+ {
+ var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
+ var source = items.FirstOrDefault(i => i.Label == "{{equip}}.Source");
+ source.ShouldNotBeNull();
+ source!.InsertText.ShouldBe("{{equip}}.Source");
+ }
+
+ [Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative()
+ {
+ // caret inside ctx.GetTag("{{equip}}.Source")
+ var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}.Source").Value;""", 1, 24))).Markdown;
+ md.ShouldNotBeNull();
+ md!.ShouldContain("Equipment-relative");
+ // the {{equip}} token must render literally (non-interpolated markdown segment)
+ md.ShouldContain("{{equip}}");
+ md.ShouldNotContain("Not a known");
+ }
+}
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 d3bd636a..cadd0441 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
@@ -16,6 +16,8 @@ public sealed class HoverSignatureTests
=> 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);
+ public Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
+ => Task.FromResult>(new[] { "Speed" });
}
[Fact] public async Task Hover_on_GetTag_returns_member_markdown()
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 2cf04eff..e2754d21 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
@@ -13,6 +13,10 @@ public sealed class TagPathCompletionTests
public Task GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult(null);
+
+ public Task> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
+ // canned leaves derived from the canned paths above (substring after the first dot)
+ => Task.FromResult>(new[] { "Speed", "Temp" });
}
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());