diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 4d671e44..ce7bef44 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -49,6 +49,7 @@ public static class EndpointRouteBuilderExtensions // Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...). services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs new file mode 100644 index 00000000..1ab440aa --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -0,0 +1,146 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +/// +/// Lists the configured tag + virtual-tag path strings a virtual-tag script author can pass to +/// ctx.GetTag("…") / ctx.SetVirtualTag("…"). A later Monaco task uses this to +/// autocomplete those string literals. +/// +public interface IScriptTagCatalog +{ + /// Distinct configured tag + virtual-tag paths (the strings a script passes to + /// ctx.GetTag/SetVirtualTag), optionally filtered by a literal prefix. + /// Case-insensitive StartsWith prefix; null/empty returns all (bounded). + /// Cancellation token. + Task> GetPathsAsync(string? filter, CancellationToken ct); +} + +/// +/// Default . Returns ONLY the resolvable keys a script may pass to +/// ctx.GetTag / ctx.SetVirtualTag — the driver FullName for equipment tags, +/// the MXAccess dot-ref for SystemPlatform tags, and the leaf Name (best-effort) for +/// virtual tags. +/// +/// +/// +/// 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 +/// into EquipmentVirtualTagPlan.DependencyRefs +/// (src/Server/…OpcUaServer/Phase7Composer.cs); those become +/// VirtualTagActor._dependencyRefs, registered with the +/// DependencyMuxActor, whose _byRef map is keyed by +/// DriverInstanceActor.AttributeValuePublished.FullReference +/// (src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97) — and that +/// FullReference is the FullName field extracted from Tag.TagConfig +/// (see Phase7Composer.ExtractTagFullName + EquipmentNodeWalker.ExtractFullName). +/// The UNS-path engine (Core.VirtualTags.VirtualTagEngine, keyed by a slash-joined +/// Enterprise/Site/Area/Line/Equipment/TagName) is dormant — it is NOT wired into the +/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested. +/// +/// +/// The per-category resolvable key: +/// +/// Equipment driver tag (EquipmentId != null) → the driver FullName +/// extracted from Tag.TagConfig (the verified DependencyMux key). +/// SystemPlatform tag (EquipmentId == null) → the MXAccess dot-ref +/// (FolderPath.Name when a folder is set, else Name) — see +/// Phase7Composer's GalaxyTagPlan.MxAccessRef. +/// VirtualTag → its leaf Name only, as a BEST-EFFORT key (the live resolution +/// of virtual-tag cascade/write targets is unconfirmed). +/// +/// +/// +/// Follow-up: surface the UNS browse path as a completion detail (a non-inserted hint +/// shown alongside the resolvable key) for discoverability, rather than as an inserted value. +/// +/// +/// Each call creates and disposes its own context via the pooled factory — the same pattern +/// UnsTreeService uses — so the service is safe to register Scoped per Blazor circuit. +/// +/// +public sealed class ScriptTagCatalog(IDbContextFactory dbFactory) : IScriptTagCatalog +{ + /// Upper bound on returned suggestions — keeps the completion list responsive on large fleets. + private const int MaxResults = 200; + + /// + public async Task> 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(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 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(); + } + + /// + /// Extracts the driver-side full reference from a Tag.TagConfig JSON blob — the + /// top-level FullName string every shipped driver stores. Mirrors + /// EquipmentNodeWalker.ExtractFullName / Phase7Composer.ExtractTagFullName + /// (AdminUI does not reference those assemblies). Falls back to the raw blob when it is not + /// a JSON object carrying a string FullName. + /// + 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; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs new file mode 100644 index 00000000..a5dbaf91 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs @@ -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; + +/// +/// Verifies projects ONLY the resolvable keys a virtual-tag script can +/// pass to ctx.GetTag("…") / ctx.SetVirtualTag("…"). +/// +/// +/// Fidelity finding (see doc comment): the runtime Akka path resolves +/// a ctx.GetTag literal against the driver FullName (the wire reference in +/// Tag.TagConfig.FullName), 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. +/// +[Trait("Category", "Unit")] +public sealed class ScriptTagCatalogTests +{ + private sealed class TestFactory(DbContextOptions opts) + : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => new(opts); + + public Task CreateDbContextAsync(CancellationToken ct = default) => + Task.FromResult(new OtOpcUaConfigDbContext(opts)); + } + + private static (ScriptTagCatalog Catalog, DbContextOptions Opts) Fresh() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"scripttagcatalog-{Guid.NewGuid():N}") + .Options; + return (new ScriptTagCatalog(new TestFactory(opts)), opts); + } + + /// + /// 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). + /// + private static void Seed(DbContextOptions 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(); + } + + /// 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. + [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"); + } + + /// The result is distinct. + [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); + } + + /// A literal prefix narrows the result (case-insensitive StartsWith) to matching keys. + [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)); + } + + /// The prefix match is case-insensitive. + [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"); + } + + /// A tag whose TagConfig is malformed JSON must not throw — the catalog falls back + /// to the raw blob and still returns every other tag. + [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 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"); + } + + /// A SystemPlatform tag with a null/empty FolderPath yields just its Name + /// (no leading dot). + [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"); + } + + /// An empty database yields an empty list rather than throwing. + [Fact] + public async Task GetPaths_empty_db_returns_empty() + { + var (catalog, _) = Fresh(); + + var paths = await catalog.GetPathsAsync(null, default); + + paths.ShouldBeEmpty(); + } +}