feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor

This commit is contained in:
Joseph Doherty
2026-06-10 08:04:51 -04:00
parent cadd6c60b7
commit c7041a24e7
5 changed files with 121 additions and 2 deletions
@@ -18,6 +18,14 @@ public interface IScriptTagCatalog
/// <summary>Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path.</summary>
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
/// <summary>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.</summary>
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Distinct leaf names.</returns>
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
}
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
@@ -33,7 +41,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <para>
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
/// literal against the driver <c>FullName</c> — the resolution chain is
/// <c>Phase7Composer.ExtractDependencyRefs</c> harvesting the <c>ctx.GetTag("…")</c> literals
/// <c>Phase7Composer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
@@ -102,6 +110,23 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
{
var entries = await BuildEntriesAsync(ct);
var leaves = new HashSet<string>(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<string> 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();
}
/// <summary>
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
/// the SINGLE source of the per-category resolvable-key projection shared by
@@ -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}**"
@@ -0,0 +1,67 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the
/// <c>{{equip}}</c> token shows an "equipment-relative" note (not the "not a known configured
/// tag path" warning), and completion after <c>{{equip}}.</c> offers the attribute leaf names.
/// </summary>
public sealed class EquipTokenEditorTests
{
/// <summary>A fake catalog whose configured paths all share an object prefix, so the attribute
/// leaf names ("Source", "Other") are what {{equip}}. completion should surface.</summary>
private sealed class FakeCatalog : IScriptTagCatalog
{
private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(Paths);
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
{
IEnumerable<string> leaves = new[] { "Source", "Other" };
if (!string.IsNullOrWhiteSpace(filter))
leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return Task.FromResult<IReadOnlyList<string>>(leaves.ToList());
}
}
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
private static async Task<IReadOnlyList<CompletionItem>> 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");
}
}
@@ -16,6 +16,8 @@ public sealed class HoverSignatureTests
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
}
[Fact] public async Task Hover_on_GetTag_returns_member_markdown()
@@ -13,6 +13,10 @@ public sealed class TagPathCompletionTests
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
=> Task.FromResult<ScriptTagInfo?>(null);
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
// canned leaves derived from the canned paths above (substring after the first dot)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" });
}
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());