diff --git a/deferment.md b/deferment.md index 843640ee..b703e827 100644 --- a/deferment.md +++ b/deferment.md @@ -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 | | **#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) | -| **G-7** `EquipmentTagConfigInspector` covers 6 of 12 driver types | Pre-existing gap, not a regression; the only §1.2 gap left. | §1.2 | -| **13 `EquipmentTagsDarkBatch4` skipped tests** | Still stale — their unblock condition already happened and the path was retired instead. | §4.4 | +| **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**~~ | ✅ **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 | | 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-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-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) @@ -283,8 +283,8 @@ commented-out DI registrations.** Real deferred work lives in prose doc-comments | 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. | -| 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. | -| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is | +| ~~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 | `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 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, `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) **The maps had to become data before they could be guarded.** A Razor `@switch` compiles into diff --git a/docs/OpcUaServer.md b/docs/OpcUaServer.md index 301c4a23..9373be48 100644 --- a/docs/OpcUaServer.md +++ b/docs/OpcUaServer.md @@ -1,5 +1,13 @@ # 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/`. 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. diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index bcbba33d..1241db9d 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -202,7 +202,7 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` + **Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")` literal against `DriverInstanceActor.AttributeValuePublished.FullReference`, 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 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 @@ -228,7 +228,7 @@ syntax token under the caret to: `LiteralExpressionSyntax` → `ArgumentSyntax` `GetTag` or `SetVirtualTag`, and (b) the **receiver is the identifier `ctx`** — a purely syntactic check that deliberately mirrors the runtime dependency 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. **`SetVirtualTag` is a no-op in production.** The live single-tag evaluator diff --git a/docs/ScriptedAlarms.md b/docs/ScriptedAlarms.md index 0ee00d36..0abde22b 100644 --- a/docs/ScriptedAlarms.md +++ b/docs/ScriptedAlarms.md @@ -1,5 +1,13 @@ # 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. 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 -`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 | |---|---| @@ -332,7 +340,7 @@ Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNod ## 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: @@ -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/MessageTemplate.cs` — `{path}` placeholder resolver - `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/Phase7Applier.cs` — applies the composed Phase 7 plan into the SDK node manager +- `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/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/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 diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index 0f9ecdcc..20032d37 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -1,5 +1,13 @@ # 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. 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` -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. @@ -118,11 +126,11 @@ Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, ## 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: -- `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. - `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/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.OpcUaServer/Phase7Composer.cs` — pure data composer: config-DB entities → `Phase7CompositionResult` (UNS topology + 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/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans) +- `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 diff --git a/docs/v2/Architecture-v2.md b/docs/v2/Architecture-v2.md index 4240635f..35e35e18 100644 --- a/docs/v2/Architecture-v2.md +++ b/docs/v2/Architecture-v2.md @@ -43,7 +43,7 @@ src/Server/ server-side projects ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons 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.Host fused binary (Program.cs) ``` diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs index bc6cfa9e..7e8a25e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.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.FOCAS; 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.TwinCAT; @@ -13,8 +14,19 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; /// AdminUI TagConfigValidator shape: a case-insensitive map from the canonical driver-type string /// to that driver's lightweight Contracts parser Inspect(), returning human-readable /// 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 -/// the AdminUI validator. Never throws. +/// structurally-unparseable TagConfig). Never throws. +/// Coverage is every driver that ships an Inspect, and that invariant is guarded by +/// EquipmentTagConfigInspectorCoverageTests, which reflects over the referenced +/// *.Contracts 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 Inspect and was never added). +/// Residual gap, deliberately recorded rather than papered over. Galaxy, OpcUaClient, Sql, +/// MTConnect and Calculation have no Inspect in a Contracts assembly, so this deploy-time +/// gate cannot check them — while the AdminUI's TagConfigValidator DOES cover all but Galaxy via +/// its *TagConfigModel.Validate() 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. /// public static class EquipmentTagConfigInspector { @@ -27,10 +39,12 @@ public static class EquipmentTagConfigInspector [DriverTypeNames.AbLegacy] = AbLegacyTagDefinitionFactory.Inspect, [DriverTypeNames.TwinCAT] = TwinCATTagDefinitionFactory.Inspect, [DriverTypeNames.FOCAS] = FocasTagDefinitionFactory.Inspect, + [DriverTypeNames.Mqtt] = MqttTagDefinitionFactory.Inspect, }; /// Inspects a tag's TagConfig 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. + /// or an empty list when the driver type has no inspector (see the class remarks) or the inputs are + /// null. /// The bound driver's DriverType (canonical string, case-insensitive). /// The equipment tag's TagConfig JSON. /// The warnings; empty when the driver is unmapped or clean. @@ -40,9 +54,12 @@ public static class EquipmentTagConfigInspector return Inspectors.TryGetValue(driverType, out var inspect) ? inspect(tagConfig) : Array.Empty(); } - /// Whether the given driver type has a mapped inspector (equipment-tag protocol drivers only). + /// Whether the given driver type has a mapped inspector. /// The bound driver's DriverType. /// when an inspector is registered for the driver type. public static bool IsMapped(string? driverType) => !string.IsNullOrEmpty(driverType) && Inspectors.ContainsKey(driverType); + + /// The driver types this gate can check — the guard test's read of the map. + public static IReadOnlyCollection MappedDriverTypes => (IReadOnlyCollection)Inspectors.Keys; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index a20477da..89d2622c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -45,6 +45,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs index a2559bcd..d9fbe6b3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; 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 /// guarantees the same ns=3;s=... names shipped since v1.0. If a release /// bump breaks these, the fixture's pinned image tag needs a coordinated bump. +/// These tests address the driver by RawPath, not by node id (fixed 2026-07-28). v3 +/// made the driver resolve every read/write/subscribe reference through its authored +/// table — a bare ns=3;s=StepUp is no longer a +/// reference the driver accepts, and one that does not resolve is failed LOCALLY as +/// BadNodeIdInvalid 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 RawTags here is what +/// makes them exercise the driver instead of its unresolved-reference guard. /// public static class OpcPlcProfile { @@ -22,11 +31,37 @@ public static class OpcPlcProfile /// opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests. public const string FastUInt1 = "ns=3;s=FastUInt1"; + /// The RawPath the smoke tests address by. + public const string StepUpRef = "Sim/OpcUaClient/plc/StepUp"; + + /// The RawPath the smoke tests address by. + public const string RandomSignedInt32Ref = "Sim/OpcUaClient/plc/RandomSignedInt32"; + + /// The RawPath the smoke tests address by. + public const string AlternatingBooleanRef = "Sim/OpcUaClient/plc/AlternatingBoolean"; + + /// The RawPath the smoke tests address by. + public const string FastUInt1Ref = "Sim/OpcUaClient/plc/FastUInt1"; + + /// The authored raw-tag table the driver resolves the *Ref RawPaths through — the + /// same {"nodeId": …} TagConfig shape the deploy artifact delivers. + public static IReadOnlyList 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); + /// Builds driver options for the OPC PLC endpoint. /// The endpoint URL of the OPC PLC simulator. /// Configured driver options for the OPC PLC. public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new() { + RawTags = RawTags, EndpointUrl = endpointUrl, SecurityPolicy = OpcUaSecurityPolicy.None, SecurityMode = OpcUaSecurityMode.None, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs index 601cd2a1..96deeaf1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs @@ -27,7 +27,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var snapshots = await drv.ReadAsync( - [OpcPlcProfile.StepUp], TestContext.Current.CancellationToken); + [OpcPlcProfile.StepUpRef], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(1); 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); var snapshots = await drv.ReadAsync( - [OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean], + [OpcPlcProfile.StepUpRef, OpcPlcProfile.RandomSignedInt32Ref, OpcPlcProfile.AlternatingBooleanRef], TestContext.Current.CancellationToken); snapshots.Count.ShouldBe(3); @@ -78,7 +78,7 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) }; var handle = await drv.SubscribeAsync( - [OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250), + [OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); // FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs new file mode 100644 index 00000000..d453cc3e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/EquipmentTagConfigInspectorCoverageTests.cs @@ -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; + +/// +/// Guards the deploy-time TagConfig gate against the failure mode that produced deferment-register G-7: +/// a driver ships an Inspect on its *TagDefinitionFactory, nobody adds it to +/// , 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. +/// Why reflection rather than a hand-written list. The sibling +/// 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. This is the same hollow-guard shape found in TagConfigDriverTypeNameGuardTests and +/// CsvColumnMapReflectionTests. Deriving the expected set from the assemblies means the test +/// fails the moment a new inspector exists, without anyone remembering to update it. +/// +public sealed class EquipmentTagConfigInspectorCoverageTests +{ + /// + /// Every *TagDefinitionFactory reachable from ControlPlane that exposes a public static + /// Inspect(string) must be wired into the dispatch map. + /// Discovery walks the referenced assemblies rather than AppDomain alone, because a + /// Contracts assembly with no other use is not loaded until first touched — an + /// AppDomain.GetAssemblies() sweep would quietly find nothing and pass vacuously. + /// + [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)); + } + + /// The map must not name a driver type with no inspector behind it — a dead key would read as + /// coverage while dispatching nowhere. + [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")); + } + } + + /// + /// Public static IReadOnlyList<string> Inspect(string) is the contract every driver's + /// deploy-time checker implements; anything else is not one of these factories. + /// Discovery scans the output DIRECTORY, not Assembly.GetReferencedAssemblies(). + /// 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. That version was written, and + /// caught only by deleting the MQTT entry and watching it stay green. MSBuild copies every + /// project-referenced assembly to the output regardless of IL usage, so the directory is the honest + /// source. + /// Boundary, stated so it is not mistaken for more: 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. + /// + private static IReadOnlyList 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).IsAssignableFrom(m.ReturnType); + } + + /// Matches a mapped driver-type string to a factory type name (ModbusTagDefinitionFactory + /// ↔ Modbus), tolerating the casing differences between the two vocabularies + /// (FOCAS/Focas, TwinCAT/TwinCAT). + private static bool LooksLike(string driverType, string factoryTypeName) => + factoryTypeName.StartsWith(driverType, StringComparison.OrdinalIgnoreCase); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index df57354f..aaa96f10 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -156,17 +156,21 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable } /// - /// Full structure-materialisation pipeline against a real SDK node manager: real Config - /// entities (Area / Line / Equipment + an Equipment-namespace Tag) → - /// → MaterialiseHierarchy + MaterialiseEquipmentTags → . Proves - /// an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal - /// Variable in a live OPC UA address space (structure-only; live values are a later milestone). - /// Also covers the compose-side EquipmentTags extraction. The cluster-level deploy + - /// network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked - /// as a follow-up. + /// Full v3 structure-materialisation pipeline against a real SDK node manager: real Config entities + /// (RawFolder → Driver → Device → Tag, plus Area / Line / Equipment and a UnsTagReference) → + /// → MaterialiseHierarchy + MaterialiseRawSubtree + + /// MaterialiseUnsReferences → . Proves the dual-namespace + /// shape lands in a live OPC UA address space: the raw device tree AND the UNS folder tree, with the + /// raw tag's variable projected into its referencing equipment as a second variable. + /// Revived from the EquipmentTagsDarkBatch4 skip. It was parked pending "the Batch-4 + /// 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 MaterialiseHierarchy_against_real_SDK still + /// covers folders only; this is the layer's only real-SDK proof that variables materialise in both + /// namespaces. /// - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] - public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK() + [Fact] + public async Task Raw_and_uns_variables_materialise_end_to_end_against_real_SDK() { await using var host = new OpcUaApplicationHost( new OpcUaApplicationHostOptions @@ -183,34 +187,46 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable await host.StartAsync(sdkServer, Ct); sdkServer.NodeManager.ShouldNotBeNull(); - // One area / line / equipment + a Modbus driver. v3: no Namespace entity, DriverInstance no - // longer binds a Namespace, and Equipment no longer binds a driver. The equipment-bound Tag - // (the Speed signal) that this end-to-end test materialised as a Variable node is DARK until - // Batch 4 — the composer emits an empty EquipmentTags set, so it is no longer seeded/passed - // here. Equipment.Name is the UNS browse segment. (Folder-only materialisation is covered live - // by the sibling MaterialiseHierarchy_against_real_SDK test; this suite's equipment-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 = "{}" }; + // The v3 raw chain: RawFolder "Plant" → Driver "Modbus" → Device "dev1" → Tag "Speed" + // (RawPath Plant/Modbus/dev1/Speed), plus the UNS tree it is projected into. + var folder = new RawFolder { RawFolderId = "rf-plant", ClusterId = "c1", Name = "Plant", ParentRawFolderId = null }; + var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "rf-plant" }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "dev1", DeviceConfig = "{}" }; + var speed = new Tag + { + 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 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 reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "tag-speed", DisplayNameOverride = null }; var composition = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equipment }, new[] { driver }, - Array.Empty()); + Array.Empty(), + unsTagReferences: new[] { reference }, tags: new[] { speed }, + rawFolders: new[] { folder }, devices: new[] { device }); - // Compose-side EquipmentTags extraction (Batch-4 subject — empty this batch). - var planned = composition.EquipmentTags.ShouldHaveSingleItem(); - planned.EquipmentId.ShouldBe("eq-1"); - planned.FullName.ShouldBe("40001"); + // Compose side: one raw tag at its RawPath, projected once into the referencing equipment. + var rawTag = composition.RawTags.ShouldHaveSingleItem(); + rawTag.NodeId.ShouldBe("Plant/Modbus/dev1/Speed"); + 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 applier = new AddressSpaceApplier(sink, NullLogger.Instance); applier.MaterialiseHierarchy(composition); - applier.MaterialiseEquipmentTags(composition); + applier.MaterialiseRawSubtree(composition); + applier.MaterialiseUnsReferences(composition); - sdkServer.NodeManager!.FolderCount.ShouldBe(3); // filling area + line-1 + station-1 equipment - sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder + // UNS: filling + line-1 + station-1. Raw: the Plant folder + the Modbus driver + the dev1 device. + 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); } /// OpcUaServer-001 — a UNS Area / Line rename-only deploy refreshes the EXISTING folder's diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs deleted file mode 100644 index 70138b07..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerAliasTagTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; - -/// -/// Characterized the live-edit compose seam admitting a Galaxy point as an ordinary equipment tag: -/// an equipment-scoped Tag bound to a GalaxyMxGateway driver surfaced under -/// carrying its driver-side FullName (coalescing a -/// null FolderPath to string.Empty). -/// v3 Batch-1 DARK + reshaped seed: equipment-tag variable plans do not materialize until -/// Batch 4 (the composer emits an empty EquipmentTags 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: -/// Tag is now raw-only (no EquipmentId/DriverInstanceId/FolderPath), the -/// Compose overload no longer takes tags/namespaces, and equipment references raw tags via -/// UnsTagReference rather than a Galaxy point carrying an EquipmentId. 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. -/// -public sealed class AddressSpaceComposerAliasTagTests -{ - /// A GalaxyMxGateway equipment tag surfaces under EquipmentTags with its FullName — - /// dark until Batch 4 (empty equipment-tag plan set) and re-authored via UnsTagReference. - [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. - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs deleted file mode 100644 index 8f7c32c9..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceComposerEquipTokenTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; - -/// -/// Verifies the live-edit compose seam () substitutes the -/// reserved {{equip}} token in a shared VirtualTag script with each owning equipment's -/// derived tag base (from its child-tag FullNames) — so one script reused across N -/// identical machines resolves to N machine-specific dependency graphs. -/// -/// v3 Batch-1 DARK: the per-equipment tag base is DERIVED from equipment-tag -/// FullNames, and equipment-tag variable plans do not materialize until Batch 4 (the composer -/// no longer takes tags — baseByEquip is empty this batch). With no base to substitute, the -/// {{equip}} 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. -/// -public sealed class AddressSpaceComposerEquipTokenTests -{ - /// One shared using ctx.GetTag("{{equip}}.Source"), 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. - [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(), - 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" }); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs index 8716ac61..e445bd99 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DarkAddressSpaceReasons.cs @@ -1,27 +1,29 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// -/// Shared xUnit Skip reason constants for OpcUaServer tests whose intent is retained but -/// whose subject is not materialized in the v3 Batch-1 DARK address space. The composer -/// () and the artifact both emit an EMPTY equipment-tag -/// variable plan set this batch — raw + UNS variable nodes are lit up in Batch 4's dual-namespace -/// UNS↔Raw fan-out. Until then any assertion whose subject is an equipment-tag variable node (its -/// FullName, its {{equip}}-token-derived base, its device-host binding) has nothing to -/// compare, so it is Skipped-with-reason rather than deleted. Mirrors -/// Runtime.Tests.Drivers.DarkAddressSpaceReasons. +/// Shared xUnit Skip reason constants for OpcUaServer tests whose subject was RETIRED by v3 +/// rather than merely deferred — architectural tombstones that keep the removed coverage discoverable +/// and name what replaced it, so nobody re-writes a test for a concept that no longer exists. +/// A skip reason is a claim with an expiry date. The one this file used to carry +/// (EquipmentTagsDarkBatch4, "dark until Batch 4") outlived its own condition by a release — +/// see the note below. If you add one here, state the condition precisely enough that a reader can +/// check whether it has already happened. +/// Mirrors Runtime.Tests.Drivers.DarkAddressSpaceReasons. /// internal static class DarkAddressSpaceReasons { - /// - /// Equipment-tag variable materialization + its per-field intent (FullName / array / historize / - /// native-alarm / {{equip}}-token base derived from child-tag FullNames) is dark until Batch 4. - /// - public const string EquipmentTagsDarkBatch4 = - "v3 dark address space: equipment-tag variable plans (FullName / array / historize / alarm / " + - "{{equip}}-token base derived from child-tag FullNames) do not materialize until Batch 4 " + - "(dual-namespace UNS↔Raw fan-out). The composer emits an empty equipment-tag plan set this batch, " + - "so there is no per-equipment tag base to substitute and no equipment-tag variable node to assert. " + - "Migrate + re-enable when Batch 4 lights the raw/UNS variable nodes."; + // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read + // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than + // lighting it, so every test wearing this reason was waiting on something that had already happened and + // gone the other way. Each was resolved on its merits rather than left to read as pending work: + // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace + // 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). /// Equipment↔device host binding is architecturally retired in v3. public const string EquipmentDeviceBindingRetired = diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs index 9632ae74..7267fdbe 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DarkAddressSpaceReasons.cs @@ -1,22 +1,28 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// -/// Shared xUnit Skip reason constants for tests whose intent is retained but whose subject is -/// not materialized in the v3 Batch-1 DARK address space. Equipment-tag variable nodes (and their -/// per-field intent: FullName / array / historize / native-alarm) are lit up by the Batch-4 dual -/// namespace + UNS↔Raw fan-out; until then the composer/artifact both emit an EMPTY equipment-tag -/// plan set, so any parity assertion over those plans has nothing to compare. The raw-tag parse -/// intent these tests characterized lives on in (RawTagEntry -/// round-trip) and TagConfigIntentTests (the parse unit). +/// Shared xUnit Skip reason constants for tests whose subject was RETIRED by v3 rather than +/// merely deferred — architectural tombstones that keep the removed coverage discoverable and name +/// what replaced it, so nobody re-writes a test for a concept that no longer exists. +/// A skip reason is a claim with an expiry date. The one this file used to carry +/// (EquipmentTagsDarkBatch4, "dark until Batch 4") outlived its own condition by a release — +/// see the note below. If you add one here, state the condition precisely enough that a reader can +/// check whether it has already happened. /// internal static class DarkAddressSpaceReasons { - /// Equipment-tag variable materialization + its per-field intent is dark until Batch 4. - public const string EquipmentTagsDarkBatch4 = - "v3 dark address space: equipment-tag variable plans (FullName/array/historize/alarm) do not " + - "materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact both emit an " + - "empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " + - "intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests."; + // EquipmentTagsDarkBatch4 was RETIRED (2026-07-28) once its own unblock condition was examined. It read + // "dark until Batch 4"; Batch 4 shipped as v3.0 and RETIRED the equipment-tag plan set rather than + // lighting it, so every test wearing this reason was waiting on something that had already happened and + // gone the other way. Each was resolved on its merits rather than left to read as pending work: + // - revived onto the raw/UNS shape that replaced the subject (the real-SDK dual-namespace + // 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 // tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs deleted file mode 100644 index 86446705..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactAliasParityTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Galaxy-as-ordinary-equipment-tag byte-parity: a Galaxy point (equipment-scoped tag bound to a -/// GalaxyMxGateway driver) surfaced into EquipmentTags with the same FullName / -/// EquipmentId / DriverInstanceId / AccessLevel→Writable / native-alarm intent on both the composer -/// and the artifact-decode seam. -/// -/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — both producers emit an -/// EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw fan-out. Additionally, the -/// v3 schema retired the Namespace/NamespaceKind entities and the Galaxy -/// ExplicitFullName / namespace-Kind gate this test keyed off; Galaxy is an ordinary -/// Device-bound driver whose raw tags reach the UNS via UnsTagReference. The AccessLevel→Writable -/// and native-alarm parse this asserted are covered at the parse unit (TagConfigIntentTests) and -/// the RawTagEntry round-trip (). Unskip + rewrite to the -/// Batch-4 UNS-projection shape when equipment-tag plans light up. -/// -/// -public sealed class DeploymentArtifactAliasParityTests -{ - /// 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. - [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. - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs deleted file mode 100644 index b0f5b6f1..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Phase 4c array intent (isArray + optional arrayLength) byte-parity between the two -/// equipment-tag producers. -/// -/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — the composer and the -/// artifact both emit an EMPTY EquipmentTags set until the Batch-4 dual-namespace UNS↔Raw -/// fan-out, so there is nothing to compare. The underlying parse of isArray / arrayLength -/// from a raw TagConfig blob is characterized today by TagConfigIntentTests and -/// round-tripped through the artifact by (RawTagEntry -/// byte-preservation). Unskip when Batch 4 lights the equipment-tag plans. -/// -/// -public sealed class DeploymentArtifactArrayParityTests -{ - /// Batch-4 pending: composer and artifact agree on array equipment-tag plans - /// (IsArray + ArrayLength). See class remarks — equipment-tag plans are dark this batch. - [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. - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs deleted file mode 100644 index 2759d519..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs +++ /dev/null @@ -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; - -/// -/// Verifies the artifact-decode mirror substitutes the reserved {{equip}} token in a -/// VirtualTag script's ctx.GetTag("…") literals with the owning equipment's tag base -/// (derived from its child Equipment-namespace tag's FullName) — byte-parity with -/// AddressSpaceComposer.Compose's live-edit path, using the same shared -/// EquipmentScriptPaths helper and the same equipmentTags-derived base. -/// -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" }); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs deleted file mode 100644 index 02c11f0e..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactHistorizeParityTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Phase C HistoryRead intent (isHistorized + optional historianTagname) byte-parity -/// between the two equipment-tag producers. -/// -/// v3 DARK (Batch 1): equipment-tag variable plans do not materialize — -/// AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition both emit an -/// EMPTY EquipmentTags 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 isHistorized / -/// historianTagname from a raw TagConfig blob is characterized today by -/// TagConfigIntentTests (the parse unit) and round-tripped through the artifact by -/// (RawTagEntry byte-preservation). Unskip + restore the -/// composer↔artifact equipment-tag comparison when Batch 4 lights the equipment-tag plans. -/// -/// -public sealed class DeploymentArtifactHistorizeParityTests -{ - /// Batch-4 pending: composer and artifact agree on historized equipment-tag plans - /// (IsHistorized + HistorianTagname). See class remarks — equipment-tag plans are dark this batch. - [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. - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs index fe2cc5ce..21acd7ff 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs @@ -23,8 +23,15 @@ public sealed class DeploymentArtifactRawUnsParityTests [Fact] 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, - // one plain read-only), an area/line/equipment, and a UNS reference with a display-name override. + // Entity-side config: folder → driver → device → group → 4 tags, an area/line/equipment, and a UNS + // 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 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 = "{}" }; @@ -39,6 +46,17 @@ public sealed class DeploymentArtifactRawUnsParityTests TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean", 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 line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" }; 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( new[] { area }, new[] { line }, new[] { equip }, new[] { driver }, Array.Empty(), - 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 }); // 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-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" } }, UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } }, @@ -70,8 +90,19 @@ public sealed class DeploymentArtifactRawUnsParityTests var decoded = DeploymentArtifact.ParseComposition(blob); - // Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised). - composed.RawTags.Count.ShouldBe(2); + // Sanity: the sets are non-empty (all four raw tags + the projected UNS variable materialised). + 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[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs index fe046209..defb41e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactTests.cs @@ -190,69 +190,6 @@ public sealed class DeploymentArtifactTests c.ScriptedAlarmPlans.Single().ScriptedAlarmId.ShouldBe("alarm-1"); } - /// - /// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an - /// Equipment-kind namespace) as EquipmentTags, with FullName 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 - /// ns.Kind == NamespaceKind.Equipment predicate (only Equipment-kind namespaces route - /// into EquipmentTags, so such a tag routes nowhere). - /// - [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"); - } - /// /// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script /// by ScriptId for the expression source) as EquipmentVirtualTags, with the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index 0b8ce73e..eb0e4e5c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -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 TimeSpan Timeout = TimeSpan.FromSeconds(5); + /// 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 + /// . + private const string WriteRawPath = "Plant/Modbus/dev1/speed"; + private const string WriteUnsNodeId = "filling/line1/station1/speed"; + + /// v3 RawPath of the seeded alarm tag. In v3 a native alarm materializes ONCE at the raw tag with + /// ConditionId == RawPath, so this is both the wire-ref the driver raises and the condition + /// NodeId the host routes to. + private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi"; + // ---------------- write gate ---------------- /// 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 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(Timeout); result.Success.ShouldBeFalse(); @@ -62,7 +73,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// Role unknown + no driver peer (count 1) ⇒ the write is SERVICED (single-node / boot-window /// posture preserved). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact] public void Unknown_role_single_driver_services_write() { var db = NewInMemoryDbFactory(); @@ -76,7 +87,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { 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(Timeout); res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary (role unknown)"); @@ -87,7 +98,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// A Primary snapshot services even at count 2; a Secondary snapshot denies with the steady-state /// reason (distinct from the boot-window reason). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact] public void Known_role_wins_over_member_count() { var db = NewInMemoryDbFactory(); @@ -101,7 +112,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { 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(Timeout); res.Reason.ShouldNotBe("not primary"); res.Success.ShouldBeTrue(res.Reason); @@ -110,7 +121,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase // Secondary snapshot ⇒ denied with the steady-state reason. TellRole(actor, RedundancyRole.Secondary); 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(Timeout); denied.Success.ShouldBeFalse(); denied.Reason.ShouldBe("not primary"); @@ -127,7 +138,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2); 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(Timeout).Success.ShouldBeFalse(); AwaitAssert(() => @@ -151,7 +162,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var (actor, _) = SpawnAlarmHost(db, dep, driverMemberCount: 2); 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(() => { @@ -163,7 +174,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase /// Role unknown + count 2 ⇒ ForwardNativeAlarm still writes the (ungated) OPC UA condition update /// but publishes NO cluster-wide alerts transition. - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact] public void Unknown_role_multi_driver_suppresses_alerts_emit_but_updates_condition() { var db = NewInMemoryDbFactory(); @@ -175,14 +186,14 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase actor.Tell(RaiseAlarm()); // The ungated OPC UA condition write still arrives. - publish.ExpectMsg(Timeout).AlarmNodeId.ShouldBe("eq-1/temp_hi"); + publish.ExpectMsg(Timeout).AlarmNodeId.ShouldBe(AlarmRawPath); // The cluster-wide alerts publish is suppressed (a real Primary peer publishes the single copy). alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } /// Role unknown + count 1 ⇒ ForwardNativeAlarm publishes the alerts transition (single-node /// posture preserved). - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact] public void Unknown_role_single_driver_publishes_alerts_emit() { var db = NewInMemoryDbFactory(); @@ -194,7 +205,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase actor.Tell(RaiseAlarm()); publish.ExpectMsg(Timeout); - alerts.ExpectMsg(Timeout).AlarmId.ShouldBe("eq-1/temp_hi"); + alerts.ExpectMsg(Timeout).AlarmId.ShouldBe(AlarmRawPath); } // ---------------- helpers ---------------- @@ -202,8 +213,8 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase private static DriverInstanceActor.AttributeAlarmPublished RaiseAlarm() => new("drv-1", new AlarmEventArgs( new StubAlarmHandle(), - SourceNodeId: "Temp", - ConditionId: "Temp.HiHi", + SourceNodeId: "Temp", // bare owning object — deliberately NOT the lookup key + ConditionId: AlarmRawPath, // v3: the condition is keyed by the raw tag's RawPath AlarmType: "OffNormalAlarm", Message: "temperature high", Severity: AlarmSeverity.High, @@ -266,60 +277,66 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase probe.ExpectMsg(Timeout); } + /// + /// Seeds the v3 raw-tag chain a write must traverse: RawFolder "Plant" → DriverInstance "drv-1" + /// (Modbus, Enabled so a real child spawns and can service the write) → Device "dev1" → Tag + /// "speed" (, ReadWrite), projected into equipment + /// filling/line1/station1 by a UnsTagReference so resolves. + /// The pre-v3 shape this replaced seeded Namespaces + equipment-bound Tags, 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. + /// private static DeploymentId SeedWriteTagDeployment(IDbContextFactory db) { 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 - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = "drv-1", - Name = "drv-1", - DriverType = "Modbus", - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, + new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), Tags = new[] { - new - { - TagId = "tag-0", - EquipmentId = "eq-1", - DriverInstanceId = "drv-1", - Name = "speed", - FolderPath = (string?)null, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = "40001" }), - }, + new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, }, + 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); } + /// + /// Seeds the same v3 chain with the tag's TagConfig carrying an alarm object, so the + /// composer makes it a Part 9 condition at rather than a value variable. + /// The driver is disabled — these cases exercise the ack/emit gates, not a live child. + /// private static DeploymentId SeedAlarmTagDeployment(IDbContextFactory db) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { - Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } }, - DriverInstances = new[] { new { DriverInstanceId = "drv-1", NamespaceId = "ns-eq" } }, + RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + 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(), Tags = new[] { new { - TagId = "tag-0", - EquipmentId = "eq-1", - DriverInstanceId = "drv-1", + TagId = "t-temp", + DeviceId = "dev-1", + TagGroupId = (string?)null, Name = "temp_hi", - FolderPath = (string?)null, DataType = "Boolean", + AccessLevel = 0, TagConfig = JsonSerializer.Serialize(new { - FullName = "Temp.HiHi", alarm = new { alarmType = "OffNormalAlarm", severity = 700 }, }), }, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 8a8c364f..5895c429 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -202,16 +202,18 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase } /// - /// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must - /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact - /// shape exercised in DeploymentArtifactTests (MAIN + SITE-A, one Galaxy driver + - /// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped - /// rebuild for the SITE-A node must surface the SITE-A tag (t-sa → folder-scoped - /// variable eq-sa/F/S1) and NOT MAIN's (t-maineq-main/F/M1); the - /// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse - /// would materialise BOTH variables on every node. + /// Wiring proof for per-ClusterId scoping: a multi-cluster artifact must materialise ONLY the local + /// node's cluster slice. MAIN and SITE-A each own a raw folder → Modbus driver → device → tag; the + /// scoped rebuild on the SITE-A node must surface SITE-A's raw variable + /// (SA/Modbus/dev-sa/S1) and NOT MAIN's (Main/Modbus/dev-main/M1), and the mirror + /// must hold on the MAIN node. Without DeploymentArtifact.ResolveClusterScope, the unscoped + /// parse materialises BOTH on every node. + /// Revived from the EquipmentTagsDarkBatch4 skip. It was parked on equipment-tag plans + /// 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). /// - [Fact(Skip = ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers.DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact] public void Rebuild_materialises_only_the_nodes_cluster() { // --- 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())); - // PureAdd (equipment + tag) ⇒ 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:eq-sa/F/S1"), duration: PresenceBudget); + // PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice. + AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget); sinkA.RebuildCalls.ShouldBe(0); - // t-main (MAIN cluster) must NOT leak onto the SITE-A node. - sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1"); + // MAIN's raw tag must NOT leak onto the SITE-A node. + sinkA.Calls.ShouldNotContain("EV:Main/Modbus/dev-main/M1"); // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. --- var dbM = NewInMemoryDbFactory(); @@ -249,16 +250,16 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase 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.Calls.ShouldNotContain("EV:eq-sa/F/S1"); + sinkM.Calls.ShouldNotContain("EV:SA/Modbus/dev-sa/S1"); } /// - /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster - /// shape the composer emits: a Clusters + Nodes map, one Equipment namespace + - /// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now). - /// Used by . + /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the v3 multi-cluster shape: + /// a Clusters + Nodes map, and per cluster a raw folder → Modbus driver → device → + /// tag chain plus its UNS area/line/equipment. Used by + /// . /// private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) { @@ -282,24 +283,31 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase }, Equipment = new[] { - new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", 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-main", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" }, + 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[] { - new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, - new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, + new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-main", ClusterId = "MAIN" }, + 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 { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 }, + new { DeviceId = "dev-main", DriverInstanceId = "main-modbus", Name = "dev-main", DeviceConfig = "{}" }, + new { DeviceId = "dev-sa", DriverInstanceId = "sa-modbus", Name = "dev-sa", DeviceConfig = "{}" }, }, + TagGroups = Array.Empty(), Tags = new[] { - new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, - new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", 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", DeviceId = "dev-sa", TagGroupId = (string?)null, Name = "S1", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, }, + UnsTagReferences = Array.Empty(), ScriptedAlarms = Array.Empty(), });