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();
}
// -----------------------------------------------------------------------