From 123ddc3f407a3515c1180d74eba7f5cbe894ec81 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Jul 2026 11:08:11 -0400 Subject: [PATCH] fix(scripteditor): complete + hover absolute RawPaths (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue reports a missing feature. The cause is a stale projection, and the consequence was worse than the gap described. `ScriptTagCatalog` still emitted `Tag.TagConfig.FullName` — the pre-v3 driver wire address. Since v3 the mux key is the **RawPath**: `AddressSpaceComposer` harvests the `ctx.GetTag("…")` literals into `DependencyRefs`, those become `VirtualTagActor` dependency refs registered with `DependencyMuxActor`, whose `_byRef` is keyed Ordinal by `AttributeValuePublished.FullReference` — and a driver's wire ref IS the RawPath (`DriverHostActor._nodeIdByDriverRef` is keyed `(DriverInstanceId, RawPath)`). The `{{equip}}/` form already substitutes to RawPaths at both compose seams. So the catalog was wrong in BOTH directions, not merely incomplete: completion offered paths that could never resolve at runtime, while a CORRECT absolute RawPath got no completion and hovered as "not a known configured tag path". The file's own comment admitted the raw/UNS catalog was left for "Batch 2/3" and it never came. Now projected through the shared `RawPathResolver` — the same byte-parity authority `DraftValidator` and `AddressSpaceComposer` use — so a suggested path is identical to the one the deploy gate and the runtime compute. A tag with a broken ancestry chain resolves to null and is omitted: the runtime could not route it either, so offering it would be a lie. Hover additionally gains the owning `DriverInstanceId`, which the topology now makes available (it was always null before). Editor-accepts <=> publish-accepts is preserved: no diagnostic keys off this catalog (`OTSCRIPT_EQUIPREF` only marks `{{equip}}` paths), so this changes completion and hover only — it cannot newly reject a script. The existing tests pinned the v2 contract and were re-authored: a RawPath only exists if the RawFolder -> DriverInstance -> Device -> TagGroup -> Tag chain does, so the seed now builds one. Added coverage for group-nested paths, a driver at the cluster root (no folder segment), a broken chain being omitted rather than guessed, and the pre-v3 FullName no longer resolving. Live-gated on docker-dev (rig rebuilt, both central nodes): - completion, empty literal -> every RawPath incl. group-nested `opcua1/plc/OpcPlc/Telemetry/Basic/AlternatingBoolean` - completion, prefix `pymod` -> `pymodbus/plc/HR200X`, `pymodbus/plc/ImportedTag` - hover `pymodbus/plc/HR200X` -> "Tag · Type UInt16 · Driver MAIN-modbus" - hover an unknown path -> "Not a known configured tag path" AdminUI 742/742; script-analysis 55/55; solution builds 0 errors. --- .../ScriptAnalysis/IScriptTagCatalog.cs | 137 ++++++------ .../ScriptAnalysis/ScriptTagCatalogTests.cs | 205 +++++++++++------- 2 files changed, 196 insertions(+), 146 deletions(-) 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 d7004c2d..a0ee9569 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; @@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st /// /// 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 FolderPath-scoped tags (EquipmentId == null), and the leaf -/// Name (best-effort) for virtual tags. +/// ctx.GetTag / ctx.SetVirtualTag — in v3 that is a raw tag's RawPath, plus 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 -/// AddressSpaceComposer (via EquipmentScriptPaths.ExtractDependencyRefs) harvesting the ctx.GetTag("…") literals -/// into EquipmentVirtualTagPlan.DependencyRefs -/// (src/Server/…OpcUaServer/AddressSpaceComposer.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 Commons.Types.TagConfigIntent.Parse, the shared byte-parity FullName authority). -/// 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. +/// Fidelity over breadth. This projects exactly what the live runtime resolves, and nothing +/// else. Verified chain: AddressSpaceComposer harvests the ctx.GetTag("…") literals +/// (via EquipmentScriptPaths.ExtractDependencyRefs) into DependencyRefs; those become +/// VirtualTagActor._dependencyRefs, registered with DependencyMuxActor, whose +/// _byRef map is keyed (Ordinal) by DriverInstanceActor.AttributeValuePublished.FullReference +/// — and in v3 a driver's wire reference is the RawPath +/// (DriverHostActor._nodeIdByDriverRef is keyed (DriverInstanceId, RawPath)). /// /// -/// The per-category resolvable key: -/// -/// Equipment driver tag (EquipmentId != null) → the driver FullName -/// extracted from Tag.TagConfig (the verified DependencyMux key). -/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve -/// by this same FullName key. -/// VirtualTag → its leaf Name only, as a BEST-EFFORT key (the live resolution -/// of virtual-tag cascade/write targets is unconfirmed). -/// +/// This replaced a stale v2 projection (Gitea #490). The catalog used to emit +/// Tag.TagConfig.FullName — the pre-v3 wire address — which since v3 is not the mux +/// key. The consequence was worse than the missing feature the issue reported: completion offered +/// paths that could never resolve at runtime, while a correct absolute RawPath got no +/// completion and hovered as "not a known configured tag path". Both directions are now right. /// /// -/// 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. +/// RawPaths are built through the shared — the same byte-parity +/// authority DraftValidator and AddressSpaceComposer use — so a suggested path is +/// identical to the one the deploy gate and the runtime compute. A tag whose ancestry chain is +/// broken (unknown device / driver, invalid segment) resolves to and is +/// omitted: the runtime could not route it either, so offering it would be a lie. +/// +/// +/// The {{equip}}/<RefName> form is served separately by +/// ; the compose seams substitute those references to +/// the same RawPaths listed here. +/// +/// +/// The catalog is fleet-wide (no cluster filter), matching the runtime's key space: the +/// deployed artifact is fleet-wide and the mux is keyed by RawPath alone. Two clusters using the +/// same RawPath collapse to one suggestion, which is correct — the string is what resolves. /// /// /// Each call creates and disposes its own context via the pooled factory — the same pattern @@ -149,22 +151,43 @@ public sealed class ScriptTagCatalog(IDbContextFactory d } /// - /// Loads Tags + VirtualTags (untracked) and projects one per row — - /// the SINGLE source of the per-category resolvable-key projection shared by - /// (distinct paths, prefix-filtered) and - /// (exact Ordinal lookup). + /// Loads the raw topology + Tags + VirtualTags (untracked) and projects one + /// per resolvable key — the SINGLE source of the projection shared by + /// (distinct paths, prefix-filtered) and + /// (exact Ordinal lookup). /// private async Task> BuildEntriesAsync(CancellationToken ct) { await using var db = await dbFactory.CreateDbContextAsync(ct); - // v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the - // equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried - // in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in - // Batch 2/3.) + // The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the + // resolver here (rather than reimplementing the join) is what keeps a suggested path + // byte-identical to the one the deploy gate and the runtime compute. + var folders = (await db.RawFolders.AsNoTracking() + .Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name }) + .ToListAsync(ct)) + .ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal); + + var drivers = (await db.DriverInstances.AsNoTracking() + .Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name }) + .ToListAsync(ct)) + .ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal); + + var devices = (await db.Devices.AsNoTracking() + .Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name }) + .ToListAsync(ct)) + .ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal); + + var groups = (await db.TagGroups.AsNoTracking() + .Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name }) + .ToListAsync(ct)) + .ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal); + + var resolver = new RawPathResolver(folders, drivers, devices, groups); + var tagRows = await db.Tags .AsNoTracking() - .Select(t => new { t.Name, t.DataType, t.TagConfig }) + .Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType }) .ToListAsync(ct); var vtagRows = await db.VirtualTags @@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory d foreach (var t in tagRows) { - // The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent. - var full = ExtractFullNameFromTagConfig(t.TagConfig); - var path = string.IsNullOrWhiteSpace(full) ? t.Name : full; - entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null)); + // v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry + // chain — the runtime could not route that tag either, so it is omitted rather than guessed. + var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name); + if (path is null) continue; + + var driverInstanceId = devices.TryGetValue(t.DeviceId, out var dev) ? dev.DriverInstanceId : null; + entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, driverInstanceId)); } 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). + // unconfirmed, so emit the leaf Name only. entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null)); } return entries; } - - /// - /// Extracts the driver-side full reference from a Tag.TagConfig JSON blob — the - /// top-level FullName string every shipped driver stores. Mirrors - /// Commons.Types.TagConfigIntent.Parse(...).FullName (the shared byte-parity authority; - /// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not - /// a JSON object carrying a string FullName. - /// - private static string ExtractFullNameFromTagConfig(string tagConfig) - { - // Best-effort: pull the driver FullName out of the TagConfig JSON blob if present. - // Returns "" when absent/unparseable so the caller falls back to the tag Name — never - // the raw blob (which would surface as a garbage completion path). - if (string.IsNullOrWhiteSpace(tagConfig)) return ""; - 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() ?? ""; - } - } - catch (System.Text.Json.JsonException) { /* fall through to tag Name */ } - return ""; - } } 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 index a9d13076..fa82c464 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs @@ -13,14 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; /// 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. v3: Tag is -/// Raw-only (no EquipmentId/FolderPath/DriverInstanceId), so the catalog projects -/// the surviving Name/DataType/TagConfig columns: the resolvable path derives from -/// the TagConfig.FullName field when present, else the tag Name; the projected -/// DriverInstanceId is always . Virtual tags emit their leaf Name. These -/// assertions check the resolvable key is present AND that the UNS browse paths are absent. +/// +/// Fidelity finding (see the doc comment): the runtime resolves a +/// ctx.GetTag literal through DependencyMuxActor._byRef, keyed Ordinal by the driver's +/// wire reference — and in v3 that wire reference is the RawPath +/// (Folder/…/Driver/Device/[TagGroup/…]/Tag). So the catalog projects RawPaths for raw tags and +/// the leaf Name for virtual tags. +/// +/// +/// These assertions were rewritten for #490. They previously pinned the pre-v3 projection — +/// Tag.TagConfig.FullName — which since v3 is not the mux key at all, so they were encoding a +/// contract the runtime had stopped honouring: completion offered paths that could never resolve, and a +/// correct absolute RawPath resolved to nothing. The seed now builds a real raw topology +/// (RawFolder → DriverInstance → Device → TagGroup → Tag) because a RawPath only exists if that chain +/// does. +/// /// [Trait("Category", "Unit")] public sealed class ScriptTagCatalogTests @@ -43,8 +50,9 @@ public sealed class ScriptTagCatalogTests } /// - /// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"), - /// one virtual tag, and one FolderPath-scoped tag (EquipmentId null, FolderPath set). + /// Seeds the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from: + /// RawFolder "Plant" → DriverInstance "Modbus" → Device "dev1", with one tag directly under the + /// device and one under a nested TagGroup. A virtual tag is added for the leaf-Name projection. /// private static void Seed(DbContextOptions opts) { @@ -61,26 +69,38 @@ public sealed class ScriptTagCatalogTests MachineCode = "machine_001", }); - // Raw tag — the GetTag key is the driver FullName from TagConfig. + // Raw topology — the ancestry a RawPath is composed from. + db.RawFolders.Add(new RawFolder { RawFolderId = "RF-1", ClusterId = "MAIN", ParentRawFolderId = null, Name = "Plant" }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-1", ClusterId = "MAIN", RawFolderId = "RF-1", + Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", + }); + db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" }); + db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", ParentTagGroupId = null, Name = "Motors" }); + + // Directly under the device -> Plant/Modbus/dev1/Speed db.Tags.Add(new Tag { TagId = "TAG-EQ", DeviceId = "DEV-1", + TagGroupId = null, Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"Motor.Speed\"}", }); - // Raw tag whose FullName is an MXAccess dot-ref; resolves by that FullName. + // Under a tag group -> Plant/Modbus/dev1/Motors/Torque db.Tags.Add(new Tag { - TagId = "TAG-SP", + TagId = "TAG-GRP", DeviceId = "DEV-1", - Name = "DownloadPath", - DataType = "String", + TagGroupId = "TG-1", + Name = "Torque", + DataType = "Double", AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}", + TagConfig = "{\"FullName\":\"Motor.Torque\"}", }); db.VirtualTags.Add(new VirtualTag @@ -95,30 +115,27 @@ public sealed class ScriptTagCatalogTests db.SaveChanges(); } - /// A null filter returns only the resolvable keys: the driver FullName for the equipment - /// tag, the MXAccess dot-ref for the FolderPath-scoped tag, and the virtual tag's leaf Name — and - /// NONE of the UNS browse paths. + /// The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the + /// virtual tag's leaf Name — and NOT the pre-v3 TagConfig.FullName, which is no longer the mux + /// key and would suggest a path that cannot resolve at runtime (#490). [Fact] - public async Task GetPaths_no_filter_returns_resolvable_keys_only() + public async Task GetPaths_no_filter_returns_rawpaths_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"); + paths.ShouldContain("Plant/Modbus/dev1/Speed"); // directly under the device + paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque"); // nested under a tag group + paths.ShouldContain("Computed"); // virtual tag: leaf Name - // FolderPath-scoped tag: MXAccess dot-ref. - paths.ShouldContain("DelmiaReceiver_001.DownloadPath"); + // The pre-v3 wire address is NOT a resolvable key in v3. + paths.ShouldNotContain("Motor.Speed"); + paths.ShouldNotContain("Motor.Torque"); - // Virtual tag: leaf Name only. - paths.ShouldContain("Computed"); - - // The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant). + // UNS browse paths are intentionally NOT suggested (that engine is dormant). paths.ShouldNotContain("Assembly/LineA/Machine1/Speed"); - paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath"); - paths.ShouldNotContain("Assembly/LineA/Machine1/Computed"); } /// The result is distinct. @@ -133,37 +150,66 @@ public sealed class ScriptTagCatalogTests paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count); } - /// A literal prefix narrows the result (case-insensitive StartsWith) to matching keys. + /// A literal prefix narrows the result (case-insensitive StartsWith). This is the completion + /// path an author actually hits: typing a partial absolute RawPath inside ctx.GetTag("…"). [Fact] - public async Task GetPaths_prefix_filter_narrows() + public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath() { var (catalog, opts) = Fresh(); Seed(opts); - var paths = await catalog.GetPathsAsync("Motor", default); + var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default); - paths.ShouldContain("Motor.Speed"); - paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath"); + paths.ShouldContain("Plant/Modbus/dev1/Speed"); + paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque"); paths.ShouldNotContain("Computed"); - paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase)); + paths.ShouldAllBe(p => p.StartsWith("Plant/Modbus/dev1/", StringComparison.OrdinalIgnoreCase)); } - /// The prefix match is case-insensitive. + /// The prefix match is case-insensitive (completion is forgiving; the exact lookup is not). [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"); + (await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/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. + /// A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could + /// not route it either, so it is omitted rather than guessed — and it must not throw or suppress the + /// tags that DO resolve. [Fact] - public async Task GetPaths_malformed_tagconfig_does_not_throw() + public async Task GetPaths_tag_with_a_broken_ancestry_chain_is_omitted() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + using (var db = new OtOpcUaConfigDbContext(opts)) + { + db.Tags.Add(new Tag + { + TagId = "TAG-ORPHAN", + DeviceId = "DEV-DOES-NOT-EXIST", + Name = "Orphan", + DataType = "Float", + AccessLevel = TagAccessLevel.Read, + TagConfig = "{}", + }); + db.SaveChanges(); + } + + IReadOnlyList paths = []; + await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default)); + + paths.ShouldNotContain("Orphan"); + paths.ShouldContain("Plant/Modbus/dev1/Speed"); + } + + /// A malformed TagConfig is irrelevant to the RawPath (which comes from the topology, + /// not the blob) — it must neither throw nor drop the tag. + [Fact] + public async Task GetPaths_malformed_tagconfig_does_not_affect_the_rawpath() { var (catalog, opts) = Fresh(); Seed(opts); @@ -185,37 +231,33 @@ public sealed class ScriptTagCatalogTests 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"); + paths.ShouldContain("Plant/Modbus/dev1/Broken"); + paths.ShouldContain("Plant/Modbus/dev1/Speed"); } - /// A FolderPath-scoped tag with a null/empty FolderPath yields just its Name - /// (no leading dot). + /// A driver at the cluster root (no RawFolder) contributes no folder segment. [Fact] - public async Task GetPaths_unbound_tag_without_folder_yields_name_only() + public async Task GetPaths_driver_at_cluster_root_has_no_folder_segment() { var (catalog, opts) = Fresh(); using (var db = new OtOpcUaConfigDbContext(opts)) { + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-ROOT", ClusterId = "MAIN", RawFolderId = null, + Name = "RootDrv", DriverType = "Modbus", DriverConfig = "{}", + }); + db.Devices.Add(new Device { DeviceId = "DEV-ROOT", DriverInstanceId = "DRV-ROOT", Name = "d0", DeviceConfig = "{}" }); db.Tags.Add(new Tag { - TagId = "TAG-SP-ROOT", - DeviceId = "DEV-1", - Name = "RootScalar", - DataType = "String", - AccessLevel = TagAccessLevel.Read, - TagConfig = "{\"FullName\":\"RootScalar\"}", + TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar", + DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }); db.SaveChanges(); } - var paths = await catalog.GetPathsAsync(null, default); - - paths.ShouldContain("RootScalar"); - paths.ShouldNotContain(".RootScalar"); + (await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar"); } /// An empty database yields an empty list rather than throwing. @@ -224,26 +266,24 @@ public sealed class ScriptTagCatalogTests { var (catalog, _) = Fresh(); - var paths = await catalog.GetPathsAsync(null, default); - - paths.ShouldBeEmpty(); + (await catalog.GetPathsAsync(null, default)).ShouldBeEmpty(); } - /// A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with - /// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null. + /// Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and — + /// new in #490 — the owning driver instance, which the topology now makes available. [Fact] - public async Task GetTagInfo_unbound_tag_returns_tag_info() + public async Task GetTagInfo_absolute_rawpath_returns_tag_info_with_driver() { var (catalog, opts) = Fresh(); Seed(opts); - var info = await catalog.GetTagInfoAsync("DelmiaReceiver_001.DownloadPath", default); + var info = await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Motors/Torque", default); info.ShouldNotBeNull(); - info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath"); + info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque"); info.Kind.ShouldBe("Tag"); - info.DataType.ShouldBe("String"); - info.DriverInstanceId.ShouldBeNull(); + info.DataType.ShouldBe("Double"); + info.DriverInstanceId.ShouldBe("DRV-1"); } /// A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver. @@ -256,12 +296,22 @@ public sealed class ScriptTagCatalogTests var info = await catalog.GetTagInfoAsync("Computed", default); info.ShouldNotBeNull(); - info!.Path.ShouldBe("Computed"); - info.Kind.ShouldBe("Virtual tag"); + info!.Kind.ShouldBe("Virtual tag"); info.DataType.ShouldBe("Double"); info.DriverInstanceId.ShouldBeNull(); } + /// The pre-v3 wire address no longer resolves — the regression #490 is really about. Hovering + /// one must report "not a known path" rather than validating a string the runtime would drop. + [Fact] + public async Task GetTagInfo_pre_v3_fullname_no_longer_resolves() + { + var (catalog, opts) = Fresh(); + Seed(opts); + + (await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldBeNull(); + } + /// An unknown path resolves to null. [Fact] public async Task GetTagInfo_unknown_path_returns_null() @@ -269,19 +319,20 @@ public sealed class ScriptTagCatalogTests var (catalog, opts) = Fresh(); Seed(opts); - (await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull(); + (await catalog.GetTagInfoAsync("Plant/Modbus/dev1/NoSuchTag", default)).ShouldBeNull(); } - /// The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT - /// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying. + /// The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's + /// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not + /// claim it does. [Fact] public async Task GetTagInfo_case_mismatch_returns_null_ordinal() { var (catalog, opts) = Fresh(); Seed(opts); - (await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull(); - (await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull(); + (await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull(); + (await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull(); } // -----------------------------------------------------------------------