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