feat(adminui): IScriptTagCatalog for tag-path completion
This commit is contained in:
@@ -49,6 +49,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
||||||
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
||||||
|
services.AddScoped<ScriptAnalysis.IScriptTagCatalog, ScriptAnalysis.ScriptTagCatalog>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists the configured tag + virtual-tag path strings a virtual-tag script author can pass to
|
||||||
|
/// <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>. A later Monaco task uses this to
|
||||||
|
/// autocomplete those string literals.
|
||||||
|
/// </summary>
|
||||||
|
public interface IScriptTagCatalog
|
||||||
|
{
|
||||||
|
/// <summary>Distinct configured tag + virtual-tag paths (the strings a script passes to
|
||||||
|
/// ctx.GetTag/SetVirtualTag), optionally filtered by a literal prefix.</summary>
|
||||||
|
/// <param name="filter">Case-insensitive StartsWith prefix; <c>null</c>/empty returns all (bounded).</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
|
||||||
|
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — the driver <c>FullName</c> for equipment tags,
|
||||||
|
/// the MXAccess dot-ref for SystemPlatform tags, and the leaf <c>Name</c> (best-effort) for
|
||||||
|
/// virtual tags.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <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
|
||||||
|
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
|
||||||
|
/// (<c>src/Server/…OpcUaServer/Phase7Composer.cs</c>); those become
|
||||||
|
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
|
||||||
|
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
|
||||||
|
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||||
|
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
|
||||||
|
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
|
||||||
|
/// (see <c>Phase7Composer.ExtractTagFullName</c> + <c>EquipmentNodeWalker.ExtractFullName</c>).
|
||||||
|
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
|
||||||
|
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
|
||||||
|
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The per-category resolvable key:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
|
||||||
|
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).</item>
|
||||||
|
/// <item>SystemPlatform tag (<c>EquipmentId == null</c>) → the MXAccess dot-ref
|
||||||
|
/// (<c>FolderPath.Name</c> when a folder is set, else <c>Name</c>) — see
|
||||||
|
/// <c>Phase7Composer</c>'s <c>GalaxyTagPlan.MxAccessRef</c>.</item>
|
||||||
|
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
|
||||||
|
/// of virtual-tag cascade/write targets is unconfirmed).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
|
||||||
|
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each call creates and disposes its own context via the pooled factory — the same pattern
|
||||||
|
/// <c>UnsTreeService</c> uses — so the service is safe to register Scoped per Blazor circuit.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IScriptTagCatalog
|
||||||
|
{
|
||||||
|
/// <summary>Upper bound on returned suggestions — keeps the completion list responsive on large fleets.</summary>
|
||||||
|
private const int MaxResults = 200;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, 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.TagConfig })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var vtagRows = await db.VirtualTags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(v => new { v.Name })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var paths = new HashSet<string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<string> query = paths;
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter))
|
||||||
|
{
|
||||||
|
query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(MaxResults)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
|
||||||
|
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
|
||||||
|
/// <c>EquipmentNodeWalker.ExtractFullName</c> / <c>Phase7Composer.ExtractTagFullName</c>
|
||||||
|
/// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not
|
||||||
|
/// a JSON object carrying a string <c>FullName</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractFullNameFromTagConfig(string tagConfig)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||||
|
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||||
|
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||||
|
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return fullName.GetString() ?? tagConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (System.Text.Json.JsonException) { /* fall through to raw blob */ }
|
||||||
|
return tagConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="ScriptTagCatalog"/> projects ONLY the resolvable keys a virtual-tag script can
|
||||||
|
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
|
||||||
|
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
|
||||||
|
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. The
|
||||||
|
/// catalog therefore emits the FullName for equipment tags, the MXAccess dot-ref for SystemPlatform
|
||||||
|
/// tags, and the leaf Name for virtual tags, and deliberately omits UNS browse paths. These
|
||||||
|
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptTagCatalogTests
|
||||||
|
{
|
||||||
|
private sealed class TestFactory(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||||
|
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
|
{
|
||||||
|
public OtOpcUaConfigDbContext CreateDbContext() => new(opts);
|
||||||
|
|
||||||
|
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new OtOpcUaConfigDbContext(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ScriptTagCatalog Catalog, DbContextOptions<OtOpcUaConfigDbContext> Opts) Fresh()
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseInMemoryDatabase($"scripttagcatalog-{Guid.NewGuid():N}")
|
||||||
|
.Options;
|
||||||
|
return (new ScriptTagCatalog(new TestFactory(opts)), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
|
||||||
|
/// one virtual tag, and one SystemPlatform tag (EquipmentId null, FolderPath set).
|
||||||
|
/// </summary>
|
||||||
|
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||||
|
{
|
||||||
|
using var db = new OtOpcUaConfigDbContext(opts);
|
||||||
|
|
||||||
|
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "Assembly" });
|
||||||
|
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "LineA" });
|
||||||
|
db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentId = "EQ-1",
|
||||||
|
EquipmentUuid = Guid.NewGuid(),
|
||||||
|
UnsLineId = "LINE-1",
|
||||||
|
Name = "Machine1",
|
||||||
|
MachineCode = "machine_001",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Equipment driver tag — the GetTag key is the driver FullName from TagConfig.
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-EQ",
|
||||||
|
DriverInstanceId = "DRV-1",
|
||||||
|
EquipmentId = "EQ-1",
|
||||||
|
Name = "Speed",
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
||||||
|
});
|
||||||
|
|
||||||
|
// SystemPlatform tag — EquipmentId null, FolderPath set; Galaxy subscribes by "FolderPath.Name".
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-SP",
|
||||||
|
DriverInstanceId = "DRV-GALAXY",
|
||||||
|
EquipmentId = null,
|
||||||
|
Name = "DownloadPath",
|
||||||
|
FolderPath = "DelmiaReceiver_001",
|
||||||
|
DataType = "String",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.VirtualTags.Add(new VirtualTag
|
||||||
|
{
|
||||||
|
VirtualTagId = "VTAG-1",
|
||||||
|
EquipmentId = "EQ-1",
|
||||||
|
Name = "Computed",
|
||||||
|
DataType = "Double",
|
||||||
|
ScriptId = "SCRIPT-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
|
||||||
|
/// tag, the MXAccess dot-ref for the SystemPlatform tag, and the virtual tag's leaf Name — and
|
||||||
|
/// NONE of the UNS browse paths.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_no_filter_returns_resolvable_keys_only()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
|
// Equipment driver tag: the authoritative GetTag key (driver FullName).
|
||||||
|
paths.ShouldContain("Motor.Speed");
|
||||||
|
|
||||||
|
// SystemPlatform tag: MXAccess dot-ref.
|
||||||
|
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||||
|
|
||||||
|
// Virtual tag: leaf Name only.
|
||||||
|
paths.ShouldContain("Computed");
|
||||||
|
|
||||||
|
// The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
|
||||||
|
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
|
||||||
|
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
|
||||||
|
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The result is distinct.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_results_are_distinct()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
|
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_prefix_filter_narrows()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync("Motor", default);
|
||||||
|
|
||||||
|
paths.ShouldContain("Motor.Speed");
|
||||||
|
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
|
||||||
|
paths.ShouldNotContain("Computed");
|
||||||
|
paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The prefix match is case-insensitive.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync("motor", default);
|
||||||
|
|
||||||
|
paths.ShouldContain("Motor.Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
|
||||||
|
/// to the raw blob and still returns every other tag.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_malformed_tagconfig_does_not_throw()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||||
|
{
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-BAD",
|
||||||
|
DriverInstanceId = "DRV-1",
|
||||||
|
EquipmentId = "EQ-1",
|
||||||
|
Name = "Broken",
|
||||||
|
DataType = "Float",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{ this is not valid json",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<string> paths = [];
|
||||||
|
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
||||||
|
|
||||||
|
// The malformed tag falls through to the raw blob (acceptable), and the other tags still appear.
|
||||||
|
paths.ShouldContain("Motor.Speed");
|
||||||
|
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
||||||
|
paths.ShouldContain("Computed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A SystemPlatform tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
|
||||||
|
/// (no leading dot).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_systemplatform_tag_without_folder_yields_name_only()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
|
||||||
|
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||||
|
{
|
||||||
|
db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagId = "TAG-SP-ROOT",
|
||||||
|
DriverInstanceId = "DRV-GALAXY",
|
||||||
|
EquipmentId = null,
|
||||||
|
Name = "RootScalar",
|
||||||
|
FolderPath = null,
|
||||||
|
DataType = "String",
|
||||||
|
AccessLevel = TagAccessLevel.Read,
|
||||||
|
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
||||||
|
});
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
|
paths.ShouldContain("RootScalar");
|
||||||
|
paths.ShouldNotContain(".RootScalar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_empty_db_returns_empty()
|
||||||
|
{
|
||||||
|
var (catalog, _) = Fresh();
|
||||||
|
|
||||||
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
|
paths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user