feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".
Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
takes the equipment's reference map (effectiveName -> RawPath) and substitutes
{{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
add ExtractEquipReferenceNames (path-literal scoped) +
ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
sides) over RawPathResolver — the single authority both compose seams + the
validator use, so resolved RawPaths agree byte-for-byte.
Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
the per-equipment reference map from UnsTagReferences + Tags + raw topology and
substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
extraction (runtime dep refs = resolved RawPaths).
Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).
Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
+52
-40
@@ -307,76 +307,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
|
||||
|
||||
### Why
|
||||
|
||||
Today each VirtualTag bound to a script typically needs its own near-duplicate
|
||||
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`).
|
||||
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at
|
||||
a single template script, and each resolves the token to its own equipment's tag
|
||||
base prefix at deploy time. No schema change is required — sharing a `Script`
|
||||
record across VirtualTags already works; `{{equip}}` is what makes the shared
|
||||
script resolve per-equipment.
|
||||
Each VirtualTag bound to a script would otherwise need its own near-duplicate
|
||||
script because tag paths are hard-coded absolute RawPaths (e.g.
|
||||
`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
|
||||
point many VirtualTags' `ScriptId` at a single template script, and each resolves
|
||||
the token through **its own equipment's tag references** at deploy time. No schema
|
||||
change is required — sharing a `Script` record across VirtualTags already works;
|
||||
`{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
|
||||
|
||||
### Before / after
|
||||
|
||||
**Before — one script per machine:**
|
||||
**Before — one script per machine (hard-coded RawPath):**
|
||||
|
||||
```csharp
|
||||
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
|
||||
return ctx.GetTag("TestMachine_001.Speed").Value;
|
||||
return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
|
||||
```
|
||||
|
||||
**After — one shared template:**
|
||||
|
||||
```csharp
|
||||
// Script "Calc_Speed" — works for any machine
|
||||
return ctx.GetTag("{{equip}}.Speed").Value;
|
||||
return ctx.GetTag("{{equip}}/Speed").Value;
|
||||
```
|
||||
|
||||
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`.
|
||||
At deploy, each VirtualTag receives its own expanded copy:
|
||||
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively.
|
||||
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
|
||||
each has a **tag reference** whose effective name is `Speed`. At deploy, each
|
||||
VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that
|
||||
equipment's referenced raw tag's RawPath.
|
||||
|
||||
### Token rules
|
||||
### Token rules (v3)
|
||||
|
||||
- The token is `{{equip}}` (double braces, lowercase).
|
||||
- The token joint is a **slash**: `{{equip}}/<RefName>` (double-brace stem,
|
||||
lowercase). `<RefName>` is a **reference effective name** — the reference's
|
||||
`DisplayNameOverride`, else the backing raw tag's `Name`.
|
||||
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
|
||||
string literals** — comments, logger strings, and other code are untouched.
|
||||
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`,
|
||||
`ctx.GetTag("{{equip}}.Sub.Field")`, etc.
|
||||
- The token expands to the equipment's **tag base prefix** — the common
|
||||
substring-before-the-first-dot of that equipment's configured driver-tag
|
||||
`FullName` values. Example: tags `TestMachine_001.Speed` and
|
||||
`TestMachine_001.Temp` → base `TestMachine_001`.
|
||||
- The whole post-prefix literal content is the reference name, so effective names
|
||||
with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
|
||||
- `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
|
||||
rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
|
||||
reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
|
||||
`Cell1/Modbus/Dev1/Speed`.
|
||||
- Scripted-alarm predicate scripts resolve `{{equip}}/<RefName>` the same way; alarm
|
||||
**message-template** tokens follow the same resolution rule.
|
||||
|
||||
### Validation requirement
|
||||
|
||||
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in
|
||||
the AdminUI if the equipment does not have at least one configured driver tag, or
|
||||
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or
|
||||
absent). The rejection message is surfaced as a clear validation error on the save
|
||||
form. This check is enforced eagerly so that an unresolved `{{equip}}` token —
|
||||
which would leave a path that resolves to nothing at runtime (Bad quality) — can
|
||||
never reach the deployed artifact.
|
||||
Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
|
||||
rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
|
||||
reference effective names. The same rule runs at the deploy gate
|
||||
(`DraftValidator.ValidateEquipReferenceResolution`, error code
|
||||
`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
|
||||
authoring check never saw. The rejection names the script/alarm, the equipment, and
|
||||
the missing ref — so an unresolved token can never reach the deployed artifact.
|
||||
|
||||
### Editor support
|
||||
|
||||
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
|
||||
inline script panel):
|
||||
|
||||
- **Hover** — hovering a `{{equip}}` path literal shows an
|
||||
*"Equipment-relative path — resolved at deploy"* note.
|
||||
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal
|
||||
offers completion of attribute leaf names (the part after the first dot of known
|
||||
tag references in the catalog).
|
||||
- **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
|
||||
*"Equipment-relative path — resolved through the equipment's tag reference at
|
||||
deploy"* note.
|
||||
- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
|
||||
offers the owning equipment's **reference effective names** (needs an equipment
|
||||
context; inert on the shared ScriptEdit page).
|
||||
- **Diagnostics** — an unresolved `{{equip}}/<RefName>` is flagged (`OTSCRIPT_EQUIPREF`)
|
||||
identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*.
|
||||
|
||||
### Maintainer note
|
||||
|
||||
Substitution runs at the two compose seams —
|
||||
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans`
|
||||
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper,
|
||||
**before** dependency extraction. The runtime, the static change-trigger
|
||||
dependency graph, and the literal-only path rule are therefore all unchanged:
|
||||
by the time they see the script, `{{equip}}` has been replaced with a concrete
|
||||
tag-base prefix and the path is a normal string literal.
|
||||
Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
|
||||
`DeploymentArtifact.ParseComposition` — via the shared
|
||||
`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
|
||||
helper, fed the equipment's reference map (effective name → RawPath) built by
|
||||
`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
|
||||
**before** dependency extraction, so the runtime, the static change-trigger
|
||||
dependency graph, and the literal-only path rule are unchanged: by the time they
|
||||
see the script, `{{equip}}/<RefName>` has been replaced with a concrete RawPath and
|
||||
the path is a normal string literal. An unresolved ref is left un-substituted (the
|
||||
validator rejects it first — substitution never throws). The two seams build the
|
||||
reference map identically, so their plans stay byte-parity.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-equipment reference map — <c>equipmentId → (effectiveName → backing-tag RawPath)</c> —
|
||||
/// the single authority both compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>)
|
||||
/// and the draft validator use to resolve <c>{{equip}}/<RefName></c> script paths. A reference's
|
||||
/// <b>effective name</b> is its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>; the
|
||||
/// RawPath is computed through the shared <see cref="RawPathResolver"/> (the same identity authority the
|
||||
/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree
|
||||
/// byte-for-byte.
|
||||
/// <para>Input-shape agnostic + pure: callers flatten their references (EF entities on one side,
|
||||
/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the
|
||||
/// effective-name rule, and the collision policy live ONLY here. References are consumed in
|
||||
/// <c>UnsTagReferenceId</c>-ordinal order and effective-name collisions keep the FIRST — a valid draft
|
||||
/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two
|
||||
/// seams parity-stable on any input.</para>
|
||||
/// </summary>
|
||||
public static class EquipmentReferenceMap
|
||||
{
|
||||
/// <summary>A flattened UNS tag reference: which equipment references which raw tag under an optional override.</summary>
|
||||
/// <param name="UnsTagReferenceId">Stable logical id — the ordering key for deterministic first-wins.</param>
|
||||
/// <param name="EquipmentId">The referencing equipment.</param>
|
||||
/// <param name="TagId">The backing raw tag.</param>
|
||||
/// <param name="DisplayNameOverride">Optional effective-name override; <see langword="null"/> = use the raw tag's Name.</param>
|
||||
public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride);
|
||||
|
||||
/// <summary>The backing raw tag's identity inputs for its RawPath + effective name.</summary>
|
||||
/// <param name="Name">The raw tag's leaf name (also the default effective name).</param>
|
||||
/// <param name="DeviceId">The tag's owning device id.</param>
|
||||
/// <param name="TagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
|
||||
public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId);
|
||||
|
||||
/// <summary>
|
||||
/// Build the reference map. For each reference (ordered by <c>UnsTagReferenceId</c>), resolve the
|
||||
/// backing tag's RawPath via <paramref name="resolver"/> and key it under the effective name within
|
||||
/// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built
|
||||
/// (broken chain) are skipped. Effective-name collisions within an equipment keep the first.
|
||||
/// </summary>
|
||||
/// <param name="references">The flattened UNS tag references (any order; sorted internally).</param>
|
||||
/// <param name="tagsById">Backing raw tags keyed by <c>TagId</c>.</param>
|
||||
/// <param name="resolver">The shared RawPath resolver over the raw topology.</param>
|
||||
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>. Equipments with no resolvable reference are absent.</returns>
|
||||
public static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Build(
|
||||
IEnumerable<ReferenceRow> references,
|
||||
IReadOnlyDictionary<string, TagRow> tagsById,
|
||||
RawPathResolver resolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(references);
|
||||
ArgumentNullException.ThrowIfNull(tagsById);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var byEquip = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue;
|
||||
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
||||
if (string.IsNullOrEmpty(effectiveName)) continue;
|
||||
var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
|
||||
|
||||
if (!byEquip.TryGetValue(r.EquipmentId, out var map))
|
||||
byEquip[r.EquipmentId] = map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions)
|
||||
}
|
||||
|
||||
return byEquip.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (IReadOnlyDictionary<string, string>)kv.Value,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The set of effective names a single equipment's references contribute (for the authoring
|
||||
/// gate + Monaco completion, which need only the names — not the RawPaths).</summary>
|
||||
/// <param name="references">The flattened UNS tag references for (typically) one equipment.</param>
|
||||
/// <param name="tagNameById">Backing raw tag Name keyed by <c>TagId</c>.</param>
|
||||
/// <returns>The distinct effective names, ordinal.</returns>
|
||||
public static IReadOnlySet<string> EffectiveNames(
|
||||
IEnumerable<ReferenceRow> references,
|
||||
IReadOnlyDictionary<string, string> tagNameById)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(references);
|
||||
ArgumentNullException.ThrowIfNull(tagNameById);
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in references)
|
||||
{
|
||||
var effectiveName = r.DisplayNameOverride
|
||||
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,29 @@ using System.Text.RegularExpressions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
|
||||
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
||||
/// replaced at the compose seams with the owning equipment's tag base prefix (derived
|
||||
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer
|
||||
/// composer and the Runtime artifact-decode path can both share it. Also the single home
|
||||
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
|
||||
/// duplicate.
|
||||
/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
|
||||
/// <c>{{equip}}/<RefName></c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
|
||||
/// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
|
||||
/// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
|
||||
/// <c>UnsTagReference</c> rows by effective name (<c><RefName></c>).
|
||||
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/<RefName></c> — replacing
|
||||
/// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c><RefName></c> is a reference's effective name
|
||||
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
|
||||
/// <c><RefName></c> is left un-substituted (substitution never throws); the deploy-time validator +
|
||||
/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant
|
||||
/// <b>the editor accepts ⇔ publish accepts</b>.</para>
|
||||
/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can
|
||||
/// both share it. Also the single home for the <c>ctx.GetTag("…")</c> dependency-ref extraction those
|
||||
/// two seams used to duplicate.
|
||||
/// </summary>
|
||||
public static class EquipmentScriptPaths
|
||||
{
|
||||
/// <summary>The reserved equipment-base token.</summary>
|
||||
/// <summary>The reserved equipment token stem.</summary>
|
||||
public const string EquipToken = "{{equip}}";
|
||||
|
||||
/// <summary>The reserved equipment reference-relative prefix — the token stem plus the slash joint.</summary>
|
||||
public const string EquipTokenPrefix = "{{equip}}/";
|
||||
|
||||
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
|
||||
private static readonly Regex GetTagRefRegex =
|
||||
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
|
||||
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
|
||||
private static readonly Regex PathLiteralRegex =
|
||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
|
||||
|
||||
// {{equip}}/<RefName> occurrence in FREE TEXT (message templates). <RefName> runs until the next
|
||||
// delimiter that cannot appear in an intra-literal RawPath segment used inline in a template —
|
||||
// whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may
|
||||
// contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the
|
||||
// free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.)
|
||||
private static readonly Regex EquipRefTextRegex =
|
||||
new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled);
|
||||
|
||||
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
|
||||
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
|
||||
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
|
||||
@@ -46,44 +64,89 @@ public static class EquipmentScriptPaths
|
||||
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Equipment tag base = the single shared substring-before-first-dot across the
|
||||
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
|
||||
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
|
||||
/// Replace each <c>{{equip}}/<RefName></c> reference-relative path with the backing raw tag's
|
||||
/// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. A path literal whose content is
|
||||
/// <c>{{equip}}/<RefName></c> is treated as a single reference: <c><RefName></c> is the
|
||||
/// entire remainder after the prefix (so reference effective names may contain internal spaces).
|
||||
/// Identity when <paramref name="source"/> is null/empty, <paramref name="referenceMap"/> is empty,
|
||||
/// or the token is absent (so every existing script — none of which use the token — is byte-unchanged).
|
||||
/// An <c><RefName></c> absent from the map is left un-substituted (never throws) — the validator
|
||||
/// rejects it first at deploy.
|
||||
/// </summary>
|
||||
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
|
||||
/// <returns>The shared base prefix, or null when none/ambiguous.</returns>
|
||||
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames)
|
||||
/// <param name="source">The script source.</param>
|
||||
/// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
|
||||
/// <returns>The source with each resolvable <c>{{equip}}/<RefName></c> substituted inside path literals.</returns>
|
||||
public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary<string, string> referenceMap)
|
||||
{
|
||||
string? found = null;
|
||||
foreach (var fn in childFullNames)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fn)) continue;
|
||||
var dot = fn.IndexOf('.');
|
||||
var prefix = dot < 0 ? fn : fn.Substring(0, dot);
|
||||
if (prefix.Length == 0) continue;
|
||||
if (found is null) found = prefix;
|
||||
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null;
|
||||
}
|
||||
return found;
|
||||
if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
|
||||
if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
|
||||
return PathLiteralRegex.Replace(source, m =>
|
||||
m.Groups[1].Value
|
||||
+ SubstituteContent(m.Groups[2].Value, referenceMap)
|
||||
+ m.Groups[3].Value);
|
||||
}
|
||||
|
||||
// Substitute the reference-relative token inside a single path-literal's content. The content is one
|
||||
// whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name.
|
||||
private static string SubstituteContent(string content, IReadOnlyDictionary<string, string> referenceMap)
|
||||
{
|
||||
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content;
|
||||
var refName = content.Substring(EquipTokenPrefix.Length);
|
||||
if (refName.Length == 0) return content;
|
||||
return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
|
||||
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
|
||||
/// script — none of which use the token — is byte-unchanged).
|
||||
/// Distinct <c><RefName></c> values used via <c>{{equip}}/<RefName></c> inside
|
||||
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
|
||||
/// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
|
||||
/// string is not reported), and the entire post-prefix remainder is the reference name (matching
|
||||
/// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what
|
||||
/// resolves. Feeds the deploy-gate + authoring rejection + editor completion.
|
||||
/// </summary>
|
||||
/// <param name="source">The script source.</param>
|
||||
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
|
||||
/// <returns>The source with the token substituted inside path literals.</returns>
|
||||
public static string SubstituteEquipmentToken(string source, string? equipBase)
|
||||
/// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
|
||||
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
|
||||
public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source;
|
||||
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source;
|
||||
return PathLiteralRegex.Replace(source, m =>
|
||||
m.Groups[1].Value
|
||||
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal)
|
||||
+ m.Groups[3].Value);
|
||||
if (string.IsNullOrEmpty(scriptSource)
|
||||
|| !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return Array.Empty<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
foreach (Match m in PathLiteralRegex.Matches(scriptSource))
|
||||
{
|
||||
var content = m.Groups[2].Value;
|
||||
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue;
|
||||
var refName = content.Substring(EquipTokenPrefix.Length);
|
||||
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct <c><RefName></c> values used via <c>{{equip}}/<RefName></c> in FREE TEXT — the
|
||||
/// scripted-alarm <c>MessageTemplate</c>, which is not a path literal — in first-seen order. Best-effort:
|
||||
/// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so
|
||||
/// a space-bearing effective name used inline in a template resolves only up to its first space (a
|
||||
/// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule
|
||||
/// for alarm message tokens.
|
||||
/// </summary>
|
||||
/// <param name="text">The free text (e.g. a scripted-alarm message template).</param>
|
||||
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
|
||||
public static IReadOnlyList<string> ExtractEquipReferenceNamesFromText(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)
|
||||
|| !text.Contains(EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return Array.Empty<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var result = new List<string>();
|
||||
foreach (Match m in EquipRefTextRegex.Matches(text))
|
||||
{
|
||||
var refName = m.Groups[1].Value;
|
||||
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -115,10 +178,11 @@ public static class EquipmentScriptPaths
|
||||
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
|
||||
/// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
|
||||
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
|
||||
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
|
||||
/// tags do) — pass the predicate source as-is.
|
||||
/// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
|
||||
/// compose seams substitute <c>{{equip}}/<RefName></c> before extraction, identically), so the
|
||||
/// merged refs are resolved RawPaths.
|
||||
/// </summary>
|
||||
/// <param name="predicateSource">The resolved predicate script source.</param>
|
||||
/// <param name="predicateSource">The resolved (substituted) predicate script source.</param>
|
||||
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
|
||||
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
|
||||
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
|
||||
@@ -154,7 +218,7 @@ public static class EquipmentScriptPaths
|
||||
/// <param name="source">The virtual-tag script source to inspect.</param>
|
||||
/// <param name="tagReference">
|
||||
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
|
||||
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}.Speed</c>);
|
||||
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}/Speed</c>);
|
||||
/// otherwise <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class DraftValidator
|
||||
ValidateRawNameCharset(draft, errors);
|
||||
ValidateHistorizedTagnameLength(draft, errors);
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateEquipReferenceResolution(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
@@ -238,6 +239,64 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>v3: every <c>{{equip}}/<RefName></c> used in an equipment's VirtualTag script,
|
||||
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
|
||||
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
|
||||
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c><RefName></c>
|
||||
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
|
||||
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
|
||||
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
|
||||
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
|
||||
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
|
||||
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
|
||||
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var r in draft.UnsTagReferences)
|
||||
{
|
||||
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (string.IsNullOrEmpty(effective)) continue;
|
||||
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
|
||||
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(effective!);
|
||||
}
|
||||
|
||||
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||
|
||||
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
|
||||
{
|
||||
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
|
||||
foreach (var name in refNames)
|
||||
{
|
||||
if (have is not null && have.Contains(name)) continue;
|
||||
errors.Add(new("EquipReferenceUnresolved",
|
||||
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
|
||||
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
|
||||
equipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var v in draft.VirtualTags)
|
||||
{
|
||||
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
|
||||
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
|
||||
}
|
||||
|
||||
foreach (var a in draft.ScriptedAlarms)
|
||||
{
|
||||
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
|
||||
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidSegment(string? s) =>
|
||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
[Parameter] public bool ReadOnly { get; set; } = false;
|
||||
[Parameter] public bool ShowToolbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Owning-equipment context for equipment-relative <c>{{equip}}/<RefName></c> completion +
|
||||
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
|
||||
/// ScriptEdit page (where the token cannot resolve to a single equipment).
|
||||
/// </summary>
|
||||
[Parameter] public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
|
||||
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
|
||||
@@ -61,7 +68,8 @@
|
||||
{
|
||||
value = Value ?? "",
|
||||
language = Language,
|
||||
readOnly = ReadOnly
|
||||
readOnly = ReadOnly,
|
||||
equipmentId = EquipmentId
|
||||
},
|
||||
_dotNetRef);
|
||||
_initialized = true;
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
Editing shared script "<span class="mono">@_scriptName</span>" — used by
|
||||
@_scriptUsageCount virtual tag(s). Changes affect all of them.
|
||||
</div>
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
|
||||
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
|
||||
@if (!string.IsNullOrWhiteSpace(_scriptError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scriptError</div>
|
||||
|
||||
@@ -23,13 +23,16 @@ public interface IScriptTagCatalog
|
||||
/// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
|
||||
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
|
||||
|
||||
/// <summary>Distinct attribute leaf names — the substring after the first dot of each
|
||||
/// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion,
|
||||
/// which needs the per-equipment object prefix stripped.</summary>
|
||||
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param>
|
||||
/// <summary>v3: the owning equipment's <c>UnsTagReference</c> effective names (its
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the resolvable set for
|
||||
/// <c>{{equip}}/<RefName></c> completion + diagnostics, matching what the compose seams substitute
|
||||
/// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has
|
||||
/// no references.</summary>
|
||||
/// <param name="equipmentId">The equipment whose references to list; blank returns empty.</param>
|
||||
/// <param name="filter">Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Distinct leaf names.</returns>
|
||||
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
|
||||
/// <returns>Distinct reference effective names.</returns>
|
||||
Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
|
||||
@@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
var entries = await BuildEntriesAsync(ct);
|
||||
var leaves = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var e in entries)
|
||||
if (string.IsNullOrEmpty(equipmentId)) return Array.Empty<string>();
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
if (refs.Count == 0) return Array.Empty<string>();
|
||||
|
||||
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
|
||||
var tagNameById = await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
|
||||
|
||||
// Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set).
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
var dot = e.Path.IndexOf('.');
|
||||
if (dot < 0 || dot + 1 >= e.Path.Length) continue;
|
||||
leaves.Add(e.Path.Substring(dot + 1));
|
||||
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (!string.IsNullOrEmpty(eff)) names.Add(eff!);
|
||||
}
|
||||
IEnumerable<string> q = leaves;
|
||||
|
||||
IEnumerable<string> q = names;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
public record DiagnoseRequest(string Code);
|
||||
// EquipmentId (optional) carries the owning-equipment context the {{equip}}/<RefName> completion +
|
||||
// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single
|
||||
// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per
|
||||
// owning equipment).
|
||||
public record DiagnoseRequest(string Code, string? EquipmentId = null);
|
||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
|
||||
int EndLineNumber, int EndColumn, string Message, string Code);
|
||||
|
||||
public record CompletionsRequest(string CodeText, int Line, int Column);
|
||||
public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
|
||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
|
||||
|
||||
public record HoverRequest(string CodeText, int Line, int Column);
|
||||
public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
|
||||
public record HoverResponse(string? Markdown);
|
||||
|
||||
public record SignatureHelpRequest(string CodeText, int Line, int Column);
|
||||
|
||||
@@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints
|
||||
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
|
||||
var group = endpoints.MapGroup("/api/script-analysis")
|
||||
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
|
||||
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
|
||||
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
|
||||
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
|
||||
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
|
||||
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
|
||||
|
||||
@@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
|
||||
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
|
||||
// SAME path literals the compose seams substitute (never a comment / logger string).
|
||||
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
|
||||
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/<RefName></c> resolution check.
|
||||
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
|
||||
/// are present, appends a marker for each <c>{{equip}}/<RefName></c> whose <c><RefName></c> is
|
||||
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
|
||||
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
||||
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
||||
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
||||
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
||||
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
|
||||
{
|
||||
var baseResp = Diagnose(req);
|
||||
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
|
||||
return baseResp;
|
||||
try
|
||||
{
|
||||
var code = Normalize(req.Code);
|
||||
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
||||
return baseResp;
|
||||
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
|
||||
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
|
||||
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
|
||||
{
|
||||
var content = m.Groups[2].Value;
|
||||
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
|
||||
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
|
||||
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
|
||||
// Mark the whole literal content span (the {{equip}}/<RefName> path).
|
||||
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
|
||||
markers.Add(ToUserMarker(code, span,
|
||||
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
|
||||
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
||||
"OTSCRIPT_EQUIPREF"));
|
||||
}
|
||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
|
||||
return baseResp;
|
||||
}
|
||||
}
|
||||
|
||||
private static int Sev(DiagnosticSeverity s) => s switch
|
||||
{
|
||||
DiagnosticSeverity.Error => 8,
|
||||
@@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService
|
||||
|
||||
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
|
||||
{
|
||||
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
|
||||
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
|
||||
// v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
|
||||
// (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
|
||||
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
|
||||
if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
|
||||
pathPrefix.Substring(equipDot.Length), CancellationToken.None);
|
||||
return new CompletionsResponse(leaves
|
||||
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
|
||||
if (string.IsNullOrEmpty(req.EquipmentId))
|
||||
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
||||
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
||||
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
|
||||
return new CompletionsResponse(names
|
||||
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService
|
||||
{
|
||||
return new HoverResponse(
|
||||
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
|
||||
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
|
||||
"`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
|
||||
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
|
||||
}
|
||||
|
||||
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
|
||||
|
||||
@@ -866,29 +866,45 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
|
||||
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
|
||||
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
|
||||
/// base exists). The script source is fetched from the supplied (in-scope) context.
|
||||
/// v3 (M1): when the bound script uses <c>{{equip}}/<RefName></c>, every <c><RefName></c> must
|
||||
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
|
||||
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
|
||||
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
|
||||
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
|
||||
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
|
||||
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
|
||||
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
|
||||
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
|
||||
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
|
||||
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
|
||||
if (used.Count == 0) return null;
|
||||
|
||||
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver
|
||||
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here.
|
||||
var fullNames = Array.Empty<string>();
|
||||
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
|
||||
// The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name.
|
||||
var refs = await db.UnsTagReferences.AsNoTracking()
|
||||
.Where(r => r.EquipmentId == equipmentId)
|
||||
.Select(r => new { r.TagId, r.DisplayNameOverride })
|
||||
.ToListAsync(ct);
|
||||
var tagIds = refs.Select(r => r.TagId).ToList();
|
||||
var tagNameById = await db.Tags.AsNoTracking()
|
||||
.Where(t => tagIds.Contains(t.TagId))
|
||||
.Select(t => new { t.TagId, t.Name })
|
||||
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
|
||||
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
return new UnsMutationResult(false,
|
||||
$"Equipment '{equipmentId}' has no single tag base, so the "
|
||||
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
|
||||
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
|
||||
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
||||
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
|
||||
}
|
||||
return null;
|
||||
|
||||
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
|
||||
if (missing.Count == 0) return null;
|
||||
return new UnsMutationResult(false,
|
||||
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
|
||||
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
|
||||
+ "Add a matching reference on the Tags tab, or correct the script.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
|
||||
|
||||
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
|
||||
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
|
||||
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
|
||||
function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; }
|
||||
|
||||
function registerCSharpProviders() {
|
||||
monaco.languages.registerCompletionItemProvider("csharp", {
|
||||
triggerCharacters: [".", "(", "\""],
|
||||
@@ -31,7 +36,7 @@
|
||||
const resp = await fetch("/api/script-analysis/completions", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return { suggestions: [] };
|
||||
const data = await resp.json();
|
||||
@@ -72,7 +77,7 @@
|
||||
const resp = await fetch("/api/script-analysis/hover", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
|
||||
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
@@ -149,7 +154,7 @@
|
||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: model.getValue() })
|
||||
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
@@ -192,6 +197,9 @@
|
||||
// inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space.
|
||||
quickSuggestions: { other: true, comments: false, strings: true }
|
||||
});
|
||||
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
|
||||
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
|
||||
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
|
||||
let diagTimer = null;
|
||||
const scheduleDiagnostics = function () {
|
||||
if (diagTimer) clearTimeout(diagTimer);
|
||||
|
||||
@@ -315,6 +315,11 @@ public static class AddressSpaceComposer
|
||||
/// <param name="scriptedAlarms">The scripted alarms.</param>
|
||||
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
|
||||
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
|
||||
/// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/<RefName></c> reference map. <c>null</c> = none.</param>
|
||||
/// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
|
||||
/// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
|
||||
/// <returns>The composition result.</returns>
|
||||
public static AddressSpaceComposition Compose(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
@@ -323,7 +328,12 @@ public static class AddressSpaceComposer
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||
IReadOnlyList<VirtualTag>? virtualTags = null,
|
||||
IReadOnlyList<Script>? scripts = null)
|
||||
IReadOnlyList<Script>? scripts = null,
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences = null,
|
||||
IReadOnlyList<Tag>? tags = null,
|
||||
IReadOnlyList<RawFolder>? rawFolders = null,
|
||||
IReadOnlyList<Device>? devices = null,
|
||||
IReadOnlyList<TagGroup>? tagGroups = null)
|
||||
{
|
||||
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
|
||||
var resolvedScripts = scripts ?? Array.Empty<Script>();
|
||||
@@ -356,14 +366,21 @@ public static class AddressSpaceComposer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
|
||||
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
|
||||
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
|
||||
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
|
||||
// use, so the three agree byte-for-byte on the resolved RawPath.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(
|
||||
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
|
||||
// expression source. The {{equip}} token is substituted with the owning equipment's tag
|
||||
// base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
||||
// expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
|
||||
// RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
|
||||
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
|
||||
// VirtualTag has no FolderPath today → "".
|
||||
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
|
||||
@@ -374,7 +391,7 @@ public static class AddressSpaceComposer
|
||||
{
|
||||
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
src, baseByEquip.GetValueOrDefault(v.EquipmentId));
|
||||
src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
|
||||
return new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: v.VirtualTagId,
|
||||
EquipmentId: v.EquipmentId,
|
||||
@@ -412,7 +429,13 @@ public static class AddressSpaceComposer
|
||||
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
|
||||
continue;
|
||||
}
|
||||
var source = s.SourceCode;
|
||||
// v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
|
||||
// substituted with the owning equipment's reference map BEFORE the dependency merge — so the
|
||||
// stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
|
||||
// artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
|
||||
// shared EquipmentScriptPaths helper.
|
||||
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
|
||||
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: a.ScriptedAlarmId,
|
||||
EquipmentId: a.EquipmentId,
|
||||
@@ -422,9 +445,6 @@ public static class AddressSpaceComposer
|
||||
MessageTemplate: a.MessageTemplate,
|
||||
PredicateScriptId: a.PredicateScriptId,
|
||||
PredicateSource: source,
|
||||
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
|
||||
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
|
||||
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
|
||||
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
|
||||
HistorizeToAveva: a.HistorizeToAveva,
|
||||
Retain: a.Retain,
|
||||
@@ -439,4 +459,48 @@ public static class AddressSpaceComposer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
|
||||
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
|
||||
/// are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
var tagRows = tags ?? Array.Empty<Tag>();
|
||||
if (refs.Count == 0 || tagRows.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
var folders = (rawFolders ?? Array.Empty<RawFolder>())
|
||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var drivers = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var deviceMap = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groups = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
|
||||
|
||||
var tagsById = tagRows
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return EquipmentReferenceMap.Build(
|
||||
refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
|
||||
r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
|
||||
tagsById,
|
||||
resolver);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,82 @@ public static class DeploymentArtifact
|
||||
return byDriver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-equipment <c>{{equip}}/<RefName></c> reference map — <c>equipmentId →
|
||||
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
|
||||
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
|
||||
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
|
||||
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
|
||||
/// the artifact carries no references / tags.
|
||||
/// </summary>
|
||||
/// <param name="root">The artifact root element.</param>
|
||||
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
|
||||
{
|
||||
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "RawFolders"))
|
||||
{
|
||||
var id = ReadString(el, "RawFolderId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "DriverInstances"))
|
||||
{
|
||||
var id = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Devices"))
|
||||
{
|
||||
var id = ReadString(el, "DeviceId");
|
||||
var di = ReadString(el, "DriverInstanceId");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
|
||||
devices[id!] = (di!, ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "TagGroups"))
|
||||
{
|
||||
var id = ReadString(el, "TagGroupId");
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
|
||||
}
|
||||
|
||||
var resolver = new RawPathResolver(folders, drivers, devices, groups);
|
||||
|
||||
var tagsById = new Dictionary<string, EquipmentReferenceMap.TagRow>(StringComparer.Ordinal);
|
||||
foreach (var el in EnumerateArray(root, "Tags"))
|
||||
{
|
||||
var tagId = ReadString(el, "TagId");
|
||||
var deviceId = ReadString(el, "DeviceId");
|
||||
var name = ReadString(el, "Name");
|
||||
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId"));
|
||||
}
|
||||
|
||||
var references = new List<EquipmentReferenceMap.ReferenceRow>();
|
||||
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
|
||||
{
|
||||
var refId = ReadString(el, "UnsTagReferenceId");
|
||||
var equipmentId = ReadString(el, "EquipmentId");
|
||||
var tagId = ReadString(el, "TagId");
|
||||
if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId))
|
||||
continue;
|
||||
references.Add(new EquipmentReferenceMap.ReferenceRow(
|
||||
refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride")));
|
||||
}
|
||||
|
||||
if (references.Count == 0 || tagsById.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
return EquipmentReferenceMap.Build(references, tagsById, resolver);
|
||||
}
|
||||
|
||||
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
|
||||
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
|
||||
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
|
||||
@@ -400,8 +476,11 @@ public static class DeploymentArtifact
|
||||
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
|
||||
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → RawPath), shared by the
|
||||
// VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(root);
|
||||
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
|
||||
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
|
||||
{
|
||||
@@ -562,30 +641,22 @@ public static class DeploymentArtifact
|
||||
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
|
||||
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
|
||||
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
|
||||
/// plans agree. The reserved <c>{{equip}}</c> token in the joined Script's <c>SourceCode</c> is
|
||||
/// substituted with the owning equipment's tag base (derived from <paramref name="equipmentTags"/>'
|
||||
/// FullNames) BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the
|
||||
/// substituted source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
|
||||
/// plans agree. Each <c>{{equip}}/<RefName></c> in the joined Script's <c>SourceCode</c> is
|
||||
/// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
|
||||
/// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
|
||||
/// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
|
||||
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
|
||||
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
|
||||
/// ordering.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
|
||||
JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags)
|
||||
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
||||
{
|
||||
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentVirtualTagPlan>();
|
||||
|
||||
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
|
||||
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
|
||||
// scripts (equipment-relative tag paths). Derived from equipmentTags so the artifact-decode
|
||||
// base matches the composer's exactly.
|
||||
var baseByEquip = equipmentTags
|
||||
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
|
||||
StringComparer.Ordinal);
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
|
||||
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
@@ -626,11 +697,11 @@ public static class DeploymentArtifact
|
||||
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
|
||||
&& hEl.GetBoolean();
|
||||
|
||||
// Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting
|
||||
// refs, so both Expression and DependencyRefs are machine-specific — byte-parity with
|
||||
// AddressSpaceComposer.Compose.
|
||||
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
|
||||
// extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
|
||||
// with AddressSpaceComposer.Compose.
|
||||
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, baseByEquip.GetValueOrDefault(equipmentId!));
|
||||
source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap));
|
||||
|
||||
result.Add(new EquipmentVirtualTagPlan(
|
||||
VirtualTagId: virtualTagId!,
|
||||
@@ -659,11 +730,13 @@ public static class DeploymentArtifact
|
||||
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
|
||||
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
|
||||
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
|
||||
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
|
||||
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
|
||||
/// predicate source's <c>{{equip}}/<RefName></c> paths are substituted with the owning equipment's
|
||||
/// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
|
||||
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
|
||||
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
|
||||
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
|
||||
{
|
||||
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
|
||||
return Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
@@ -684,6 +757,8 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
|
||||
foreach (var el in alarmsArr.EnumerateArray())
|
||||
{
|
||||
@@ -710,9 +785,14 @@ public static class DeploymentArtifact
|
||||
|
||||
// Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour
|
||||
// so both sides emit the same set (byte-parity).
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source))
|
||||
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource))
|
||||
continue;
|
||||
|
||||
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE the
|
||||
// dependency merge — byte-parity with AddressSpaceComposer.Compose.
|
||||
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap));
|
||||
|
||||
result.Add(new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: scriptedAlarmId!,
|
||||
EquipmentId: equipmentId ?? string.Empty,
|
||||
|
||||
@@ -6,6 +6,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
|
||||
|
||||
public class EquipmentScriptPathsTests
|
||||
{
|
||||
// Effective-name → backing-tag RawPath map used across the substitution tests.
|
||||
private static readonly IReadOnlyDictionary<string, string> RefMap =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Speed"] = "Cell1/Modbus/Dev1/Speed",
|
||||
["Out"] = "Cell1/Modbus/Dev1/Out",
|
||||
["A"] = "Cell1/Modbus/Dev1/A",
|
||||
["B"] = "Cell1/Modbus/Dev1/B",
|
||||
// an override-named reference: the effective name differs from the backing tag leaf name.
|
||||
["MotorSpeed"] = "Cell1/Modbus/Dev1/raw_speed_01",
|
||||
};
|
||||
|
||||
// ---- ExtractAlarmDependencyRefs (scripted-alarm dep graph; byte-parity seam) ----
|
||||
|
||||
[Fact]
|
||||
@@ -33,123 +45,81 @@ public class EquipmentScriptPathsTests
|
||||
.ShouldBe(["Line.Temp"]);
|
||||
}
|
||||
|
||||
// ---- DeriveEquipmentBase ----
|
||||
// ---- SubstituteEquipmentToken (v3 reference-relative, slash syntax) ----
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_shared_prefix()
|
||||
public void SubstituteEquipmentToken_resolves_ref_to_rawpath_inside_GetTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"])
|
||||
.ShouldBe("TestMachine_001");
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/Speed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_when_prefixes_diverge()
|
||||
public void SubstituteEquipmentToken_resolves_ref_inside_SetVirtualTag()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"])
|
||||
.ShouldBeNull();
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.SetVirtualTag(\"{{equip}}/Out\", x)", RefMap)
|
||||
.ShouldBe("ctx.SetVirtualTag(\"Cell1/Modbus/Dev1/Out\", x)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_null_for_empty_sequence()
|
||||
public void SubstituteEquipmentToken_override_named_ref_resolves_to_its_backing_rawpath()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase([])
|
||||
.ShouldBeNull();
|
||||
// Effective name "MotorSpeed" (a DisplayNameOverride) resolves to the backing tag's RawPath whose
|
||||
// leaf name ("raw_speed_01") differs from the effective name — the override indirection.
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}/MotorSpeed\")", RefMap)
|
||||
.ShouldBe("ctx.GetTag(\"Cell1/Modbus/Dev1/raw_speed_01\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_returns_whole_value_when_no_dot()
|
||||
public void SubstituteEquipmentToken_leaves_unresolved_ref_intact_without_throwing()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase(["NoDot"])
|
||||
.ShouldBe("NoDot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveEquipmentBase_skips_null_empty_and_whitespace_entries()
|
||||
{
|
||||
EquipmentScriptPaths
|
||||
.DeriveEquipmentBase([null, "", " ", "TestMachine_001.A"])
|
||||
.ShouldBe("TestMachine_001");
|
||||
}
|
||||
|
||||
// ---- SubstituteEquipmentToken ----
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_inside_GetTag()
|
||||
{
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.GetTag(\"{{equip}}.Source\")", "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_inside_SetVirtualTag()
|
||||
{
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
"ctx.SetVirtualTag(\"{{equip}}.Out\", x)", "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.SetVirtualTag(\"TestMachine_001.Out\"");
|
||||
// An unknown reference name is left un-substituted (the validator rejects it first at deploy).
|
||||
var source = "ctx.GetTag(\"{{equip}}/Missing\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_comment_unchanged()
|
||||
{
|
||||
var source = "// uses {{equip}} here";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "// uses {{equip}}/Speed here";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
|
||||
{
|
||||
var source = "ctx.Logger.Information(\"{{equip}}\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "ctx.Logger.Information(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_substitutes_all_occurrences()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");";
|
||||
var source = "ctx.GetTag(\"{{equip}}/A\"); ctx.GetTag(\"{{equip}}/B\");";
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap);
|
||||
|
||||
var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001");
|
||||
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/A\")");
|
||||
result.ShouldContain("ctx.GetTag(\"Cell1/Modbus/Dev1/B\")");
|
||||
result.ShouldNotContain(EquipmentScriptPaths.EquipToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null()
|
||||
public void SubstituteEquipmentToken_is_identity_when_map_empty()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, null)
|
||||
.ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_equipBase_is_empty()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "")
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(
|
||||
source, new Dictionary<string, string>(StringComparer.Ordinal))
|
||||
.ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubstituteEquipmentToken_is_identity_when_token_absent()
|
||||
{
|
||||
var source = "ctx.GetTag(\"TestMachine_001.Source\")";
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
var source = "ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,10 +127,59 @@ public class EquipmentScriptPathsTests
|
||||
{
|
||||
// Documents the regex limitation: only "double-quote" literals are handled,
|
||||
// matching the existing seam extractors. A raw-string literal is left as-is.
|
||||
var source = "ctx.GetTag(\"\"\"{{equip}}.X\"\"\")";
|
||||
var source = "ctx.GetTag(\"\"\"{{equip}}/Speed\"\"\")";
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, RefMap).ShouldBe(source);
|
||||
}
|
||||
|
||||
EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
|
||||
.ShouldBe(source);
|
||||
// ---- ExtractEquipReferenceNames (script path-literal scoped) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_returns_distinct_refs_first_seen()
|
||||
{
|
||||
var source = "ctx.GetTag(\"{{equip}}/Speed\"); ctx.SetVirtualTag(\"{{equip}}/Out\", x); ctx.GetTag(\"{{equip}}/Speed\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Speed", "Out"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_ignores_token_in_comment_or_logger()
|
||||
{
|
||||
// Only ctx.GetTag/SetVirtualTag path literals are scanned — a token in a comment / logger string
|
||||
// is NOT reported (so it can never block a deploy).
|
||||
var source = "// {{equip}}/InComment\nctx.Logger.Information(\"{{equip}}/InLog\"); ctx.GetTag(\"{{equip}}/Real\");";
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBe(["Real"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNames_supports_ref_names_with_spaces()
|
||||
{
|
||||
// The whole post-prefix literal content is the reference name — so a space-bearing effective name
|
||||
// (valid raw name) is captured intact, matching what substitution resolves.
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames("ctx.GetTag(\"{{equip}}/My Tag\")")
|
||||
.ShouldBe(["My Tag"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("ctx.GetTag(\"Absolute/Path\")")]
|
||||
public void ExtractEquipReferenceNames_empty_when_no_token(string? source)
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNames(source).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractEquipReferenceNamesFromText (message-template free text) ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_captures_refs_bounded_by_whitespace()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("Temp {{equip}}/Speed exceeded on {{equip}}/Limit")
|
||||
.ShouldBe(["Speed", "Limit"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractEquipReferenceNamesFromText_empty_when_no_token()
|
||||
{
|
||||
EquipmentScriptPaths.ExtractEquipReferenceNamesFromText("plain message {Foo.Bar}").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- ExtractDependencyRefs ----
|
||||
@@ -197,7 +216,7 @@ public class EquipmentScriptPathsTests
|
||||
[Fact]
|
||||
public void ContainsEquipToken_true_when_token_present()
|
||||
{
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue();
|
||||
EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}/X\")").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +236,7 @@ public class EquipmentScriptPathsTests
|
||||
[Theory]
|
||||
[InlineData("return ctx.GetTag(\"TestMachine_020.TestChangingInt\").Value;", "TestMachine_020.TestChangingInt")]
|
||||
[InlineData(" return ctx . GetTag ( \"A.B\" ) . Value ; ", "A.B")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}.Speed\").Value;", "{{equip}}.Speed")]
|
||||
[InlineData("return ctx.GetTag(\"{{equip}}/Speed\").Value;", "{{equip}}/Speed")]
|
||||
public void TryParseRelayBody_accepts_exact_passthrough(string src, string expectedRef)
|
||||
{
|
||||
EquipmentScriptPaths.TryParseRelayBody(src, out var r).ShouldBeTrue();
|
||||
|
||||
@@ -214,6 +214,117 @@ public sealed class DraftValidatorTests
|
||||
ScriptId = "s-1",
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// ValidateEquipReferenceResolution — v3 WP4: {{equip}}/<RefName> must resolve to a reference
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
private static Script BuildScript(string id, string source) => new()
|
||||
{
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = $"h-{id}",
|
||||
};
|
||||
|
||||
private static Tag BuildRawTag(string tagId, string name) => new()
|
||||
{
|
||||
TagId = tagId, DeviceId = "dev-1", Name = name, DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference BuildReference(string id, string equipmentId, string tagId, string? overrideName = null) => new()
|
||||
{
|
||||
UnsTagReferenceId = id, EquipmentId = equipmentId, TagId = tagId, DisplayNameOverride = overrideName,
|
||||
};
|
||||
|
||||
/// <summary>A VirtualTag script using <c>{{equip}}/Speed</c> with no reference "Speed" on the equipment is
|
||||
/// a deploy error naming the equipment + the missing ref.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
};
|
||||
|
||||
var err = DraftValidator.Validate(draft).First(e => e.Code == "EquipReferenceUnresolved");
|
||||
err.Message.ShouldContain("eq-1");
|
||||
err.Message.ShouldContain("Speed");
|
||||
}
|
||||
|
||||
/// <summary>The same script resolves cleanly when the equipment has a reference whose effective name is
|
||||
/// "Speed" (backing raw tag's Name).</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolved_is_clean()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A DisplayNameOverride is the effective name: <c>{{equip}}/MotorSpeed</c> resolves to a
|
||||
/// reference overridden to "MotorSpeed" even though the backing raw tag's Name is "raw_speed".</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolves_by_override_name()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/MotorSpeed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "raw_speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-1", "tag-1", overrideName: "MotorSpeed")],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>A reference on a DIFFERENT equipment does not satisfy the token — resolution is per owning
|
||||
/// equipment.</summary>
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_does_not_resolve_across_equipment()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return ctx.GetTag(\"{{equip}}/Speed\").Value;")],
|
||||
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "vt")],
|
||||
Tags = [BuildRawTag("tag-1", "Speed")],
|
||||
UnsTagReferences = [BuildReference("ref-1", "eq-2", "tag-1")], // reference is on eq-2, not eq-1
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
/// <summary>Alarm message-template <c>{{equip}}/Temp</c> follows the same rule — an unresolved ref in the
|
||||
/// template is a deploy error.</summary>
|
||||
[Fact]
|
||||
public void ScriptedAlarm_message_template_equip_ref_unresolved_is_deploy_error()
|
||||
{
|
||||
var draft = new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
Scripts = [BuildScript("s-1", "return true;")],
|
||||
ScriptedAlarms =
|
||||
[
|
||||
new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "overheat",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "Too hot: {{equip}}/Temp",
|
||||
PredicateScriptId = "s-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipReferenceUnresolved");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public sealed class CtxCompletionGuardTests
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1.Speed" });
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Virtual tag", "Double", null));
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? f, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? f, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
|
||||
}
|
||||
|
||||
|
||||
+59
-26
@@ -5,63 +5,96 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the two {{equip}}-aware editor niceties: hover on a path literal containing the
|
||||
/// <c>{{equip}}</c> token shows an "equipment-relative" note (not the "not a known configured
|
||||
/// tag path" warning), and completion after <c>{{equip}}.</c> offers the attribute leaf names.
|
||||
/// v3 {{equip}}/<RefName> editor niceties: hover on a path literal containing the token shows an
|
||||
/// "equipment-relative" note (not the "not a known configured tag path" warning); completion after
|
||||
/// <c>{{equip}}/</c> offers the owning equipment's reference effective names; and the diagnostic flags an
|
||||
/// unresolved <c>{{equip}}/<RefName></c> exactly as the deploy gate does (editor accepts ⇔ publish
|
||||
/// accepts).
|
||||
/// </summary>
|
||||
public sealed class EquipTokenEditorTests
|
||||
{
|
||||
/// <summary>A fake catalog whose configured paths all share an object prefix, so the attribute
|
||||
/// leaf names ("Source", "Other") are what {{equip}}. completion should surface.</summary>
|
||||
/// <summary>A fake catalog whose equipment has two references — "Speed" and "Temp" — so those are the
|
||||
/// resolvable {{equip}}/<RefName> names for the completion + diagnostic.</summary>
|
||||
private sealed class FakeCatalog : IScriptTagCatalog
|
||||
{
|
||||
private static readonly string[] Paths = { "Mixer_001.Source", "Mixer_001.Other" };
|
||||
|
||||
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(Paths);
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Cell1/Modbus/Dev1/Speed" });
|
||||
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(null);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<string> leaves = new[] { "Source", "Other" };
|
||||
IEnumerable<string> names = new[] { "Speed", "Temp" };
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
leaves = leaves.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult<IReadOnlyList<string>>(leaves.ToList());
|
||||
names = names.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult<IReadOnlyList<string>>(names.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly ScriptAnalysisService Svc = new(new FakeCatalog());
|
||||
|
||||
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col)
|
||||
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
|
||||
private static async Task<IReadOnlyList<CompletionItem>> Items(string code, int line, int col, string? equipmentId)
|
||||
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col, equipmentId))).Items;
|
||||
|
||||
[Fact] public async Task Completion_after_equip_dot_offers_attribute_leaves()
|
||||
[Fact] public async Task Completion_after_equip_slash_offers_reference_names()
|
||||
{
|
||||
// caret right after the dot of "{{equip}}." — column 30 sits between the trailing dot and
|
||||
// the closing quote of ctx.GetTag("{{equip}}.").
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}.Source");
|
||||
// caret right after the slash of "{{equip}}/" — column 30 sits between the trailing slash and the
|
||||
// closing quote of ctx.GetTag("{{equip}}/").
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}/Speed");
|
||||
items.Select(i => i.Label).ShouldContain("{{equip}}/Temp");
|
||||
items.ShouldAllBe(i => i.Detail == "tag path");
|
||||
}
|
||||
|
||||
[Fact] public async Task Completion_after_equip_dot_inserts_the_full_token_qualified_leaf()
|
||||
[Fact] public async Task Completion_after_equip_slash_inserts_the_full_token_qualified_ref()
|
||||
{
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}.").Value;""", 1, 30);
|
||||
var source = items.FirstOrDefault(i => i.Label == "{{equip}}.Source");
|
||||
source.ShouldNotBeNull();
|
||||
source!.InsertText.ShouldBe("{{equip}}.Source");
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, "EQ-1");
|
||||
var speed = items.FirstOrDefault(i => i.Label == "{{equip}}/Speed");
|
||||
speed.ShouldNotBeNull();
|
||||
speed!.InsertText.ShouldBe("{{equip}}/Speed");
|
||||
}
|
||||
|
||||
[Fact] public async Task Completion_after_equip_slash_is_inert_without_equipment_context()
|
||||
{
|
||||
// Shared ScriptEdit page (no EquipmentId) can't resolve references → no equip completions.
|
||||
var items = await Items("""return ctx.GetTag("{{equip}}/").Value;""", 1, 30, null);
|
||||
items.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact] public async Task Hover_on_equip_token_literal_notes_equipment_relative()
|
||||
{
|
||||
// caret inside ctx.GetTag("{{equip}}.Source")
|
||||
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}.Source").Value;""", 1, 24))).Markdown;
|
||||
// caret inside ctx.GetTag("{{equip}}/Speed")
|
||||
var md = (await Svc.Hover(new HoverRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", 1, 24))).Markdown;
|
||||
md.ShouldNotBeNull();
|
||||
md!.ShouldContain("Equipment-relative");
|
||||
// the {{equip}} token must render literally (non-interpolated markdown segment)
|
||||
md.ShouldContain("{{equip}}");
|
||||
md.ShouldNotContain("Not a known");
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_flags_unresolved_equip_reference()
|
||||
{
|
||||
// "Missing" is not one of the equipment's references (Speed/Temp) → a diagnostic, matching the
|
||||
// deploy gate. Uses the equipment context via the request's EquipmentId.
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;""", "EQ-1"));
|
||||
resp.Markers.ShouldContain(m => m.Code == "OTSCRIPT_EQUIPREF" && m.Message.Contains("Missing"));
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_clean_for_resolved_equip_reference()
|
||||
{
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Speed").Value;""", "EQ-1"));
|
||||
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
|
||||
}
|
||||
|
||||
[Fact] public async Task Diagnostic_equipref_inert_without_equipment_context()
|
||||
{
|
||||
// No EquipmentId (shared ScriptEdit page) → the equip-ref check is inert (base markers only),
|
||||
// matching the deploy gate which only resolves per owning equipment.
|
||||
var resp = await Svc.DiagnoseAsync(
|
||||
new DiagnoseRequest("""return ctx.GetTag("{{equip}}/Missing").Value;"""));
|
||||
resp.Markers.ShouldNotContain(m => m.Code == "OTSCRIPT_EQUIPREF");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class HoverSignatureTests
|
||||
=> Task.FromResult<IReadOnlyList<string>>(System.Array.Empty<string>());
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult(path == "Line1.Speed" ? new ScriptTagInfo("Line1.Speed", "Tag", "Double", "MAIN-modbus") : null);
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
|
||||
}
|
||||
|
||||
|
||||
+54
-70
@@ -285,82 +285,66 @@ public sealed class ScriptTagCatalogTests
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEquipmentRelativeLeavesAsync — DB-backed tests
|
||||
// GetEquipmentReferenceNamesAsync — DB-backed tests (v3 reference-relative {{equip}}/<RefName>)
|
||||
// -----------------------------------------------------------------------
|
||||
// Seeded paths and their leaf derivation:
|
||||
// TAG-EQ → "Motor.Speed" → leaf "Speed"
|
||||
// TAG-SP → "DelmiaReceiver_001.DownloadPath" → leaf "DownloadPath"
|
||||
// VTAG-1 → "Computed" → NO dot → excluded
|
||||
// Effective name = UnsTagReference.DisplayNameOverride else the backing raw tag's Name.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>A null filter returns one leaf per dot-bearing path; the no-dot virtual-tag path is excluded.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_no_filter_returns_dot_bearing_leaves_only()
|
||||
/// <summary>Seeds equipment EQ-1 with two references: one to TAG-EQ (Name "Speed", no override) and one
|
||||
/// to TAG-SP (Name "DownloadPath") under a display override "Path".</summary>
|
||||
private static void SeedReferences(DbContextOptions<OtOpcUaConfigDbContext> opts)
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
|
||||
|
||||
// Leaves derived from the two dot-bearing paths in Seed().
|
||||
leaves.ShouldContain("Speed"); // from "Motor.Speed"
|
||||
leaves.ShouldContain("DownloadPath"); // from "DelmiaReceiver_001.DownloadPath"
|
||||
|
||||
// Virtual tag "Computed" has no dot — must be excluded.
|
||||
leaves.ShouldNotContain("Computed");
|
||||
}
|
||||
|
||||
/// <summary>A prefix filter narrows to leaves that start with the given string (e.g. "Sp" → "Speed", not "DownloadPath").</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_prefix_filter_narrows_to_matching_leaves()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("Sp", default);
|
||||
|
||||
leaves.ShouldContain("Speed");
|
||||
leaves.ShouldNotContain("DownloadPath");
|
||||
leaves.ShouldAllBe(l => l.StartsWith("Sp", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>The prefix filter is case-insensitive: "sp" (lowercase) still matches the leaf "Speed".</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync("sp", default);
|
||||
|
||||
leaves.ShouldContain("Speed");
|
||||
}
|
||||
|
||||
/// <summary>Two paths sharing the same leaf after different object prefixes produce one distinct entry.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentRelativeLeaves_duplicate_leaves_are_deduplicated()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
|
||||
// Add a second equipment tag whose FullName produces the same leaf "Speed" as TAG-EQ.
|
||||
using (var db = new OtOpcUaConfigDbContext(opts))
|
||||
using var db = new OtOpcUaConfigDbContext(opts);
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-EQ2",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "Speed2",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"Conveyor.Speed\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
UnsTagReferenceId = "REF-1", EquipmentId = "EQ-1", TagId = "TAG-EQ", DisplayNameOverride = null,
|
||||
});
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "REF-2", EquipmentId = "EQ-1", TagId = "TAG-SP", DisplayNameOverride = "Path",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var leaves = await catalog.GetEquipmentRelativeLeavesAsync(null, default);
|
||||
/// <summary>Returns the equipment's reference effective names — backing tag Name when no override, the
|
||||
/// override otherwise.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_returns_effective_names()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
// "Speed" must appear exactly once despite being produced by two different paths.
|
||||
leaves.Count(l => string.Equals(l, "Speed", StringComparison.Ordinal)).ShouldBe(1);
|
||||
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", null, default);
|
||||
|
||||
names.ShouldContain("Speed"); // TAG-EQ Name, no override
|
||||
names.ShouldContain("Path"); // TAG-SP under the "Path" override
|
||||
names.ShouldNotContain("DownloadPath"); // the raw Name is superseded by the override
|
||||
}
|
||||
|
||||
/// <summary>A prefix filter narrows (case-insensitively) to reference names that start with the prefix.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_prefix_filter_is_case_insensitive()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
var names = await catalog.GetEquipmentReferenceNamesAsync("EQ-1", "sp", default);
|
||||
|
||||
names.ShouldContain("Speed");
|
||||
names.ShouldNotContain("Path");
|
||||
}
|
||||
|
||||
/// <summary>An unknown equipment (or one with no references) yields an empty set; a blank id yields empty.</summary>
|
||||
[Fact]
|
||||
public async Task GetEquipmentReferenceNames_empty_for_unknown_or_blank_equipment()
|
||||
{
|
||||
var (catalog, opts) = Fresh();
|
||||
Seed(opts);
|
||||
SeedReferences(opts);
|
||||
|
||||
(await catalog.GetEquipmentReferenceNamesAsync("EQ-UNKNOWN", null, default)).ShouldBeEmpty();
|
||||
(await catalog.GetEquipmentReferenceNamesAsync("", null, default)).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@ public sealed class TagPathCompletionTests
|
||||
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult<ScriptTagInfo?>(null);
|
||||
|
||||
public Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
|
||||
// canned leaves derived from the canned paths above (substring after the first dot)
|
||||
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Speed", "Temp" });
|
||||
}
|
||||
|
||||
|
||||
+43
-82
@@ -8,29 +8,17 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the save-time equipment-relative-path guard on <see cref="UnsTreeService"/>: when a
|
||||
/// VirtualTag binds a script that uses the reserved <c>{{equip}}</c> token, the owning equipment
|
||||
/// must have a derivable tag base (≥1 driver tag, all sharing one object prefix). Otherwise the
|
||||
/// create/update is rejected with an error naming the equipment and the unresolvable token.
|
||||
/// v3 (M1): verifies the save-time equipment-relative-path guard on <see cref="UnsTreeService"/>. When a
|
||||
/// VirtualTag binds a script that uses <c>{{equip}}/<RefName></c>, every <c><RefName></c> must
|
||||
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names — otherwise the
|
||||
/// create/update is rejected with an error naming the equipment and the unresolved token. This mirrors the
|
||||
/// deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>): editor/authoring accept ⇔ publish accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reuses the shared <see cref="UnsTreeTestDb"/> InMemory fixture pattern: a uniquely-named
|
||||
/// InMemory database seeded with an area→line→equipment path plus a script, with driver tags
|
||||
/// added per-test so the base-derivation outcome is what each case isolates.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagEquipTokenValidationTests
|
||||
{
|
||||
private const string EquipBaseScript = "return ctx.GetTag(\"{{equip}}.X\");";
|
||||
private const string PlainScript = "return ctx.GetTag(\"TestMachine_001.X\");";
|
||||
|
||||
// v3 Batch-1: the equipment↔Tag binding was retired (Tag is Raw-only), so the {{equip}} token's
|
||||
// equipment-tag-derived base can never resolve — ValidateEquipTokenAsync now derives from an empty
|
||||
// tag set, so any {{equip}} script is rejected. The token-present/no-base rejection and the
|
||||
// no-token success cases stay live below; the tests that assert a DERIVABLE base (success) or the
|
||||
// divergent-prefix derivation logic are dark until per-equipment tag references return in Batch 3.
|
||||
private const string DarkUntilBatch3 =
|
||||
"v3 Batch-1: {{equip}} equipment-tag-derived base is dark — per-equipment tag references return in Batch 3.";
|
||||
private const string EquipRefScript = "return ctx.GetTag(\"{{equip}}/Speed\").Value;";
|
||||
private const string PlainScript = "return ctx.GetTag(\"Cell1/Modbus/Dev1/Speed\").Value;";
|
||||
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
@@ -40,10 +28,11 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an area→line→equipment path (equipment id <c>EQ-1</c>) plus one script whose source is
|
||||
/// <paramref name="scriptSource"/>, and optionally a driver tag whose <c>TagConfig</c> carries the
|
||||
/// supplied <c>FullName</c> so the equipment has a derivable base.
|
||||
/// <paramref name="scriptSource"/>, and — when <paramref name="referenceName"/> is set — a raw tag with
|
||||
/// that Name plus an <c>UnsTagReference</c> from EQ-1 to it (so the equipment has a reference whose
|
||||
/// effective name is <paramref name="referenceName"/>).
|
||||
/// </summary>
|
||||
private static void Seed(string dbName, string scriptSource, string? tagFullName)
|
||||
private static void Seed(string dbName, string scriptSource, string? referenceName)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
|
||||
@@ -64,17 +53,23 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
SourceHash = "hash-1",
|
||||
Language = "CSharp",
|
||||
});
|
||||
if (tagFullName is not null)
|
||||
if (referenceName is not null)
|
||||
{
|
||||
// v3: raw tag (no equipment binding). Kept so the dark {{equip}}-base tests still compile.
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-1",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "x",
|
||||
Name = referenceName,
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = $"{{\"FullName\":\"{tagFullName}\"}}",
|
||||
TagConfig = "{}",
|
||||
});
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "REF-1",
|
||||
EquipmentId = "EQ-1",
|
||||
TagId = "TAG-1",
|
||||
DisplayNameOverride = null,
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
@@ -102,12 +97,12 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
|
||||
// ----- Create -----
|
||||
|
||||
/// <summary>{{equip}} script + a driver tag whose FullName gives a base → create succeeds.</summary>
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Create_equip_token_with_derivable_base_succeeds()
|
||||
/// <summary>{{equip}}/Speed + a matching reference named "Speed" → create succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task Create_equip_ref_that_resolves_succeeds()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
|
||||
Seed(dbName, EquipRefScript, referenceName: "Speed");
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
|
||||
|
||||
@@ -115,30 +110,30 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>{{equip}} script + no driver tags → create rejected, error names equipment + token.</summary>
|
||||
/// <summary>{{equip}}/Speed + no matching reference → create rejected, error names equipment + token.</summary>
|
||||
[Fact]
|
||||
public async Task Create_equip_token_without_base_rejected()
|
||||
public async Task Create_equip_ref_unresolved_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipBaseScript, tagFullName: null);
|
||||
Seed(dbName, EquipRefScript, referenceName: null);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("EQ-1");
|
||||
result.Error.ShouldContain("{{equip}}");
|
||||
result.Error.ShouldContain("{{equip}}/Speed");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A script with no {{equip}} token → create succeeds regardless of tags.</summary>
|
||||
/// <summary>A script with no {{equip}} token → create succeeds regardless of references.</summary>
|
||||
[Fact]
|
||||
public async Task Create_no_equip_token_succeeds_without_tags()
|
||||
public async Task Create_no_equip_token_succeeds_without_references()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, PlainScript, tagFullName: null);
|
||||
Seed(dbName, PlainScript, referenceName: null);
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
|
||||
|
||||
@@ -146,48 +141,14 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// {{equip}} script + TWO driver tags whose FullNames have DIFFERENT object prefixes
|
||||
/// (no single base can be derived) → create rejected, error names equipment + token.
|
||||
/// </summary>
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Create_equip_token_with_divergent_prefixes_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = "TAG-2",
|
||||
DeviceId = "DEV-1",
|
||||
Name = "y",
|
||||
DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.Y\"}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var result = await service.CreateVirtualTagAsync("EQ-1", Input());
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("EQ-1");
|
||||
result.Error.ShouldContain("{{equip}}");
|
||||
|
||||
using var verifyDb = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verifyDb.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- Update -----
|
||||
|
||||
/// <summary>{{equip}} script + a derivable base → update succeeds.</summary>
|
||||
[Fact(Skip = DarkUntilBatch3)]
|
||||
public async Task Update_equip_token_with_derivable_base_succeeds()
|
||||
/// <summary>{{equip}}/Speed + a matching reference → update succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task Update_equip_ref_that_resolves_succeeds()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipBaseScript, tagFullName: "TestMachine_001.X");
|
||||
Seed(dbName, EquipRefScript, referenceName: "Speed");
|
||||
var rv = SeedVirtualTagAndRowVersion(dbName);
|
||||
|
||||
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
|
||||
@@ -196,12 +157,12 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>{{equip}} script + no driver tags → update rejected, error names equipment + token.</summary>
|
||||
/// <summary>{{equip}}/Speed + no matching reference → update rejected, error names equipment + token.</summary>
|
||||
[Fact]
|
||||
public async Task Update_equip_token_without_base_rejected()
|
||||
public async Task Update_equip_ref_unresolved_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipBaseScript, tagFullName: null);
|
||||
Seed(dbName, EquipRefScript, referenceName: null);
|
||||
var rv = SeedVirtualTagAndRowVersion(dbName);
|
||||
|
||||
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
|
||||
@@ -209,18 +170,18 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("EQ-1");
|
||||
result.Error.ShouldContain("{{equip}}");
|
||||
result.Error.ShouldContain("{{equip}}/Speed");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").Name.ShouldBe("computed");
|
||||
}
|
||||
|
||||
/// <summary>A script with no {{equip}} token → update succeeds regardless of tags.</summary>
|
||||
/// <summary>A script with no {{equip}} token → update succeeds regardless of references.</summary>
|
||||
[Fact]
|
||||
public async Task Update_no_equip_token_succeeds_without_tags()
|
||||
public async Task Update_no_equip_token_succeeds_without_references()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, PlainScript, tagFullName: null);
|
||||
Seed(dbName, PlainScript, referenceName: null);
|
||||
var rv = SeedVirtualTagAndRowVersion(dbName);
|
||||
|
||||
var result = await service.UpdateVirtualTagAsync("VTAG-1", Input(name: "renamed"), rv);
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// v3 WP4 byte-parity: the artifact-decode seam (<c>DeploymentArtifact.ParseComposition</c>) and the
|
||||
/// live compose seam (<c>AddressSpaceComposer.Compose</c>) must resolve <c>{{equip}}/<RefName></c>
|
||||
/// script paths to the SAME backing RawPath — both build the per-equipment reference map (effective name
|
||||
/// → RawPath) from <c>UnsTagReferences</c> + <c>Tags</c> + the raw topology via the shared
|
||||
/// <c>EquipmentReferenceMap</c> + <c>RawPathResolver</c>. Exercises both a VirtualTag script and a
|
||||
/// scripted-alarm predicate, plus an override-named reference.
|
||||
/// </summary>
|
||||
public sealed class DeploymentArtifactEquipRefParityTests
|
||||
{
|
||||
// Reference effective name "MotorSpeed" (a DisplayNameOverride) backing raw tag leaf "Speed" →
|
||||
// RawPath "Modbus/Dev1/Speed" (driver at cluster root, no folder / group).
|
||||
private const string VtScript = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 50;";
|
||||
private const string AlarmScript = "return System.Convert.ToDouble(ctx.GetTag(\"{{equip}}/MotorSpeed\").Value) > 90;";
|
||||
private const string ExpectedRawPath = "Modbus/Dev1/Speed";
|
||||
|
||||
private static DriverInstance Driver() => new()
|
||||
{
|
||||
DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus",
|
||||
DriverConfig = "{}", RawFolderId = null,
|
||||
};
|
||||
|
||||
private static Device Dev() => new()
|
||||
{
|
||||
DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}",
|
||||
};
|
||||
|
||||
private static Tag RawTag() => new()
|
||||
{
|
||||
TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static UnsTagReference Ref() => new()
|
||||
{
|
||||
UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_equip_ref_resolves_byte_parity()
|
||||
{
|
||||
var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = VtScript, SourceHash = "h" };
|
||||
var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" };
|
||||
|
||||
var composed = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
new[] { Driver() }, Array.Empty<ScriptedAlarm>(),
|
||||
virtualTags: new[] { vt }, scripts: new[] { script },
|
||||
unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() },
|
||||
rawFolders: Array.Empty<RawFolder>(), devices: new[] { Dev() }, tagGroups: Array.Empty<TagGroup>());
|
||||
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
|
||||
Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } },
|
||||
Scripts = new[] { new { ScriptId = "s-1", SourceCode = VtScript } },
|
||||
VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Boolean", ScriptId = "s-1" } },
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
// The token resolved to the backing RawPath on BOTH sides, and the plans are byte-identical.
|
||||
var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem();
|
||||
cPlan.Expression.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")");
|
||||
cPlan.Expression.ShouldNotContain("{{equip}}");
|
||||
cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath });
|
||||
|
||||
var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem();
|
||||
dPlan.ShouldBe(cPlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_predicate_equip_ref_resolves_byte_parity()
|
||||
{
|
||||
var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = AlarmScript, SourceHash = "h" };
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm",
|
||||
Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1",
|
||||
HistorizeToAveva = true, Retain = true, Enabled = true,
|
||||
};
|
||||
|
||||
var composed = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
new[] { Driver() }, new[] { alarm },
|
||||
virtualTags: Array.Empty<VirtualTag>(), scripts: new[] { script },
|
||||
unsTagReferences: new[] { Ref() }, tags: new[] { RawTag() },
|
||||
rawFolders: Array.Empty<RawFolder>(), devices: new[] { Dev() }, tagGroups: Array.Empty<TagGroup>());
|
||||
|
||||
var blob = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = (string?)null } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
|
||||
Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", Name = "Speed", DataType = "Float", TagConfig = "{}" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-1", DisplayNameOverride = "MotorSpeed" } },
|
||||
Scripts = new[] { new { ScriptId = "s-1", SourceCode = AlarmScript } },
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
new { ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "Hot", AlarmType = "LimitAlarm", Severity = 700, MessageTemplate = "hot", PredicateScriptId = "s-1", HistorizeToAveva = true, Retain = true, Enabled = true },
|
||||
},
|
||||
});
|
||||
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
var cPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
cPlan.PredicateSource.ShouldContain($"ctx.GetTag(\"{ExpectedRawPath}\")");
|
||||
cPlan.PredicateSource.ShouldNotContain("{{equip}}");
|
||||
cPlan.DependencyRefs.ShouldBe(new[] { ExpectedRawPath });
|
||||
|
||||
var dPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
|
||||
dPlan.ShouldBe(cPlan);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user