feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor
This commit is contained in:
@@ -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>
|
/// <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);
|
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>
|
/// <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>
|
/// <para>
|
||||||
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
|
/// <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
|
/// 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>
|
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
|
||||||
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
|
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
|
||||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
|
/// <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));
|
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>
|
/// <summary>
|
||||||
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
|
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
|
||||||
/// the SINGLE source of the per-category resolvable-key projection shared by
|
/// 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;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
@@ -161,6 +162,16 @@ public sealed class ScriptAnalysisService
|
|||||||
|
|
||||||
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix))
|
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);
|
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
|
||||||
return new CompletionsResponse(
|
return new CompletionsResponse(
|
||||||
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
|
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.
|
// (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))
|
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("`", "\\`");
|
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
|
var tagMd = info is null
|
||||||
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
||||||
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
|
: $"**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>());
|
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
|
||||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||||
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
|
=> 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()
|
[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)
|
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||||
=> Task.FromResult<ScriptTagInfo?>(null);
|
=> 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());
|
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
|
||||||
|
|||||||
Reference in New Issue
Block a user