test+docs: retire the stale EquipmentTagsDarkBatch4 skips, guard the deploy-time

tag gate, and fix three silently-broken OpcUaClient integration tests

Continues working through deferment.md's remaining items.

SKIPPED TESTS (13 -> 0 for this reason). The skip said "dark until Batch 4";
Batch 4 shipped as v3.0 and RETIRED the equipment-tag path rather than lighting
it, so every one of them was waiting on something that had already happened and
gone the other way. Resolved individually rather than in bulk:
  - REVIVED onto the raw/UNS shape (6): four DriverHostActor primary-gate cases,
    the cluster-scoping rebuild, and the real-SDK dual-namespace materialisation
    E2E. The behaviour never went dark, only the v2 seeding did.
  - FOLDED into a live parity test (3): DeploymentArtifactRawUnsParityTests
    already compared RawTagPlan record-equal and RawTagPlan carries array /
    historize / alarm intent, so widening its corpus by two tags covers what
    three empty placeholder suites had been promising. Those suites are deleted.
  - DELETED (4): subjects genuinely retired -- equipment-namespace EquipmentTags,
    and both dot-joint {{equip}}.X token suites (that resolver was deleted in
    Batch 3; the slash-joint form is covered by DeploymentArtifactEquipRefParityTests).
Falsified, not assumed: removing the seeded UnsTagReference turns two revived
primary-gate tests red; re-homing the SITE-A node to MAIN turns the scoping test
red. Widening a parity corpus also needed direct value assertions -- parity alone
cannot distinguish "both seams right" from "both seams blind".
Runtime.Tests skips 13->3, OpcUaServer.Tests 4->1; all remaining are deliberate
EquipmentDeviceBindingRetired tombstones.

G-7 (deploy-time TagConfig gate). MQTT shipped an Inspect() nobody ever called;
now wired. Replaced the hand-maintained list with a reflection guard, because the
existing test enumerates driver types by hand and so guards renames but can never
notice a gap. NOTE: the first version of that guard was hollow -- it discovered
candidates via GetReferencedAssemblies(), which is circular, since the compiler
elides a reference nothing in the IL uses. Removing MQTT from the map also removed
its Contracts assembly from the manifest, and the guard passed green with the bug
reintroduced. Rewritten to scan the output directory; re-falsified and it now
fails naming MqttTagDefinitionFactory. Residual recorded at the code: Sql,
MTConnect, Calculation and OpcUaClient have no Contracts-side Inspect at all, so
the deploy gate stays weaker than the AdminUI's authoring gate for them.

OPCUACLIENT INTEGRATION TESTS (3 of 4 failing, on master too). Not fixture rot:
the simulator was healthy and Client.CLI read the very same node Good. v3 made the
driver resolve every reference through its authored RawTags table, so a bare
"ns=3;s=StepUp" fails LOCALLY at the resolver with BadNodeIdInvalid and never
reaches the wire -- the tests were exercising the unresolved-reference guard.
Fixed by seeding OpcPlcProfile.RawTags in the deploy artifact's TagConfig shape
and addressing by RawPath. 4/4 pass.

DOCS. Applied the Phase7* -> AddressSpace* rename across the four live docs
(historical bannered files deliberately untouched), resolving the three that are
not renames to their real members. Found en route that the earlier banner pass
missed three files: VirtualTags.md, OpcUaServer.md and ScriptedAlarms.md all
still presented the test-scaffolding GenericDriverNodeManager as the production
dispatch path. All three now carry the correction.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-28 14:32:13 -04:00
parent 5fff597324
commit 4624141a5d
24 changed files with 556 additions and 495 deletions
+134 -5
View File
@@ -47,8 +47,8 @@ themes into tracked decisions. What remains:
| **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 | | **#521** `IHostConnectivityProbe` / `DriverHostStatus` dead surface | Decision: build the publisher or delete it. The table was deliberately re-created in the v3 initial migration, so deleting on inference would be wrong. | §3.2 |
| **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 | | **#522** driver stability tiers inert | Decision: pass real tiers or delete the machinery. Docs now say every driver runs Tier A. | §3.3 |
| ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) | | ~~**#523** remove the periodic 5-min device re-browse~~ | ✅ **Done** — removed. **AbCip and FOCAS now have no tag-change signal at all**; that is the accepted consequence, not an oversight. | §9 (2026-07-28, #523) |
| **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 | | **G-7** `EquipmentTagConfigInspector` coverage | ◐ **Narrowed + guarded** (2026-07-28): MQTT wired (it shipped an `Inspect` nobody called), and a reflection guard now fails the build if a reachable driver ships one and is not mapped. **Residual:** Sql / MTConnect / Calculation / OpcUaClient have no Contracts-side `Inspect` at all, so the deploy gate stays blind for them while the AdminUI validates them — see §1.2. | §1.2 |
| **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 | | ~~**13 `EquipmentTagsDarkBatch4` skipped tests**~~ | ✅ **Done** — 6 revived onto the raw/UNS shape, 3 folded into a widened live parity corpus, 4 deleted as retired. The reason constant is gone. **Skips now: `Runtime.Tests` 13 → 3, `OpcUaServer.Tests` 4 → 1** — all remaining are deliberate `EquipmentDeviceBindingRetired` tombstones. | §9 (2026-07-28, skips) |
| 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 | | 17 other tracker issues | Driver features + deferred work, unchanged by this remediation. | §2 |
| Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 | | Open live gates | #491 continuous historization, #468 browser tree, archreview R2-03 / R2-10. *(The un-gated drift detector's gate closed by deletion — see #523.)* | §5 |
@@ -99,7 +99,7 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`**`RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames`**picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` | | G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`**`RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames`**picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. | ✅ Fixed — `DriverFormMapParityTests` + `DriverDispatchMapParityTests` |
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. | ✅ Fixed — CSV maps for Sql/Mqtt/MTConnect | | G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. | ✅ Fixed — CSV maps for Sql/Mqtt/MTConnect |
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. | ✅ Fixed — **and at the browser end too**, which the audit missed | | G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. | ✅ Fixed — **and at the browser end too**, which the audit missed |
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. | ⏳ **Open** — pre-existing, unchanged | | G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. **The audit understated it:** the AdminUI's `TagConfigValidator` covers **11 of 12**, so the *deploy* gate is weaker than the *authoring* gate — and config arriving by CSV import or API only meets the deploy gate. | ◐ **Narrowed + guarded** — MQTT wired, reflection guard added; 4 drivers still have no Contracts-side `Inspect`. See §9 |
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader) ### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
@@ -283,8 +283,8 @@ commented-out DI registrations.** Real deferred work lives in prose doc-comments
| Count | Reason | Assessment | | Count | Reason | Assessment |
|---|---|---| |---|---|---|
| ~~18~~ 0 | ~~`DiscoveryInjectionDormantV3`~~ | ✅ **Deleted** (`adce27e7`). The audit's "Real — blocked on #507" was **wrong**: these were v2 characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce, so they could never have unskipped as written. `Runtime.Tests` skipped went 31 → 13. | | ~~18~~ 0 | ~~`DiscoveryInjectionDormantV3`~~ | ✅ **Deleted** (`adce27e7`). The audit's "Real — blocked on #507" was **wrong**: these were v2 characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce, so they could never have unskipped as written. `Runtime.Tests` skipped went 31 → 13. |
| 13 | `EquipmentTagsDarkBatch4` | **Still open.** **STALE** — the reason says these unblock "at Batch 4"; Batch 4 shipped as v3.0 and the path was *retired* (`AddressSpaceApplier.cs:415` calls it "the vestigial equipment-tag path"). These will never unskip as written — delete or rewrite the reason. | | ~~13~~ 0 | ~~`EquipmentTagsDarkBatch4`~~ | **Resolved** (2026-07-28). The audit's "STALE" call was right and the constant is now **deleted**. Not by bulk-unskipping: 6 were **revived** onto the raw/UNS shape that replaced the subject, 3 were **folded** into a live parity test whose corpus was widened to cover them, 4 were **deleted** as genuinely retired. See §9. |
| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is | | 4 | `EquipmentDeviceBindingRetired` | Intentional tombstones preserving retirement rationale — fine as-is |
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`, fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
@@ -712,6 +712,135 @@ Build clean; `Runtime.Tests` 476 passed / 13 skipped (unchanged skip set — the
`EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342, `EquipmentTagsDarkBatch4` group), FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342,
`Core.Abstractions` 266, all green. `Core.Abstractions` 266, all green.
### 2026-07-28 — three silently-broken OpcUaClient integration tests (found by the full-suite run)
Not a register item — found because the full-solution run for the work above showed **three** failing
suites where §9's 2026-07-27 entry had recorded two. `Driver.OpcUaClient.IntegrationTests` was failing 3
of 4.
**It reproduced identically on clean `master`** (verified in a throwaway worktree), so it was not caused
by this work — but it was also not what it first looked like. The chain of wrong guesses is worth
recording, because each one would have closed the investigation early:
1. *"The fixture is down."*`otopcua-opc-plc` was `Up 12 days (unhealthy)`. Restarted it, waited for
healthy. **Still 3/4 failing.**
2. *"The simulator's node set drifted."* — the tests expect `ns=3;s=StepUp`. Browsed the live server:
all three nodes present at exactly those ids. Read `ns=3;s=StepUp` through Client.CLI against the
same endpoint: **`Good`, value 1379.**
3. *"So the driver has a read defect."* — the status code was `2150825984`, which I first decoded by
hand as `BadAttributeIdInvalid` and started hunting a wrong `AttributeId`. **The decode was wrong**
(`0x80330000`, not `0x80350000`) — it is `BadNodeIdInvalid`, and grep found that exact constant on
the driver's own *local* fault path, reached without any wire request.
The actual cause: **v3 made the driver resolve every reference through its authored `RawTags` table**
(RawPath → `TagConfig.nodeId` → live NodeId re-bound against the session's namespace table). A bare
`ns=3;s=StepUp` is no longer a reference the driver accepts. The smoke tests still passed bare node ids
and constructed the driver with an empty `RawTags`, so all three failed locally at the resolver — they
had been testing the unresolved-reference guard, against a perfectly healthy simulator.
Fixed by seeding `OpcPlcProfile.RawTags` with the four nodes in the same `{"nodeId": …}` TagConfig shape
the deploy artifact delivers, and addressing them by RawPath. **4/4 pass.**
Two things this says about the register itself: the 2026-07-27 "only pre-existing environment failures
remain" note was measured against an incomplete baseline (this suite was already broken and simply not
enumerated); and **fixture-gated integration suites rot invisibly** — a driver contract can change under
them and nothing on the local box notices, because the suite is expected to be red-or-skipped depending
on whether a remote container happens to be up.
### 2026-07-28 — §7.2 priority 3: the `Phase7*` → `AddressSpace*` rename
Applied across the four **live** docs (`VirtualTags.md`, `ScriptedAlarms.md`, `ScriptEditor.md`,
`v2/Architecture-v2.md`). The already-bannered historical files (`v2/phase-7-status.md`,
`v2/implementation/*`) were deliberately **left alone** — they are records of what existed at the time,
and renaming inside a historical record would falsify it.
§7.2 was right that three of these are not renames, and each was resolved to the real member:
| Cited | Reality |
|---|---|
| `Phase7CompositionResult` | → **`AddressSpaceComposition`** (and it now also carries the raw subtree, so the doc text was widened, not just renamed) |
| `Phase7Composer.ExtractTagFullName` | → **`ScriptTagCatalog.ExtractFullNameFromTagConfig`** |
| `Phase7EngineComposer.RouteToHistorianAsync` | **Gone, not renamed** — the routing moved into `HistorianAdapterActor` → the registered `IAlarmHistorianSink`. Called out inline so the next reader does not go looking for a renamed symbol. |
**Found en route — the banner pass missed three files.** `GenericDriverNodeManager` is test scaffolding
(§3.4, its own source says "zero production references"), and the earlier remediation bannered
`AddressSpace.md` and `IncrementalSync.md` for it. But `VirtualTags.md`, `OpcUaServer.md` and
`ScriptedAlarms.md` all still presented it as **the** production dispatch path — `VirtualTags.md:3` even
built its opening explanation of driver-vs-virtual dispatch on it. All three now carry the same
correction naming the real chain (`OpcUaPublishActor``SdkAddressSpaceSink``OtOpcUaNodeManager`, two
namespaces). Banners, not rewrites — the underlying rewrite stays owed, consistent with how §7.2 treats
the other two.
### 2026-07-28 — G-7, and what the audit missed about it
The register called this "pre-existing (OpcUaClient + Galaxy absent too); no new driver was added" and
ranked it last. Reading both maps side by side makes it sharper than that:
- The AdminUI's `TagConfigValidator` covers **11 of 12** driver types (all but Galaxy).
- The deploy-time `EquipmentTagConfigInspector` covers **6**.
So the gate that runs at *authoring* is stronger than the gate that runs at *deploy* — and the deploy
gate is the only one config sees when it arrives by **CSV import or API** rather than through the modal.
That is the wrong way round.
**MQTT was a plain hole:** `MqttTagDefinitionFactory.Inspect` has existed since the driver merged and
nothing ever called it. Now wired. The inspector's own doc-comment also claimed "unmapped drivers
(Galaxy, OpcUaClient) are skipped exactly as in the AdminUI validator" — untrue, OpcUaClient *is* mapped
there; corrected.
**A reflection guard replaces the hand-maintained list**, same lesson as G-4: the existing
`EquipmentTagConfigInspectorTests` enumerates driver-type strings by hand, so it guards renames but can
never notice a gap — a newly-inspectable driver simply never appears in it.
⚠️ **The first version of that guard was hollow, and only the falsification step caught it.** It
discovered candidates via `Assembly.GetReferencedAssemblies()`, which is **circular**: the C# compiler
elides a reference nothing in the IL uses, so removing MQTT from the map also removed its Contracts
assembly from ControlPlane's manifest, and the sweep stopped looking for exactly the driver that had just
gone missing. It passed green with the bug reintroduced. Rewritten to scan the output *directory*
(MSBuild copies project-referenced assemblies regardless of IL usage) — re-falsified, and it now fails
naming `MqttTagDefinitionFactory`. It also carries a positive control asserting discovery found more than
one factory, since an empty expected-set is satisfied by an empty map.
**Residual, deliberately not papered over.** Sql, MTConnect, Calculation and OpcUaClient have **no**
`Inspect` in a Contracts assembly — their only checker is the AdminUI `*TagConfigModel.Validate()`, which
lives in the AdminUI project and cannot be referenced from ControlPlane. Closing that means promoting
those models (or an equivalent parser) into each driver's Contracts assembly — a real piece of work, not
a map entry. Recorded in the inspector's own doc-comment so the next reader finds it at the code.
### 2026-07-28 — the 13 stale `EquipmentTagsDarkBatch4` skips
§4.4 flagged these as stale and it was right: the reason said "dark until Batch 4", Batch 4 shipped as
v3.0, and the equipment-tag path was **retired** rather than lit — so every one of them was waiting on
something that had already happened and gone the other way.
**Unskipping them in bulk would have failed, and deleting them in bulk would have thrown away live
coverage.** Each was resolved on its own merits:
| Disposition | Tests | Why |
|---|---|---|
| **Revived** onto the raw/UNS shape | 4 primary-gate cases (`DriverHostActorPrimaryGateTests`), the cluster-scoping rebuild (`OpcUaPublishActorRebuildTests`), the real-SDK materialisation E2E (`AddressSpaceApplierHierarchyTests`) | The *behaviour* never went dark — only the v2 seeding did. Each seeds the v3 chain (RawFolder → Driver → Device → Tag, + `UnsTagReference`) instead. |
| **Folded** into a live parity test | array intent, historize intent, native-alarm/writable intent | `DeploymentArtifactRawUnsParityTests` already compared `RawTagPlan` **record-equal**, and `RawTagPlan` carries all three fields — so widening its corpus by two tags covers what three empty placeholder suites had been promising. Those suites are deleted. |
| **Deleted** | equipment-namespace `EquipmentTags` parse, both `{{equip}}`-token suites | The subjects are retired. The token tests used the **dot-joint** `{{equip}}.X` form, whose resolver (`EquipmentScriptPaths.DeriveEquipmentBase`) was deleted in Batch 3; the slash-joint form is covered live by `DeploymentArtifactEquipRefParityTests` (4 tests). |
Three things worth keeping:
- **The revivals are the point, not the unskipping.** `Unknown_role_single_driver_services_write` asserts
`recorder.Writes.ShouldNotBeEmpty()` — it needs the write to actually reach a driver, which needs a
routable tag. **Verified by deleting the `UnsTagReference` from the seed: it and
`Known_role_wins_over_member_count` go red.** Same check on the cluster-scoping test (re-home the SITE-A
node to MAIN ⇒ red). The three *denial* cases in that file were passing all along — they deny before
routing — which is exactly why the gap was invisible.
- **Widening a parity corpus needed a non-parity assertion to go with it.** A composer that dropped array
intent *entirely* would still satisfy `decoded.RawTags.ShouldBe(composed.RawTags)`, because the artifact
decode would drop it too and the two empties compare equal. Parity cannot distinguish "both right" from
"both blind", so the widened test also asserts the composed values directly.
- **The reason constant is deleted, replaced by a note explaining why.** A skip reason is a claim with an
expiry date, and this one outlived its own condition by a release. Both `DarkAddressSpaceReasons` files
now say so, so the next person writing one states a condition a reader can actually check.
Skipped counts: `Runtime.Tests` **13 → 3**, `OpcUaServer.Tests` **4 → 1**. Every remaining skip is an
`EquipmentDeviceBindingRetired` tombstone — deliberate, and §4.4 already calls those fine as-is.
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6) ### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into **The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
+8
View File
@@ -1,5 +1,13 @@
# OPC UA Server # OPC UA Server
> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
> production references"). The production chain is `OpcUaPublishActor``IOpcUaAddressSpaceSink`
> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
> *through that class* describe a design, not the running server. The same correction is already carried by
> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
The OPC UA server component (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) hosts the OPC UA stack and exposes a browsable address space built from the registered drivers. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`. The OPC UA server component (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) hosts the OPC UA stack and exposes a browsable address space built from the registered drivers. The server itself is driver-agnostic — Galaxy/MXAccess, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client are all plugged in as `IDriver` implementations via the capability interfaces in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`.
In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM.WW.OtOpcUa.Host` binary. Which subsystems start (OPC UA endpoint, Admin UI, control plane, driver runtime) is decided by the `OTOPCUA_ROLES` gate, not by running separate executables. See `docs/ServiceHosting.md` for the role model. In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM.WW.OtOpcUa.Host` binary. Which subsystems start (OPC UA endpoint, Admin UI, control plane, driver runtime) is decided by the `OTOPCUA_ROLES` gate, not by running separate executables. See `docs/ServiceHosting.md` for the role model.
+2 -2
View File
@@ -202,7 +202,7 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` +
**Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")` **Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")`
literal against `DriverInstanceActor.AttributeValuePublished.FullReference`, literal against `DriverInstanceActor.AttributeValuePublished.FullReference`,
which is the `FullName` field from `Tag.TagConfig` which is the `FullName` field from `Tag.TagConfig`
(see `Phase7Composer.ExtractTagFullName` + `EquipmentNodeWalker.ExtractFullName`). (see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`).
The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is
dormant — it is not wired into the host — so UNS browse paths never resolve at dormant — it is not wired into the host — so UNS browse paths never resolve at
@@ -228,7 +228,7 @@ syntax token under the caret to: `LiteralExpressionSyntax` → `ArgumentSyntax`
`GetTag` or `SetVirtualTag`, and (b) the **receiver is the identifier `ctx`** `GetTag` or `SetVirtualTag`, and (b) the **receiver is the identifier `ctx`**
a purely syntactic check that deliberately mirrors the runtime dependency a purely syntactic check that deliberately mirrors the runtime dependency
harvest (`EquipmentScriptPaths.GetTagRefRegex` is `ctx`-anchored), so the editor harvest (`EquipmentScriptPaths.GetTagRefRegex` is `ctx`-anchored), so the editor
offers tag-path completion/hover for exactly what `Phase7Composer` harvests at offers tag-path completion/hover for exactly what `AddressSpaceComposer` harvests at
deploy time. An unrelated `anything.GetTag("…")` no longer triggers it. deploy time. An unrelated `anything.GetTag("…")` no longer triggers it.
**`SetVirtualTag` is a no-op in production.** The live single-tag evaluator **`SetVirtualTag` is a no-op in production.** The live single-tag evaluator
+12 -4
View File
@@ -1,5 +1,13 @@
# Scripted Alarms # Scripted Alarms
> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
> production references"). The production chain is `OpcUaPublishActor``IOpcUaAddressSpaceSink`
> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
> *through that class* describe a design, not the running server. The same correction is already carried by
> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
`Core.ScriptedAlarms` is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native `IAlarmSource` implementations unchanged, and a `ScriptedAlarmSource` simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared `ITagUpstreamSource` and emit condition transitions through the engine's Part 9 state machine. `Core.ScriptedAlarms` is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native `IAlarmSource` implementations unchanged, and a `ScriptedAlarmSource` simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared `ITagUpstreamSource` and emit condition transitions through the engine's Part 9 state machine.
This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-`IAlarmSource` adapter. The server-side plumbing that turns those emissions into OPC UA `AlarmConditionState` nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's `AlarmAck` permission lives in [AlarmTracking.md](AlarmTracking.md) and is not repeated here. This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-`IAlarmSource` adapter. The server-side plumbing that turns those emissions into OPC UA `AlarmConditionState` nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's `AlarmAck` permission lives in [AlarmTracking.md](AlarmTracking.md) and is not repeated here.
@@ -10,7 +18,7 @@ Scripted-alarm definitions are created and edited per-equipment on the **`/uns/e
## Definition shape ## Definition shape
`ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7Composer.Compose` + the driver-role host actor startup path. `ScriptedAlarmDefinition` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `AddressSpaceComposer.Compose` + the driver-role host actor startup path.
| Field | Notes | | Field | Notes |
|---|---| |---|---|
@@ -332,7 +340,7 @@ Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNod
## Composition ## Composition
`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure data composer; it has no knowledge of `ScriptedAlarmEngine`. It maps `ScriptedAlarm` config-DB rows into `ScriptedAlarmPlan` records that the driver-role host actor startup path consumes. `AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure data composer; it has no knowledge of `ScriptedAlarmEngine`. It maps `ScriptedAlarm` config-DB rows into `ScriptedAlarmPlan` records that the driver-role host actor startup path consumes.
In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host: In the v2 actor system, scripted-alarm engine composition is owned by the driver-role host:
@@ -355,8 +363,8 @@ Both engine and source are disposed on server shutdown via the driver-role host
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs``AlarmKind` + `ShelvingKind` + four Part 9 state enums (`AlarmEnabledState`, `AlarmActiveState`, `AlarmAckedState`, `AlarmConfirmedState`); `AlarmSeverity` (`Low`/`Medium`/`High`/`Critical`) lives in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` - `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs``AlarmKind` + `ShelvingKind` + four Part 9 state enums (`AlarmEnabledState`, `AlarmActiveState`, `AlarmAckedState`, `AlarmConfirmedState`); `AlarmSeverity` (`Low`/`Medium`/`High`/`Critical`) lives in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs``{path}` placeholder resolver - `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs``{path}` placeholder resolver
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default - `src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans) - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans)
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed Phase 7 plan into the SDK node manager - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs` — actor that owns the per-alarm state machine; publishes `AlarmTransitionEvent` on the cluster `alerts` DPS topic - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs` — actor that owns the per-alarm state machine; publishes `AlarmTransitionEvent` on the cluster `alerts` DPS topic
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs` — production `IAlarmActorStateStore` backed by the `ScriptedAlarmState` config-DB table - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmActorStateStore.cs` — production `IAlarmActorStateStore` backed by the `ScriptedAlarmState` config-DB table
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs` — production Roslyn predicate evaluator - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs` — production Roslyn predicate evaluator
+13 -5
View File
@@ -1,5 +1,13 @@
# Virtual Tags # Virtual Tags
> ⚠️ **Correction (2026-07-28): `GenericDriverNodeManager` is NOT a production dispatch path.** It is test
> scaffolding — its own source says so (`Core/OpcUa/GenericDriverNodeManager.cs:71`, "verified 07/#10: zero
> production references"). The production chain is `OpcUaPublishActor``IOpcUaAddressSpaceSink`
> (`SdkAddressSpaceSink`) → `OtOpcUaNodeManager`, materialising the **two v3 namespaces** (`.../raw` and
> `.../uns`). Sections below that describe per-node dispatch, rediscovery re-walks, or alarm-sink capture
> *through that class* describe a design, not the running server. The same correction is already carried by
> `AddressSpace.md` and `IncrementalSync.md`; this file was missed in that pass.
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `EquipmentNodeWalker` stamps each `DriverAttributeInfo` with `NodeSourceKind` (`Driver` / `Virtual` / `ScriptedAlarm`) at address-space build time, and `GenericDriverNodeManager` routes reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision. Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `EquipmentNodeWalker` stamps each `DriverAttributeInfo` with `NodeSourceKind` (`Driver` / `Virtual` / `ScriptedAlarm`) at address-space build time, and `GenericDriverNodeManager` routes reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to. In the v2 actor system, `VirtualTagActor` + `DependencyMuxActor` (in `Core.Runtime`) own the per-instance state and upstream-feed wiring; `RoslynVirtualTagEvaluator` (in `Host.Engines`) is the production `IVirtualTagEvaluator` binding. The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to. In the v2 actor system, `VirtualTagActor` + `DependencyMuxActor` (in `Core.Runtime`) own the per-instance state and upstream-feed wiring; `RoslynVirtualTagEvaluator` (in `Host.Engines`) is the production `IVirtualTagEvaluator` binding.
@@ -94,7 +102,7 @@ What the engine pulls driver-tag values from. Reads are **synchronous** because
### `IHistoryWriter` ### `IHistoryWriter`
Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `Phase7EngineComposer.RouteToHistorianAsync` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
**Equipment-namespace path (H5).** The `Historize` flag is threaded end-to-end on the equipment path: `VirtualTag.Historize` → composer + artifact-decode (byte-parity) → `EquipmentVirtualTagPlan.Historize``VirtualTagHostActor`, which calls `IHistoryWriter.Record(nodeId, snapshot)` for every historized result (in addition to publishing the live value). The writer is injectable via DI — `DriverHostActor` resolves `IHistoryWriter` (`TryAddSingleton`, `NullHistoryWriter` default) and threads it into `VirtualTagHostActor`. **This `IHistoryWriter` seam still ships no durable binding** (`NullHistoryWriter` default). Durable continuous historization of driver/virtual values is now handled by the separate `ContinuousHistorizationRecorder` (it taps the dependency-mux value fan-out → a crash-safe FasterLog outbox → the HistorianGateway's `WriteLiveValues` path; see [Historian.md](Historian.md)), not through this seam. A deployment can still bind a custom `IHistoryWriter` via DI. **Equipment-namespace path (H5).** The `Historize` flag is threaded end-to-end on the equipment path: `VirtualTag.Historize` → composer + artifact-decode (byte-parity) → `EquipmentVirtualTagPlan.Historize``VirtualTagHostActor`, which calls `IHistoryWriter.Record(nodeId, snapshot)` for every historized result (in addition to publishing the live value). The writer is injectable via DI — `DriverHostActor` resolves `IHistoryWriter` (`TryAddSingleton`, `NullHistoryWriter` default) and threads it into `VirtualTagHostActor`. **This `IHistoryWriter` seam still ships no durable binding** (`NullHistoryWriter` default). Durable continuous historization of driver/virtual values is now handled by the separate `ContinuousHistorizationRecorder` (it taps the dependency-mux value fan-out → a crash-safe FasterLog outbox → the HistorianGateway's `WriteLiveValues` path; see [Historian.md](Historian.md)), not through this seam. A deployment can still bind a custom `IHistoryWriter` via DI.
@@ -118,11 +126,11 @@ Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B,
## Composition ## Composition
`Phase7Composer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs`) is a pure static function that flattens config-DB entities into a `Phase7CompositionResult` value (UNS topology + driver-instance plans + scripted-alarm plans). `Phase7Applier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`. `AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree: In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree:
- `Phase7Composer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm. - `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use. - `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref. - `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
@@ -151,6 +159,6 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache` - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + driver/alarm plans) - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans)
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — applies the composed plan into the SDK node manager - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — driver-agnostic OPC UA node-manager backbone; per-variable `NodeSourceKind` drives dispatch - `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — driver-agnostic OPC UA node-manager backbone; per-variable `NodeSourceKind` drives dispatch
+1 -1
View File
@@ -43,7 +43,7 @@ src/Server/ server-side projects
ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService
ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons
ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors
ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + Phase7Composer ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + AddressSpaceComposer
ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library
ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs) ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs)
``` ```
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.S7; using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -13,8 +14,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string /// AdminUI <c>TagConfigValidator</c> shape: a case-insensitive map from the canonical driver-type string
/// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, returning human-readable /// to that driver's lightweight <c>Contracts</c> parser <c>Inspect()</c>, returning human-readable
/// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus /// warnings (present-but-invalid enum values that the lenient runtime silently defaults, plus
/// structurally-unparseable TagConfig). Unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in /// structurally-unparseable TagConfig). Never throws.
/// the AdminUI validator. Never throws. /// <para><b>Coverage is every driver that ships an <c>Inspect</c>, and that invariant is guarded</b> by
/// <c>EquipmentTagConfigInspectorCoverageTests</c>, which reflects over the referenced
/// <c>*.Contracts</c> assemblies — so a driver that gains an inspector cannot be silently left out. This
/// is the same guard shape the driver-form dispatch maps use, and for the same reason: the previous
/// hand-maintained list had drifted (MQTT shipped an <c>Inspect</c> and was never added).</para>
/// <para><b>Residual gap, deliberately recorded rather than papered over.</b> Galaxy, OpcUaClient, Sql,
/// MTConnect and Calculation have <b>no</b> <c>Inspect</c> in a Contracts assembly, so this deploy-time
/// gate cannot check them — while the AdminUI's <c>TagConfigValidator</c> DOES cover all but Galaxy via
/// its <c>*TagConfigModel.Validate()</c> models, which live in the AdminUI project and cannot be
/// referenced from here. The asymmetry runs the wrong way: config authored through the modal is checked,
/// but config arriving by CSV import or API is not. Closing it means promoting those models (or an
/// equivalent parser) into each driver's Contracts assembly. Tracked as deferment-register G-7.</para>
/// </summary> /// </summary>
public static class EquipmentTagConfigInspector public static class EquipmentTagConfigInspector
{ {
@@ -27,10 +39,12 @@ public static class EquipmentTagConfigInspector
[DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect, [DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect,
[DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect, [DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect,
[DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect, [DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect,
[DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect,
}; };
/// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings, /// <summary>Inspects a tag's <c>TagConfig</c> for the given driver type. Returns the driver's warnings,
/// or an empty list when the driver type is unmapped (Galaxy/OpcUaClient) or the inputs are null.</summary> /// or an empty list when the driver type has no inspector (see the class remarks) or the inputs are
/// null.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c> (canonical string, case-insensitive).</param> /// <param name="driverType">The bound driver's <c>DriverType</c> (canonical string, case-insensitive).</param>
/// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param> /// <param name="tagConfig">The equipment tag's <c>TagConfig</c> JSON.</param>
/// <returns>The warnings; empty when the driver is unmapped or clean.</returns> /// <returns>The warnings; empty when the driver is unmapped or clean.</returns>
@@ -40,9 +54,12 @@ public static class EquipmentTagConfigInspector
return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>(); return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty<string>();
} }
/// <summary>Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only).</summary> /// <summary>Whether the given driver type has a mapped inspector.</summary>
/// <param name="driverType">The bound driver's <c>DriverType</c>.</param> /// <param name="driverType">The bound driver's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns> /// <returns><see langword="true"/> when an inspector is registered for the driver type.</returns>
public static bool IsMapped(string? driverType) => public static bool IsMapped(string? driverType) =>
!string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType); !string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType);
/// <summary>The driver types this gate can check — the guard test's read of the map.</summary>
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Inspectors.Keys;
} }
@@ -45,6 +45,7 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,3 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
@@ -7,6 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// tests address. Node IDs are stable across opc-plc releases — the simulator /// tests address. Node IDs are stable across opc-plc releases — the simulator
/// guarantees the same <c>ns=3;s=...</c> names shipped since v1.0. If a release /// guarantees the same <c>ns=3;s=...</c> names shipped since v1.0. If a release
/// bump breaks these, the fixture's pinned image tag needs a coordinated bump. /// bump breaks these, the fixture's pinned image tag needs a coordinated bump.
/// <para><b>These tests address the driver by RawPath, not by node id (fixed 2026-07-28).</b> v3
/// made the driver resolve every read/write/subscribe reference through its authored
/// <see cref="OpcUaClientDriverOptions.RawTags"/> table — a bare <c>ns=3;s=StepUp</c> is no longer a
/// reference the driver accepts, and one that does not resolve is failed LOCALLY as
/// <c>BadNodeIdInvalid</c> with no wire round-trip. The smoke tests still passed bare node ids, so all
/// three had been failing against a perfectly healthy simulator. Seeding <c>RawTags</c> here is what
/// makes them exercise the driver instead of its unresolved-reference guard.</para>
/// </summary> /// </summary>
public static class OpcPlcProfile public static class OpcPlcProfile
{ {
@@ -22,11 +31,37 @@ public static class OpcPlcProfile
/// <summary>opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.</summary> /// <summary>opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.</summary>
public const string FastUInt1 = "ns=3;s=FastUInt1"; public const string FastUInt1 = "ns=3;s=FastUInt1";
/// <summary>The RawPath the smoke tests address <see cref="StepUp"/> by.</summary>
public const string StepUpRef = "Sim/OpcUaClient/plc/StepUp";
/// <summary>The RawPath the smoke tests address <see cref="RandomSignedInt32"/> by.</summary>
public const string RandomSignedInt32Ref = "Sim/OpcUaClient/plc/RandomSignedInt32";
/// <summary>The RawPath the smoke tests address <see cref="AlternatingBoolean"/> by.</summary>
public const string AlternatingBooleanRef = "Sim/OpcUaClient/plc/AlternatingBoolean";
/// <summary>The RawPath the smoke tests address <see cref="FastUInt1"/> by.</summary>
public const string FastUInt1Ref = "Sim/OpcUaClient/plc/FastUInt1";
/// <summary>The authored raw-tag table the driver resolves the <c>*Ref</c> RawPaths through — the
/// same <c>{"nodeId": …}</c> TagConfig shape the deploy artifact delivers.</summary>
public static IReadOnlyList<RawTagEntry> RawTags { get; } =
[
Entry(StepUpRef, StepUp),
Entry(RandomSignedInt32Ref, RandomSignedInt32),
Entry(AlternatingBooleanRef, AlternatingBoolean),
Entry(FastUInt1Ref, FastUInt1),
];
private static RawTagEntry Entry(string rawPath, string nodeId) =>
new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
/// <summary>Builds driver options for the OPC PLC endpoint.</summary> /// <summary>Builds driver options for the OPC PLC endpoint.</summary>
/// <param name="endpointUrl">The endpoint URL of the OPC PLC simulator.</param> /// <param name="endpointUrl">The endpoint URL of the OPC PLC simulator.</param>
/// <returns>Configured driver options for the OPC PLC.</returns> /// <returns>Configured driver options for the OPC PLC.</returns>
public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new() public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new()
{ {
RawTags = RawTags,
EndpointUrl = endpointUrl, EndpointUrl = endpointUrl,
SecurityPolicy = OpcUaSecurityPolicy.None, SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None, SecurityMode = OpcUaSecurityMode.None,
@@ -27,7 +27,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync( var snapshots = await drv.ReadAsync(
[OpcPlcProfile.StepUp], TestContext.Current.CancellationToken); [OpcPlcProfile.StepUpRef], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(1); snapshots.Count.ShouldBe(1);
snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end"); snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end");
@@ -45,7 +45,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snapshots = await drv.ReadAsync( var snapshots = await drv.ReadAsync(
[OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean], [OpcPlcProfile.StepUpRef, OpcPlcProfile.RandomSignedInt32Ref, OpcPlcProfile.AlternatingBooleanRef],
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(3); snapshots.Count.ShouldBe(3);
@@ -78,7 +78,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
}; };
var handle = await drv.SubscribeAsync( var handle = await drv.SubscribeAsync(
[OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250), [OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250),
TestContext.Current.CancellationToken); TestContext.Current.CancellationToken);
// FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver. // FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver.
@@ -0,0 +1,113 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Guards the deploy-time TagConfig gate against the failure mode that produced deferment-register G-7:
/// a driver ships an <c>Inspect</c> on its <c>*TagDefinitionFactory</c>, nobody adds it to
/// <see cref="EquipmentTagConfigInspector"/>, and the gate is silently blind for that driver at deploy
/// while the AdminUI still validates it at authoring time. MQTT sat in exactly that state.
/// <para><b>Why reflection rather than a hand-written list.</b> The sibling
/// <c>EquipmentTagConfigInspectorTests</c> enumerates driver-type strings by hand, so it guards
/// <i>renames</i> but can never notice a <i>gap</i> — a newly-inspectable driver simply never appears in
/// it. This is the same hollow-guard shape found in <c>TagConfigDriverTypeNameGuardTests</c> and
/// <c>CsvColumnMapReflectionTests</c>. Deriving the expected set from the assemblies means the test
/// fails the moment a new inspector exists, without anyone remembering to update it.</para>
/// </summary>
public sealed class EquipmentTagConfigInspectorCoverageTests
{
/// <summary>
/// Every <c>*TagDefinitionFactory</c> reachable from ControlPlane that exposes a public static
/// <c>Inspect(string)</c> must be wired into the dispatch map.
/// <para>Discovery walks the referenced assemblies rather than <c>AppDomain</c> alone, because a
/// Contracts assembly with no other use is not loaded until first touched — an
/// <c>AppDomain.GetAssemblies()</c> sweep would quietly find nothing and pass vacuously.</para>
/// </summary>
[Fact]
public void Every_driver_shipping_an_Inspect_is_wired_into_the_dispatch_map()
{
var inspectable = DiscoverInspectableFactories();
// Positive control: the discovery itself must find something, or the assertion below is vacuous —
// an empty expected-set is satisfied by an empty map, which is the exact bug this guards.
inspectable.Count.ShouldBeGreaterThan(
1,
"the reflection sweep found no (or one) driver factory with an Inspect — discovery is broken, "
+ "and the coverage assertion below would pass no matter what the map contained");
var mapped = EquipmentTagConfigInspector.MappedDriverTypes;
var missing = inspectable
.Where(t => !mapped.Any(m => LooksLike(m, t.Name)))
.Select(t => t.Name)
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
missing.ShouldBeEmpty(
"these drivers ship an Inspect() but the deploy-time gate does not call it, so their TagConfig "
+ "is checked at authoring time and NOT at deploy — config arriving by CSV import or API is "
+ "unchecked: " + string.Join(", ", missing));
}
/// <summary>The map must not name a driver type with no inspector behind it — a dead key would read as
/// coverage while dispatching nowhere.</summary>
[Fact]
public void Every_mapped_driver_type_resolves_to_a_real_inspector()
{
foreach (var driverType in EquipmentTagConfigInspector.MappedDriverTypes)
{
EquipmentTagConfigInspector.IsMapped(driverType).ShouldBeTrue(driverType);
// A structurally invalid blob must come back as warnings, not an exception — the gate is on the
// deploy path and must never throw.
Should.NotThrow(() => EquipmentTagConfigInspector.Inspect(driverType, "{not json"));
}
}
/// <summary>
/// Public static <c>IReadOnlyList&lt;string&gt; Inspect(string)</c> is the contract every driver's
/// deploy-time checker implements; anything else is not one of these factories.
/// <para><b>Discovery scans the output DIRECTORY, not <c>Assembly.GetReferencedAssemblies()</c>.</b>
/// The obvious implementation walks the reference graph — and it is circular, because the C# compiler
/// elides an assembly reference nothing in the IL uses. Drop a driver from the dispatch map and its
/// Contracts assembly drops out of ControlPlane's manifest too, so the sweep stops looking for
/// exactly the driver that just went missing and the test passes. <b>That version was written, and
/// caught only by deleting the MQTT entry and watching it stay green.</b> MSBuild copies every
/// project-referenced assembly to the output regardless of IL usage, so the directory is the honest
/// source.</para>
/// <para><b>Boundary, stated so it is not mistaken for more:</b> this sees the Contracts assemblies
/// ControlPlane project-references. A driver whose Contracts project is not referenced at all (Sql,
/// MTConnect, Calculation, OpcUaClient) is invisible here — adding the reference is itself the act
/// that makes this guard start demanding a map entry.</para>
/// </summary>
private static IReadOnlyList<Type> DiscoverInspectableFactories() =>
Directory.GetFiles(AppContext.BaseDirectory, "ZB.MOM.WW.OtOpcUa.Driver.*.Contracts.dll")
.Select(TryLoad)
.Where(a => a is not null)
.SelectMany(a => a!.GetTypes())
.Where(t => t is { IsClass: true, IsPublic: true }
&& t.Name.EndsWith("TagDefinitionFactory", StringComparison.Ordinal)
&& HasInspect(t))
.DistinctBy(t => t.FullName)
.ToArray();
private static Assembly? TryLoad(string path)
{
try { return Assembly.LoadFrom(path); }
catch (BadImageFormatException) { return null; }
}
private static bool HasInspect(Type t)
{
var m = t.GetMethod("Inspect", BindingFlags.Public | BindingFlags.Static, [typeof(string)]);
return m is not null && typeof(IReadOnlyList<string>).IsAssignableFrom(m.ReturnType);
}
/// <summary>Matches a mapped driver-type string to a factory type name (<c>ModbusTagDefinitionFactory</c>
/// ↔ <c>Modbus</c>), tolerating the casing differences between the two vocabularies
/// (<c>FOCAS</c>/<c>Focas</c>, <c>TwinCAT</c>/<c>TwinCAT</c>).</summary>
private static bool LooksLike(string driverType, string factoryTypeName) =>
factoryTypeName.StartsWith(driverType, StringComparison.OrdinalIgnoreCase);
}
@@ -156,17 +156,21 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
} }
/// <summary> /// <summary>
/// Full structure-materialisation pipeline against a real SDK node manager: real Config /// Full v3 structure-materialisation pipeline against a real SDK node manager: real Config entities
/// entities (Area / Line / Equipment + an Equipment-namespace Tag) → <see cref="AddressSpaceComposer.Compose"/> /// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a <c>UnsTagReference</c>) →
/// → MaterialiseHierarchy + MaterialiseEquipmentTags → <see cref="OtOpcUaNodeManager"/>. Proves /// <see cref="AddressSpaceComposer.Compose"/> → MaterialiseHierarchy + MaterialiseRawSubtree +
/// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal /// MaterialiseUnsReferences → <see cref="OtOpcUaNodeManager"/>. Proves the <b>dual-namespace</b>
/// Variable in a live OPC UA address space (structure-only; live values are a later milestone). /// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the
/// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy + /// raw tag's variable projected into its referencing equipment as a second variable.
/// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked /// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> skip. It was parked pending "the Batch-4
/// as a follow-up. /// UnsTagReference fan-out", which shipped as v3.0 — but the equipment-tag path it was written
/// against was RETIRED rather than lit, so unskipping alone would have failed. Rewritten to the
/// shape that actually replaced it. The sibling <c>MaterialiseHierarchy_against_real_SDK</c> still
/// covers folders only; this is the layer's only real-SDK proof that variables materialise in both
/// namespaces.</para>
/// </summary> /// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK() public async Task Raw_and_uns_variables_materialise_end_to_end_against_real_SDK()
{ {
await using var host = new OpcUaApplicationHost( await using var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions new OpcUaApplicationHostOptions
@@ -183,34 +187,46 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
await host.StartAsync(sdkServer, Ct); await host.StartAsync(sdkServer, Ct);
sdkServer.NodeManager.ShouldNotBeNull(); sdkServer.NodeManager.ShouldNotBeNull();
// One area / line / equipment + a Modbus driver. v3: no Namespace entity, DriverInstance no // The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed"
// longer binds a Namespace, and Equipment no longer binds a driver. The equipment-bound Tag // (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into.
// (the Speed signal) that this end-to-end test materialised as a Variable node is DARK until var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null };
// Batch 4 — the composer emits an empty EquipmentTags set, so it is no longer seeded/passed var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" };
// here. Equipment.Name is the UNS browse segment. (Folder-only materialisation is covered live var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" };
// by the sibling MaterialiseHierarchy_against_real_SDK test; this suite's equipment-tag var speed = new Tag
// Variable end-to-end leg re-enables in Batch 4 via the UnsTagReference fan-out.) {
var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" }; TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = null, Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" }; var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" }; var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" }; var equipment = new Equipment { EquipmentId = "eq-1", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null };
var composition = AddressSpaceComposer.Compose( var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver }, new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>()); Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed },
rawFolders: new[] { folder }, devices: new[] { device });
// Compose-side EquipmentTags extraction (Batch-4 subject — empty this batch). // Compose side: one raw tag at its RawPath, projected once into the referencing equipment.
var planned = composition.EquipmentTags.ShouldHaveSingleItem(); var rawTag = composition.RawTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1"); rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed");
planned.FullName.ShouldBe("40001"); rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev1/Speed");
var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!); var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance); var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition); applier.MaterialiseHierarchy(composition);
applier.MaterialiseEquipmentTags(composition); applier.MaterialiseRawSubtree(composition);
applier.MaterialiseUnsReferences(composition);
sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment // UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device.
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder sdkServer.NodeManager!.FolderCount.ShouldBe(6);
// ONE source value, TWO nodes — the raw tag's variable and its UNS projection. This is the
// single-source-fan-out shape; a regression that dropped either namespace shows up here as 1.
sdkServer.NodeManager!.VariableCount.ShouldBe(2);
} }
/// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's /// <summary>OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's
@@ -1,30 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Characterized the live-edit compose seam admitting a Galaxy point as an ordinary equipment tag:
/// an equipment-scoped <c>Tag</c> bound to a <c>GalaxyMxGateway</c> driver surfaced under
/// <see cref="AddressSpaceComposition.EquipmentTags"/> carrying its driver-side FullName (coalescing a
/// null FolderPath to <c>string.Empty</c>).
/// <para><b>v3 Batch-1 DARK + reshaped seed:</b> equipment-tag variable plans do not materialize until
/// Batch 4 (the composer emits an empty <c>EquipmentTags</c> set — the raw + UNS variable nodes light
/// up in Batch 4's dual-namespace UNS↔Raw fan-out). The seed mechanism this test used is also gone:
/// <c>Tag</c> is now raw-only (no <c>EquipmentId</c>/<c>DriverInstanceId</c>/<c>FolderPath</c>), the
/// Compose overload no longer takes tags/namespaces, and equipment references raw tags via
/// <c>UnsTagReference</c> rather than a Galaxy point carrying an <c>EquipmentId</c>. So there is no
/// Batch-1 analog to assert. Re-author against the UnsTagReference → EquipmentTags fan-out when Batch 4
/// lights the equipment-tag variable nodes.</para>
/// </summary>
public sealed class AddressSpaceComposerAliasTagTests
{
/// <summary>A <c>GalaxyMxGateway</c> equipment tag surfaces under EquipmentTags with its FullName —
/// dark until Batch 4 (empty equipment-tag plan set) and re-authored via UnsTagReference.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
{
// Dark until Batch 4: see class summary. EquipmentTags is emitted empty this batch and the
// Tag.EquipmentId binding this test relied on is retired (Tag is raw-only). The Batch-4 seam
// fans equipment tags in via UnsTagReference; re-author the assertion then.
}
}
@@ -1,81 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Verifies the live-edit compose seam (<see cref="AddressSpaceComposer.Compose"/>) substitutes the
/// reserved <c>{{equip}}</c> token in a shared VirtualTag script with each owning equipment's
/// derived tag base (from its child-tag <c>FullName</c>s) — so one script reused across N
/// identical machines resolves to N machine-specific dependency graphs.
///
/// <para><b>v3 Batch-1 DARK:</b> the per-equipment tag base is DERIVED from equipment-tag
/// <c>FullName</c>s, and equipment-tag variable plans do not materialize until Batch 4 (the composer
/// no longer takes tags — <c>baseByEquip</c> is empty this batch). With no base to substitute, the
/// <c>{{equip}}</c> token stays literal and these per-machine assertions have nothing to compare, so
/// the test is Skipped-with-reason. The seed + assertions are preserved verbatim (minus the retired
/// entity columns) so re-enabling in Batch 4 is a one-line change once the fan-out feeds the base.</para>
/// </summary>
public sealed class AddressSpaceComposerEquipTokenTests
{
/// <summary>One shared <see cref="Script"/> using <c>ctx.GetTag("{{equip}}.Source")</c>, bound
/// to two equipments (TestMachine_001 / _002) each with one equipment Tag whose FullName carries
/// the per-machine base. Compose must expand the token per equipment in both the Expression and
/// the parsed DependencyRefs.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Compose_substitutes_equip_token_per_equipment()
{
// v3: no Namespace entity; DriverInstance no longer binds a Namespace; Equipment no longer
// binds a driver. In Batch 4 the {{equip}} base derives from the equipment's child-tag
// FullNames — here TestMachine_001.Source / TestMachine_002.Source — which flow through the
// (dark this batch) equipment-tag plan set. Seeded for intent; not passed to Compose.
var driver1 = new DriverInstance
{
DriverInstanceId = "drv-1",
ClusterId = "c1",
Name = "Modbus1",
DriverType = "Modbus",
DriverConfig = "{}",
};
var driver2 = new DriverInstance
{
DriverInstanceId = "drv-2",
ClusterId = "c1",
Name = "Modbus2",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip1 = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
var equip2 = new Equipment { EquipmentId = "eq-2", UnsLineId = "line-1", Name = "TestMachine_002", MachineCode = "TESTMACHINE_002" };
var script = new Script
{
ScriptId = "s-equip",
Name = "over-50",
SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
SourceHash = "hash-equip",
};
var vt1 = new VirtualTag { VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
var vt2 = new VirtualTag { VirtualTagId = "vt-2", EquipmentId = "eq-2", Name = "over50", DataType = "Boolean", ScriptId = "s-equip" };
var result = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip1, equip2 },
new[] { driver1, driver2 }, Array.Empty<ScriptedAlarm>(),
virtualTags: new[] { vt1, vt2 },
scripts: new[] { script });
result.EquipmentVirtualTags.Count.ShouldBe(2);
var plan1 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-1");
plan1.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
plan1.Expression.ShouldNotContain("{{equip}}");
plan1.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
var plan2 = result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-2");
plan2.Expression.ShouldContain("ctx.GetTag(\"TestMachine_002.Source\")");
plan2.Expression.ShouldNotContain("{{equip}}");
plan2.DependencyRefs.ShouldBe(new[] { "TestMachine_002.Source" });
}
}
@@ -1,27 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary> /// <summary>
/// Shared xUnit <c>Skip</c> reason constants for OpcUaServer tests whose intent is retained but /// Shared xUnit <c>Skip</c> reason constants for OpcUaServer tests whose subject was RETIRED by v3
/// whose subject is not materialized in the v3 Batch-1 DARK address space. The composer /// rather than merely deferred — architectural tombstones that keep the removed coverage discoverable
/// (<see cref="AddressSpaceComposer.Compose"/>) and the artifact both emit an EMPTY equipment-tag /// and name what replaced it, so nobody re-writes a test for a concept that no longer exists.
/// variable plan set this batch — raw + UNS variable nodes are lit up in Batch 4's dual-namespace /// <para><b>A skip reason is a claim with an expiry date.</b> The one this file used to carry
/// UNS↔Raw fan-out. Until then any assertion whose subject is an equipment-tag variable node (its /// (<c>EquipmentTagsDarkBatch4</c>, "dark until Batch 4") outlived its own condition by a release —
/// FullName, its <c>{{equip}}</c>-token-derived base, its device-host binding) has nothing to /// see the note below. If you add one here, state the condition precisely enough that a reader can
/// compare, so it is Skipped-with-reason rather than deleted. Mirrors /// check whether it has already happened.</para>
/// <c>Runtime.Tests.Drivers.DarkAddressSpaceReasons</c>. /// Mirrors <c>Runtime.Tests.Drivers.DarkAddressSpaceReasons</c>.
/// </summary> /// </summary>
internal static class DarkAddressSpaceReasons internal static class DarkAddressSpaceReasons
{ {
/// <summary> // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read
/// Equipment-tag variable materialization + its per-field intent (FullName / array / historize / // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than
/// native-alarm / <c>{{equip}}</c>-token base derived from child-tag FullNames) is dark until Batch 4. // lighting it, so every test wearing this reason was waiting on something that had already happened and
/// </summary> // gone the other way. Each was resolved on its merits rather than left to read as pending work:
public const string EquipmentTagsDarkBatch4 = // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace
"v3 dark address space: equipment-tag variable plans (FullName / array / historize / alarm / " + // materialisation E2E in AddressSpaceApplierHierarchyTests; the cluster-scoping rebuild and the
"{{equip}}-token base derived from child-tag FullNames) do not materialize until Batch 4 " + // four primary-gate write/alarm cases in Runtime.Tests),
"(dual-namespace UNS↔Raw fan-out). The composer emits an empty equipment-tag plan set this batch, " + // - folded into a live parity test whose corpus was widened to cover them (array + native-alarm
"so there is no per-equipment tag base to substitute and no equipment-tag variable node to assert. " + // intent, now in DeploymentArtifactRawUnsParityTests),
"Migrate + re-enable when Batch 4 lights the raw/UNS variable nodes."; // - or deleted, where the subject itself is retired (equipment-namespace EquipmentTags, the
// dot-joint {{equip}}.X token — superseded by the slash-joint form in
// DeploymentArtifactEquipRefParityTests).
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary> /// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired = public const string EquipmentDeviceBindingRetired =
@@ -1,22 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary> /// <summary>
/// Shared xUnit <c>Skip</c> reason constants for tests whose intent is retained but whose subject is /// Shared xUnit <c>Skip</c> reason constants for tests whose subject was RETIRED by v3 rather than
/// not materialized in the v3 Batch-1 DARK address space. Equipment-tag variable nodes (and their /// merely deferred — architectural tombstones that keep the removed coverage discoverable and name
/// per-field intent: FullName / array / historize / native-alarm) are lit up by the Batch-4 dual /// what replaced it, so nobody re-writes a test for a concept that no longer exists.
/// namespace + UNS↔Raw fan-out; until then the composer/artifact both emit an EMPTY equipment-tag /// <para><b>A skip reason is a claim with an expiry date.</b> The one this file used to carry
/// plan set, so any parity assertion over those plans has nothing to compare. The raw-tag parse /// (<c>EquipmentTagsDarkBatch4</c>, "dark until Batch 4") outlived its own condition by a release —
/// intent these tests characterized lives on in <see cref="TagConfigCorpusParityTests"/> (RawTagEntry /// see the note below. If you add one here, state the condition precisely enough that a reader can
/// round-trip) and <c>TagConfigIntentTests</c> (the parse unit). /// check whether it has already happened.</para>
/// </summary> /// </summary>
internal static class DarkAddressSpaceReasons internal static class DarkAddressSpaceReasons
{ {
/// <summary>Equipment-tag variable materialization + its per-field intent is dark until Batch 4.</summary> // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read
public const string EquipmentTagsDarkBatch4 = // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than
"v3 dark address space: equipment-tag variable plans (FullName/array/historize/alarm) do not " + // lighting it, so every test wearing this reason was waiting on something that had already happened and
"materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact both emit an " + // gone the other way. Each was resolved on its merits rather than left to read as pending work:
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; // materialisation E2E in AddressSpaceApplierHierarchyTests; the cluster-scoping rebuild and the
// four primary-gate write/alarm cases in Runtime.Tests),
// - folded into a live parity test whose corpus was widened to cover them (array + native-alarm
// intent, now in DeploymentArtifactRawUnsParityTests),
// - or deleted, where the subject itself is retired (equipment-namespace EquipmentTags, the
// dot-joint {{equip}}.X token — superseded by the slash-joint form in
// DeploymentArtifactEquipRefParityTests).
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18 // DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft // tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
@@ -1,32 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Galaxy-as-ordinary-equipment-tag byte-parity: a Galaxy point (equipment-scoped tag bound to a
/// <c>GalaxyMxGateway</c> driver) surfaced into <c>EquipmentTags</c> with the same FullName /
/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer
/// and the artifact-decode seam.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — both producers emit an
/// EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the
/// v3 schema retired the <c>Namespace</c>/<c>NamespaceKind</c> entities and the Galaxy
/// <c>ExplicitFullName</c> / namespace-Kind gate this test keyed off; Galaxy is an ordinary
/// Device-bound driver whose raw tags reach the UNS via <c>UnsTagReference</c>. The AccessLevel→Writable
/// and native-alarm parse this asserted are covered at the parse unit (<c>TagConfigIntentTests</c>) and
/// the RawTagEntry round-trip (<see cref="TagConfigCorpusParityTests"/>). Unskip + rewrite to the
/// Batch-4 UNS-projection shape when equipment-tag plans light up.
/// </para>
/// </summary>
public sealed class DeploymentArtifactAliasParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on a Galaxy equipment tag surfaced in
/// EquipmentTags (FullName + Writable + native alarm). See class remarks — dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_galaxy_equipment_tag()
{
// Restored + rewritten to the Batch-4 UNS-projection shape when equipment-tag variable plans
// materialize: assert a Galaxy point + a Modbus point round-trip identically (FullName, Writable
// from AccessLevel, native-alarm intent incl. historizeToAveva) across composer and artifact.
}
}
@@ -1,28 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Phase 4c array intent (<c>isArray</c> + optional <c>arrayLength</c>) byte-parity between the two
/// equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize — the composer and the
/// artifact both emit an EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw
/// fan-out, so there is nothing to compare. The underlying parse of <c>isArray</c> / <c>arrayLength</c>
/// from a raw <c>TagConfig</c> blob is characterized today by <c>TagConfigIntentTests</c> and
/// round-tripped through the artifact by <see cref="TagConfigCorpusParityTests"/> (RawTagEntry
/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactArrayParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on array equipment-tag plans
/// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_array_equipment_tags()
{
// Restored when Batch 4 materializes equipment-tag variable plans: compose a draft exercising every
// array branch (isArray:true+length, absent, isArray:true no length, non-number length) and assert
// decoded EquipmentTags == composed element-wise.
}
}
@@ -1,74 +0,0 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the artifact-decode mirror substitutes the reserved <c>{{equip}}</c> token in a
/// VirtualTag script's <c>ctx.GetTag("…")</c> literals with the owning equipment's tag base
/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with
/// <c>AddressSpaceComposer.Compose</c>'s live-edit path, using the same shared
/// <c>EquipmentScriptPaths</c> helper and the same equipmentTags-derived base.
/// </summary>
public sealed class DeploymentArtifactEquipTokenTests
{
// v3 dark: the {{equip}} tag base is DERIVED from the owning equipment's child equipment-tag FullNames,
// and equipment-tag plans do not materialize until Batch 4 — so baseByEquip is empty and {{equip}} has
// no per-equipment base to substitute. Unskip when Batch 4 lights the equipment-tag plans.
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void ParseComposition_substitutes_equip_token_in_virtual_tag_expression()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-source",
DriverInstanceId = "drv-modbus",
EquipmentId = "TestMachine_001",
Name = "Source",
FolderPath = (string?)null,
DataType = "Float",
TagConfig = "{\"FullName\":\"TestMachine_001.Source\"}",
},
},
Scripts = new[]
{
new
{
ScriptId = "s-equip",
SourceCode = "return System.Convert.ToInt32(ctx.GetTag(\"{{equip}}.Source\").Value) > 50;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-equip",
EquipmentId = "TestMachine_001",
Name = "OverThreshold",
DataType = "Boolean",
ScriptId = "s-equip",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
vt.Expression.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
vt.Expression.ShouldNotContain("{{equip}}");
vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.Source" });
}
}
@@ -1,30 +0,0 @@
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Phase C HistoryRead intent (<c>isHistorized</c> + optional <c>historianTagname</c>) byte-parity
/// between the two equipment-tag producers.
/// <para>
/// <b>v3 DARK (Batch 1):</b> equipment-tag variable plans do not materialize —
/// <c>AddressSpaceComposer.Compose</c> and <c>DeploymentArtifact.ParseComposition</c> both emit an
/// EMPTY <c>EquipmentTags</c> set until the Batch-4 dual-namespace UNS↔Raw fan-out. There is nothing
/// to compare, so the parity assertion is parked. The underlying parse of <c>isHistorized</c> /
/// <c>historianTagname</c> from a raw <c>TagConfig</c> blob is characterized today by
/// <c>TagConfigIntentTests</c> (the parse unit) and round-tripped through the artifact by
/// <see cref="TagConfigCorpusParityTests"/> (RawTagEntry byte-preservation). Unskip + restore the
/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans.
/// </para>
/// </summary>
public sealed class DeploymentArtifactHistorizeParityTests
{
/// <summary>Batch-4 pending: composer and artifact agree on historized equipment-tag plans
/// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Composer_and_artifact_agree_on_historized_equipment_tags()
{
// Restored when Batch 4 materializes equipment-tag variable plans: compose a draft with a
// historized tag (no explicit tagname → HistorianTagname null), a historized tag WITH an explicit
// historianTagname override, and a plain tag; assert decoded EquipmentTags == composed element-wise.
}
}
@@ -23,8 +23,15 @@ public sealed class DeploymentArtifactRawUnsParityTests
[Fact] [Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode() public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{ {
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override, // Entity-side config: folder → driver → device → group → 4 tags, an area/line/equipment, and a UNS
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override. // reference with a display-name override. The four tags cover every per-field intent RawTagPlan
// carries: historize (+ tagname override), plain read-only, ARRAY (isArray + arrayLength), and
// NATIVE ALARM (a TagConfig.alarm object → EquipmentTagAlarmInfo).
//
// The array and alarm tags were folded in here when the three placeholder suites
// (DeploymentArtifact{Array,Historize,Alias}ParityTests) were deleted — they had been parked on
// "unskip when Batch 4 lights the equipment-tag plans", but Batch 4 RETIRED equipment-tag plans
// instead of lighting them, so their subject moved onto the raw tag and belongs in this comparison.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null }; var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" }; var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" }; var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
@@ -39,6 +46,17 @@ public sealed class DeploymentArtifactRawUnsParityTests
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean", TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
}; };
var profile = new Tag
{
TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":16}",
};
var tempHi = new Tag
{
TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = null, Name = "TempHi", DataType = "Boolean",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
};
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" }; var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" }; var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" }; var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
@@ -47,7 +65,7 @@ public sealed class DeploymentArtifactRawUnsParityTests
var composed = AddressSpaceComposer.Compose( var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip }, new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(), new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed, run }, unsTagReferences: new[] { reference }, tags: new[] { speed, run, profile, tempHi },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group }); rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0). // Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
@@ -61,6 +79,8 @@ public sealed class DeploymentArtifactRawUnsParityTests
{ {
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" }, new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
new { TagId = "tag-profile", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Profile", DataType = "Float", AccessLevel = 0, TagConfig = "{\"isArray\":true,\"arrayLength\":16}" },
new { TagId = "tag-temphi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "TempHi", DataType = "Boolean", AccessLevel = 1, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}" },
}, },
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } }, UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } }, UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
@@ -70,8 +90,19 @@ public sealed class DeploymentArtifactRawUnsParityTests
var decoded = DeploymentArtifact.ParseComposition(blob); var decoded = DeploymentArtifact.ParseComposition(blob);
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised). // Sanity: the sets are non-empty (all four raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(2); composed.RawTags.Count.ShouldBe(4);
// Sanity on the two folded-in branches — without these, a composer that dropped array/alarm intent
// ENTIRELY would still pass the parity assertion below, because the artifact decode would drop it too
// and the two empties would compare equal. Parity alone cannot tell "both right" from "both blind".
var composedArray = composed.RawTags.Single(t => t.Name == "Profile");
composedArray.IsArray.ShouldBeTrue();
composedArray.ArrayLength.ShouldBe(16u);
var composedAlarm = composed.RawTags.Single(t => t.Name == "TempHi");
composedAlarm.Alarm.ShouldNotBeNull();
composedAlarm.Alarm!.AlarmType.ShouldBe("OffNormalAlarm");
composedAlarm.Writable.ShouldBeTrue();
composed.UnsReferenceVariables.Count.ShouldBe(1); composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed"); composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
@@ -190,69 +190,6 @@ public sealed class DeploymentArtifactTests
c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1"); c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1");
} }
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
/// from the tag's TagConfig blob. A tag in a non-Equipment (Simulated) namespace with a
/// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
/// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (only Equipment-kind namespaces route
/// into EquipmentTags, so such a tag routes nowhere).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "ns-sim", Kind = 1 }, // NamespaceKind.Simulated (non-Equipment)
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-eq" },
new { DriverInstanceId = "drv-sim", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sim" },
},
Tags = new object[]
{
new
{
TagId = "tag-eq",
DriverInstanceId = "drv-modbus",
EquipmentId = "eq-1",
Name = "Speed",
FolderPath = (string?)null,
DataType = "Float",
TagConfig = "{\"FullName\":\"40001\"}",
},
new
{
TagId = "tag-sim",
DriverInstanceId = "drv-sim",
EquipmentId = (string?)null,
Name = "Temp",
FolderPath = "area",
DataType = "Float",
TagConfig = "{\"FullName\":\"area.Temp\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var tag = c.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-eq");
tag.EquipmentId.ShouldBe("eq-1");
tag.DriverInstanceId.ShouldBe("drv-modbus");
tag.Name.ShouldBe("Speed");
tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
// The tag in the non-Equipment (Simulated) namespace, with null EquipmentId, does NOT leak
// into EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-sim");
}
/// <summary> /// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script /// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the /// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
@@ -39,6 +39,17 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static readonly DateTime Ts = new(2026, 7, 13, 10, 0, 0, DateTimeKind.Utc); private static readonly DateTime Ts = new(2026, 7, 13, 10, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>v3 RawPath of the seeded write tag: RawFolder "Plant" / driver Name "Modbus" / Device "dev1" /
/// Tag "speed". Its UNS projection (Area "filling" / Line "line1" / Equipment "station1") is
/// <see cref="WriteUnsNodeId"/>.</summary>
private const string WriteRawPath = "Plant/Modbus/dev1/speed";
private const string WriteUnsNodeId = "filling/line1/station1/speed";
/// <summary>v3 RawPath of the seeded alarm tag. In v3 a native alarm materializes ONCE at the raw tag with
/// <c>ConditionId == RawPath</c>, so this is both the wire-ref the driver raises and the condition
/// NodeId the host routes to.</summary>
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
// ---------------- write gate ---------------- // ---------------- write gate ----------------
/// <summary>Role unknown + a real driver peer (count 2) ⇒ RouteNodeWrite is DENIED with the /// <summary>Role unknown + a real driver peer (count 2) ⇒ RouteNodeWrite is DENIED with the
@@ -52,7 +63,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2); var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse(); result.Success.ShouldBeFalse();
@@ -62,7 +73,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window /// <summary>Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window
/// posture preserved).</summary> /// posture preserved).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Unknown_role_single_driver_services_write() public void Unknown_role_single_driver_services_write()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -76,7 +87,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() => AwaitAssert(() =>
{ {
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)"); res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -87,7 +98,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state /// <summary>A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state
/// reason (distinct from the boot-window reason).</summary> /// reason (distinct from the boot-window reason).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Known_role_wins_over_member_count() public void Known_role_wins_over_member_count()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -101,7 +112,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() => AwaitAssert(() =>
{ {
var asker1 = CreateTestProbe(); var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason); res.Success.ShouldBeTrue(res.Reason);
@@ -110,7 +121,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason. // Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary); TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe(); var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout); var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse(); denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary"); denied.Reason.ShouldBe("not primary");
@@ -127,7 +138,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2); var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe(); var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref); actor.Tell(new DriverHostActor.RouteNodeWrite(WriteUnsNodeId, 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse(); asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() => AwaitAssert(() =>
@@ -151,7 +162,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2); var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2);
EventFilter.Warning(contains: "role unknown").ExpectOne(() => EventFilter.Warning(contains: "role unknown").ExpectOne(() =>
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", Comment: null, OperatorUser: "op"))); actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, Comment: null, OperatorUser: "op")));
AwaitAssert(() => AwaitAssert(() =>
{ {
@@ -163,7 +174,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
/// <summary>Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update /// <summary>Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update
/// but publishes NO cluster-wide alerts transition.</summary> /// but publishes NO cluster-wide alerts transition.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition() public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -175,14 +186,14 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm()); actor.Tell(RaiseAlarm());
// The ungated OPC UA condition write still arrives. // The ungated OPC UA condition write still arrives.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).AlarmNodeId.ShouldBe("eq-1/temp_hi"); publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout).AlarmNodeId.ShouldBe(AlarmRawPath);
// The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy). // The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy).
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
} }
/// <summary>Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node /// <summary>Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node
/// posture preserved).</summary> /// posture preserved).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Unknown_role_single_driver_publishes_alerts_emit() public void Unknown_role_single_driver_publishes_alerts_emit()
{ {
var db = NewInMemoryDbFactory(); var db = NewInMemoryDbFactory();
@@ -194,7 +205,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
actor.Tell(RaiseAlarm()); actor.Tell(RaiseAlarm());
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout); publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
alerts.ExpectMsg<AlarmTransitionEvent>(Timeout).AlarmId.ShouldBe("eq-1/temp_hi"); alerts.ExpectMsg<AlarmTransitionEvent>(Timeout).AlarmId.ShouldBe(AlarmRawPath);
} }
// ---------------- helpers ---------------- // ---------------- helpers ----------------
@@ -202,8 +213,8 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() => private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() =>
new("drv-1", new AlarmEventArgs( new("drv-1", new AlarmEventArgs(
new StubAlarmHandle(), new StubAlarmHandle(),
SourceNodeId: "Temp", SourceNodeId: "Temp", // bare owning object — deliberately NOT the lookup key
ConditionId: "Temp.HiHi", ConditionId: AlarmRawPath, // v3: the condition is keyed by the raw tag's RawPath
AlarmType: "OffNormalAlarm", AlarmType: "OffNormalAlarm",
Message: "temperature high", Message: "temperature high",
Severity: AlarmSeverity.High, Severity: AlarmSeverity.High,
@@ -266,60 +277,66 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
probe.ExpectMsg<SubscribeAck>(Timeout); probe.ExpectMsg<SubscribeAck>(Timeout);
} }
/// <summary>
/// Seeds the v3 raw-tag chain a write must traverse: RawFolder "Plant" → DriverInstance "drv-1"
/// (Modbus, <b>Enabled</b> so a real child spawns and can service the write) → Device "dev1" → Tag
/// "speed" (<see cref="WriteRawPath"/>, ReadWrite), projected into equipment
/// <c>filling/line1/station1</c> by a <c>UnsTagReference</c> so <see cref="WriteUnsNodeId"/> resolves.
/// <para>The pre-v3 shape this replaced seeded <c>Namespaces</c> + equipment-bound <c>Tags</c>, which
/// v3 parses to an EMPTY tag set — the write had nothing to route to, which is why the
/// write-is-serviced cases were skipped rather than failing.</para>
/// </summary>
private static DeploymentId SeedWriteTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db) private static DeploymentId SeedWriteTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{ {
var artifact = JsonSerializer.SerializeToUtf8Bytes(new var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[] DriverInstances = new[]
{ {
new new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = "drv-1",
Name = "drv-1",
DriverType = "Modbus",
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
}, },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[] Tags = new[]
{ {
new new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
{
TagId = "tag-0",
EquipmentId = "eq-1",
DriverInstanceId = "drv-1",
Name = "speed",
FolderPath = (string?)null,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = "40001" }),
},
}, },
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
}); });
return SealArtifact(db, artifact); return SealArtifact(db, artifact);
} }
/// <summary>
/// Seeds the same v3 chain with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object, so the
/// composer makes it a Part 9 condition at <see cref="AlarmRawPath"/> rather than a value variable.
/// The driver is <b>disabled</b> — these cases exercise the ack/emit gates, not a live child.
/// </summary>
private static DeploymentId SeedAlarmTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db) private static DeploymentId SeedAlarmTagDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db)
{ {
var artifact = JsonSerializer.SerializeToUtf8Bytes(new var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{ {
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", NamespaceId = "ns-eq" } }, DriverInstances = new[]
{
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[] Tags = new[]
{ {
new new
{ {
TagId = "tag-0", TagId = "t-temp",
EquipmentId = "eq-1", DeviceId = "dev-1",
DriverInstanceId = "drv-1", TagGroupId = (string?)null,
Name = "temp_hi", Name = "temp_hi",
FolderPath = (string?)null,
DataType = "Boolean", DataType = "Boolean",
AccessLevel = 0,
TagConfig = JsonSerializer.Serialize(new TagConfig = JsonSerializer.Serialize(new
{ {
FullName = "Temp.HiHi",
alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
}), }),
}, },
@@ -202,16 +202,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
} }
/// <summary> /// <summary>
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must /// Wiring proof for per-ClusterId scoping: a multi-cluster artifact must materialise ONLY the local
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact /// node's cluster slice. MAIN and SITE-A each own a raw folder → Modbus driver → device → tag; the
/// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver + /// scoped rebuild on the SITE-A node must surface SITE-A's raw variable
/// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped /// (<c>SA/Modbus/dev-sa/S1</c>) and NOT MAIN's (<c>Main/Modbus/dev-main/M1</c>), and the mirror
/// rebuild for the SITE-A node must surface the SITE-A tag (<c>t-sa</c> → folder-scoped /// must hold on the MAIN node. Without <c>DeploymentArtifact.ResolveClusterScope</c>, the unscoped
/// variable <c>eq-sa/F/S1</c>) and NOT MAIN's (<c>t-main</c> → <c>eq-main/F/M1</c>); the /// parse materialises BOTH on every node.
/// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse /// <para>Revived from the <c>EquipmentTagsDarkBatch4</c> skip. It was parked on equipment-tag plans
/// would materialise BOTH variables on every node. /// lighting up "at Batch 4"; Batch 4 retired them instead, so the subject moved to the raw subtree.
/// The behaviour under test — cluster scoping — never went dark, and matters more since the mesh
/// split (a site node must not materialise another cluster's tags).</para>
/// </summary> /// </summary>
[Fact(Skip = ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers.DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] [Fact]
public void Rebuild_materialises_only_the_nodes_cluster() public void Rebuild_materialises_only_the_nodes_cluster()
{ {
// --- SITE-A node: only the SITE-A tag's variable, never MAIN's. --- // --- SITE-A node: only the SITE-A tag's variable, never MAIN's. ---
@@ -228,12 +230,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
// PureAdd (equipment + tag) ⇒ no full rebuild; the materialise passes still run the cluster slice. // PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice.
// t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1") → folder-scoped variable "eq-sa/F/S1". AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget);
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:eq-sa/F/S1"), duration: PresenceBudget);
sinkA.RebuildCalls.ShouldBe(0); sinkA.RebuildCalls.ShouldBe(0);
// t-main (MAIN cluster) must NOT leak onto the SITE-A node. // MAIN's raw tag must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); sinkA.Calls.ShouldNotContain("EV:Main/Modbus/dev-main/M1");
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. --- // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
var dbM = NewInMemoryDbFactory(); var dbM = NewInMemoryDbFactory();
@@ -249,16 +250,16 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:eq-main/F/M1"), duration: PresenceBudget); AwaitAssert(() => sinkM.Calls.ShouldContain("EV:Main/Modbus/dev-main/M1"), duration: PresenceBudget);
sinkM.RebuildCalls.ShouldBe(0); sinkM.RebuildCalls.ShouldBe(0);
sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1"); sinkM.Calls.ShouldNotContain("EV:SA/Modbus/dev-sa/S1");
} }
/// <summary> /// <summary>
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the v3 multi-cluster shape:
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one Equipment namespace + /// a <c>Clusters</c> + <c>Nodes</c> map, and per cluster a raw folder → Modbus driver → device
/// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now). /// tag chain plus its UNS area/line/equipment. Used by
/// Used by <see cref="Rebuild_materialises_only_the_nodes_cluster"/>. /// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// </summary> /// </summary>
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{ {
@@ -282,24 +283,31 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
}, },
Equipment = new[] Equipment = new[]
{ {
new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" }, new { EquipmentId = "eq-main", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" }, new { EquipmentId = "eq-sa", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
},
RawFolders = new[]
{
new { RawFolderId = "rf-main", ParentRawFolderId = (string?)null, Name = "Main", ClusterId = "MAIN" },
new { RawFolderId = "rf-sa", ParentRawFolderId = (string?)null, Name = "SA", ClusterId = "SITE-A" },
}, },
DriverInstances = new[] DriverInstances = new[]
{ {
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-main", ClusterId = "MAIN" },
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-sa", ClusterId = "SITE-A" },
}, },
Namespaces = new[] Devices = new[]
{ {
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment new { DeviceId = "dev-main", DriverInstanceId = "main-modbus", Name = "dev-main", DeviceConfig = "{}" },
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 }, new { DeviceId = "dev-sa", DriverInstanceId = "sa-modbus", Name = "dev-sa", DeviceConfig = "{}" },
}, },
TagGroups = Array.Empty<object>(),
Tags = new[] Tags = new[]
{ {
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-main", DeviceId = "dev-main", TagGroupId = (string?)null, Name = "M1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DeviceId = "dev-sa", TagGroupId = (string?)null, Name = "S1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
}, },
UnsTagReferences = Array.Empty<object>(),
ScriptedAlarms = Array.Empty<object>(), ScriptedAlarms = Array.Empty<object>(),
}); });