Merge pull request 'v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> resolution' (#471) from v3/batch3-uns-rework into master
v2-ci / build (push) Successful in 3m28s
v2-ci / unit-tests (push) Failing after 8m23s

This commit was merged in pull request #471.
This commit is contained in:
2026-07-16 08:59:01 -04:00
43 changed files with 3416 additions and 1525 deletions
+2
View File
@@ -217,6 +217,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor``RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`.
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
## Scripting / Script Editor
+52 -40
View File
@@ -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.
---
+39 -12
View File
@@ -74,26 +74,53 @@ changed by editing the area's cluster in the Area modal, which moves the
whole branch. There is no separate "served-by" concept and no migration —
it is simply `UnsArea.ClusterId`.
### Tags
### Tags — reference-only (v3)
Tags created on the equipment page are **equipment-bound** and require a driver
instance. The driver list on the Tags tab is scoped to the equipment's cluster
and to drivers on an **Equipment-kind** namespace, so a driver-less equipment
shows no eligible drivers until you bind one (edit the equipment on the Details
tab and pick a driver).
> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
> equipment's **Tags** tab holds **references** to those raw tags. The old
> driver-bound Tag modal on this tab is retired.
**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to
a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the
standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy
so you can select the attribute and set `TagConfig.FullName`. There is no
separate alias concept or `SystemPlatform`-kind namespace.
The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
**effective name**, the backing tag's **RawPath**, its inherited **DataType** and
**AccessLevel** (read-only — they come from the raw tag), and an editable
**display-name override**. The effective name is the override else the raw tag's
`Name`.
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
**scoped to the equipment's cluster** — cross-cluster tags are structurally
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
tags below"* menu to pull many at once.
**Effective-name uniqueness:** within an equipment the effective name must be
unique across references, VirtualTags, and ScriptedAlarms (they share the
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
both at authoring (a readable rejection naming the colliding source) and at the
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
what catches **rename-induced** collisions a raw rename produced after the
reference was authored, naming both colliding sources and the equipment.
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
(the error names the referencing equipment); renaming a raw tag or any ancestor
warns when a beneath-it tag is historized (no `historianTagname` override),
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
### Virtual tags
A virtual tag is bound to an equipment and driven by a **script** (no driver).
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
data type is chosen from the standard OPC UA type list and the Monaco script
editor is available inline.
editor is available inline. Scripts may read the equipment's references
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
resolves through the equipment's `UnsTagReference` rows (by effective name) to
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
[`ScriptEditor.md`](ScriptEditor.md).
### Galaxy tags
+89
View File
@@ -0,0 +1,89 @@
# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/<RefName>` reference-relative resolution
Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1WP4), executed via the
`/v3-batch 3` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on
docker-dev.
## What landed
- **Contracts** — `IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements,
WP1 consumes).
- **Wave A** (3 parallel agents):
- **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited
DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in
`PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped**
(structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD;
consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput`
dropped `DriverInstanceId`; deleted the retired equipment `TagModal`.
- **WP2** — `EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate
`UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name,
so it catches rename-induced collisions; ordinal; names both sources + equipment).
- **WP3** — `RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without**
a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode`
for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal).
- **Wave B** (1 integration agent):
- **WP4** — `{{equip}}/<RefName>` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase`
deleted; slash joint replaces the dot joint; new shared `EquipmentReferenceMap` (`equipmentId →
effectiveName → RawPath`) built identically at both compose seams (`AddressSpaceComposer` +
`DeploymentArtifact`, byte-parity). Unresolved-ref deploy error (`EquipReferenceUnresolved`) covering VT
scripts, SA predicates, and SA message-template tokens; authoring guard (`ValidateEquipTokenAsync`,
the Wave-A M1 fix); Monaco `{{equip}}/` completion of reference effective names + unresolved diagnostic
(`OTSCRIPT_EQUIPREF`), `EquipmentId` threaded request→model→JS.
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; Commons **309**,
Configuration **121**, AdminUI **659**, OpcUaServer **335/4-skip**, Runtime **361/42-skip** (the skips are
the Batch-1 dark-address-space tests Batch 4 un-skips).
## Reviewer findings fixed in-branch
- **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the
NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping
structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation.
L1 (misleading concurrency comment) fixed.
- **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy
invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs
validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact
+ folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null).
- **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not
the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths
in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4.
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt on Batch-3 code)
Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is
demonstrable. (Hand-seeded equipment surfaced the Batch-1 `EquipmentIdNotDerived` invariant — the canonical
`EQ-<hash>` id was applied before the gate.)
1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*;
only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy
expansion; a device **"Select all tags below"** pulled tags across a collapsed group → **4 references in
one commit**. Rows show effective name / RawPath (incl. deep `opcua1/plc/OpcPlc/Telemetry/Basic/…`
ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated
`HR200 → MainPressure`, RawPath unchanged.
2. **Collision***authoring:* setting a reference override to an existing VirtualTag's name → red banner
**"effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'"** (not persisted).
*rename-induced:* renamed a raw tag so two references collide (allowed at rename) → deploy **422**
`[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…',
reference '…'`.
3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled
`{{equip}}/MainPresure` deploys **422** `[EquipReferenceUnresolved] … has no reference named 'MainPresure'`.
Monaco: red squiggle + hover *"'{{equip}}/MainPresure' does not resolve — … no reference named
'MainPresure' … (OTSCRIPT_EQUIPREF)"*; typing `{{equip}}/` completes the four reference **effective**
names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32).
4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced
+ named in a script literal → **"Renamed — with warnings"** listing all three (historized fork,
UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming
an unrelated driver → **no warning dialog**. (Note: direct *tag* rename flows through `UpdateTagAsync`
which has no warnings surface — warnings fire on container renames affecting descendants; documented
follow-up if per-tag-rename warnings are wanted.)
5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId`
(+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**.
## Docs
`docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the
slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph.
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
@@ -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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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>&lt;RefName&gt;</c>).
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/&lt;RefName&gt;</c> — replacing
/// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c>&lt;RefName&gt;</c> is a reference's effective name
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
/// <c>&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</c> is treated as a single reference: <c>&lt;RefName&gt;</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>&lt;RefName&gt;</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}}/&lt;RefName&gt;</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>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</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>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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>&lt;RefName&gt;</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);
@@ -78,17 +78,9 @@ else
</InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in _driverOptions)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div>
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label>
@@ -138,38 +130,48 @@ else
}
else if (_activeTab == "tags")
{
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
override is the only per-reference editable field. *@
<div class="d-flex justify-content-end align-items-center gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button>
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
</div>
@if (!string.IsNullOrWhiteSpace(_tagError))
@if (!string.IsNullOrWhiteSpace(_refError))
{
<div class="text-danger small mb-2">@_tagError</div>
<div class="text-danger small mb-2">@_refError</div>
}
@if (_tags is null)
@if (_refs is null)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (_tags.Count == 0)
else if (_refs.Count == 0)
{
<p class="text-muted">No tags yet.</p>
<p class="text-muted">No tag references yet.</p>
}
else
{
<table class="table table-sm">
<table class="table table-sm align-middle">
<thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr>
<tr>
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
<th>Display-name override</th><th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in _tags)
@foreach (var r in _refs)
{
<tr @key="t.TagId">
<td>@t.Name</td>
<td class="mono">@t.DriverInstanceId</td>
<td>@t.DataType</td>
<td>@t.AccessLevel</td>
<td class="text-end">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button>
<tr @key="r.UnsTagReferenceId">
<td>@r.EffectiveName</td>
<td class="mono small">@r.RawPath</td>
<td>@r.DataType</td>
<td>@r.AccessLevel</td>
<td>
<input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
placeholder="(uses raw name)" />
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
</td>
</tr>
}
@@ -177,9 +179,8 @@ else
</table>
}
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions"
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
<AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
}
else if (_activeTab == "vtags")
{
@@ -290,15 +291,13 @@ else
private EquipmentEditDto? _equipment;
private FormModel _form = new();
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). ---
private IReadOnlyList<EquipmentTagRow>? _tags;
private string? _tagError;
private bool _tagModalVisible;
private bool _tagModalIsNew;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
// spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
private IReadOnlyList<EquipmentReferenceRow>? _refs;
private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
private string? _refError;
private bool _refModalVisible;
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
@@ -329,54 +328,48 @@ else
{
_activeTab = tab;
if (IsNew) { return; }
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); }
if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
}
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) ---
// --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
private async Task ReloadTagsAsync()
private async Task ReloadReferencesAsync()
{
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!);
_refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
_overrideEdits.Clear();
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
}
private async Task OpenAddTag()
private void OpenAddReference()
{
_tagError = null;
_tagModalIsNew = true;
_tagModalExisting = null;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
_refError = null;
_refModalVisible = true;
}
private async Task OpenEditTag(string tagId)
private async Task OnReferencesCommittedAsync()
{
_tagError = null;
var dto = await Svc.LoadTagAsync(tagId);
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_tagModalIsNew = false;
_tagModalExisting = dto;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
_refModalVisible = false;
await ReloadReferencesAsync();
}
private async Task OnTagSavedAsync()
private async Task SaveOverride(EquipmentReferenceRow r)
{
_tagModalVisible = false;
await ReloadTagsAsync();
_refError = null;
var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
}
private async Task DeleteTag(string tagId)
private async Task RemoveReference(EquipmentReferenceRow r)
{
_tagModalVisible = false;
_tagError = null;
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete.
var dto = await Svc.LoadTagAsync(tagId);
if (dto is null) { await ReloadTagsAsync(); return; }
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
if (r.Ok) { await ReloadTagsAsync(); }
else { _tagError = r.Error; }
_refError = null;
var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
}
// --- Virtual Tags tab handlers ---
@@ -478,7 +471,7 @@ else
// path lands on Details because the field initializes to "details" and a fresh page instance
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
_tags = null;
_refs = null;
_vtags = null;
_alarms = null;
if (!IsNew)
@@ -489,7 +482,6 @@ else
LoadFormFrom(_equipment);
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
_lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
}
}
else
@@ -497,7 +489,6 @@ else
_form = new FormModel { UnsLineId = LineId ?? "" };
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
_lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
}
_loading = false;
}
@@ -510,7 +501,6 @@ else
Name = e.Name,
MachineCode = e.MachineCode,
UnsLineId = e.UnsLineId,
DriverInstanceId = e.DriverInstanceId,
ZTag = e.ZTag,
SAPID = e.SAPID,
Manufacturer = e.Manufacturer,
@@ -536,7 +526,6 @@ else
_form.Name,
_form.MachineCode,
_form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag,
_form.SAPID,
_form.Manufacturer,
@@ -582,7 +571,6 @@ else
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
@@ -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}}/&lt;RefName&gt;</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;
@@ -69,6 +69,28 @@
/// </summary>
[Parameter] public EventCallback OnRootsChanged { get; set; }
// ---------------------------------------------------------------------------
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
// ---------------------------------------------------------------------------
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
[Parameter] public bool PickerMode { get; set; }
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
[Parameter] public EventCallback OnSelectionChanged { get; set; }
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
// --- Configure-driver modal (edit) ---
private bool _driverCfgVisible;
private string? _driverCfgId;
@@ -198,6 +220,12 @@
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{
<span class="d-inline-flex align-items-center gap-1">
@if (PickerMode && node.Kind == RawNodeKind.Tag)
{
<input type="checkbox" class="form-check-input me-1"
checked="@IsTagSelected(node)"
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
}
<span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted)
@@ -270,17 +298,67 @@
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node) => node.Kind switch
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
// Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
// affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
if (PickerMode)
{
return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
? PickerContainerMenu(node)
: Array.Empty<ContextMenuItem>();
}
return node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
}
// --- Picker-mode menu + selection helpers ---
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
{
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
};
private bool IsTagSelected(RawNode node) =>
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
private async Task ToggleTagSelection(RawNode node, bool selected)
{
if (SelectedTagIds is null || node.EntityId is null) { return; }
if (selected) { SelectedTagIds.Add(node.EntityId); }
else { SelectedTagIds.Remove(node.EntityId); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task SelectAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Add(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task ClearAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Remove(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
@@ -0,0 +1,125 @@
@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to
multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural —
IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any
other cluster are simply unreachable through the tree, not merely hidden. On commit the selected
tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add tag references</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Pick raw tags to reference under this equipment. The tree is scoped to the
equipment's cluster. Tick individual tags, or use a device / tag-group's
<em>“Select all tags below”</em> menu to pull many at once.
</p>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (_roots.Count == 0)
{
<p class="text-muted">This equipment does not resolve to a cluster, so there are no raw tags to reference.</p>
}
else
{
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<RawTree Roots="_roots" PickerMode="true" SelectedTagIds="_selected"
OnSelectionChanged="OnSelectionChanged"
ResolveDescendantTagIds="ResolveDescendantsAsync" />
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@(_busy || _selected.Count == 0)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add @_selected.Count reference@(_selected.Count == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The equipment the selected raw tags are referenced under.</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>Raised after references are committed so the host can reload its Tags list and close.</summary>
[Parameter] public EventCallback OnCommitted { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
private readonly HashSet<string> _selected = new(StringComparer.Ordinal);
private bool _busy;
private bool _loading;
private string? _error;
private bool _wasVisible;
protected override async Task OnParametersSetAsync()
{
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
if (Visible && !_wasVisible)
{
_selected.Clear();
_error = null;
_roots = Array.Empty<RawNode>();
_loading = true;
StateHasChanged();
var root = string.IsNullOrEmpty(EquipmentId)
? null
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
_loading = false;
}
_wasVisible = Visible;
}
private Task<IReadOnlyList<string>> ResolveDescendantsAsync(RawNode node) =>
Svc.LoadDescendantTagIdsAsync(node);
// RawTree mutates _selected in place; this just re-renders the footer count.
private void OnSelectionChanged() => StateHasChanged();
private async Task CommitAsync()
{
if (_selected.Count == 0) { return; }
_busy = true;
_error = null;
try
{
var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList());
if (res.Ok) { await OnCommitted.InvokeAsync(); }
else { _error = res.Error; }
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
}
@@ -2,9 +2,10 @@
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
Required header columns (in order): Name, MachineCode, UnsLineId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc
@@ -21,7 +22,7 @@
<div class="modal-body">
<p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
<span class="mono">Name, MachineCode, UnsLineId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates).
@@ -29,7 +30,7 @@
<textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
@if (!string.IsNullOrWhiteSpace(_parseError))
@@ -72,8 +73,8 @@
}
@code {
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
// Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
@@ -133,7 +134,7 @@
/// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
/// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary>
@@ -169,11 +170,10 @@
Name: parts[0],
MachineCode: parts[1],
UnsLineId: parts[2],
DriverInstanceId: NullIfEmpty(parts, 3),
ZTag: NullIfEmpty(parts, 4),
SAPID: NullIfEmpty(parts, 5),
Manufacturer: NullIfEmpty(parts, 6),
Model: NullIfEmpty(parts, 7),
ZTag: NullIfEmpty(parts, 3),
SAPID: NullIfEmpty(parts, 4),
Manufacturer: NullIfEmpty(parts, 5),
Model: NullIfEmpty(parts, 6),
SerialNumber: null,
HardwareRevision: null,
SoftwareRevision: null,
@@ -1,557 +0,0 @@
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-id">TagId</label>
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
<ValidationMessage For="@(() => _form.TagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-name">Name</label>
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var d in Drivers)
{
<option value="@d.Id">@d.Display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-dtype">Data type</label>
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-access">Access level</label>
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label">Tag config</label>
@{
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
}
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="form-text">Pick a driver above to configure this tag.</div>
}
else if (IsGalaxyDriver)
{
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="@(() => _showGalaxyPicker = true)">
Browse Galaxy
</button>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
<div class="form-text">
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
</div>
@if (_showGalaxyPicker)
{
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
Title="Galaxy address"
CurrentAddress="@_galaxyAddress"
OnPickAddress="@OnGalaxyAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
GetConfigJson="@(() => SelectedDriverConfig)" />
</DriverTagPicker>
}
}
else if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
}
else
{
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
`isHistorized` / `historianTagname`, read by AddressSpaceComposer.ExtractTagHistorize),
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
composes with the typed editor's driver-specific fields (both preserve unknown keys).
Shown for EVERY driver once one is picked. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">History</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-historize"
checked="@_historizeState.IsHistorized"
@onchange="OnHistorizeChanged" />
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
</div>
<div class="form-text">
When checked, the server serves OPC UA HistoryRead over this tag's value
from the configured historian.
</div>
@if (_historizeState.IsHistorized)
{
<div class="mt-2">
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
value="@_historizeState.HistorianTagname"
@onchange="OnHistorianTagnameChanged" />
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
</div>
}
</div>
}
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
with the typed editor's driver-specific fields (both preserve unknown keys). When
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
driver once one is picked — same place/pattern as the historize control above. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">Array</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-is-array"
checked="@_arrayState.IsArray"
@onchange="OnIsArrayChanged" />
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
</div>
<div class="form-text">
When checked, the server materialises this tag as a fixed-length 1-D array node.
</div>
@if (_arrayState.IsArray)
{
<div class="mt-2">
<label class="form-label" for="tag-array-length">Array length</label>
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
value="@_arrayState.ArrayLength"
@onchange="OnArrayLengthChanged" />
<div class="form-text">Number of elements (must be a positive whole number).</div>
</div>
}
</div>
}
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
@if (HasNativeAlarm)
{
<div class="mb-3">
<label class="form-label">Native alarm</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
checked="@AlarmHistorizeToAveva"
@onchange="OnAlarmHistorizeChanged" />
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
</div>
<div class="form-text">
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
(the live alerts feed is unaffected). Checked is the default.
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// True when the attribute most-recently selected in the Galaxy picker is itself an alarm
// (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the
// TagConfig (when none is present) so the operator can author the alarm in one pass.
private bool _galaxyPickedIsAlarm;
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
private TagArrayConfig.ArrayState _arrayState;
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
private string SelectedDriverConfig
{
get
{
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
}
}
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
private void OnDriverChanged()
{
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
_galaxyPickedIsAlarm = false;
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Likewise the reset TagConfig carries no array intent.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
//
// When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default
// native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's
// "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel
// .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName.
private void OnGalaxyAddressPicked(string address)
{
_galaxyAddress = address;
// Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING
// TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm`
// object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array
// intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom
// as the historize/array merge seams.
var config = TagConfigJson.SetFullName(_form.TagConfig, address);
_form.TagConfig = _galaxyPickedIsAlarm
? NativeAlarmModel.SeedDefaultAlarm(config)
: config;
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = SelectedDriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
};
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
private string? _nativeAlarmSource;
private NativeAlarmModel NativeAlarm
{
get
{
if (_nativeAlarmSource != _form.TagConfig)
{
_nativeAlarmSource = _form.TagConfig;
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
}
return _nativeAlarm;
}
}
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
model.HistorizeToAveva = e.Value is true ? null : false;
_form.TagConfig = model.ToJson();
}
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
private void OnHistorizeChanged(ChangeEventArgs e)
{
var isHistorized = e.Value is true;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
private void OnHistorianTagnameChanged(ChangeEventArgs e)
{
var tagname = e.Value?.ToString() ?? string.Empty;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
private void OnIsArrayChanged(ChangeEventArgs e)
{
var isArray = e.Value is true;
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
// an array with no positive length).
private void OnArrayLengthChanged(ChangeEventArgs e)
{
var length = ParsePositiveLength(e.Value?.ToString());
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
private static uint? ParsePositiveLength(string? raw)
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel();
}
else if (Existing is not null)
{
_form = new FormModel
{
TagId = Existing.TagId,
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
WriteIdempotent = Existing.WriteIdempotent,
PollGroupId = Existing.PollGroupId,
TagConfig = Existing.TagConfig,
};
}
_error = null;
_showGalaxyPicker = false;
_galaxyPickedIsAlarm = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Seed the array controls from any existing root isArray/arrayLength keys.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
private static string ReadFullName(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return "";
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == JsonValueKind.String
? fn.GetString() ?? ""
: "";
}
catch (JsonException)
{
return "";
}
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
// Client-side per-driver config validation (the typed editor's Validate()), so a blank
// required field is caught here rather than silently saving and failing at deploy/connect.
var configError = TagConfigValidator.Validate(SelectedDriverType, _form.TagConfig);
if (configError is not null)
{
_error = configError;
return;
}
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
// per-driver config validation above so a missing length is caught here rather than at deploy.
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
if (arrayError is not null)
{
_error = arrayError;
return;
}
var input = new TagInput(
_form.TagId,
_form.Name,
_form.DriverInstanceId,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
_form.PollGroupId,
_form.TagConfig);
var result = IsNew
? await Svc.CreateTagAsync(EquipmentId!, input)
: await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}
@@ -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>
@@ -49,6 +49,10 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
// v3 Batch 3 (WP2): authoring-time UNS effective-name uniqueness guard. Consumed by
// UnsTreeService's reference/VirtualTag/ScriptedAlarm mutations (WP1); mirrors the
// deploy-time DraftValidator UnsEffectiveNameCollision rule.
services.AddScoped<IEffectiveNameGuard, EffectiveNameGuard>();
// WP5 CSV export enumeration (read-only; does not extend the closed IRawTreeService prelude).
services.AddScoped<RawTagCsvExportReader>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
@@ -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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</c> whose <c>&lt;RefName&gt;</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);
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Default <see cref="IEffectiveNameGuard"/>. Loads the owning equipment's current effective-name
/// set — references (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>),
/// VirtualTags (<c>Name</c>), and ScriptedAlarms (<c>Name</c>) — and reports the first collision
/// with a proposed name. Comparison is <see cref="StringComparer.Ordinal"/> to match the
/// <c>{EquipmentId}/{EffectiveName}</c> NodeId semantics and the deploy-time
/// <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule.
/// </summary>
/// <remarks>
/// Each call creates and disposes its own pooled context — the same per-call pattern
/// <see cref="UnsTreeService"/> uses — so the guard is safe to register <c>Scoped</c>.
/// </remarks>
public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IEffectiveNameGuard
{
/// <inheritdoc />
public async Task<string?> CheckAsync(
string equipmentId,
string proposedEffectiveName,
EffectiveNameSourceKind kind,
string? excludeSourceId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// References for this equipment. The effective name is the display-name override else the
// backing raw tag's current Name, so the two sets (references + tags) are loaded and joined
// in memory — the per-equipment set is small and this mirrors the deploy rule exactly
// (a reference whose backing tag is missing contributes no effective name, so it is skipped).
var references = await db.UnsTagReferences
.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList();
var tagNameById = (await db.Tags
.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToListAsync(ct))
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
foreach (var r in references)
{
if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId))
continue;
var effective = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (Collides(effective, proposedEffectiveName))
return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId);
}
var virtualTags = await db.VirtualTags
.AsNoTracking()
.Where(v => v.EquipmentId == equipmentId)
.Select(v => new { v.VirtualTagId, v.Name })
.ToListAsync(ct);
foreach (var v in virtualTags)
{
if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId))
continue;
if (Collides(v.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId);
}
var scriptedAlarms = await db.ScriptedAlarms
.AsNoTracking()
.Where(a => a.EquipmentId == equipmentId)
.Select(a => new { a.ScriptedAlarmId, a.Name })
.ToListAsync(ct);
foreach (var a in scriptedAlarms)
{
if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId))
continue;
if (Collides(a.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId);
}
return null;
}
/// <summary>True when a row is the self-row being edited — same kind AND same logical id — so it
/// is excluded from the collision set (a rename / override-change of a row cannot collide with itself).</summary>
private static bool IsSelf(
EffectiveNameSourceKind rowKind,
string rowId,
EffectiveNameSourceKind editedKind,
string? excludeSourceId) =>
excludeSourceId is not null
&& rowKind == editedKind
&& string.Equals(rowId, excludeSourceId, StringComparison.Ordinal);
private static bool Collides(string? existing, string proposed) =>
existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal);
private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) =>
$"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'";
}
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
/// <summary>
/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
/// row projects a <see cref="Configuration.Entities.UnsTagReference"/> together with the backing raw
/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
/// <c>DisplayNameOverride</c> when set, else the backing raw tag's <c>Name</c>. <c>RowVersion</c> is the
/// reference row's concurrency token, echoed on the override-set / remove mutations.
/// </summary>
/// <param name="UnsTagReferenceId">The reference's stable logical id (the mutation key).</param>
/// <param name="EffectiveName">Override else the raw tag's Name — the UNS leaf name.</param>
/// <param name="RawPath">The backing raw tag's computed RawPath (folder/driver/device/group/tag).</param>
/// <param name="DataType">Inherited OPC UA built-in type name from the raw tag (read-only).</param>
/// <param name="AccessLevel">Inherited access level from the raw tag (read-only).</param>
/// <param name="DisplayNameOverride">The optional display-name override; <c>null</c> = use the raw name.</param>
/// <param name="SortOrder">Sibling display ordering within the equipment's Tags list.</param>
/// <param name="RowVersion">The reference row's optimistic-concurrency token.</param>
public sealed record EquipmentReferenceRow(
string UnsTagReferenceId,
string EffectiveName,
string RawPath,
string DataType,
TagAccessLevel AccessLevel,
string? DisplayNameOverride,
int SortOrder,
byte[] RowVersion);
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
string Name,
string MachineCode,
string UnsLineId,
string? DriverInstanceId,
string? ZTag,
string? SAPID,
string? Manufacturer,
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Which authoring surface a proposed effective name comes from. Used to word the collision
/// error and to exclude the correct same-kind self-row on an edit (rename / override change).
/// </summary>
public enum EffectiveNameSourceKind
{
/// <summary>A <see cref="Configuration.Entities.UnsTagReference"/> (effective name =
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>).</summary>
Reference,
/// <summary>An equipment-bound <see cref="Configuration.Entities.VirtualTag"/> (effective name = <c>Name</c>).</summary>
VirtualTag,
/// <summary>An equipment-bound <see cref="Configuration.Entities.ScriptedAlarm"/> (effective name = <c>Name</c>).</summary>
ScriptedAlarm,
}
/// <summary>
/// v3 Batch 3 authoring-time guard for the UNS effective-name uniqueness rule. Within an
/// equipment the EFFECTIVE leaf name must be unique across its <c>UnsTagReferences</c>
/// (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>), its <c>VirtualTags</c>
/// (<c>Name</c>), and its <c>ScriptedAlarms</c> (<c>Name</c>) — the three share the equipment's
/// UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>). This is the authoring mirror of the
/// deploy-time <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule; comparison is
/// <see cref="StringComparer.Ordinal"/> to match NodeId semantics and the validator.
/// </summary>
/// <remarks>
/// Injectable (registered <c>Scoped</c>, its own pooled context per call — the same pattern
/// <c>UnsTreeService</c> uses). Consumed by <c>UnsTreeService</c>'s reference / virtual-tag /
/// scripted-alarm mutations, which call <see cref="CheckAsync"/> before persisting and surface a
/// non-null result as the mutation's readable failure. The deploy gate remains the backstop for
/// rename-induced collisions the authoring check never saw.
/// </remarks>
public interface IEffectiveNameGuard
{
/// <summary>
/// Tests whether <paramref name="proposedEffectiveName"/> would collide with an existing
/// effective name within <paramref name="equipmentId"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment's logical id.</param>
/// <param name="proposedEffectiveName">The effective name to be authored (override else raw name for a reference; Name for a VT/alarm).</param>
/// <param name="kind">Which surface the proposed name is for (words the error; identifies the self-row kind to exclude).</param>
/// <param name="excludeSourceId">
/// On an edit, the logical id of the row being changed (its <c>UnsTagReferenceId</c> /
/// <c>VirtualTagId</c> / <c>ScriptedAlarmId</c>) so it is excluded from the collision set;
/// <see langword="null"/> on a create.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// <see langword="null"/> when the name is free; otherwise a readable error naming the
/// colliding existing source and the equipment.
/// </returns>
Task<string?> CheckAsync(
string equipmentId,
string proposedEffectiveName,
EffectiveNameSourceKind kind,
string? excludeSourceId,
CancellationToken ct = default);
}
@@ -139,6 +139,82 @@ public interface IUnsTreeService
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
/// equipment has no references.
/// </summary>
/// <param name="equipmentId">The equipment whose references to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's reference rows; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
/// override on add) must be unique within the equipment across references, VirtualTags, and
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
/// the equipment is rejected. The batch is all-or-nothing.
/// </summary>
/// <param name="equipmentId">The referencing equipment.</param>
/// <param name="tagIds">The raw tag ids to reference.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
/// <summary>
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
/// </summary>
/// <param name="unsTagReferenceId">The reference to remove.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
/// optimistic concurrency.
/// </summary>
/// <param name="unsTagReferenceId">The reference to update.</param>
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a name-collision failure, or a concurrency failure.</returns>
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
/// </summary>
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
/// <param name="ct">A token to cancel the query.</param>
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
@@ -284,24 +360,22 @@ public interface IUnsTreeService
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
/// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
/// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns>
/// <returns>Success, a missing-line failure, or a duplicate-MachineCode failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the cluster
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// exist is an error; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier
/// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
@@ -309,16 +383,16 @@ public interface IUnsTreeService
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010
/// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
/// no longer binds a driver, so there is no driver-cluster guard.)
/// </summary>
/// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
/// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
@@ -1282,24 +1282,41 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and
/// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname /
/// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here.
/// A raw tag affected by a rename, carrying the fields needed to (a) read its platform intents and
/// (b) recompute its OLD RawPath (still the in-DB state at warning time — the rename is not yet saved).
/// </summary>
private readonly record struct AffectedTag(
string TagId, string TagConfig, string DeviceId, string? TagGroupId, string Name);
/// <summary>
/// Builds the non-blocking rename warnings answerable from this batch's schema for the tags beneath the
/// renamed node (whose RawPath — and thus historian tagname / UNS projection path / script literal —
/// changes with the rename): (a) historized tags WITHOUT a <c>historianTagname</c> override (their
/// history forks, since the default tagname is the moving RawPath; pinned tags are unaffected),
/// (b) equipment-referenced tags, and (c) tags whose OLD RawPath appears as a literal substring in any
/// <see cref="Script"/> body (tolerates false positives by design — no AST).
/// </summary>
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct)
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var warnings = new List<string>();
if (tags.Count == 0) return warnings;
var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized);
if (historized > 0)
// (a) Historized WITHOUT a historianTagname override: the default tagname is the RawPath, which is
// moving, so history forks on the next deploy. A tag WITH the override is pinned and does not warn.
var forking = tags.Count(t =>
{
warnings.Add(historized == 1
? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path."
: $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths.");
var intent = TagConfigIntent.Parse(t.TagConfig);
return intent.IsHistorized && intent.HistorianTagname is null;
});
if (forking > 0)
{
warnings.Add(forking == 1
? "1 historized tag beneath this node has no historianTagname override; its history forks to a new tagname when its RawPath changes on the next deploy. Set a historianTagname to pin it."
: $"{forking} historized tags beneath this node have no historianTagname override; their history forks to new tagnames when their RawPaths change on the next deploy. Set a historianTagname to pin them.");
}
// (b) UNS-referenced: name the referencing equipment.
var tagIds = tags.Select(t => t.TagId).ToList();
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
.Where(r => tagIds.Contains(r.TagId))
@@ -1313,12 +1330,94 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
}
// Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append
// a "N script(s) reference tags beneath this node" warning to this same list.
// (c) Script literals: substring-scan every Script body for the affected tags' OLD RawPaths.
var scriptWarning = await BuildScriptReferenceWarningAsync(db, tags, ct);
if (scriptWarning is not null) warnings.Add(scriptWarning);
return warnings;
}
/// <summary>
/// Scans every <see cref="Script"/> body for the OLD RawPath of any affected tag as a plain ordinal
/// substring (no AST — false positives tolerated by design; <c>{{equip}}</c>-relative refs are NOT
/// scanned, they resolve through references and the deploy gate catches breakage). Returns a warning
/// naming the matched scripts, or <see langword="null"/> when none match.
/// </summary>
private static async Task<string?> BuildScriptReferenceWarningAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var oldPaths = await ComputeOldRawPathsAsync(db, tags, ct);
if (oldPaths.Count == 0) return null;
var scripts = await db.Scripts.AsNoTracking()
.Select(s => new { s.ScriptId, s.Name, s.SourceCode })
.ToListAsync(ct);
var matched = scripts
.Where(s => oldPaths.Any(p => s.SourceCode.Contains(p, StringComparison.Ordinal)))
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => s.Name)
.ToList();
if (matched.Count == 0) return null;
var names = string.Join(", ", matched.Select(n => $"'{n}'"));
return matched.Count == 1
? $"1 script ({names}) references a tag beneath this node by its raw path; the reference will break unless the script is updated."
: $"{matched.Count} scripts ({names}) reference tags beneath this node by their raw paths; the references will break unless the scripts are updated.";
}
/// <summary>
/// Recomputes the OLD RawPath of each affected tag from the current (pre-save) in-DB raw topology,
/// dropping any tag whose ancestry chain is broken (a null RawPath). Loads only the topology the affected
/// tags need: their devices + drivers, the drivers' clusters' folders, and the devices' tag groups.
/// </summary>
private static async Task<IReadOnlyList<string>> ComputeOldRawPathsAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<AffectedTag> tags, CancellationToken ct)
{
var deviceIds = tags.Select(t => t.DeviceId).Distinct(StringComparer.Ordinal).ToList();
var deviceRows = await db.Devices.AsNoTracking()
.Where(d => deviceIds.Contains(d.DeviceId))
.Select(d => new { d.DeviceId, d.DriverInstanceId, d.Name })
.ToListAsync(ct);
var driverIds = deviceRows.Select(d => d.DriverInstanceId).Distinct(StringComparer.Ordinal).ToList();
var driverRows = await db.DriverInstances.AsNoTracking()
.Where(d => driverIds.Contains(d.DriverInstanceId))
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name, d.ClusterId })
.ToListAsync(ct);
var clusterIds = driverRows.Select(d => d.ClusterId).Distinct(StringComparer.Ordinal).ToList();
var folderRows = await db.RawFolders.AsNoTracking()
.Where(f => clusterIds.Contains(f.ClusterId))
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct);
var groupRows = await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct);
var folders = folderRows.ToDictionary(
f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = driverRows.ToDictionary(
d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var devices = deviceRows.ToDictionary(
d => d.DeviceId, d => (d.DriverInstanceId, d.Name), StringComparer.Ordinal);
var groups = groupRows.ToDictionary(
g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var paths = new List<string>(tags.Count);
foreach (var t in tags)
{
var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (path is not null) paths.Add(path);
}
return paths;
}
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
private static async Task<string> DescribeEquipmentAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
@@ -1336,11 +1435,11 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathFolderAsync(
OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
{
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
if (folder is null) return Array.Empty<(string, string)>();
if (folder is null) return Array.Empty<AffectedTag>();
// All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
var clusterFolders = await db.RawFolders.AsNoTracking()
@@ -1359,44 +1458,44 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
.Select(d => d.DriverInstanceId)
.ToListAsync(ct);
if (driverIds.Count == 0) return Array.Empty<(string, string)>();
if (driverIds.Count == 0) return Array.Empty<AffectedTag>();
var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => dev.DeviceId)
.ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDriverAsync(
OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
{
var deviceIds = await db.Devices.AsNoTracking()
.Where(dev => dev.DriverInstanceId == driverInstanceId)
.Select(dev => dev.DeviceId)
.ToListAsync(ct);
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
if (deviceIds.Count == 0) return Array.Empty<AffectedTag>();
return await TagsForDevicesAsync(db, deviceIds, ct);
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathDeviceAsync(
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
{
return (await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == deviceId)
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync(
private static async Task<IReadOnlyList<AffectedTag>> CollectTagsBeneathGroupAsync(
OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
{
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
if (group is null) return Array.Empty<(string, string)>();
if (group is null) return Array.Empty<AffectedTag>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
@@ -1412,20 +1511,20 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return (await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync(
private static async Task<IReadOnlyList<AffectedTag>> TagsForDevicesAsync(
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
{
return (await db.Tags.AsNoTracking()
.Where(t => deviceIds.Contains(t.DeviceId))
.Select(t => new { t.TagId, t.TagConfig })
.Select(t => new { t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name })
.ToListAsync(ct))
.Select(t => (t.TagId, t.TagConfig))
.Select(t => new AffectedTag(t.TagId, t.TagConfig, t.DeviceId, t.TagGroupId, t.Name))
.ToList();
}
@@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
/// Blazor circuit.
/// </remarks>
public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IUnsTreeService
public sealed class UnsTreeService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IEffectiveNameGuard? guard = null) : IUnsTreeService
{
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
// db factory. Tests that DO exercise the guard pass a fake.
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
/// <inheritdoc />
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
{
@@ -97,6 +106,371 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToListAsync(ct);
}
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
// ---------------------------------------------------------------------------------------------
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
.Select(r => new
{
r.UnsTagReferenceId,
r.TagId,
r.DisplayNameOverride,
r.SortOrder,
r.RowVersion,
})
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tags = (await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
.ToListAsync(ct))
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
var rows = new List<EquipmentReferenceRow>(refs.Count);
foreach (var r in refs)
{
if (!tags.TryGetValue(r.TagId, out var tag))
{
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
continue;
}
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
}
return rows;
}
/// <inheritdoc />
public async Task<UnsMutationResult> AddReferencesAsync(
string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tagIds);
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
var tags = await db.Tags.AsNoTracking()
.Where(t => distinctTagIds.Contains(t.TagId))
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
.ToListAsync(ct);
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
// Existing references on the equipment (to reject re-referencing the same tag).
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => r.TagId)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var maxSort = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => (int?)r.SortOrder)
.MaxAsync(ct) ?? -1;
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
var batchNames = new HashSet<string>(StringComparer.Ordinal);
var pending = new List<UnsTagReference>();
foreach (var tagId in distinctTagIds)
{
if (!tagById.TryGetValue(tagId, out var tag))
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
return new UnsMutationResult(false,
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
if (alreadyReferenced.Contains(tagId))
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
// Effective name at add = the raw tag's Name (no override yet).
if (!batchNames.Add(tag.Name))
return new UnsMutationResult(false,
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
pending.Add(new UnsTagReference
{
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = null,
SortOrder = ++maxSort,
});
}
db.UnsTagReferences.AddRange(pending);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateException)
{
// The UX_UnsTagReference_Equip_Tag unique index tripped: a concurrent add slipped a
// same-(equipment,tag) row past the pre-check. (Effective-name collisions have no DB
// uniqueness — those are caught by the authoring guard and the deploy gate, not here.)
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> RemoveReferenceAsync(
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(true, null);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsTagReferences.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> SetReferenceOverrideAsync(
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
{
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Effective name = the override when set, else the backing raw tag's Name.
var rawName = await db.Tags.AsNoTracking()
.Where(t => t.TagId == entity.TagId)
.Select(t => t.Name)
.FirstOrDefaultAsync(ct);
var effectiveName = normalized ?? rawName ?? string.Empty;
var collision = await _guard.CheckAsync(
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
entity.DisplayNameOverride = normalized;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
}
}
/// <inheritdoc />
public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (clusterId is null) return null;
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
if (cluster is null) return null;
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
var rootFolders = await db.RawFolders.AsNoTracking()
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
var rootDrivers = await db.DriverInstances.AsNoTracking()
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
return new RawNode
{
Kind = RawNodeKind.Cluster,
Key = $"clu:{clusterId}",
DisplayName = cluster.Name,
ClusterId = clusterId,
EntityId = clusterId,
HasLazyChildren = true,
ChildCount = rootFolders + rootDrivers,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(node);
if (node.EntityId is null) return Array.Empty<string>();
await using var db = await dbFactory.CreateDbContextAsync(ct);
switch (node.Kind)
{
case RawNodeKind.Device:
// Every tag under the device (across all its tag groups).
return await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == node.EntityId)
.Select(t => t.TagId)
.ToListAsync(ct);
case RawNodeKind.TagGroup:
{
// The group + all descendant groups, then every tag in that group set.
var group = await db.TagGroups.AsNoTracking()
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
if (group is null) return Array.Empty<string>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
.ToListAsync(ct);
var childrenByParent = deviceGroups
.Where(g => g.ParentTagGroupId is not null)
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
return await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => t.TagId)
.ToListAsync(ct);
}
default:
// The picker only offers "select all" on Device / TagGroup containers.
return Array.Empty<string>();
}
}
/// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
private static HashSet<string> DescendantGroupsInclusive(
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
{
var result = new HashSet<string>(StringComparer.Ordinal);
var stack = new Stack<string>();
stack.Push(rootId);
while (stack.Count > 0)
{
var id = stack.Pop();
if (!result.Add(id)) continue;
if (childrenByParent.TryGetValue(id, out var kids))
{
foreach (var kid in kids) stack.Push(kid);
}
}
return result;
}
/// <summary>
/// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
/// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
/// </summary>
private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
{
if (string.IsNullOrEmpty(clusterId)) return null;
var folders = (await db.RawFolders.AsNoTracking()
.Where(f => f.ClusterId == clusterId)
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var driverIds = drivers.Keys.ToList();
var devices = (await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var deviceIds = devices.Keys.ToList();
var groups = (await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
/// <summary>
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
/// backstop. Production always injects the real WP2 guard.
/// </summary>
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
{
public static readonly NoOpEffectiveNameGuard Instance = new();
public Task<string?> CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
}
/// <inheritdoc />
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{
@@ -446,11 +820,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
}
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
// v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
// the old decision-#122 driver-cluster guard is gone.
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -514,14 +885,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
// Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a
// driver/line cluster mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
continue;
}
// v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -578,11 +942,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
}
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
// v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
entity.UnsLineId = input.UnsLineId;
@@ -866,29 +1226,82 @@ 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}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</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;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
// 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)
/// <summary>
/// Scripted-alarm variant of <see cref="ValidateEquipTokenAsync"/>: validates <c>{{equip}}/&lt;RefName&gt;</c>
/// tokens in BOTH the predicate script AND the message template against the equipment's reference set —
/// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates).
/// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenForAlarmAsync(
OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct)
{
var used = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(predicateScriptId))
{
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 src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u);
}
return null;
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u);
if (used.Count == 0) return null;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
/// <summary>The equipment's reference effective-name set: <c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c> (empty override treated as absent, matching <c>EquipmentReferenceMap.Build</c>), ordinal.</summary>
private static async Task<HashSet<string>> LoadEquipReferenceNamesAsync(
OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct)
{
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)
{
var eff = string.IsNullOrEmpty(r.DisplayNameOverride)
? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null)
: r.DisplayNameOverride;
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
return effectiveNames;
}
/// <summary>Names the unresolved <c>{{equip}}/&lt;RefName&gt;</c> tokens (or null when all resolve).</summary>
private static UnsMutationResult? CheckEquipReferencesResolve(
IReadOnlyCollection<string> used, HashSet<string> effectiveNames, string equipmentId)
{
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 />
@@ -920,6 +1333,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
// v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
// alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
@@ -969,6 +1387,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
// v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
@@ -1193,58 +1615,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return null;
}
/// <summary>
/// An equipment may only bind to a driver in the same cluster as its line.
/// Policy:
/// <list type="bullet">
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
/// <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
/// line cannot guarantee cluster alignment, so the bind is unsafe).</item>
/// <item>Driver bound, line resolves, clusters differ → rejected.</item>
/// <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
/// </list>
/// </summary>
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
OtOpcUaConfigDbContext db,
EquipmentInput input,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
var lineCluster = area?.ClusterId;
if (lineCluster is null)
{
return new UnsMutationResult(
false,
$"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
}
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is null)
{
return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
}
if (driverCluster != lineCluster)
{
return new UnsMutationResult(
false,
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
@@ -1277,6 +1647,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = input.ScriptedAlarmId,
@@ -1307,6 +1685,14 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.AlarmType = input.AlarmType;
@@ -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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt;</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();
@@ -205,6 +205,71 @@ public sealed class DraftValidatorTests
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
}
/// <summary>The rename-induced case the authoring guard never sees: an authoring-clean draft where
/// a reference (no override, effective name = backing raw tag's current Name) ends up sharing an
/// effective name with a VirtualTag after a raw-tag rename. The deploy gate is the backstop.</summary>
[Fact]
public void Reference_effective_from_raw_name_collides_with_virtualtag_after_rename()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "speed")], // raw tag renamed to "speed"
UnsTagReferences =
[
// No override → effective name is the backing raw tag's Name ("speed").
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
// Names both colliding sources + the equipment.
var msg = errors.First(e => e.Code == "UnsEffectiveNameCollision").Message;
msg.ShouldContain("ref-1");
msg.ShouldContain("vtag-eq-1-speed");
msg.ShouldContain("eq-1");
}
/// <summary>A reference's DisplayNameOverride (not its backing raw name) is the effective name and
/// must be unique against a VirtualTag in the same equipment.</summary>
[Fact]
public void Reference_override_collides_with_virtualtag()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "raw-name")], // backing raw name differs
UnsTagReferences =
[
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: "computed"),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "computed")],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "UnsEffectiveNameCollision");
}
/// <summary>Effective-name comparison is ordinal: "Speed" (VirtualTag) and "speed" (reference's
/// backing raw name) do NOT collide — they are distinct OPC UA browse names.</summary>
[Fact]
public void Effective_name_collision_is_ordinal_case_sensitive()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(tagId: "tag-1", name: "speed")],
UnsTagReferences =
[
BuildReference(refId: "ref-1", equipmentId: "eq-1", tagId: "tag-1", overrideName: null),
],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "Speed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "UnsEffectiveNameCollision");
}
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
{
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
@@ -214,6 +279,130 @@ public sealed class DraftValidatorTests
ScriptId = "s-1",
};
private static Tag BuildTag(string tagId, string name) => new()
{
TagId = tagId,
DeviceId = "dev-1",
Name = name,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static UnsTagReference BuildReference(string refId, string equipmentId, string tagId, string? overrideName = null) => new()
{
UnsTagReferenceId = refId,
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = overrideName,
};
// ------------------------------------------------------------------------------------
// 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 = "{}",
};
/// <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)
// ------------------------------------------------------------------------------------
@@ -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>());
}
@@ -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}}/&lt;RefName&gt; 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}}/&lt;RefName&gt;</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}}/&lt;RefName&gt; 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" });
}
@@ -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();
}
}
@@ -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" });
}
@@ -0,0 +1,203 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Unit tests for <see cref="EffectiveNameGuard"/> — the authoring-time UNS effective-name
/// uniqueness guard. Backed by an InMemory EF database seeded with one equipment carrying a
/// reference (override + backing raw tag), a VirtualTag, and a ScriptedAlarm.
/// </summary>
public sealed class EffectiveNameGuardTests
{
private const string EquipmentId = "EQ-000000000001";
// Reference REF-1 → raw tag TAG-1 (Name "speed"), NO override → effective "speed".
// Reference REF-2 → raw tag TAG-2 (Name "raw-temp"), override "temperature" → effective "temperature".
// VirtualTag VT-1 (Name "computed"). ScriptedAlarm AL-1 (Name "highpress").
private const string RefNoOverrideId = "REF-1";
private const string RefWithOverrideId = "REF-2";
private const string VirtualTagId = "VT-1";
private const string ScriptedAlarmId = "AL-1";
private static IDbContextFactory<OtOpcUaConfigDbContext> SeedFactory()
{
var name = $"engname-{Guid.NewGuid():N}";
var factory = new NamedFactory(name);
using var db = factory.CreateDbContext();
db.Tags.Add(new Tag
{
TagId = "TAG-1",
DeviceId = "DEV-1",
Name = "speed",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Tags.Add(new Tag
{
TagId = "TAG-2",
DeviceId = "DEV-1",
Name = "raw-temp",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefNoOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-1",
DisplayNameOverride = null, // effective = raw tag Name "speed"
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = RefWithOverrideId,
EquipmentId = EquipmentId,
TagId = "TAG-2",
DisplayNameOverride = "temperature", // effective = "temperature"
});
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = VirtualTagId,
EquipmentId = EquipmentId,
Name = "computed",
DataType = "Float",
ScriptId = "SCR-1",
});
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = ScriptedAlarmId,
EquipmentId = EquipmentId,
Name = "highpress",
AlarmType = "AlarmCondition",
MessageTemplate = "{TagPath} high",
PredicateScriptId = "SCR-2",
});
db.SaveChanges();
return factory;
}
private static EffectiveNameGuard Guard(IDbContextFactory<OtOpcUaConfigDbContext> factory) => new(factory);
[Fact]
public async Task Free_name_returns_null()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "brand-new-name", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Reference_vs_new_virtualtag_clash_returns_error_naming_the_reference()
{
var guard = Guard(SeedFactory());
// A new VirtualTag proposing "speed" collides with reference REF-1's effective name.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Override_effective_name_vs_new_reference_clash_returns_error()
{
var guard = Guard(SeedFactory());
// A new reference proposing "temperature" collides with REF-2's OVERRIDE effective name.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'temperature' already used by reference '{RefWithOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task New_virtualtag_vs_scripted_alarm_clash_returns_error_naming_the_alarm()
{
var guard = Guard(SeedFactory());
var result = await guard.CheckAsync(
EquipmentId, "highpress", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'highpress' already used by ScriptedAlarm '{ScriptedAlarmId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Self_exclude_on_override_change_returns_null()
{
var guard = Guard(SeedFactory());
// Editing REF-2 to change its override to "temperature" (its own current effective name)
// must NOT collide with itself.
var result = await guard.CheckAsync(
EquipmentId, "temperature", EffectiveNameSourceKind.Reference, excludeSourceId: RefWithOverrideId);
result.ShouldBeNull();
}
[Fact]
public async Task Self_exclude_only_applies_to_same_kind()
{
var guard = Guard(SeedFactory());
// Excluding a VirtualTag whose id happens to equal a reference id must NOT excuse the
// reference collision — the self-row is identified by (kind, id) together.
var result = await guard.CheckAsync(
EquipmentId, "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: RefNoOverrideId);
result.ShouldNotBeNull();
result.ShouldBe($"effective name 'speed' already used by reference '{RefNoOverrideId}' in equipment '{EquipmentId}'");
}
[Fact]
public async Task Ordinal_case_sensitivity_Speed_does_not_collide_with_speed()
{
var guard = Guard(SeedFactory());
// "Speed" (capital S) must NOT collide with the existing "speed" (ordinal comparison).
var result = await guard.CheckAsync(
EquipmentId, "Speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
[Fact]
public async Task Collision_is_scoped_to_the_equipment()
{
var guard = Guard(SeedFactory());
// Same name, different equipment → no collision (per-equipment NodeId space).
var result = await guard.CheckAsync(
"EQ-000000000002", "speed", EffectiveNameSourceKind.VirtualTag, excludeSourceId: null);
result.ShouldBeNull();
}
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(name)
.Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}
@@ -0,0 +1,231 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Covers the B3-WP3 rename-warning extension of <see cref="RawTreeService"/>: the three warning kinds a
/// tag/ancestor rename can raise — (a) historized WITHOUT a <c>historianTagname</c> override (history
/// forks), (b) UNS-referenced (names the equipment), and (c) matched by a substring scan of a
/// <c>Script</c> body for the affected tag's OLD RawPath.
/// <para>
/// Seeds a bespoke InMemory slice (independent of the shared <c>RawTreeTestDb</c>) so each device holds
/// exactly one tag with a controlled property mix. The EF InMemory provider does not enforce
/// <c>RowVersion</c> tokens, so renames commit against the seeded row versions.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceRenameWarningTests
{
private const string ClusterId = "MAIN";
private const string FolderId = "RF-plant";
private const string DriverId = "DRV-modbus";
// Dev-all → the "everything" tag: historized (no override) + referenced + named in a script literal.
private const string DevAllId = "DEV-all";
private const string FlowTagId = "TAG-flow";
private const string FlowRawPath = "Plant/modbus-1/Dev-all/flow";
// Dev-pinned → a historized tag WITH a historianTagname override (pinned; must NOT warn).
private const string DevPinnedId = "DEV-pinned";
private const string TempTagId = "TAG-temp";
// Dev-plain → a plain tag, unreferenced + absent from every script (must warn nothing).
private const string DevPlainId = "DEV-plain";
private const string IdleTagId = "TAG-idle";
private const string EquipmentId = "EQ-000000000001";
private const string EquipmentName = "packer-1";
private const string ScriptName = "AreaCalc";
private static (RawTreeService Service, string DbName) Seeded()
{
var name = $"rawwarn-{Guid.NewGuid():N}";
using var db = CreateNamed(name);
Seed(db);
return (new RawTreeService(new NamedFactory(name)), name);
}
private static OtOpcUaConfigDbContext CreateNamed(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static byte[] DeviceRowVersion(string dbName, string deviceId)
{
using var db = CreateNamed(dbName);
return db.Devices.Single(d => d.DeviceId == deviceId).RowVersion;
}
private static byte[] DriverRowVersion(string dbName, string driverId)
{
using var db = CreateNamed(dbName);
return db.DriverInstances.Single(d => d.DriverInstanceId == driverId).RowVersion;
}
// ------------------------------------------------------------------------------------- Scenarios
[Fact]
public async Task Rename_of_ancestor_of_a_historized_referenced_scripted_tag_raises_all_three_warnings()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevAllId);
var result = await service.RenameDeviceAsync(DevAllId, "Dev-renamed", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldContain(w => w.Contains("historian")); // (a)
result.Warnings.ShouldContain(w => w.Contains(EquipmentName)); // (b)
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName)); // (c)
result.Warnings.Count.ShouldBe(3);
}
[Fact]
public async Task Rename_of_a_pinned_historized_tag_raises_no_historized_warning()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevPinnedId);
var result = await service.RenameDeviceAsync(DevPinnedId, "Dev-pinned2", rv);
result.Ok.ShouldBeTrue();
// A historianTagname override pins the tagname, so its history does NOT fork — no (a) warning; and
// the pinned tag is neither referenced nor scripted, so nothing else fires either.
result.Warnings.ShouldNotContain(w => w.Contains("historian"));
result.Warnings.ShouldBeEmpty();
}
[Fact]
public async Task Rename_of_an_unrelated_tags_ancestor_raises_no_warnings()
{
var (service, dbName) = Seeded();
var rv = DeviceRowVersion(dbName, DevPlainId);
var result = await service.RenameDeviceAsync(DevPlainId, "Dev-plain2", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldBeEmpty();
}
[Fact]
public async Task Script_scan_matches_when_an_ancestor_is_renamed_and_the_old_path_is_in_a_script_body()
{
var (service, dbName) = Seeded();
var rv = DriverRowVersion(dbName, DriverId);
// Renaming the DRIVER (an ancestor of the scripted tag) still computes the tag's unchanged OLD
// RawPath (rename not yet saved) and finds it as a substring of the script body.
var result = await service.RenameDriverAsync(DriverId, "modbus-renamed", rv);
result.Ok.ShouldBeTrue();
result.Warnings.ShouldContain(w => w.Contains("script") && w.Contains(ScriptName));
}
// ------------------------------------------------------------------------------------------ Seed
private static void Seed(OtOpcUaConfigDbContext db)
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = ClusterId,
Name = "Main",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
db.RawFolders.Add(new RawFolder
{
RawFolderId = FolderId,
ClusterId = ClusterId,
ParentRawFolderId = null,
Name = "Plant",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = DriverId,
ClusterId = ClusterId,
RawFolderId = FolderId,
Name = "modbus-1",
DriverType = "Modbus",
DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = DevAllId, DriverInstanceId = DriverId, Name = "Dev-all", DeviceConfig = "{}" });
db.Devices.Add(new Device { DeviceId = DevPinnedId, DriverInstanceId = DriverId, Name = "Dev-pinned", DeviceConfig = "{}" });
db.Devices.Add(new Device { DeviceId = DevPlainId, DriverInstanceId = DriverId, Name = "Dev-plain", DeviceConfig = "{}" });
// Historized, no historianTagname override → its default tagname is FlowRawPath, which moves.
db.Tags.Add(new Tag
{
TagId = FlowTagId,
DeviceId = DevAllId,
TagGroupId = null,
Name = "flow",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isHistorized\":true}",
});
// Historized WITH a historianTagname override → pinned, must not fork/warn.
db.Tags.Add(new Tag
{
TagId = TempTagId,
DeviceId = DevPinnedId,
TagGroupId = null,
Name = "temp",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"Plant.Pinned.Temp\"}",
});
// Plain, unreferenced, absent from scripts.
db.Tags.Add(new Tag
{
TagId = IdleTagId,
DeviceId = DevPlainId,
TagGroupId = null,
Name = "idle",
DataType = "Boolean",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.Equipment.Add(new Equipment
{
EquipmentId = EquipmentId,
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = EquipmentName,
MachineCode = "packer_001",
});
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = "REF-flow",
EquipmentId = EquipmentId,
TagId = FlowTagId,
});
// A script whose body names the flow tag by its absolute RawPath literal.
db.Scripts.Add(new Script
{
ScriptId = "SC-areacalc",
Name = ScriptName,
SourceCode = $"var v = ctx.GetTag(\"{FlowRawPath}\"); return v * 2;",
SourceHash = "hash-areacalc",
});
db.SaveChanges();
}
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}
}
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -7,10 +6,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>, including the
/// system-generated <c>EQ-</c> id, fleet-wide MachineCode uniqueness, and the decision-#122
/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
/// the equipment's line.
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>: the system-generated
/// <c>EQ-</c> id and fleet-wide MachineCode uniqueness. (v3: equipment no longer binds a driver — the
/// reference-only Tags tab points at raw tags — so the old decision-#122 driver-cluster guard is gone,
/// and its tests with it.)
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
@@ -25,32 +24,17 @@ public sealed class UnsTreeServiceEquipmentTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
/// <summary>Seeds an area → line (id <c>LINE-1</c>) under <paramref name="cluster"/>.</summary>
private static void SeedLine(string dbName, string cluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = cluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
private static EquipmentInput Input(string name, string machineCode, string unsLineId) =>
new(name, machineCode, unsLineId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
@@ -62,9 +46,9 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task CreateEquipment_generates_EQ_id_and_persists()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
@@ -83,10 +67,10 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task CreateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1"));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
@@ -98,120 +82,28 @@ public sealed class UnsTreeServiceEquipmentTests
{
var (service, _) = Fresh();
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", ""));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Pick a UNS line.");
}
/// <summary>The #122 guard blocks binding equipment to a driver in a different cluster than the line.</summary>
/// <summary>CreateEquipmentAsync returns the generated EQ- id in <c>CreatedId</c> so callers can navigate to the new page.</summary>
[Fact]
public async Task CreateEquipment_driver_in_other_cluster_blocked()
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
null, null, null, null, null, null, null, null, null, null, null, true);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("DRV-1");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>
/// Passing a driver in the same cluster as the line passes the #122 guard and the equipment
/// persists. v3: the equipment↔driver binding column was retired, so the driver is validated by
/// the guard but not stored — the assertion drops to equipment existence.
/// </summary>
[Fact]
public async Task CreateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
var result = await service.CreateEquipmentAsync(input);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
}
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
[Fact]
public async Task CreateEquipment_driverless_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").UnsLineId.ShouldBe("LINE-1");
}
/// <summary>
/// The #122 guard blocks binding equipment to a driver when the UNS line does not resolve to
/// a cluster (e.g. the line does not exist in the DB).
/// </summary>
[Fact]
public async Task CreateEquipment_driver_bound_unresolvable_line_blocked()
{
var (service, dbName) = Fresh();
// Seed a driver in MAIN cluster, but do NOT create the UnsLine that the input references.
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = "MAIN",
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
db.SaveChanges();
}
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-BOGUS", "DRV-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("LINE-BOGUS");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>Binding equipment to a DriverInstanceId that does not exist is blocked with a "not found" error.</summary>
[Fact]
public async Task CreateEquipment_driver_not_found_blocked()
{
var (service, dbName) = Fresh();
// Seed area + line in MAIN cluster, but NO DriverInstance.
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(
Input("machine-1", "machine_001", "LINE-1", "DRV-GHOST"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("not found");
result.Error.ShouldContain("DRV-GHOST");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
result.CreatedId.ShouldNotBeNull();
result.CreatedId!.ShouldStartWith("EQ-");
}
// ----- UpdateEquipment -----
@@ -221,8 +113,8 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task UpdateEquipment_changes_fields()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -233,7 +125,7 @@ public sealed class UnsTreeServiceEquipmentTests
rv = eq.RowVersion;
}
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1",
ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false);
@@ -258,49 +150,20 @@ public sealed class UnsTreeServiceEquipmentTests
{
var (service, _) = Fresh();
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1"), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks an update that binds equipment to a driver in another cluster.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
// The equipment row is unchanged (v3 has no persisted driver-binding column to check).
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("machine_001");
}
/// <summary>Updating equipment with a MachineCode that already belongs to another row is blocked.</summary>
[Fact]
public async Task UpdateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1", null));
await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-a", "mc_a", "LINE-1"));
await service.CreateEquipmentAsync(Input("machine-b", "mc_b", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -313,7 +176,7 @@ public sealed class UnsTreeServiceEquipmentTests
// Try to rename mc_a → mc_b (which already exists).
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-a", "mc_b", "LINE-1", null), rv);
equipmentId, Input("machine-a", "mc_b", "LINE-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
@@ -324,52 +187,6 @@ public sealed class UnsTreeServiceEquipmentTests
verify.Equipment.Single(e => e.EquipmentId == equipmentId).MachineCode.ShouldBe("mc_a");
}
/// <summary>Updating equipment to bind a driver that is in the SAME cluster as the line is allowed.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).UnsLineId.ShouldBe("LINE-1");
}
/// <summary>CreateEquipmentAsync returns the generated EQ- id in <c>CreatedId</c> so callers can navigate to the new page.</summary>
[Fact]
public async Task CreateEquipment_returns_generated_id_in_CreatedId()
{
var dbName = $"uns-eq-createdid-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var input = new EquipmentInput("machine-2", "machine_002", "LINE-1",
null, null, null, null, null, null, null, null, null, null, null, null, true);
var result = await service.CreateEquipmentAsync(input);
result.Ok.ShouldBeTrue();
result.CreatedId.ShouldNotBeNull();
result.CreatedId!.ShouldStartWith("EQ-");
}
// ----- DeleteEquipment -----
/// <summary>Deleting equipment removes the row.</summary>
@@ -377,8 +194,8 @@ public sealed class UnsTreeServiceEquipmentTests
public async Task DeleteEquipment_removes_row()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
SeedLine(dbName, "MAIN");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1"));
string equipmentId;
byte[] rv;
@@ -6,10 +6,10 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert,
/// rows whose MachineCode already exists (in the DB or earlier in the same batch) are skipped,
/// rows referencing an unknown UNS line or unknown driver are reported as errors, and the
/// decision-#122 driver-cluster guard rejects a driver in a different cluster than the line.
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert, rows
/// whose MachineCode already exists (in the DB or earlier in the same batch) are skipped, and rows
/// referencing an unknown UNS line are reported as errors. (v3: equipment no longer binds a driver, so
/// the old DriverInstanceId column and its decision-#122 cluster guard — and their tests — are gone.)
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceImportTests
@@ -20,32 +20,17 @@ public sealed class UnsTreeServiceImportTests
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
/// <summary>Seeds an area → line (id <c>LINE-1</c>) under <paramref name="cluster"/>.</summary>
private static void SeedLine(string dbName, string cluster)
{
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = cluster, Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
if (driverCluster is not null)
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1",
ClusterId = driverCluster,
Name = "drv",
DriverType = "Modbus",
DriverConfig = "{}",
});
}
db.SaveChanges();
}
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
new(name, machineCode, unsLineId, driverInstanceId,
private static EquipmentInput Input(string name, string machineCode, string unsLineId) =>
new(name, machineCode, unsLineId,
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
@@ -55,12 +40,12 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_inserts_valid_rows()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", null),
Input("machine-2", "mc_2", "LINE-1", null),
Input("machine-1", "mc_1", "LINE-1"),
Input("machine-2", "mc_2", "LINE-1"),
]);
result.Inserted.ShouldBe(2);
@@ -85,15 +70,15 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_skips_duplicate_machinecode()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
// Pre-existing equipment with MachineCode "mc_existing".
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1"));
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_existing", "LINE-1", null), // dup of DB row → skip
Input("machine-2", "mc_new", "LINE-1", null), // inserts
Input("machine-3", "mc_new", "LINE-1", null), // dup of in-batch row → skip
Input("machine-1", "mc_existing", "LINE-1"), // dup of DB row → skip
Input("machine-2", "mc_new", "LINE-1"), // inserts
Input("machine-3", "mc_new", "LINE-1"), // dup of in-batch row → skip
]);
result.Inserted.ShouldBe(1);
@@ -110,12 +95,12 @@ public sealed class UnsTreeServiceImportTests
public async Task Import_reports_unknown_line()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
SeedLine(dbName, "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-BOGUS", null),
Input("machine-2", "mc_2", "LINE-1", null),
Input("machine-1", "mc_1", "LINE-BOGUS"),
Input("machine-2", "mc_2", "LINE-1"),
]);
result.Inserted.ShouldBe(1);
@@ -128,73 +113,4 @@ public sealed class UnsTreeServiceImportTests
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
db.Equipment.Any(e => e.MachineCode == "mc_2").ShouldBeTrue();
}
/// <summary>A driver-bound row whose DriverInstanceId does not resolve is reported as an error.</summary>
[Fact]
public async Task Import_reports_unknown_driver()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); // no driver seeded
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-GHOST"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("DRV-GHOST");
result.Errors[0].ShouldContain("not found");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>
/// The decision-#122 guard rejects a row that binds a driver living in a different cluster than
/// the row's UNS line.
/// </summary>
[Fact]
public async Task Import_enforces_122_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(0);
result.Skipped.ShouldBe(0);
result.Errors.Count.ShouldBe(1);
result.Errors[0].ShouldContain("mc_1");
result.Errors[0].ShouldContain("decision #122");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
}
/// <summary>A driver in the same cluster as the line imports cleanly.</summary>
[Fact]
public async Task Import_allows_driver_in_same_cluster()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.ImportEquipmentAsync(
[
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
]);
result.Inserted.ShouldBe(1);
result.Skipped.ShouldBe(0);
result.Errors.ShouldBeEmpty();
// v3: the driver passes the #122 guard but is not persisted (binding column retired).
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "mc_1").UnsLineId.ShouldBe("LINE-1");
}
}
@@ -0,0 +1,314 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies the v3 Batch-3 UNS reference-only equipment mutations on <see cref="UnsTreeService"/>:
/// adding references (with computed RawPath + inherited datatype/access), removing them, setting the
/// display-name override, the cross-cluster rejection, the same-tag / same-name duplicate rejections,
/// and the <see cref="IEffectiveNameGuard"/>-consumed collision rejection (via a configurable fake).
/// </summary>
/// <remarks>
/// The EF InMemory provider enforces neither <c>RowVersion</c> concurrency nor the filtered-unique
/// indexes, so the service's own pre-checks are what protect the data in these tests.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceReferenceTests
{
private const string EquipmentId = "EQ-1";
/// <summary>A configurable <see cref="IEffectiveNameGuard"/>: returns <see cref="Result"/> from every check.</summary>
private sealed class FakeGuard : IEffectiveNameGuard
{
public string? Result { get; set; }
public string? LastProposedName { get; private set; }
public Task<string?> CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default)
{
LastProposedName = proposedEffectiveName;
return Task.FromResult(Result);
}
}
private static OtOpcUaConfigDbContext Db(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => UnsTreeTestDb.Factory(name);
/// <summary>
/// Seeds two clusters (MAIN, SITE-A), each with driver→device→tags, plus an equipment (<c>EQ-1</c>)
/// under MAIN. MAIN tags: <c>MAIN/dev1/speed</c> (Float, Read) and <c>MAIN/dev1/running</c>
/// (Boolean, ReadWrite), with <c>speed</c> also nested under a group <c>grp</c> variant. SITE-A tag:
/// <c>OTHER/dev2/temp</c> (for the cross-cluster rejection).
/// </summary>
private static void Seed(string name)
{
using var db = Db(name);
db.ServerClusters.Add(new ServerCluster { ClusterId = "MAIN", Name = "Main", Enterprise = "zb", Site = "s1", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
db.ServerClusters.Add(new ServerCluster { ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "s2", RedundancyMode = RedundancyMode.None, CreatedBy = "t" });
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "assembly" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "line-a" });
db.Equipment.Add(new Equipment { EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-1", Name = "machine-1", MachineCode = "mc_1" });
// MAIN raw topology.
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-1", ClusterId = "MAIN", Name = "MAIN", DriverType = "Modbus", DriverConfig = "{}" });
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", Name = "grp" });
db.Tags.Add(new Tag { TagId = "TAG-speed", DeviceId = "DEV-1", Name = "speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-running", DeviceId = "DEV-1", Name = "running", DataType = "Boolean", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-grouped", DeviceId = "DEV-1", TagGroupId = "TG-1", Name = "nested", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
// SITE-A raw topology (for the cross-cluster rejection).
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = "DRV-2", ClusterId = "SITE-A", Name = "OTHER", DriverType = "Modbus", DriverConfig = "{}" });
db.Devices.Add(new Device { DeviceId = "DEV-2", DriverInstanceId = "DRV-2", Name = "dev2", DeviceConfig = "{}" });
db.Tags.Add(new Tag { TagId = "TAG-other", DeviceId = "DEV-2", Name = "temp", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" });
db.SaveChanges();
}
// ----- AddReferences -----
/// <summary>Adding two raw tags persists two UnsTagReference rows for the equipment.</summary>
[Fact]
public async Task AddReferences_persists_rows()
{
var name = $"ref-add-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-running"]);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = Db(name);
var refs = db.UnsTagReferences.Where(r => r.EquipmentId == EquipmentId).ToList();
refs.Count.ShouldBe(2);
refs.Select(r => r.TagId).ShouldBe(new[] { "TAG-speed", "TAG-running" }, ignoreOrder: true);
refs.All(r => r.DisplayNameOverride == null).ShouldBeTrue();
}
/// <summary>Loaded reference rows carry the computed RawPath and inherited (raw) datatype/access.</summary>
[Fact]
public async Task LoadReferences_projects_rawpath_and_inherited_type()
{
var name = $"ref-load-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed", "TAG-grouped"]);
var rows = await service.LoadReferencesForEquipmentAsync(EquipmentId);
rows.Count.ShouldBe(2);
var speed = rows.Single(r => r.RawPath == "MAIN/dev1/speed");
speed.EffectiveName.ShouldBe("speed");
speed.DataType.ShouldBe("Float");
speed.AccessLevel.ShouldBe(TagAccessLevel.Read);
var nested = rows.Single(r => r.EffectiveName == "nested");
nested.RawPath.ShouldBe("MAIN/dev1/grp/nested");
nested.DataType.ShouldBe("Int32");
}
/// <summary>A raw tag whose cluster differs from the equipment's is rejected (cross-cluster).</summary>
[Fact]
public async Task AddReferences_cross_cluster_rejected()
{
var name = $"ref-xcluster-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-other"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("cross-cluster");
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
}
/// <summary>Re-referencing an already-referenced raw tag is rejected.</summary>
[Fact]
public async Task AddReferences_duplicate_tag_rejected()
{
var name = $"ref-dup-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("already referenced");
using var db = Db(name);
db.UnsTagReferences.Count(r => r.EquipmentId == EquipmentId).ShouldBe(1);
}
/// <summary>A guard-reported collision (fake) becomes the mutation's readable failure; nothing is added.</summary>
[Fact]
public async Task AddReferences_guard_collision_rejected()
{
var name = $"ref-guard-{Guid.NewGuid():N}";
Seed(name);
var guard = new FakeGuard { Result = "'speed' already exists on equipment 'machine-1'." };
var service = new UnsTreeService(Factory(name), guard);
var result = await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("'speed' already exists on equipment 'machine-1'.");
guard.LastProposedName.ShouldBe("speed");
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
}
// ----- SetReferenceOverride -----
/// <summary>Setting an override changes the effective name; clearing it falls back to the raw name.</summary>
[Fact]
public async Task SetReferenceOverride_sets_and_clears()
{
var name = $"ref-override-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
var set = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "line-speed", row.RowVersion);
set.Ok.ShouldBeTrue();
var afterSet = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
afterSet.EffectiveName.ShouldBe("line-speed");
afterSet.DisplayNameOverride.ShouldBe("line-speed");
afterSet.RawPath.ShouldBe("MAIN/dev1/speed"); // raw path is unchanged by an override
var clear = await service.SetReferenceOverrideAsync(afterSet.UnsTagReferenceId, " ", afterSet.RowVersion);
clear.Ok.ShouldBeTrue();
var afterClear = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
afterClear.EffectiveName.ShouldBe("speed");
afterClear.DisplayNameOverride.ShouldBeNull();
}
/// <summary>Setting an override that the guard reports as colliding is rejected.</summary>
[Fact]
public async Task SetReferenceOverride_guard_collision_rejected()
{
var name = $"ref-override-guard-{Guid.NewGuid():N}";
Seed(name);
var guard = new FakeGuard();
var service = new UnsTreeService(Factory(name), guard);
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
guard.Result = "collides with a virtual tag.";
var result = await service.SetReferenceOverrideAsync(row.UnsTagReferenceId, "running", row.RowVersion);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("collides with a virtual tag.");
guard.LastProposedName.ShouldBe("running");
}
// ----- RemoveReference -----
/// <summary>Removing a reference deletes the row; a missing row is a no-op success.</summary>
[Fact]
public async Task RemoveReference_removes_row()
{
var name = $"ref-remove-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
await service.AddReferencesAsync(EquipmentId, ["TAG-speed"]);
var row = (await service.LoadReferencesForEquipmentAsync(EquipmentId)).Single();
var result = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
result.Ok.ShouldBeTrue();
using var db = Db(name);
db.UnsTagReferences.Any(r => r.EquipmentId == EquipmentId).ShouldBeFalse();
// Already gone → idempotent success.
var again = await service.RemoveReferenceAsync(row.UnsTagReferenceId, row.RowVersion);
again.Ok.ShouldBeTrue();
}
// ----- Picker support -----
/// <summary>The picker root is the equipment's own cluster node (structurally cluster-scoped).</summary>
[Fact]
public async Task LoadReferencePickerRoot_returns_cluster_node()
{
var name = $"ref-picker-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var root = await service.LoadReferencePickerRootAsync(EquipmentId);
root.ShouldNotBeNull();
root!.Kind.ShouldBe(RawNodeKind.Cluster);
root.EntityId.ShouldBe("MAIN");
root.ClusterId.ShouldBe("MAIN");
root.HasLazyChildren.ShouldBeTrue();
}
/// <summary>Select-all under a device returns every tag id in the device subtree.</summary>
[Fact]
public async Task LoadDescendantTagIds_device_returns_subtree_tags()
{
var name = $"ref-descend-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var deviceNode = new RawNode
{
Kind = RawNodeKind.Device,
Key = "dev:DEV-1",
DisplayName = "dev1",
ClusterId = "MAIN",
EntityId = "DEV-1",
};
var ids = await service.LoadDescendantTagIdsAsync(deviceNode);
ids.ShouldBe(new[] { "TAG-speed", "TAG-running", "TAG-grouped" }, ignoreOrder: true);
}
/// <summary>Select-all under a tag-group returns only the tags in that group's subtree.</summary>
[Fact]
public async Task LoadDescendantTagIds_group_returns_group_tags()
{
var name = $"ref-descend-grp-{Guid.NewGuid():N}";
Seed(name);
var service = new UnsTreeService(Factory(name));
var groupNode = new RawNode
{
Kind = RawNodeKind.TagGroup,
Key = "grp:TG-1",
DisplayName = "grp",
ClusterId = "MAIN",
EntityId = "TG-1",
};
var ids = await service.LoadDescendantTagIdsAsync(groupNode);
ids.ShouldBe(new[] { "TAG-grouped" });
}
}
@@ -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}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</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);
@@ -228,4 +189,55 @@ public sealed class VirtualTagEquipTokenValidationTests
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
// ----- ScriptedAlarm (Wave-B review MEDIUM-1: the invariant must hold on the SA surface too) -----
private static ScriptedAlarmInput AlarmInput(string messageTemplate = "value out of range") =>
new("ALM-1", "HighTemp", AlarmType: "Rate", Severity: 500, MessageTemplate: messageTemplate,
PredicateScriptId: "SCRIPT-1", HistorizeToAveva: false, Retain: false, Enabled: true);
/// <summary>SA predicate uses {{equip}}/Speed with no matching reference → create rejected, error names the token.</summary>
[Fact]
public async Task Create_alarm_equip_ref_in_predicate_unresolved_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipRefScript, referenceName: null);
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput());
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("EQ-1");
result.Error.ShouldContain("{{equip}}/Speed");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.ScriptedAlarms.Any(a => a.ScriptedAlarmId == "ALM-1").ShouldBeFalse();
}
/// <summary>SA message template carries {{equip}}/Bad with no matching reference → create rejected (template scan).</summary>
[Fact]
public async Task Create_alarm_equip_ref_in_message_template_unresolved_rejected()
{
var (service, dbName) = Fresh();
Seed(dbName, PlainScript, referenceName: "Speed"); // predicate has no token; only the template does
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput(messageTemplate: "bad {{equip}}/Missing here"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("{{equip}}/Missing");
}
/// <summary>SA predicate {{equip}}/Speed with a matching reference → create succeeds.</summary>
[Fact]
public async Task Create_alarm_equip_ref_that_resolves_succeeds()
{
var (service, dbName) = Fresh();
Seed(dbName, EquipRefScript, referenceName: "Speed");
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput());
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
}
@@ -0,0 +1,201 @@
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}}/&lt;RefName&gt;</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);
}
/// <summary>Wave-B review LOW-3 edge: an UNRESOLVED <c>{{equip}}/&lt;RefName&gt;</c> (no matching reference)
/// must be left literally intact — identically — on both seams (the deploy gate rejects it separately; the
/// compose seams must not diverge). Here the only reference is "MotorSpeed", so "Nope" cannot resolve.</summary>
[Fact]
public void VirtualTag_equip_ref_unresolved_left_intact_byte_parity()
{
const string unresolvedScript = "return ctx.GetTag(\"{{equip}}/Nope\").Value;";
var script = new Script { ScriptId = "s-1", Name = "n", SourceCode = unresolvedScript, SourceHash = "h" };
var vt = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Double", 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 = unresolvedScript } },
VirtualTags = new[] { new { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Over", DataType = "Double", ScriptId = "s-1" } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem();
cPlan.Expression.ShouldContain("{{equip}}/Nope"); // left intact — not substituted
var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem();
dPlan.ShouldBe(cPlan);
}
/// <summary>Wave-B review LOW-3 edge: RawPath ancestry (folder + tag-group) must resolve identically on both
/// seams. Driver under folder "Cell1", tag under group "Fast" → RawPath "Cell1/Modbus/Dev1/Fast/Speed".</summary>
[Fact]
public void VirtualTag_equip_ref_resolves_through_folder_and_group_byte_parity()
{
const string deepRawPath = "Cell1/Modbus/Dev1/Fast/Speed";
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null };
var tag = new Tag { TagId = "tag-1", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" };
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[] { tag },
rawFolders: new[] { folder }, devices: new[] { Dev() }, tagGroups: new[] { group });
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1" } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1" } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, Name = "Fast" } },
Tags = new[] { new { TagId = "tag-1", DeviceId = "dev-1", TagGroupId = "grp-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);
var cPlan = composed.EquipmentVirtualTags.ShouldHaveSingleItem();
cPlan.Expression.ShouldContain($"ctx.GetTag(\"{deepRawPath}\")");
cPlan.DependencyRefs.ShouldBe(new[] { deepRawPath });
var dPlan = decoded.EquipmentVirtualTags.ShouldHaveSingleItem();
dPlan.ShouldBe(cPlan);
}
}