fix(scripteditor): complete + hover absolute RawPaths (#490)
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}}/<RefName>` 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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
@@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
|
/// 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,
|
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
|
||||||
/// the MXAccess dot-ref for FolderPath-scoped tags (<c>EquipmentId == null</c>), and the leaf
|
/// leaf <c>Name</c> (best-effort) for virtual tags.
|
||||||
/// <c>Name</c> (best-effort) for virtual tags.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c>
|
/// <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
|
||||||
/// literal against the driver <c>FullName</c> — the resolution chain is
|
/// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
|
||||||
/// <c>AddressSpaceComposer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals
|
/// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
|
||||||
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c>
|
/// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
|
||||||
/// (<c>src/Server/…OpcUaServer/AddressSpaceComposer.cs</c>); those become
|
/// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
|
||||||
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the
|
/// — and in v3 a driver's wire reference <b>is the RawPath</b>
|
||||||
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by
|
/// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
|
||||||
/// <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>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
|
|
||||||
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The per-category resolvable key:
|
/// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
|
||||||
/// <list type="bullet">
|
/// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
|
||||||
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
|
/// key. The consequence was worse than the missing feature the issue reported: completion offered
|
||||||
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).
|
/// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
|
||||||
/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve
|
/// completion and hovered as "not a known configured tag path". Both directions are now right.
|
||||||
/// by this same <c>FullName</c> key.</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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint
|
/// RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
|
||||||
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value.
|
/// authority <c>DraftValidator</c> and <c>AddressSpaceComposer</c> 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 <see langword="null"/> and is
|
||||||
|
/// omitted: the runtime could not route it either, so offering it would be a lie.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The <c>{{equip}}/<RefName></c> form is served separately by
|
||||||
|
/// <see cref="GetEquipmentReferenceNamesAsync"/>; the compose seams substitute those references to
|
||||||
|
/// the same RawPaths listed here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The catalog is <b>fleet-wide</b> (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.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Each call creates and disposes its own context via the pooled factory — the same pattern
|
/// Each call creates and disposes its own context via the pooled factory — the same pattern
|
||||||
@@ -149,22 +151,43 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row —
|
/// Loads the raw topology + Tags + VirtualTags (untracked) and projects one
|
||||||
/// the SINGLE source of the per-category resolvable-key projection shared by
|
/// <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
|
||||||
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and
|
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
|
||||||
/// <see cref="GetTagInfoAsync"/> (exact Ordinal lookup).
|
/// (exact Ordinal lookup).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
|
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the
|
// The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
|
||||||
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried
|
// resolver here (rather than reimplementing the join) is what keeps a suggested path
|
||||||
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in
|
// byte-identical to the one the deploy gate and the runtime compute.
|
||||||
// Batch 2/3.)
|
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
|
var tagRows = await db.Tags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Select(t => new { t.Name, t.DataType, t.TagConfig })
|
.Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var vtagRows = await db.VirtualTags
|
var vtagRows = await db.VirtualTags
|
||||||
@@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
|||||||
|
|
||||||
foreach (var t in tagRows)
|
foreach (var t in tagRows)
|
||||||
{
|
{
|
||||||
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent.
|
// v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
|
||||||
var full = ExtractFullNameFromTagConfig(t.TagConfig);
|
// chain — the runtime could not route that tag either, so it is omitted rather than guessed.
|
||||||
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full;
|
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||||
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null));
|
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)
|
foreach (var v in vtagRows)
|
||||||
{
|
{
|
||||||
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
|
// 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));
|
entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (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 <c>FullName</c>.
|
|
||||||
/// </summary>
|
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-77
@@ -13,14 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
|||||||
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves
|
/// <para>
|
||||||
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in
|
/// Fidelity finding (see the <see cref="ScriptTagCatalog"/> doc comment): the runtime resolves a
|
||||||
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. v3: Tag is
|
/// <c>ctx.GetTag</c> literal through <c>DependencyMuxActor._byRef</c>, keyed Ordinal by the driver's
|
||||||
/// Raw-only (no <c>EquipmentId</c>/<c>FolderPath</c>/<c>DriverInstanceId</c>), so the catalog projects
|
/// wire reference — and in v3 that wire reference <b>is the RawPath</b>
|
||||||
/// the surviving <c>Name</c>/<c>DataType</c>/<c>TagConfig</c> columns: the resolvable path derives from
|
/// (<c>Folder/…/Driver/Device/[TagGroup/…]/Tag</c>). So the catalog projects RawPaths for raw tags and
|
||||||
/// the <c>TagConfig.FullName</c> field when present, else the tag <c>Name</c>; the projected
|
/// the leaf <c>Name</c> for virtual tags.
|
||||||
/// <c>DriverInstanceId</c> is always <see langword="null"/>. Virtual tags emit their leaf Name. These
|
/// </para>
|
||||||
/// assertions check the resolvable key is present AND that the UNS browse paths are absent.
|
/// <para>
|
||||||
|
/// These assertions were rewritten for #490. They previously pinned the pre-v3 projection —
|
||||||
|
/// <c>Tag.TagConfig.FullName</c> — 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.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class ScriptTagCatalogTests
|
public sealed class ScriptTagCatalogTests
|
||||||
@@ -43,8 +50,9 @@ public sealed class ScriptTagCatalogTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"),
|
/// Seeds the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from:
|
||||||
/// one virtual tag, and one FolderPath-scoped tag (EquipmentId null, FolderPath set).
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||||
{
|
{
|
||||||
@@ -61,26 +69,38 @@ public sealed class ScriptTagCatalogTests
|
|||||||
MachineCode = "machine_001",
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-EQ",
|
TagId = "TAG-EQ",
|
||||||
DeviceId = "DEV-1",
|
DeviceId = "DEV-1",
|
||||||
|
TagGroupId = null,
|
||||||
Name = "Speed",
|
Name = "Speed",
|
||||||
DataType = "Float",
|
DataType = "Float",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"Motor.Speed\"}",
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-SP",
|
TagId = "TAG-GRP",
|
||||||
DeviceId = "DEV-1",
|
DeviceId = "DEV-1",
|
||||||
Name = "DownloadPath",
|
TagGroupId = "TG-1",
|
||||||
DataType = "String",
|
Name = "Torque",
|
||||||
|
DataType = "Double",
|
||||||
AccessLevel = TagAccessLevel.Read,
|
AccessLevel = TagAccessLevel.Read,
|
||||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}",
|
TagConfig = "{\"FullName\":\"Motor.Torque\"}",
|
||||||
});
|
});
|
||||||
|
|
||||||
db.VirtualTags.Add(new VirtualTag
|
db.VirtualTags.Add(new VirtualTag
|
||||||
@@ -95,30 +115,27 @@ public sealed class ScriptTagCatalogTests
|
|||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment
|
/// <summary>The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the
|
||||||
/// tag, the MXAccess dot-ref for the FolderPath-scoped tag, and the virtual tag's leaf Name — and
|
/// virtual tag's leaf Name — and NOT the pre-v3 <c>TagConfig.FullName</c>, which is no longer the mux
|
||||||
/// NONE of the UNS browse paths.</summary>
|
/// key and would suggest a path that cannot resolve at runtime (#490).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPaths_no_filter_returns_resolvable_keys_only()
|
public async Task GetPaths_no_filter_returns_rawpaths_only()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
var paths = await catalog.GetPathsAsync(null, default);
|
||||||
|
|
||||||
// Equipment driver tag: the authoritative GetTag key (driver FullName).
|
paths.ShouldContain("Plant/Modbus/dev1/Speed"); // directly under the device
|
||||||
paths.ShouldContain("Motor.Speed");
|
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.
|
// The pre-v3 wire address is NOT a resolvable key in v3.
|
||||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
paths.ShouldNotContain("Motor.Speed");
|
||||||
|
paths.ShouldNotContain("Motor.Torque");
|
||||||
|
|
||||||
// Virtual tag: leaf Name only.
|
// UNS browse paths are intentionally NOT suggested (that engine is dormant).
|
||||||
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("Assembly/LineA/Machine1/Speed");
|
||||||
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
|
|
||||||
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The result is distinct.</summary>
|
/// <summary>The result is distinct.</summary>
|
||||||
@@ -133,37 +150,66 @@ public sealed class ScriptTagCatalogTests
|
|||||||
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary>
|
/// <summary>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("…").</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPaths_prefix_filter_narrows()
|
public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync("Motor", default);
|
var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default);
|
||||||
|
|
||||||
paths.ShouldContain("Motor.Speed");
|
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath");
|
paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");
|
||||||
paths.ShouldNotContain("Computed");
|
paths.ShouldNotContain("Computed");
|
||||||
paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase));
|
paths.ShouldAllBe(p => p.StartsWith("Plant/Modbus/dev1/", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The prefix match is case-insensitive.</summary>
|
/// <summary>The prefix match is case-insensitive (completion is forgiving; the exact lookup is not).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
public async Task GetPaths_prefix_filter_is_case_insensitive()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync("motor", default);
|
(await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
|
|
||||||
paths.ShouldContain("Motor.Speed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back
|
/// <summary>A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could
|
||||||
/// to the raw blob and still returns every other tag.</summary>
|
/// not route it either, so it is omitted rather than guessed — and it must not throw or suppress the
|
||||||
|
/// tags that DO resolve.</summary>
|
||||||
[Fact]
|
[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<string> paths = [];
|
||||||
|
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
||||||
|
|
||||||
|
paths.ShouldNotContain("Orphan");
|
||||||
|
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A malformed <c>TagConfig</c> is irrelevant to the RawPath (which comes from the topology,
|
||||||
|
/// not the blob) — it must neither throw nor drop the tag.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPaths_malformed_tagconfig_does_not_affect_the_rawpath()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
@@ -185,37 +231,33 @@ public sealed class ScriptTagCatalogTests
|
|||||||
IReadOnlyList<string> paths = [];
|
IReadOnlyList<string> paths = [];
|
||||||
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
|
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("Plant/Modbus/dev1/Broken");
|
||||||
paths.ShouldContain("Motor.Speed");
|
paths.ShouldContain("Plant/Modbus/dev1/Speed");
|
||||||
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
|
|
||||||
paths.ShouldContain("Computed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A FolderPath-scoped tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c>
|
/// <summary>A driver at the cluster root (no RawFolder) contributes no folder segment.</summary>
|
||||||
/// (no leading dot).</summary>
|
|
||||||
[Fact]
|
[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();
|
var (catalog, opts) = Fresh();
|
||||||
|
|
||||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
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
|
db.Tags.Add(new Tag
|
||||||
{
|
{
|
||||||
TagId = "TAG-SP-ROOT",
|
TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar",
|
||||||
DeviceId = "DEV-1",
|
DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||||
Name = "RootScalar",
|
|
||||||
DataType = "String",
|
|
||||||
AccessLevel = TagAccessLevel.Read,
|
|
||||||
TagConfig = "{\"FullName\":\"RootScalar\"}",
|
|
||||||
});
|
});
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
(await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar");
|
||||||
|
|
||||||
paths.ShouldContain("RootScalar");
|
|
||||||
paths.ShouldNotContain(".RootScalar");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
/// <summary>An empty database yields an empty list rather than throwing.</summary>
|
||||||
@@ -224,26 +266,24 @@ public sealed class ScriptTagCatalogTests
|
|||||||
{
|
{
|
||||||
var (catalog, _) = Fresh();
|
var (catalog, _) = Fresh();
|
||||||
|
|
||||||
var paths = await catalog.GetPathsAsync(null, default);
|
(await catalog.GetPathsAsync(null, default)).ShouldBeEmpty();
|
||||||
|
|
||||||
paths.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with
|
/// <summary>Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and —
|
||||||
/// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null.</summary>
|
/// new in #490 — the owning driver instance, which the topology now makes available.</summary>
|
||||||
[Fact]
|
[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();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
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.ShouldNotBeNull();
|
||||||
info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath");
|
info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque");
|
||||||
info.Kind.ShouldBe("Tag");
|
info.Kind.ShouldBe("Tag");
|
||||||
info.DataType.ShouldBe("String");
|
info.DataType.ShouldBe("Double");
|
||||||
info.DriverInstanceId.ShouldBeNull();
|
info.DriverInstanceId.ShouldBe("DRV-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
|
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
|
||||||
@@ -256,12 +296,22 @@ public sealed class ScriptTagCatalogTests
|
|||||||
var info = await catalog.GetTagInfoAsync("Computed", default);
|
var info = await catalog.GetTagInfoAsync("Computed", default);
|
||||||
|
|
||||||
info.ShouldNotBeNull();
|
info.ShouldNotBeNull();
|
||||||
info!.Path.ShouldBe("Computed");
|
info!.Kind.ShouldBe("Virtual tag");
|
||||||
info.Kind.ShouldBe("Virtual tag");
|
|
||||||
info.DataType.ShouldBe("Double");
|
info.DataType.ShouldBe("Double");
|
||||||
info.DriverInstanceId.ShouldBeNull();
|
info.DriverInstanceId.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTagInfo_pre_v3_fullname_no_longer_resolves()
|
||||||
|
{
|
||||||
|
var (catalog, opts) = Fresh();
|
||||||
|
Seed(opts);
|
||||||
|
|
||||||
|
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An unknown path resolves to null.</summary>
|
/// <summary>An unknown path resolves to null.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTagInfo_unknown_path_returns_null()
|
public async Task GetTagInfo_unknown_path_returns_null()
|
||||||
@@ -269,19 +319,20 @@ public sealed class ScriptTagCatalogTests
|
|||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
(await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull();
|
(await catalog.GetTagInfoAsync("Plant/Modbus/dev1/NoSuchTag", default)).ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT
|
/// <summary>The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's
|
||||||
/// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying.</summary>
|
/// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not
|
||||||
|
/// claim it does.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
|
public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
|
||||||
{
|
{
|
||||||
var (catalog, opts) = Fresh();
|
var (catalog, opts) = Fresh();
|
||||||
Seed(opts);
|
Seed(opts);
|
||||||
|
|
||||||
(await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull();
|
(await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull();
|
||||||
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull();
|
(await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user