From c7041a24e7f87630cd647e517c07a8e83d9b306d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 08:04:51 -0400 Subject: [PATCH] feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor --- .../ScriptAnalysis/IScriptTagCatalog.cs | 27 +++++++- .../ScriptAnalysis/ScriptAnalysisService.cs | 23 ++++++- .../ScriptAnalysis/EquipTokenEditorTests.cs | 67 +++++++++++++++++++ .../ScriptAnalysis/HoverSignatureTests.cs | 2 + .../ScriptAnalysis/TagPathCompletionTests.cs | 4 ++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs 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());