diff --git a/CLAUDE.md b/CLAUDE.md index 892546f2..7ef172fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,17 +155,48 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double, `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. -⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to -`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event -onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes` -(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an -unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored -through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the -served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT, -MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()` -has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518** -(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone -changes nothing observable. +**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27). +`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is +load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around +the same driver object. A raise is recorded and rides the driver-health snapshot +(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a +**"re-browse" chip**. + +⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw +tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and +commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact +records. + +Two gotchas if you touch this: + +- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in + that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the + operator. Pinned by `DriverInstanceActorRediscoverySignalTests`. +- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on + every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests + use a stub with stable health for exactly this reason. + +**#507 is closed as superseded, and the injection path is deleted.** Its retained code read +`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3 +(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log +line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, +`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, +`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop +(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18 +`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft +(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw` +browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through +the actor. + +**Deliberately NOT built: a discovered-vs-authored drift detector.** Modbus, S7, MQTT, AbLegacy and Sql +all *echo authored config* from `DiscoverAsync` rather than browsing the device, so such a diff is +permanently empty for them — another plausible-looking inert seam. `IRediscoverable` is the +trustworthy signal because the driver asserts it deliberately. + +⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and +nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so +confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked +as Gitea **#521**. ## mxaccessgw diff --git a/deferment.md b/deferment.md index 4363b39f..b2801ebc 100644 --- a/deferment.md +++ b/deferment.md @@ -347,7 +347,9 @@ appear in §7.1 as well. `docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~ ✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9. (`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.) -2. **#518 + #507 together** — neither fix is observable alone. +2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right + that they had to be handled together, but wrong about the resolution: **#507 was not revivable**, + so it was deleted rather than fixed. See §9. 3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes. 4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would have caught G-1 and G-2 for free, and this class has now recurred twice. @@ -366,7 +368,7 @@ the work, so this register never disagrees with the tree. | §8 item | Status | Commit | |---|---|---| | 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` | -| 2. #518 + #507 | ⏳ Not started — decided *signal, not mutation* | — | +| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` | | 3. #516 config discard | ⏳ Not started — decided *both* (per-driver re-parse **and** seam respawn) | — | | 4. G-4 dispatch-map parity | ⏳ Not started | — | | 5. Bookkeeping sweep | ⏳ Not started | — | @@ -415,6 +417,50 @@ Also recorded in #520 and not previously known: every existing evaluator unit te is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle silently. +### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted + +Decision: **signal, not mutation.** + +`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach +in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is +advisory — the served address space is unchanged, because v3 authors raw tags through `/raw` +browse-commit and a runtime graft would materialise nodes nobody approved. + +**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)". +That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are +*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing. +Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`, +`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`, +`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the +connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse +picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor. + +**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2 +characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3 +cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in +`Runtime.Tests` went 31 → 13. + +**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time +discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT, +AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so +the diff is permanently empty for them. It would have been another plausible-looking inert seam — +precisely the class this register exists to remove. That also removed the last consumer of the +connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect +and dropping the result. + +Two traps worth keeping: + +- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the + four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal. + **Verified by reverting the one line — 3 of the 5 new tests go red.** +- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call + and the dedup never engages. A dedup test built on it would pass whether or not the fix is present; + the new tests use a stub with stable health. + +`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the +`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on +inference would be wrong. Split to Gitea **#521** with both options costed. + --- *Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents. diff --git a/docs/drivers/Galaxy.md b/docs/drivers/Galaxy.md index 14def81a..4807d4fb 100644 --- a/docs/drivers/Galaxy.md +++ b/docs/drivers/Galaxy.md @@ -70,7 +70,7 @@ Project root files: | Capability | Implementation entry point | |------------|---------------------------| | `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` | -| `IRediscoverable` | `Browse/DeployWatcher.cs` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) | +| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) | | `IReadable` | `Runtime/GalaxyMxSession.cs` | | `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` | | `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) | diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index f1f15662..e694b5eb 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a **before** the sequence-gap check (a restart also usually trips the gap, so the two need disambiguating, not just OR-ing together). On a changed `instanceId` the driver clears its cached probe model and raises -`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why -that signal currently has no consumer. +`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI +`/hosts` page — see [Known limitations](#known-limitations) for what that does +and does not do. ## Browse — free via the universal browser @@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build). These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions. -1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the - server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and - `IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to - `OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver` - wiring its own internal `DeployWatcher` sub-component. So a restarted - Agent (a changed `instanceId`) leaves a stale address space behind an - otherwise-Healthy driver. **This is a pre-existing fleet-wide gap - affecting every driver that implements either interface** (eight drivers - besides Galaxy), not something specific to MTConnect — this build simply - surfaced it again. +1. **A restarted Agent prompts an operator; it does not re-shape the address + space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded` + (2026-07-27), so a changed `instanceId` raises a "re-browse" chip against + this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors + raw tags through the `/raw` browse-commit flow, so until an operator + re-browses the Agent and commits, any DataItem that appeared or vanished is + not reflected in the served tree. A runtime graft was deliberately rejected + — it would materialise nodes nobody approved and no deployment artifact + records. + + **`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has + no production call site and nothing writes a `DriverHostStatus` row. That + half remains a fleet-wide gap affecting every driver that implements it + (Gitea #521). 2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for routing.** One driver instance owns exactly one Agent client scoped by `MTConnectDriverOptions.DeviceName` (the top-level config key); the diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 252157fd..081f8752 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -155,7 +155,7 @@ fixture is down during normal dev. **Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router). **Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional -arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518) +arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes). See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 2c6b8d41..bb776ad2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent: - /// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so - /// this is safely re-run after every address-space rebuild. Folders are ensured parent-first. - /// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only - /// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass. - /// - /// The equipment root node the discovered nodes hang under; the - /// NodeAdded model-change is announced under this node. - /// The discovered folders to ensure (parent-first by depth). - /// The discovered variables to ensure (read-only value nodes). - /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor - /// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1). - public int MaterialiseDiscoveredNodes( - string equipmentRootNodeId, - IReadOnlyList folders, - IReadOnlyList variables) - { - ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId); - ArgumentNullException.ThrowIfNull(folders); - ArgumentNullException.ThrowIfNull(variables); - if (folders.Count == 0 && variables.Count == 0) return 0; - - var failed = 0; - // Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth. - foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/'))) - if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++; - - foreach (var v in variables) - { - // Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays). - var writable = v.Writable && !v.IsArray; - if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable, - AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++; - } - - _sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw); - - _logger.LogInformation( - "AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})", - equipmentRootNodeId, folders.Count, variables.Count, failed); - return failed; - } /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs deleted file mode 100644 index 90a1abe1..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/DiscoveredInjection.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; - -/// A folder to ensure during discovered-node injection (NodeId + parent + display). -public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName); - -/// A read-or-write variable to ensure during discovered-node injection. -public sealed record DiscoveredVariable( - string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs deleted file mode 100644 index 626807d5..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs +++ /dev/null @@ -1,71 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// An that RECORDS the streamed tree instead of creating OPC UA -/// nodes — used to capture an driver's discovered hierarchy so the -/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder -/// carries its accumulated path), so every variable records its full . -/// Value nodes only: is ignored and alarm marking returns a no-op sink -/// (discovered alarms are out of scope — alarms come via the config path). -/// Single-threaded: a driver's DiscoverAsync streams on one caller; the root and its child -/// builders share one . Not thread-safe by design. -/// -public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder -{ - private readonly List _nodes; - private readonly IReadOnlyList _path; - - /// Create a root capturing builder with an empty folder path and a fresh node list. - public CapturingAddressSpaceBuilder() : this([], []) { } - - private CapturingAddressSpaceBuilder(List nodes, IReadOnlyList path) - { - _nodes = nodes; - _path = path; - } - - /// All variables captured across the whole tree (shared by the root and every child scope). - public IReadOnlyList Nodes => _nodes; - - /// - public IAddressSpaceBuilder Folder(string browseName, string displayName) - => new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]); - - /// - public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) - { - _nodes.Add(new DiscoveredNode( - FolderPathSegments: _path, - BrowseName: browseName, - DisplayName: displayName, - FullReference: attributeInfo.FullName, - DataType: attributeInfo.DriverDataType, - IsArray: attributeInfo.IsArray, - ArrayDim: attributeInfo.ArrayDim, - Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly, - IsHistorized: attributeInfo.IsHistorized)); - return new NullHandle(attributeInfo.FullName); - } - - /// - public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ } - - /// A variable handle whose alarm marking is a no-op (discovered alarms are out of scope). - private sealed class NullHandle(string fullRef) : IVariableHandle - { - /// - public string FullReference => fullRef; - - /// - public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); - } - - /// A null sink that ignores alarm condition transitions. - private sealed class NullSink : IAlarmConditionSink - { - /// - public void OnTransition(AlarmEventArgs args) { } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs deleted file mode 100644 index 7ff7d43e..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNode.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// -/// A flattened variable captured from a driver's stream -/// by . Folder nesting is preserved in -/// so the injector can re-root the node under an equipment. -/// -public sealed record DiscoveredNode( - IReadOnlyList FolderPathSegments, - string BrowseName, - string DisplayName, - string FullReference, - DriverDataType DataType, - bool IsArray, - uint? ArrayDim, - bool Writable, - bool IsHistorized); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs deleted file mode 100644 index a31b1a85..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DiscoveredNodeMapper.cs +++ /dev/null @@ -1,118 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; - -/// The mapped result of grafting discovered nodes under an equipment node. -/// -/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT -/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a -/// global parent-before-child ordering across the whole list. -/// -/// Variables to ensure under the (post-collapse) folders. -/// Driver FullReference -> equipment NodeId, for live-value routing. -public sealed record DiscoveredInjectionPlan( - IReadOnlyList Folders, - IReadOnlyList Variables, - IReadOnlyDictionary RoutingByRef); // driver FullReference -> equipment NodeId - -/// -/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping -/// authored Config-DB refs and collapsing the single device-host folder. See the design doc -/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md. -/// -public static class DiscoveredNodeMapper -{ - /// - /// Maps captured into folders + variables (NodeIds scoped under - /// ) plus a driver-FullReference → equipment-NodeId routing map. - /// - /// The owning equipment's NodeId (root of the grafted subtree). - /// The captured discovery tree (from CapturingAddressSpaceBuilder). - /// - /// Driver FullReferences already authored as Config-DB equipment tags for this driver — - /// skipped so a discovered node never shadows an authored one. - /// - /// The folders, variables, and routing map to apply against the OPC UA address space. - public static DiscoveredInjectionPlan Map( - string rootNodeId, IReadOnlyList nodes, IReadOnlySet authoredRefs) - { - // v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is - // // (slash-joined RawPath), NOT the retired equipment-scoped - // {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath). - static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail; - var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList(); - - // Device-folder collapse: when every kept node shares one identical index-1 segment (the single - // device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads - // FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the - // level is retained so identical leaf names across devices don't collide (degrades gracefully). - var collapseIndex1 = kept.Count > 0 - && kept.All(n => n.FolderPathSegments.Count >= 2) - && kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1; - - static IReadOnlyList Effective(IReadOnlyList segs, bool collapse) - => collapse ? [segs[0], .. segs.Skip(2)] : segs; - - var folders = new Dictionary(StringComparer.Ordinal); - var variables = new List(); - var routing = new Dictionary(StringComparer.Ordinal); - - foreach (var n in kept) - { - var segs = Effective(n.FolderPathSegments, collapseIndex1); - - // Ensure every prefix folder, deduped, each parented at its prefix (the first segment's - // parent is the equipment itself). - for (var i = 0; i < segs.Count; i++) - { - var folderPath = string.Join('/', segs.Take(i + 1)); - var nodeId = Combine(rootNodeId, folderPath); - if (folders.ContainsKey(nodeId)) continue; - var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i))); - folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]); - } - - var varFolderPath = string.Join('/', segs); - var varNodeId = string.IsNullOrEmpty(varFolderPath) - ? Combine(rootNodeId, n.BrowseName) - : Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName); - // A folder-less variable parents directly at the root device node. - var varParent = string.IsNullOrEmpty(varFolderPath) - ? rootNodeId - : Combine(rootNodeId, varFolderPath); - variables.Add(new DiscoveredVariable( - varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim)); - routing[n.FullReference] = varNodeId; - } - - return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing); - } - - /// - /// Maps a to the OPC-UA-built-in type STRING that - /// OtOpcUaNodeManager.EnsureVariable's ResolveBuiltInDataType accepts — so a - /// discovered variable resolves to the same built-in type as an authored equipment tag. Most - /// enum names pass through verbatim; / - /// map to the SDK's "Float"/"Double" names, and (a Galaxy - /// attribute reference) is carried as an OPC UA String per the enum's own contract. - /// - private static string ToBuiltinTypeString(DriverDataType dt) => dt switch - { - DriverDataType.Boolean => "Boolean", - DriverDataType.Int16 => "Int16", - DriverDataType.Int32 => "Int32", - DriverDataType.Int64 => "Int64", - DriverDataType.UInt16 => "UInt16", - DriverDataType.UInt32 => "UInt32", - DriverDataType.UInt64 => "UInt64", - DriverDataType.Float32 => "Float", - DriverDataType.Float64 => "Double", - DriverDataType.String => "String", - DriverDataType.DateTime => "DateTime", - DriverDataType.Reference => "String", - _ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."), - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index c1025d56..cb2152ee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -245,36 +245,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly NativeAlarmProjector _nativeAlarmProjector = new(); /// The composition from the most-recent apply (set at the END of - /// ). Discovered-node injection - /// () reads it to resolve the equipment bound to a driver (from the - /// composition's EquipmentNodes whose DriverInstanceId matches, UNION the authored - /// EquipmentTags for that driver — so a driver with zero authored tags can still graft onto an - /// equipment bound via EquipmentNode.DriverInstanceId) and to recompute the authored value + alarm - /// subscription sets when merging FixedTree refs. Null until the first apply — a - /// arriving before any apply is ignored. + /// ). Null until the first apply. private AddressSpaceComposition? _lastComposition; - /// The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy - /// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery. - /// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment - /// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one - /// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold - /// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable - /// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed. - /// Last-writer-wins on a re-discovery (the whole inner map is replaced). - private readonly Dictionary> _discoveredByDriver = - new(StringComparer.Ordinal); - - /// Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous - /// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a - /// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and - /// DEBUG-logged on the identical repeat passes. Folding in makes a redeploy - /// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is - /// separately short-circuited by . Cleared for a driver whose partition comes - /// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the - /// multi-candidate path (). - private readonly Dictionary _lastPartitionWarnSignature = new(StringComparer.Ordinal); - /// /// Cached local from the latest /// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary @@ -894,7 +867,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); @@ -928,7 +900,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(ForwardToMux); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); - Receive(HandleDiscoveredNodes); Receive(HandleDeltaApplied); Receive(HandleRestartDriver); Receive(HandleReconnectDriver); @@ -973,349 +944,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - /// - /// Handles a driver child's post-connect : - /// resolves the equipment the driver is bound to from the most-recent applied composition (its - /// EquipmentNodes bound by DriverInstanceId UNION its authored EquipmentTags), - /// maps the captured FixedTree under it via (deduping any node that - /// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the - /// served address space + live-value maps + subscription set via - /// . Idempotent / duplicate-safe: the mapper is pure, - /// materialisation is idempotent, and the routing-map extension + subscription merge are set-based. - /// - private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg) - { - // v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a - // driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3 - // retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are - // authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The - // downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an - // equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any - // caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert - // too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree - // is a separate follow-up. - _log.Debug( - "DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)", - _localNode, msg.DriverInstanceId); - return; - -#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up. - if (_lastComposition is null) - { - _log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored", - _localNode, msg.DriverInstanceId); - return; - } - - // Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose - // DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less - // equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a - // driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once. - var fromNodes = _lastComposition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList(); - if (equipmentIds.Count == 0) - { - _log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection", - _localNode, msg.DriverInstanceId); - return; - } - // Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never - // shadows an authored one — the mapper drops any captured node whose FullReference is already authored. - // May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps - // everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed, - // so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is - // correct per partition. - var authoredRefs = _lastComposition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .ToHashSet(StringComparer.Ordinal); - - // Build this discovery's per-equipment plan map. - // • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single - // device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before. - // • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder - // segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E - // part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case - // (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost. - Dictionary newPlans; - if (equipmentIds.Count == 1) - { - var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs); - if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored) - newPlans = new Dictionary(StringComparer.Ordinal) { [equipmentIds[0]] = plan }; - } - else - { - newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs); - if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged - } - - // Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers - // every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each - // pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to - // UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping - // authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing - // is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This - // is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we - // short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.) - if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached) - && PlansRoutingEqual(cached, newPlans)) - { - var total = newPlans.Values.Sum(p => p.Variables.Count); - _log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped", - _localNode, msg.DriverInstanceId, total, newPlans.Count); - return; - } - - _discoveredByDriver[msg.DriverInstanceId] = newPlans; - ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); -#pragma warning restore CS0162 - } - - /// - /// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment - /// (FolderPathSegments[1]) and maps each device's subset under the candidate equipment whose - /// matches — the multi-device graft. Returns - /// the per-equipment plan map (one entry per device that matched AND had at least one new variable); - /// EMPTY when nothing is graftable. - /// - /// Builds normalizedHost → equipmentId from the candidate s - /// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host - /// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment. - /// I1 divergence: a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only, - /// no device binding) simply gets no partition — the FixedTree is the device's structure, so it - /// belongs under the device-bound equipment. No crash; that candidate is just not a partition target. - /// If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒ - /// warn-skip the whole driver (returns empty). - /// A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no - /// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft. - /// - /// The device-host folder segment AND the stored DeviceHost are both run through the SAME - /// (single source of truth), so they compare equal - /// regardless of case/whitespace. - /// Warn-spam taming. The unmatched/ambiguous/degenerate condition is warned ONCE then - /// Debug-logged on the repeated re-discovery passes (see ). - /// Mid-connect partition shrink. If a later pass yields FEWER device partitions than a - /// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT - /// actively pruned until the next full redeploy ( Clears + rebuilds - /// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption — - /// no mid-connect pruning is built here (out of scope). - /// - private Dictionary PartitionDiscoveredByDeviceHost( - DriverInstanceActor.DiscoveredNodesReady msg, - IReadOnlyList equipmentIds, - IReadOnlySet authoredRefs) - { - var driverId = msg.DriverInstanceId; - var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal); - - // normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by - // two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily. - var hostToEquipment = new Dictionary(StringComparer.Ordinal); - var ambiguousHosts = new HashSet(StringComparer.Ordinal); - foreach (var node in _lastComposition!.EquipmentNodes) - { - if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue; - // DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so - // the comparison is the single source of truth (idempotent — harmless if it was already normalized). - var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost); - if (ambiguousHosts.Contains(host)) continue; - if (hostToEquipment.TryGetValue(host, out var existing)) - { - if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal)) - { - hostToEquipment.Remove(host); - ambiguousHosts.Add(host); - } - continue; - } - hostToEquipment[host] = node.EquipmentId; - } - - // DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the - // whole driver. (Falls through the same warn-once dedup as the unmatched case.) - if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0) - { - if (ShouldWarnPartition(driverId, "degenerate")) - _log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)", - _localNode, driverId, equipmentIds.Count); - else - _log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)", - _localNode, driverId, equipmentIds.Count); - return new Dictionary(StringComparer.Ordinal); - } - - // Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with - // <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate. - var matchedNodes = new Dictionary>(StringComparer.Ordinal); - var unmatchedHosts = new HashSet(StringComparer.Ordinal); - foreach (var n in msg.Nodes) - { - var key = n.FolderPathSegments.Count >= 2 - ? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1]) - : null; - if (key is not null && hostToEquipment.ContainsKey(key)) - { - if (!matchedNodes.TryGetValue(key, out var list)) - matchedNodes[key] = list = new List(); - list.Add(n); - } - else - { - unmatchedHosts.Add(key ?? "(no-device-host-folder)"); - } - } - - // Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses - // that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all - // shadowed by authored refs) contributes no entry. - // NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW - // StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device - // emits one consistent HostAddress string per device, so a partition is single-host either way (collapse - // fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be - // a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope - // already isolates them). - var plans = new Dictionary(StringComparer.Ordinal); - foreach (var (host, nodes) in matchedNodes) - { - var equipmentId = hostToEquipment[host]; - var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs); - if (plan.Variables.Count > 0) plans[equipmentId] = plan; - } - - // Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions - // above still graft regardless. When the partition came back fully clean, drop the driver's signature so - // a later recurrence re-warns. - if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0) - { - var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal)); - var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal)); - if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous)) - _log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted", - _localNode, driverId, unmatched, ambiguous); - else - _log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)", - _localNode, driverId, unmatched, ambiguous); - } - else - { - _lastPartitionWarnSignature.Remove(driverId); - } - - return plans; - } - - /// Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN) - /// when is newly-seen for the driver this revision, false (⇒ DEBUG) on the - /// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a - /// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only - /// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes - /// one duplicate warn to Debug. - private bool ShouldWarnPartition(string driverId, string conditionKey) - { - var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey; - var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev) - || !string.Equals(prev, signature, StringComparison.Ordinal); - _lastPartitionWarnSignature[driverId] = signature; - return isNew; - } - - /// Routing-map equality: same count + every key maps to the same NodeId. Lets - /// skip re-applying an unchanged discovered set across the driver's - /// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies). - private static bool RoutingEquals(IReadOnlyDictionary a, IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal)); - - /// Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the - /// same (via ). Lets - /// short-circuit a re-discovery whose WHOLE per-driver set is unchanged - /// (a grown/changed set on any equipment differs and re-applies). - private static bool PlansRoutingEqual( - IReadOnlyDictionary a, - IReadOnlyDictionary b) - => a.Count == b.Count - && a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef)); - - /// - /// Grafts a driver's per-equipment map onto the served state in - /// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition - /// follow-up needs without resubscribe churn): - /// - /// Materialise per equipment — for each (equipmentId, plan) entry, extend the - /// live-value routing map (mirroring ' fan-out so - /// lands FixedTree values on the right node) and Tell the publish actor - /// for - /// that equipment (idempotent). - /// Subscribe ONCE per driver — compute the union of the driver's authored value refs - /// (recomputed the same way does) and the FixedTree refs of - /// ALL the driver's cached plans, then Tell the child a single - /// so the poll engine reads them and the - /// values flow. For a single-equipment driver this equals the prior per-plan behavior. - /// - /// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after - /// an address-space rebuild without re-running discovery. - /// - private void ApplyDiscoveredPlansForDriver( - string driverId, IReadOnlyDictionary plansByEquipment) - { - // (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions' - // pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is - // purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes - // until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because - // a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect. - var totalVariables = 0; - foreach (var (equipmentId, plan) in plansByEquipment) - { - foreach (var (driverRef, nodeId) in plan.RoutingByRef) - { - var key = (driverId, driverRef); - if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) - _nodeIdByDriverRef[key] = set = new HashSet(); - // v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route - // through the Raw realm. - set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key; - } - _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( - equipmentId, plan.Folders, plan.Variables)); - totalVariables += plan.Variables.Count; - } - - // (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the - // driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values. - // Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the - // FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the - // multi-device task adds inner-map entries without changing this single-send shape. - if (!_children.TryGetValue(driverId, out var entry)) return; - // The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it - // non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them - // so that re-apply path can't NRE. - var authoredValueRefs = _lastComposition is null - ? Enumerable.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName); - var alarmRefs = _lastComposition is null - ? Array.Empty() - : _lastComposition.EquipmentTags - .Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.FullName) - .Distinct(StringComparer.Ordinal) - .ToArray(); - var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys); - var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray(); - entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs)); - - _log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)", - _localNode, totalVariables, driverId, plansByEquipment.Count); - } - /// /// Routes a native alarm transition (published by a driver child as /// ) to its materialised Part 9 condition @@ -1719,10 +1347,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers Receive(msg => _log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)", _localNode, msg.ConditionNodeId)); - // A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is - // applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it - // and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above). - Receive(_ => { }); // A child connectivity transition while the host is Stale has no live address space to annotate — drop // it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry). Receive(_ => { }); @@ -2316,15 +1940,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers } } - // Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver - // is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the - // bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the - // authored∪discovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully - // dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child - // to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the - // authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass. - var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal); - // One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND // the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the // SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained @@ -2341,9 +1956,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers var total = 0; foreach (var (driverId, entry) in _children) { - // Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached - // drivers keep the bulk authored-only send exactly as before. - if (cachedDriverIds.Contains(driverId)) continue; total += SendAuthoredOnly(entry.Actor, driverId); } @@ -2380,94 +1992,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _localNode, composition.EquipmentScriptedAlarms.Count); } - // Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve - // the equipment bound to a driver + recompute the authored subscription sets when a driver later - // reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore - // paths — which both route through this method — leave a current composition. + // Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and + // bootstrap-restore paths — which both route through this method — leave a current composition. _lastComposition = composition; - // Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared - // _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS - // redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated) - // would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect - // and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor - // is freshly constructed so _discoveredByDriver is empty — restart survival comes from the - // post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments - // from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes - // uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its - // equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to - // that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving - // entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has - // exactly one inner entry; the multi-device task adds more.) - foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below - { - var fromNodes = composition.EquipmentNodes - .Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(e => e.EquipmentId); - var fromTags = composition.EquipmentTags - .Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal)) - .Select(t => t.EquipmentId); - var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal); - - var plansByEquipment = _discoveredByDriver[driverId]; - // Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this - // driver's discovery exactly ONCE after the inner map is processed (see the post-loop block). - var droppedAny = false; - foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict - { - var plan = plansByEquipment[equipmentId]; - if (!candidates.Contains(equipmentId)) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId); - continue; - } - // If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop + - // let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...". - var planEquipmentConsistent = plan.Variables.Count > 0 - && plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal); - if (!planEquipmentConsistent) - { - plansByEquipment.Remove(equipmentId); - droppedAny = true; - _log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId); - } - } - - // Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED - // rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT - // restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree - // subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now - // ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new - // _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no - // stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in - // DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is - // processed — so even when the inner map empties below), guarded on the child still existing. - if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry)) - rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - if (plansByEquipment.Count == 0) - { - _discoveredByDriver.Remove(driverId); - // Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't - // leak a stale entry (log-level-only state; bounded by driver count — just tidiness). - _lastPartitionWarnSignature.Remove(driverId); - // FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its - // plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would - // otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only - // set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass. - // (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps - // the authored values live meanwhile.) Guarded on the child still existing — a driver removed by - // ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk - // loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops - // the stale FixedTree handle without a spurious subscribe). - if (_children.TryGetValue(driverId, out var fallbackEntry)) - SendAuthoredOnly(fallbackEntry.Actor, driverId); - continue; - } - ApplyDiscoveredPlansForDriver(driverId, plansByEquipment); - } } private void SpawnChild(DriverInstanceSpec spec) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index b8a24c08..5fc2139e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -32,16 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10); - /// Default interval between bounded post-connect re-discovery passes. - public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2); - - /// Default cap on the number of post-connect re-discovery passes. - public const int DefaultRediscoverMaxAttempts = 15; - - /// Default per-pass timeout for during - /// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s. - public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30); - public sealed record InitializeRequested(string DriverConfigJson); public sealed record InitializeSucceeded(int Generation); public sealed record InitializeFailed(string Reason, int Generation); @@ -124,21 +114,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// subscription that un-gates an driver's feed. Handled async so the /// call is bounded + off the synchronous handlers. private sealed record SubscribeAlarms; - /// Published to the parent (DriverHostActor) after each post-connect discovery pass so it can - /// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine — - /// the parent dedups and injection is idempotent. - public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList Nodes); - - /// - /// Sent by to ask this driver child to re-run post-connect discovery - /// after the host rebinds the driver to a new equipment. Handled only in Connected, where it - /// re-kicks — which already honours the driver's - /// and the guard, tagging the - /// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op: - /// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log. - /// - public sealed record TriggerRediscovery; - /// Self-sent when the wrapped driver raises — /// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread /// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no @@ -146,10 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// between connects), and dropping it in one state would lose the signal silently. private sealed record RediscoveryRaised(RediscoveryEventArgs Args); - /// Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect). - /// is the ordered-distinct full-reference signature of the prior pass's - /// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it. - private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature); public sealed class RetryConnect { public static readonly RetryConnect Instance = new(); @@ -174,19 +145,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private readonly IDriverHealthPublisher _healthPublisher; private readonly TimeSpan _reconnectInterval; - /// Interval between bounded post-connect re-discovery passes. Production default 2s; tests - /// inject a tiny value so the loop runs without real-time waits. - private readonly TimeSpan _rediscoverInterval; private readonly TimeSpan _healthPollInterval; - /// Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising - /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15. - private readonly int _rediscoverMaxAttempts; - - /// Per-pass timeout for during bounded post-connect - /// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter - /// value. Stored to allow injection rather than hardcoding. - private readonly TimeSpan _rediscoverDiscoverTimeout; private readonly ILoggingAdapter _log = Context.GetLogger(); private string? _currentConfigJson; @@ -252,9 +212,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// stub paths don't need to provide one. /// Optional cluster identifier forwarded in messages; /// defaults to an empty string when not provided (e.g. in unit tests). - /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Optional cap on re-discovery passes; defaults to 15. - /// Optional per-pass timeout for ; defaults to 30 seconds. /// Optional Phase 6.1 resilience invoker wrapping this driver's capability /// calls; defaults to (pass-through) when not supplied. /// Optional period of the health-poll heartbeat, which also drives the @@ -267,9 +224,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, TimeSpan? healthPollInterval = null) => Akka.Actor.Props.Create(() => new DriverInstanceActor( @@ -278,9 +232,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers startStubbed, healthPublisher ?? NullDriverHealthPublisher.Instance, clusterId ?? string.Empty, - rediscoverInterval, - rediscoverMaxAttempts, - rediscoverDiscoverTimeout, invoker, healthPollInterval)); @@ -311,9 +262,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// If true, start in stub mode for testing or unavailable platforms. /// Sink for health-change notifications; must not be null. /// Cluster identifier forwarded in health snapshots. - /// Interval between post-connect re-discovery passes; defaults to 2 seconds. - /// Cap on the number of re-discovery passes; defaults to 15. - /// Per-pass timeout for ; defaults to 30 seconds. /// Phase 6.1 resilience invoker wrapping this driver's capability calls; /// defaults to (pass-through) when null. /// Period of the health-poll heartbeat, which also drives the @@ -324,9 +272,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers bool startStubbed = false, IDriverHealthPublisher? healthPublisher = null, string? clusterId = null, - TimeSpan? rediscoverInterval = null, - int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, - TimeSpan? rediscoverDiscoverTimeout = null, IDriverCapabilityInvoker? invoker = null, TimeSpan? healthPollInterval = null) { @@ -336,9 +281,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers _clusterId = clusterId ?? string.Empty; _healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance; _reconnectInterval = reconnectInterval; - _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; - _rediscoverMaxAttempts = rediscoverMaxAttempts; - _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; _healthPollInterval = healthPollInterval ?? HealthPollInterval; OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"), @@ -382,9 +324,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(StoreDesiredSubscriptions); // Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a // re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message. - Receive(_ => { }); - // A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it. - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -411,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); }); Receive(msg => { @@ -439,12 +377,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); } @@ -461,7 +393,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers { _log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, msg.Reason); - Timers.Cancel("rediscover"); DetachSubscription(); RecordFault(); Become(Reconnecting); @@ -470,25 +401,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers Receive(_ => { _log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId); - Timers.Cancel("rediscover"); DetachSubscription(); Become(Reconnecting); PublishHealthSnapshot(); }); - ReceiveAsync(HandleRediscoverAsync); - // The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any - // pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left - // over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop - // via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current - // _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick - // that still slips through (one already mid-async-handler) is benign: the parent dedups - // DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double - // pass in the common case. - Receive(_ => - { - Timers.Cancel("rediscover"); - StartDiscovery(); - }); ReceiveAsync(HandleWriteAsync); ReceiveAsync(HandleAcknowledgeAsync); ReceiveAsync(HandleSubscribeAsync); @@ -602,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers ResubscribeDesired(); AttachAlarmSource(); SubscribeDesiredAlarms(); - StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed }); // A failure here is a no-op regardless of generation — the retry timer keeps trying the // current config; only a (generation-matched) InitializeSucceeded transitions state. @@ -625,12 +540,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers // A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into // this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes. Receive(_ => { }); - // Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an - // already-queued disconnect; swallow it — the next Connected entry re-kicks discovery. - Receive(_ => { }); - // A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path - // re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event). - Receive(_ => { }); Receive(HandleRediscoveryRaised); Receive(_ => PublishHealthSnapshot()); Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval); @@ -971,97 +880,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers } } - /// Kick the bounded post-connect re-discovery loop on a Connected entry. A no-op unless the - /// driver exposes (nothing to inject otherwise). Self-sends the first - /// tagged with the current init generation so a tick that outlives a reconnect - /// is rejected by the generation guard in . - /// Honours the driver's : Never opts out entirely - /// (no tick scheduled); Once runs a single pass (the loop stops after the first publish in - /// ); UntilStable retries each (re)connect, bounded by - /// stop-on-stable (the discovered-set signature repeats) + the attempt cap. - private void StartDiscovery() - { - if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) - { - // Driver opts out of post-connect discovery — don't even schedule the first tick. - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId); - return; - } - Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty)); - } - - /// Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a - /// and ships the result to the parent as - /// (empty/duplicate sets are fine — the parent dedups and injection - /// is idempotent). Retries on the until the non-empty discovered SET - /// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged - /// browsers where a count alone could falsely settle a partial tree) or the - /// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may - /// still be populating. - /// Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for - /// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config _options.Tags is - /// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and - /// later grows could stop early on a transient repeat; acceptable for current scope. - private async Task HandleRediscoverAsync(RediscoverTick tick) - { - if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass) - if (_driver is not ITagDiscovery discovery) return; - - IReadOnlyList nodes; - try - { - var builder = new CapturingAddressSpaceBuilder(); - // Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded - // DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it. - using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout); - // NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy / - // OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell + - // Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume - // off-context and throw NotSupportedException("no active ActorContext"). The invoker's own - // internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor - // continuation still resumes on the captured actor scheduler. (Discover retries per tier.) - await _invoker.ExecuteAsync( - DriverCapability.Discover, - _driverInstanceId, - async ct => await discovery.DiscoverAsync(builder, ct), - cts.Token); - nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors - } - catch (Exception ex) - { - _log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt); - nodes = Array.Empty(); - } - - // Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so - // _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on - // disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes. - if (tick.Generation != _initGeneration) return; - - Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes)); - - // Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per - // (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is - // complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here — - // StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable + - // attempt-cap logic below. - if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once) - { - _log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId); - return; - } - - // Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap - // is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "". - var signature = string.Join('\u0001', - nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal)); - var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal); - if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty) - Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval); - else - _log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count); - } /// Records the host's desired subscription set without touching the live subscription. /// The set is (re)applied by on the next Connected entry. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 61a0019f..e3125339 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record RebuildAddressSpace( CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null); - /// Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect). - /// The OPC UA NodeId of the equipment root folder to inject the - /// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is - /// announced under. - public sealed record MaterialiseDiscoveredNodes( - string EquipmentRootNodeId, - IReadOnlyList Folders, - IReadOnlyList Variables); - public sealed record ServiceLevelChanged(byte ServiceLevel); private readonly IOpcUaAddressSpaceSink _sink; @@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); - Receive(HandleMaterialiseDiscovered); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - /// Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under - /// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the - /// optional-applier tolerance of . - private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg) - { - if (_applier is null) - { - _log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId); - return; - } - var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables); - if (failedNodes > 0) - { - // archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the - // dedicated meter instead of vanishing into per-node Warnings. - OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair("kind", "nodes")); - _log.Error( - "OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing", - msg.EquipmentRootNodeId, failedNodes); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 7bcfe1ee..b2b970e3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests applier.MaterialiseEquipmentTags(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); - applier.MaterialiseDiscoveredNodes( - "eq-1", - Array.Empty(), - new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) }) - .ShouldBe(0); } // ---------------- fixtures ---------------- diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index ddbf81bd..a25562e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class AddressSpaceApplierTests { - /// Verifies that an empty plan does not call the sink or trigger a rebuild. - [Fact] - public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var outcome = applier.Apply(EmptyPlan); - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(0); - outcome.RemovedNodes.ShouldBe(0); - outcome.ChangedNodes.ShouldBe(0); - sink.RebuildCalls.ShouldBe(0); - sink.AlarmWrites.ShouldBeEmpty(); - } - /// R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal - /// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via - /// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the - /// pre-R2-07 "removed equipment ⇒ rebuild" pin.) - [Fact] - public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1", "eq-2"); - var outcome = applier.Apply(plan); - - outcome.RemovedNodes.ShouldBe(2); - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - // Terminal "no-event" condition state written per id (inactive + acked + confirmed). - sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" }); - sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue(); - // Each equipment torn down as a subtree. - sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") }); - } - - /// R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent - /// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm - /// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") }, - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: Array.Empty(), - RemovedDrivers: Array.Empty(), - ChangedDrivers: Array.Empty(), - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved - outcome.AddedNodes.ShouldBe(1); - sink.AlarmWrites.ShouldBeEmpty(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that driver-only changes do not trigger address space rebuild. - [Fact] - public void Driver_only_changes_do_not_trigger_address_space_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = new AddressSpacePlan( - AddedEquipment: Array.Empty(), - RemovedEquipment: Array.Empty(), - ChangedEquipment: Array.Empty(), - AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") }, - RemovedDrivers: Array.Empty(), - ChangedDrivers: new[] - { - new AddressSpacePlan.DriverDelta( - new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"), - new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")), - }, - AddedAlarms: Array.Empty(), - RemovedAlarms: Array.Empty(), - ChangedAlarms: Array.Empty()); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - } - - /// Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires. - [Fact] - public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires() - { - var sink = new ThrowingSink(throwOnAlarmWrite: true); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = WithEquipmentRemoval("eq-1"); - - var outcome = applier.Apply(plan); // should not throw - outcome.RemovedNodes.ShouldBe(1); - outcome.RebuildCalled.ShouldBeTrue(); - } - - /// Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly - /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw - /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment - /// folder (decision #4). - [Fact] - public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - UnsAreas: Array.Empty(), - UnsLines: Array.Empty(), - EquipmentNodes: Array.Empty(), - DriverInstancePlans: Array.Empty(), - ScriptedAlarmPlans: Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - } - - /// Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment - /// folder (not the namespace root), with the variable parented to that sub-folder and a - /// folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // A Read plan threads Writable: false (the node stays CurrentRead). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false)); - // Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath). - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - } - - /// Regression for the FullName-as-NodeId collision: two identical machines exposing the - /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one - /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName. - [Fact] - public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false)); - sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false)); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag (Alarm is not null) materialises a - /// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value - /// variable; a plain tag (Alarm == null) stays a value variable. The alarm tag's condition - /// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's - /// AlarmType/Severity. Proves BOTH branches in one composition. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition. - var plainNodeId = V3NodeIds.Uns("eq-1", "Speed"); - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true)); - sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId); - - // The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent, - // matching display/type/severity) and did NOT drive EnsureVariable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp"); - // A native equipment-tag alarm: the call-site threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true)); - sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId); - } - - /// Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its - /// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder), - /// using the folder-scoped NodeId. - [Fact] - public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // The sub-folder is still created for an alarm tag with a FolderPath. - sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics")); - // Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable. - var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp"); - // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true. - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true)); - sink.VariableCalls.ShouldBeEmpty(); - } - - /// Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it - /// to EnsureVariable: a historized tag with NO override falls back to its FullName; a - /// historized tag WITH an override passes the override verbatim; a non-historized tag passes null. - [Fact] - public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Historized, no override ⇒ tagname defaults to FullName ("T.A"). - new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null), - // Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName. - new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float", - FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"), - // Not historized ⇒ tagname is null. - new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float", - FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname); - byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName - byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim - byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null - } - - /// Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to - /// FullName (the resolve uses string.IsNullOrWhiteSpace, not just null). - [Fact] - public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.HistorianCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.HistorianTagname.ShouldBe("40001"); - } - - /// Array-support Task 2 — an with IsArray: true, - /// ArrayLength: 16 flowing through must - /// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or - /// accidental drops in the wire-through. - [Fact] - public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer")); - call.IsArray.ShouldBeTrue(); - call.ArrayLength.ShouldBe(16u); - } - - /// Array-support Task 2 — a scalar (IsArray: false, - /// ArrayLength: null) must pass isArray == false through to the sink. Guards against a - /// default flip that would silently materialise scalar tags as 1-D arrays. - [Fact] - public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var call = sink.ArrayCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.IsArray.ShouldBeFalse(); - call.ArrayLength.ShouldBeNull(); - } - - /// Review M-1 — an array equipment tag authored with Writable: true must be - /// materialised as READ-ONLY (writable == false) because array writes are out of scope - /// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash - /// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the - /// writable path for arrays. - [Fact] - public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite AND IsArray — the applier must clamp to read-only. - new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - // writable must be false (array writes out of scope), isArray must be true (forwarded verbatim). - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Review M-1 regression — a scalar tag authored with Writable: true must still - /// be materialised as read/write (writable == true). The array-clamp must NOT affect - /// scalar tags. - [Fact] - public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - // Authored ReadWrite, scalar — must pass through writable: true unchanged. - new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeFalse(); - } - - /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly - /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the - /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create - /// the equipment folder (no sub-folder when FolderPath is empty). - [Fact] - public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed - // VirtualTags are computed outputs — always read-only (Writable: false). - sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - // Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula. - sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm")); - } - - /// Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and - /// the equipment-VirtualTag pass is byte-identical to V3NodeIds.Uns — the - /// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty - /// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test - /// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the - /// shared helper fails here. - [Fact] - public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentVirtualTags(composition); - - var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList(); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency")); - nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg")); - } - - /// Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables - /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder. - [Fact] - public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64", - Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }), - new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64", - Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }), - }, - }; - - applier.MaterialiseEquipmentVirtualTags(composition); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.Count.ShouldBe(2); - sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false)); - sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false)); - } - - /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by - /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS - /// disabled alarms. - [Fact] - public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var composition = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentScriptedAlarms = new[] - { - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm", - Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true), - new EquipmentScriptedAlarmPlan( - ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition", - Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;", - DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false), - }, - }; - - applier.MaterialiseScriptedAlarms(composition); - - // Only the enabled alarm is materialised; the disabled one is skipped entirely. - // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap). - sink.AlarmConditionCalls.ShouldHaveSingleItem() - .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false)); - } - - /// Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by - /// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables - /// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the - /// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them - /// parent-first before ensuring (a child folder's parent must exist first). - [Fact] - public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Child folder listed BEFORE its parent — the applier must re-order parent-first. - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - - // Folders ensured parent-first regardless of input order (shallowest depth first). - sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS")); - sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity")); - - // Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY. - sink.VariableCalls.ShouldHaveSingleItem() - .ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false)); - - // Exactly one NodeAdded model-change, announced under the equipment root. - sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1"); - } - - /// Task 4 — a discovered array variable (rare) authored Writable: true is forced - /// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the - /// IsArray / ArrayLength flags are forwarded verbatim to the sink. - [Fact] - public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16", - Writable: true, IsArray: true, ArrayLength: 8u), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), variables); - - var varCall = sink.VariableCalls.ShouldHaveSingleItem(); - varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true - var arrCall = sink.ArrayCalls.ShouldHaveSingleItem(); - arrCall.IsArray.ShouldBeTrue(); - arrCall.ArrayLength.ShouldBe(8u); - } - - /// Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the - /// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on - /// existing nodes), and a model-change is raised once PER call (twice across two calls). - [Fact] - public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var folders = new[] - { - new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"), - new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"), - }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", - "String", Writable: false, IsArray: false, ArrayLength: null), - }; - - applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables); - Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - // Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the - // DISTINCT set the applier produces is stable across re-applies. - sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" }); - sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" }); - // One model-change per call ⇒ two across two calls. - sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" }); - } - - /// Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no - /// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change. - [Fact] - public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty(), Array.Empty()); - - sink.FolderCalls.ShouldBeEmpty(); - sink.VariableCalls.ShouldBeEmpty(); - sink.ModelChangeCalls.ShouldBeEmpty(); - } /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs deleted file mode 100644 index f1da0dfd..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CapturingAddressSpaceBuilderTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class CapturingAddressSpaceBuilderTests -{ - [Fact] - public void Records_nested_path_segments_full_reference_and_metadata() - { - var b = new CapturingAddressSpaceBuilder(); - var focas = b.Folder("FOCAS", "FOCAS"); - var device = focas.Folder("10.0.0.5:8193", "cnc"); - var identity = device.Folder("Identity", "Identity"); - identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo( - FullName: "10.0.0.5:8193/Identity/SeriesNumber", - DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false)); - - b.Nodes.Count.ShouldBe(1); - var n = b.Nodes[0]; - n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" }); - n.BrowseName.ShouldBe("SeriesNumber"); - n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber"); - n.DataType.ShouldBe(DriverDataType.String); - n.Writable.ShouldBeFalse(); // ViewOnly -> read-only - } - - [Fact] - public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink() - { - var b = new CapturingAddressSpaceBuilder(); - var f = b.Folder("FOCAS", "FOCAS"); - f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw - var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null, - SecurityClassification.ViewOnly, false, IsAlarm: true)); - var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null)); - sink.ShouldNotBeNull(); // no-op sink, alarms out of scope - b.Nodes.Count.ShouldBe(1); - } -} 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 c8d5ca33..9632ae74 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 @@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons "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."; - /// Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's - /// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered - /// raw tags are authored explicitly via the Batch-2 /raw browse-commit flow. HandleDiscoveredNodes - /// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection - /// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by - /// DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3. - public const string DiscoveryInjectionDormantV3 = - "v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " + - "v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " + - "browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " + - "re-migrating injection onto the raw device subtree is a separate follow-up."; + // 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 + // (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped. + // Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a + // re-browse prompt instead (DriverInstanceActorRediscoverySignalTests). /// 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/DiscoveredNodeMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs deleted file mode 100644 index cdc2dca0..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveredNodeMapperTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using Shouldly; -using Xunit; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -[Trait("Category", "Unit")] -public sealed class DiscoveredNodeMapperTests -{ - private static DiscoveredNode Node(string[] path, string name, string fullRef, - DriverDataType dt = DriverDataType.Float64, bool writable = false) - => new(path, name, name, fullRef, dt, false, null, writable, false); - - [Fact] - public void Maps_under_equipment_collapsing_single_device_folder() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"), - }; - - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/Identity/SeriesNumber", - "EQ-1/FOCAS/Axes/X/AbsolutePosition", - }, ignoreOrder: true); - result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X"); - result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes"); - result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse(); - } - - [Fact] - public void Dedups_authored_refs() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"), - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet { "parts-count" }); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber"); - } - - [Fact] - public void Does_not_collapse_when_two_devices_present() - { - var nodes = new[] - { - Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String), - Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String), - }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.Select(v => v.NodeId).ShouldBe(new[] - { - "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber", - "EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber", - }, ignoreOrder: true); - } - - [Fact] - public void Empty_input_yields_empty_plan() - { - var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty(), authoredRefs: new HashSet()); - result.Folders.ShouldBeEmpty(); - result.Variables.ShouldBeEmpty(); - result.RoutingByRef.ShouldBeEmpty(); - } - - [Fact] - public void Array_metadata_passes_through_unchanged() - { - var node = new DiscoveredNode( - FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"], - BrowseName: "Positions", - DisplayName: "Positions", - FullReference: "10.0.0.5:8193/Axes/Positions", - DataType: DriverDataType.Float64, - IsArray: true, - ArrayDim: 8u, - Writable: false, - IsHistorized: false); - - var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet()); - - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].IsArray.ShouldBeTrue(); - result.Variables[0].ArrayLength.ShouldBe(8u); - } - - [Theory] - // Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float", - // Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through - // members must keep their enum name so the node manager resolves them to the matching built-in type. - [InlineData(DriverDataType.Float64, "Double")] - [InlineData(DriverDataType.Float32, "Float")] - [InlineData(DriverDataType.Reference, "String")] - [InlineData(DriverDataType.Boolean, "Boolean")] - [InlineData(DriverDataType.Int16, "Int16")] - [InlineData(DriverDataType.Int32, "Int32")] - [InlineData(DriverDataType.Int64, "Int64")] - [InlineData(DriverDataType.UInt16, "UInt16")] - [InlineData(DriverDataType.UInt32, "UInt32")] - [InlineData(DriverDataType.UInt64, "UInt64")] - [InlineData(DriverDataType.String, "String")] - [InlineData(DriverDataType.DateTime, "DateTime")] - public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected) - { - var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) }; - var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet()); - result.Variables.ShouldHaveSingleItem(); - result.Variables[0].DataType.ShouldBe(expected); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs deleted file mode 100644 index b692f994..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ /dev/null @@ -1,353 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.OpcUaServer; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the -/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites -/// (which wire the OPC UA publish side as a and assert on the -/// intercepted / -/// messages), this suite wires the FULL real chain: -/// -/// -/// a real (resolves equipment, maps via -/// , extends the live-value routing map, caches the plan); -/// a real as its opcUaPublishActor seam (so -/// MaterialiseDiscoveredNodes + AttributeValueUpdate are actually handled, not -/// intercepted); -/// a real over a recording -/// (so the materialise + value-write reach the sink). -/// -/// -/// -/// The assertions are therefore made on the SINK's recorded EnsureVariable / -/// RaiseNodesAddedModelChange / WriteValue calls — i.e. the discovered node was -/// materialised through the real applier AND a published value surfaces -/// at the mapped NodeId (in production this overwrites the BadWaitingForInitialData seed that -/// OtOpcUaNodeManager.EnsureVariable stamps on a freshly-materialised variable; the recording -/// sink does not model that seed, so the faithful assertion available here is that the live value -/// lands Good at the same NodeId the materialise created). -/// -/// -/// -/// Seam choices (faithful to the sibling suites). Discovery is driven by Telling the host -/// directly, and the polled value by Telling -/// directly — exactly the seams the Task-7/8 -/// tests use (there is no test seam to drive a real poll loop through a -/// child to Connected, and the spawned child is a ). The publish -/// actor is wired WITHOUT a dbFactory, so the host's apply-time -/// falls back to a raw sink.RebuildAddressSpace() (no EnsureVariable); this keeps the -/// ONLY EnsureVariable traffic on the sink the discovered-node materialise itself, so the -/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing -/// map) is fully real. -/// -/// -[Trait("Category", "Unit")] -public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - // The FixedTree node the driver "discovers": FOCAS//Identity/SeriesNumber, a String value, - // whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored - // node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber. - private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber"; - private const string FixedTreeDisplayName = "SeriesNumber"; - - // The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) + - // the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host - // segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse - // "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this - // EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the - // collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber"). - private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber"; - - private static DiscoveredNode[] FixedTreeNodes() => new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "SeriesNumber", - DisplayName: FixedTreeDisplayName, - FullReference: FixedTreeRef, - DataType: DriverDataType.String, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - /// - /// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows - /// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a - /// real publish actor + real applier + recording sink, then asserts: - /// (a) the sink recorded an EnsureVariable for the FixedTree node under EQ-1 (materialised - /// through the REAL applier — the node now exists in the served address space), with a - /// RaiseNodesAddedModelChange under EQ-1 so connected clients refresh; - /// (b) after an for the FixedTree ref, the - /// sink recorded a WriteValue at THAT SAME NodeId carrying the value with - /// — proving the live value routed end-to-end and (in production) - /// overwrote the BadWaitingForInitialData seed. - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId); - - // Driver reports its captured FixedTree (the faithful Task-7/8 seam). - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT - // collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse). - AwaitAssert(() => - { - var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName); - v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1 - v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink - v.Writable.ShouldBeFalse(); // discovered nodes are read-only - sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment - }, duration: Timeout); - - // (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live - // value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts)); - - AwaitAssert(() => - { - var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId); - write.NodeId.ShouldBe(ExpectedFixedTreeNodeId); - write.Value.ShouldBe("SN-12345"); - write.Quality.ShouldBe(OpcUaQuality.Good); - write.Ts.ShouldBe(Ts); - }, duration: Timeout); - } - - /// - /// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a - /// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new - /// revision, same d1 → EQ-1 binding) re-runs PushDesiredSubscriptions — which clears the - /// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts - /// the cached discovered plan. Asserts the sink records the FixedTree EnsureVariable AGAIN - /// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL - /// WriteValues Good there (the routing map was rebuilt, not left empty by the Clear()). - /// - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_and_value_survive_a_redeploy() - { - var db = NewInMemoryDbFactory(); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId); - - host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes())); - - // First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1. - AwaitAssert( - () => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName), - duration: Timeout); - - // First value flows Good (pre-redeploy baseline). - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - - var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId); - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions - // (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild. - AwaitAssert( - () => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore), - duration: Timeout); - - // (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the - // live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it). - var tsAfter = Ts.AddSeconds(5); - host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter)); - AwaitAssert( - () => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good), - duration: Timeout); - } - - /// Spawns the real chain — recording sink → real → real - /// (the host's opcUaPublishActor seam) → real - /// backed by a — dispatches the - /// deployment, and waits for the Applied ACK so _lastComposition + the live child + the initial - /// subscribe pass have completed before discovery is injected. The publish actor is wired with the - /// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable) - /// and the only EnsureVariable traffic is the discovered-node materialise itself. - private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor( - IDbContextFactory db, DeploymentId deploymentId) - { - var coordinator = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var host = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish, - virtualTagHostOverride: vtHost.Ref)); - - host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - return (host, sink, coordinator); - } - - /// Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project - /// equipment tags + a real (non-stubbed) child for each driver - /// (mirrors DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags). An authored value tag - /// both sets _lastComposition and binds the driver → equipment (the only way the host resolves the - /// equipment a discovered node grafts under). - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, so a real (non-stubbed) child is spawned for the driver and - /// the host's subscribe path is exercised (mirrors - /// DriverHostActorDiscoveryTests.SubscribingDriverFactory). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Recording — captures the EnsureFolder / EnsureVariable / - /// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can - /// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the - /// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread). - private sealed class RecordingSink : IOpcUaAddressSpaceSink - { - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new(); - private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new(); - private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new(); - private readonly ConcurrentQueue _modelChanges = new(); - - /// Gets a snapshot of the recorded EnsureFolder calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList(); - /// Gets a snapshot of the recorded EnsureVariable calls. - public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList(); - /// Gets a snapshot of the recorded WriteValue calls. - public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList(); - /// Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements. - public List ModelChanges => _modelChanges.ToList(); - /// Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback). - public int RebuildCalls; - - /// Records a live-value write. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _values.Enqueue((nodeId, value, quality, sourceTimestampUtc)); - - /// No-op: alarm writes are not exercised by this suite. - public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { } - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { } - - /// No-op: alarm materialise is not exercised by this suite. - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { } - - /// Records an EnsureFolder call. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) - => _folders.Enqueue((folderNodeId, parentNodeId, displayName)); - - /// Records an EnsureVariable call. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); - - /// Records a raw rebuild (the apply-time fallback when no dbFactory is wired). - public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls); - - /// Records a NodeAdded model-change announcement. - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId); - public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } - public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs deleted file mode 100644 index 2054eb51..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ /dev/null @@ -1,1327 +0,0 @@ -using System.Text.Json; -using Akka.Actor; -using Microsoft.EntityFrameworkCore; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; -using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; -using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; -using ZB.MOM.WW.OtOpcUa.Commons.Types; -using ZB.MOM.WW.OtOpcUa.Configuration; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Verifies the discovered-node injection wired into (Task 7): when a -/// driver child reports a captured FixedTree via , -/// the host resolves the bound equipment from the authored composition, maps the nodes under it via -/// , materialises them on the OPC UA publish side -/// (), extends the live-value routing map -/// (_nodeIdByDriverRef), and merges the FixedTree refs into the driver's desired subscription set -/// (). -/// -/// -/// Drives a real apply through the existing harness (same artifact shape as -/// DriverHostActorLiveValueTests / DriverHostActorWriteRoutingTests) so -/// _lastComposition is set and a real (non-stubbed) child -/// is spawned for d1. The child is backed by the shared -/// (records LastSubscribedRefs/SubscribeCount, exactly as -/// DriverInstanceActorTests asserts) so the merged subscription is observable; the OPC UA -/// publish actor is a (as in -/// DriverHostActorLiveValueTests) so the materialise + the post-injection value route are -/// observable. There is no test seam to inject a probe AS a driver child, so this is the faithful -/// end-to-end approach the harness allows. -/// -/// -[Trait("Category", "Unit")] -public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase -{ - private static readonly NodeId TestNode = NodeId.Parse("driver-disc-test"); - private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); - private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); - private static readonly RevisionHash RevC = RevisionHash.Parse(new string('c', 64)); - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); - private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc); - - /// v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited. - /// A driver reports a FixedTree via , but the host must - /// NOT materialise anything (no reaches the publish - /// side) — discovered raw tags are authored via the /raw browse-commit flow, not injected at runtime. - /// The skipped v2 injection scenarios below are the counterpart of this guard. - [Fact] - public void Discovered_nodes_are_ignored_dormant_in_v3() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the - /// bound equipment: (a) the publish side receives - /// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the - /// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the - /// live-value routing map was extended). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // One authored value tag: equipment EQ-1, driver d1, FullName "40001" — this both sets - // _lastComposition AND binds d1 → EQ-1 (the only way the equipment is resolved, since EquipmentNode - // carries no DriverInstanceId). - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // A FixedTree discovered node whose FullReference DIFFERS from the authored tag's FullName, so the - // mapper keeps it (it does not shadow an authored ref). - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", - DisplayName: "Model", - FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - Writable: false, - IsHistorized: false), - }; - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // (a) The publish side materialises the discovered folders + variables UNDER the equipment root "EQ-1". - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - materialise.Folders.Count.ShouldBeGreaterThan(0); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver re-subscribed the UNION of the authored value ref AND the FixedTree ref. The union - // push is the LAST SetDesiredSubscriptions, so the most recent subscribe carries both. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref now routes to the mapped FixedTree NodeId — proving the - // _nodeIdByDriverRef live-value map was extended by the injection. - actor.Tell(new DriverInstanceActor.AttributeValuePublished( - "d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - update.TimestampUtc.ShouldBe(Ts); - } - - /// NEW capability (follow-up E): a driver bound to an equipment via - /// with ZERO authored equipment tags can still graft its - /// discovered FixedTree. The equipment is resolved from the composition's EquipmentNodes (not just - /// the authored EquipmentTags), so a tag-less equipment receives - /// rooted at its NodeId and the driver - /// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with - /// "no equipment/authored tags". - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // EQ-1 bound to driver d1 via EquipmentNode.DriverInstanceId, with NO authored equipment tags for d1. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // (a) The discovered nodes materialise UNDER EQ-1 even though no authored tag binds d1 → EQ-1. - var materialise = publish.ExpectMsg(Timeout); - materialise.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise.Variables[0].NodeId; - - // (b) The driver subscribes the discovered ref (the union is just the FixedTree ref — no authored ref). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref routes to its mapped NodeId (routing map was extended). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - } - - /// DEGENERATE multi-equipment case (follow-up E, part 2): a driver that resolves to MORE THAN ONE - /// equipment but where NONE of the candidates carries a has nothing - /// to partition the FixedTree on, so the whole driver is warn-skipped (nothing grafted, no crash). Here d1 - /// is bound to two equipments via two AUTHORED tags only (no Device rows ⇒ no DeviceHost), which is exactly - /// the degenerate shape: no is told. - [Fact] - public void Driver_mapping_to_more_than_one_equipment_with_no_device_host_warn_skips() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 is bound to TWO equipments via two authored tags (no devices ⇒ no DeviceHost) ⇒ degenerate ⇒ warn-skip. - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"), - (Equip: "EQ-2", Driver: "d1", FullName: "40002", Folder: (string?)null, Name: "speed2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Nothing is grafted — no candidate has a DeviceHost, so there's nothing to partition on (degenerate). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Multi-device split (follow-up E, part 2): a driver that resolves to >1 equipment, each bound to - /// a DEVICE with a distinct , partitions its discovered FixedTree by - /// the (normalized) device-host folder segment and grafts each device's subset under the equipment whose - /// DeviceHost matches. Asserts (a) TWO (one per - /// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref - /// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1" - /// vs stored "h1" wrinkle proves the SHARED DeviceConfigIntent.NormalizeHost match. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 fans out to EQ-A (device host "h1") + EQ-B (device host "h2"), tag-less, bound via EquipmentNode. - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Discovered nodes split across two device-host folder segments. "H1" is UPPERCASE to prove the shared - // NormalizeDeviceHost lower-cases the segment to match the stored lower-cased "h1" DeviceHost. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "H1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // (a) Each device's subset materialises under its OWN equipment — two messages, one per equipment. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - var byEquipment = new[] { m1, m2 }.ToDictionary(m => m.EquipmentRootNodeId, m => m); - byEquipment.Keys.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - byEquipment["EQ-A"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Model"); - byEquipment["EQ-B"].Variables.ShouldHaveSingleItem().DisplayName.ShouldBe("Run"); - var eqANodeId = byEquipment["EQ-A"].Variables[0].NodeId; - var eqBNodeId = byEquipment["EQ-B"].Variables[0].NodeId; - // Single device per partition ⇒ the mapper collapses the host folder ⇒ the NodeId carries NO host - // segment and reads EQ-n/FOCAS//. Assert the EXACT collapsed path (depth + leaf) so a - // collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) — - // fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY - // (a lowercase-only "h1" check would miss a leaked raw "H1"). - eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model")); - eqANodeId.ShouldNotContain("H1"); - eqANodeId.ShouldNotContain("h1"); - eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run")); - eqBNodeId.ShouldNotContain("h2"); - - // (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-h1-1"); - refs.ShouldContain("ft-h2-1"); - }, duration: Timeout); - - // (c) Routing is keyed per device: h1's ref lands on EQ-A's node, h2's on EQ-B's node. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h1-1", 1.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqANodeId); - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h2-1", 2.0, OpcUaQuality.Good, Ts)); - publish.ExpectMsg(Timeout).NodeId.ShouldBe(eqBNodeId); - } - - /// Multi-device unmatched warn-skip (follow-up E, part 2): a discovered device-host partition with - /// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here - /// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only - /// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - })); - - // Exactly TWO materialise (EQ-A, EQ-B); the unmatched "h3" grafts nowhere ⇒ no third materialise. - var m1 = publish.ExpectMsg(Timeout); - var m2 = publish.ExpectMsg(Timeout); - new[] { m1.EquipmentRootNodeId, m2.EquipmentRootNodeId }.ShouldBe(new[] { "EQ-A", "EQ-B" }, ignoreOrder: true); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The ghost ref was never materialised, so a value for it routes nowhere — dropped, not a crash. - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-h3-1", 9.0, OpcUaQuality.Good, Ts)); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// Warn-spam taming (follow-up E, part 2): an unmatched device-host partition WARNS exactly ONCE, - /// then the identical repeated re-discovery passes (the driver re-discovers ~15×/connect, re-sending the - /// same set) are quiet — proving ShouldWarnPartition's per-driver signature dedup. The repeat is - /// logged at Debug, which the suite's loglevel = WARNING HOCON suppresses at source, so EventFilter - /// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on - /// pass 1; pass 2's matched routing is short-circuited by PlansRoutingEqual.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithMultiDeviceEquipments(db, RevA, driverId: "d1", - (Equip: "EQ-A", DeviceId: "dev-a", Host: "h1"), - (Equip: "EQ-B", DeviceId: "dev-b", Host: "h2")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h1", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-h1-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h2", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-h2-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - new DiscoveredNode(FolderPathSegments: new[] { "FOCAS", "h3", "Identity" }, - BrowseName: "Ghost", DisplayName: "Ghost", FullReference: "ft-h3-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, Writable: false, IsHistorized: false), - }; - - // Pass 1: the unmatched "h3" partition warns EXACTLY once. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(1, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - - // Drain the matched EQ-A + EQ-B grafts from pass 1 so the assertion below is unambiguous. - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); - - // Pass 2: the IDENTICAL set (same revision) does NOT warn again — the repeat is Debug (suppressed by the - // suite's WARNING loglevel), so EventFilter sees ZERO further matching warnings. - EventFilter.Warning(contains: "discovered device-host partition(s) skipped") - .Expect(0, () => actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered))); - } - - /// Guard: a arriving BEFORE any deployment - /// is applied (_lastComposition still null) is ignored — nothing is materialised on the publish - /// side (the equipment can't be resolved without a composition). - [Fact] - public void DiscoveredNodes_before_any_apply_are_ignored() - { - var db = NewInMemoryDbFactory(); - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - // No deployment dispatched ⇒ Bootstrap enters Steady with no composition ⇒ _lastComposition is null. - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: new SubscribingDriverFactory("Modbus"), - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // No composition ⇒ no materialise (and no RebuildAddressSpace either, since nothing was applied). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Dedup: a discovered node whose FullReference equals an authored equipment tag's - /// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs - /// are materialised. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_node_shadowing_an_authored_ref_is_not_injected() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - // Two captured nodes: one SHADOWS the authored ref "40001" (must be dropped), one is genuinely new. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Registers" }, - BrowseName: "speed", DisplayName: "Speed", FullReference: "40001", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // Exactly ONE variable materialised — the new "Model", not the authored-shadow "Speed". - var materialise = publish.ExpectMsg(Timeout); - materialise.Variables.Count.ShouldBe(1); - materialise.Variables[0].DisplayName.ShouldBe("Model"); - } - - /// Idempotency / the unchanged-plan short-circuit: re-sending the SAME discovered set (the driver - /// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to - /// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising - /// FixedTree still converges. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory); - - var node1 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - var node2 = new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Status" }, - BrowseName: "Run", DisplayName: "Run", FullReference: "ft-ref-2", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false); - - // Pass 1: a new set ⇒ one materialise + a union re-subscribe. - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - var subscribeCountAfterFirst = factory.SubscribeCount; - - // Pass 2: the IDENTICAL set ⇒ short-circuited (no materialise, no re-subscribe). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1 })); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.SubscribeCount.ShouldBe(subscribeCountAfterFirst); - - // Pass 3: a GROWN set (superset) ⇒ re-applies (materialise again + re-subscribe with both refs). - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] { node1, node2 })); - publish.ExpectMsg(Timeout).Variables.Count.ShouldBe(2); - AwaitAssert(() => - { - factory.SubscribeCount.ShouldBeGreaterThan(subscribeCountAfterFirst); - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - refs.ShouldContain("ft-ref-2"); - }, duration: Timeout); - } - - /// Task 8 survival: discovered (FixedTree) nodes injected after the first apply must SURVIVE a - /// redeploy. A second deployment re-runs PushDesiredSubscriptions, which clears the live-value - /// routing maps and re-pushes an authored-only subscription set; without the tail re-apply the injected - /// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached - /// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's - /// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_survive_a_redeploy_rebuild() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - var discovered = new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - }; - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", discovered)); - - // First injection: materialise #1 under EQ-1 — capture the mapped NodeId for the survival asserts. - var materialise1 = publish.ExpectMsg(Timeout); - materialise1.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise1.Variables.Count.ShouldBe(1); - var fixedTreeNodeId = materialise1.Variables[0].NodeId; - - // Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding so HandleDispatchFromSteady doesn't - // short-circuit on an identical rev). This re-runs PushDesiredSubscriptions, which clears + rebuilds - // the routing maps and re-pushes the authored-only subscription set — the exact path Task 8 self-heals. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace first (drain it) ... - publish.ExpectMsg(Timeout); - - // (a) ... then the cached discovered plan is RE-MATERIALISED under EQ-1 (the Task-8 tail re-apply), - // at the SAME NodeId the first injection placed it. - var materialise2 = publish.ExpectMsg(Timeout); - materialise2.EquipmentRootNodeId.ShouldBe("EQ-1"); - materialise2.Variables.Count.ShouldBe(1); - materialise2.Variables[0].NodeId.ShouldBe(fixedTreeNodeId); - - // (b) The child's post-redeploy subscription STILL carries the FixedTree ref — it was re-merged onto - // the freshly-cleared authored-only set, not dropped by the _nodeIdByDriverRef.Clear(). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - - // (c) A value published for the FixedTree ref STILL routes to its mapped NodeId — proving the - // live-value routing map was rebuilt by the re-apply (not left empty after the Clear()). - actor.Tell(new DriverInstanceActor.AttributeValuePublished("d1", "ft-ref-1", 42.0, OpcUaQuality.Good, Ts)); - var update = publish.ExpectMsg(Timeout); - update.NodeId.ShouldBe(fixedTreeNodeId); - update.Value.ShouldBe(42.0); - update.Quality.ShouldBe(OpcUaQuality.Good); - } - - /// Task 8 cache-drop: a redeploy whose composition no longer binds the driver to any equipment - /// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a - /// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell - /// for the now-unresolved driver. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1. - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment that binds a DIFFERENT driver (d2 → EQ-2) and carries NO authored tags for - // d1, so d1's equipment can no longer be resolved (equipmentIds.Count == 0) — the cache entry is dropped. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d2", FullName: "40002", Folder: (string?)null, Name: "speed2")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The redeploy fires a fresh RebuildAddressSpace; after draining it, NO MaterialiseDiscoveredNodes is - // re-told (the cached d1 plan was dropped because its equipment no longer resolves). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Task 8 rebind-guard: a redeploy that REBINDS the driver to a DIFFERENT equipment must DROP the - /// cached discovered plan rather than re-graft EQ-1-scoped nodes under EQ-2. d1 still resolves to exactly - /// one equipment (so the Count==0 drop does NOT fire), but the cached plan's NodeIds are scoped to the OLD - /// equipment (EQ-1), so the StartsWith(equipmentId + "/") guard sees they no longer match EQ-2 and - /// drops the entry. After the redeploy NO is - /// re-told. (Complements , which - /// covers the Count==0 branch; this covers the rebind/StartsWith branch.) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection materialises under EQ-1 (the cached plan's NodeIds are scoped to EQ-1). - publish.ExpectMsg(Timeout); - - // Apply a SECOND deployment where d1 is REBOUND to a DIFFERENT equipment EQ-2 (d1 still present + still - // resolves to exactly one equipment, but the cached plan is scoped to EQ-1). The DriverConfig is - // unchanged ("{}") so ReconcileDrivers does NOT restart d1 — exactly the config-unchanged rebind the - // guard's known-limitation comment describes. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // After draining the fresh RebuildAddressSpace, NO MaterialiseDiscoveredNodes is re-told — the cached - // EQ-1-scoped plan was dropped by the rebind guard (its NodeId no longer starts with "EQ-2/"). - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - } - - /// Follow-up C, part 2: a CONFIG-UNCHANGED rebind must RE-TRIGGER discovery on the driver's child - /// so the dropped FixedTree re-grafts under the NEW equipment on the next pass (rather than staying absent - /// until the driver's next natural reconnect). Wires a REAL child (policy - /// , ever-growing set) so the connect-time pass populates the - /// per-equipment cache under EQ-1, then redeploys to rebind d1 → EQ-2 with the SAME (unchanged) DriverConfig - /// (so ReconcileDrivers does NOT restart the child — exactly the config-unchanged rebind). The - /// re-inject tail drops the stale EQ-1 entry and must send - /// to d1's child. The trigger reaching the child is observed at the host level (the only faithful seam — there - /// is no probe-as-driver-child seam): the child re-runs discovery (its DiscoverCount advances past the - /// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks - /// discovery) AND a fresh re-grafts the FixedTree - /// under the new equipment EQ-2. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() - { - var db = NewInMemoryDbFactory(); - // A REAL ITagDiscovery child (Once policy) — so the connect-time pass populates the cache and a - // TriggerRediscovery re-runs discovery, making the trigger observable at the host level. - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // The child connects and runs its single (Once) post-connect discovery pass, which the host grafts - // under EQ-1 — this POPULATES the per-equipment cache (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy: REBIND d1 from EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged so ReconcileDrivers - // does NOT restart the child). The re-inject tail drops the stale EQ-1-scoped cache entry. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - // The drop must SEND DriverInstanceActor.TriggerRediscovery to d1's (still-Connected) child, which - // re-runs discovery: DiscoverCount advances past the single connect pass — the observable proof the - // trigger reached the child. (Pre-task: no trigger ⇒ Once already settled, child never reconnects ⇒ - // DiscoverCount stays 1 ⇒ this fails.) - AwaitAssert(() => factory.DiscoverCount.ShouldBeGreaterThan(1), duration: TimeSpan.FromSeconds(10)); - - // ... and the re-triggered pass re-grafts the FixedTree under the NEW equipment EQ-2 (the host resolves - // d1 → EQ-2 against the new _lastComposition). The re-discovery re-populates the cache as - // { EQ-2: plan-scoped-to-EQ-2 }. - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-2", Timeout); - var discoverCountAfterRebind = factory.DiscoverCount; // 2 (connect pass + the rebind re-trigger pass) - - // Convergence: a FURTHER unchanged redeploy (SAME EQ-2 composition) must NOT drop again, NOT re-trigger, - // and NOT advance discovery — proving the re-trigger CONVERGES and doesn't loop. The cached EQ-2 plan now - // SURVIVES the re-inject tail (candidate + NodeIds still scoped to EQ-2), so EQ-2 simply re-materialises - // (the Task-8 survivor re-apply) — but droppedAny stays false, so no TriggerRediscovery is sent and no - // fresh discovery pass runs. - var deploymentId3 = SeedDeploymentWithEquipmentTags(db, RevC, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId3, RevC, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The survivor re-apply re-materialises EQ-2 (NOT a drop+re-trigger). - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-2"); - // No further publish traffic and NO further discovery pass — the re-trigger converged. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(discoverCountAfterRebind); - } - - /// Negative / regression guard: a routine redeploy of a discovery-capable driver that does NOT - /// rebind (SAME composition, new revision) must NOT spuriously re-trigger discovery. The cached EQ-1 plan - /// SURVIVES the re-inject tail (its equipment still resolves and its NodeIds are still EQ-1-scoped), so it is - /// re-applied (EQ-1 re-materialises — the Task-8 survival path) — but NOTHING is dropped, so droppedAny - /// stays false and no is sent. Uses the REAL - /// child (the existing ExpectNoMsg drop tests use a NON-discovery driver, - /// whose child would no-op a TriggerRediscovery in StartDiscovery's is not ITagDiscovery - /// guard — so they would NOT catch a future regression that sets droppedAny on a non-drop path). The - /// regression is observable here: a spurious trigger would advance DiscoverCount past the single Once - /// pass. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void No_drop_redeploy_does_not_re_trigger_discovery() - { - var db = NewInMemoryDbFactory(); - var factory = new DiscoverableSubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - - // Connect-time discovery grafts under EQ-1 and populates the cache (DiscoverCount == 1). - publish.FishForMessage( - m => m.EquipmentRootNodeId == "EQ-1", Timeout); - AwaitAssert(() => factory.DiscoverCount.ShouldBe(1), duration: Timeout); - - // Redeploy with the SAME composition (new revision so it applies; NO rebind ⇒ NO drop). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // The cached EQ-1 plan SURVIVES the re-inject tail and is re-applied — EQ-1 re-materialises. - publish.ExpectMsg(Timeout) - .EquipmentRootNodeId.ShouldBe("EQ-1"); - // But NOTHING was dropped, so NO TriggerRediscovery was sent: no fresh discovery pass runs and no - // further publish traffic arrives. DiscoverCount stays 1. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - factory.DiscoverCount.ShouldBe(1); - } - - /// Follow-up D (one-send invariant — SURVIVOR path): a CACHED (FixedTree-discovered) driver whose - /// plan SURVIVES a redeploy must receive EXACTLY ONE - /// for that pass — the authored∪discovered UNION the re-inject tail sends — NOT the old TWO (a bulk - /// authored-only send that dropped the whole handle, then the tail union that re-subscribed it). Observed via - /// the shared driver's SubscribeCount (each non-empty SetDesiredSubscriptions ⇒ exactly one - /// SubscribeAsync — no de-dup in ): the count rises by EXACTLY 1 across the - /// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only - /// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".) - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the union subscribe (authored "40001" + discovered "ft-ref-1") lands and the cache is - // populated (_discoveredByDriver[d1] = { EQ-1: plan }). - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - // Let the first-injection traffic settle, then snapshot the subscribe count as the redeploy baseline. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy the SAME composition (new revision so it applies; d1 → EQ-1 unchanged ⇒ the cached plan - // SURVIVES the re-inject tail ⇒ re-applied as a single union send). - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectMsg(Timeout); // tail survivor re-materialise - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the count rises by 1 and the set is the union (the - // combined condition is UNSATISFIABLE under the old double-send — at count+1 the set was authored-only - // (no "ft-ref-1"), at count+2 the count overshoots — so this fails RED before the fix). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - // Settle + re-confirm the count did NOT creep to +2 (the retired bulk authored-only + tail union double-send). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — DROPPED path): a CACHED driver whose plan is FULLY DROPPED by a - /// config-unchanged rebind (the inner map empties ⇒ the driver is removed from _discoveredByDriver) - /// must still receive EXACTLY ONE — the AUTHORED-ONLY - /// fallback — so its authored subscriptions are not lost now that the bulk loop SKIPS cached drivers. The - /// re-inject tail no longer re-applies a (now-empty) plan for it, so the fallback is the only send. Observed - /// via SubscribeCount (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also - /// gets a — a different message type the non-discovery - /// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING d1 EQ-1 → EQ-2 (same FullName; DriverConfig "{}" unchanged ⇒ child NOT restarted). - // The cached EQ-1-scoped plan is dropped by the rebind guard ⇒ the inner map empties ⇒ d1 is removed from - // _discoveredByDriver ⇒ NO survivor re-apply. The fallback must send the authored-only set so "40001" - // stays subscribed this pass. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-2", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No MaterialiseDiscoveredNodes — the plan was dropped, not re-grafted — so no further publish traffic. - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // EXACTLY ONE SetDesiredSubscriptions this redeploy: the authored-only fallback. The count rises by 1 and - // the set is "40001" only (the dropped FixedTree ref is gone). - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.ShouldNotContain("ft-ref-1"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — NON-CACHED path): a driver that was NEVER cached (no FixedTree - /// discovered) is unaffected by the cached-driver bulk-loop skip — it still gets EXACTLY ONE bulk - /// authored-only per redeploy (the re-inject tail - /// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's - /// send. Observed via SubscribeCount (+1) and the subscribed set ("40001" only). - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - // No DiscoveredNodesReady ⇒ d1 is never cached. Wait for the initial bulk subscribe to settle, then - // snapshot the count as the redeploy baseline. - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy SAME composition (new rev). d1 is NOT in _discoveredByDriver ⇒ the bulk loop sends it once and - // the re-inject tail skips it. - var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB, - (Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed")); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // no materialise — d1 was never cached - - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("40001"); - refs.Count.ShouldBe(1); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - factory.SubscribeCount.ShouldBe(countBeforeRedeploy + 1); - } - - /// Follow-up D (one-send invariant — EMPTY-authored DROPPED path, a capability THIS branch newly - /// enabled): a CACHED driver with ZERO authored tags (bound to its equipment only via - /// ) whose FixedTree plan is FULLY DROPPED by a rebind redeploy - /// receives an EMPTY authored-only fallback , which - /// the Connected handler routes to Unsubscribe (dropping the stale FixedTree handle) — NOT a subscribe. - /// Proven by SubscribeCount staying FLAT across the redeploy (no spurious subscribe), closing the - /// SubscribeCount-proxy blind spot for the empty-set fallback. - [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] - public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() - { - var db = NewInMemoryDbFactory(); - var factory = new SubscribingDriverFactory("Modbus"); - // d1 bound to EQ-1 via EquipmentNode.DriverInstanceId, with NO authored tags. - var deploymentId = SeedDeploymentWithTagLessEquipment(db, RevA, equipmentId: "EQ-1", driverId: "d1"); - - var (actor, publish, coordinator) = SpawnHostAndApply(db, deploymentId, factory); - - actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[] - { - new DiscoveredNode( - FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" }, - BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1", - DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null, - Writable: false, IsHistorized: false), - })); - - // First injection: the discovered FixedTree materialises under EQ-1 and the child subscribes the - // discovered-only set (no authored ref to union) — this populates _discoveredByDriver[d1] = { EQ-1: plan }. - publish.ExpectMsg(Timeout); - AwaitAssert(() => - { - var refs = factory.LastSubscribedRefs; - refs.ShouldNotBeNull(); - refs!.ShouldContain("ft-ref-1"); - }, duration: Timeout); - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - var countBeforeRedeploy = factory.SubscribeCount; - - // Redeploy REBINDING the tag-less d1 EQ-1 → EQ-2 (still tag-less; DriverConfig "{}" unchanged ⇒ child NOT - // restarted). The cached EQ-1 plan is no longer a candidate ⇒ dropped ⇒ inner map empties ⇒ d1 removed - // from _discoveredByDriver. With ZERO authored tags the fallback set is EMPTY ⇒ the child Unsubscribes. - var deploymentId2 = SeedDeploymentWithTagLessEquipment(db, RevB, equipmentId: "EQ-2", driverId: "d1"); - actor.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - publish.ExpectMsg(Timeout); - // No re-materialise — the cached plan was dropped (not re-grafted). - publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); - - // The empty fallback routes to Unsubscribe, NOT SubscribeAsync ⇒ the count does NOT rise. (A non-empty or - // spurious subscribe — the bug this guards against — would increment it.) - factory.SubscribeCount.ShouldBe(countBeforeRedeploy); - } - - /// Spawns the host with the subscribing driver factory + a publish probe, dispatches the - /// deployment, and waits for the Applied ACK so the apply (and thus _lastComposition + the live - /// child + the initial SubscribeBulk pass) has completed before the test injects discovered nodes. A - /// VirtualTag-host probe is injected so the real host isn't spawned. The - /// that lands on the publish probe during apply is drained so the test's materialise / value-update - /// assertions see only post-apply traffic. - private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply( - IDbContextFactory db, DeploymentId deploymentId, IDriverFactory factory) - { - var coordinator = CreateTestProbe(); - var publish = CreateTestProbe(); - var vtHost = CreateTestProbe(); - - var actor = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, - localRoles: new HashSet { "driver" }, - opcUaPublishActor: publish.Ref, - virtualTagHostOverride: vtHost.Ref)); - - actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); - coordinator.ExpectMsg(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied); - - publish.ExpectMsg(Timeout); - - return (actor, publish, coordinator); - } - - /// - /// Seeds a Sealed deployment whose artifact carries the minimal arrays - /// DeploymentArtifact.BuildEquipmentTagPlans needs to project equipment tags, plus a - /// DriverInstances row with a non-Windows-only DriverType ("Modbus") + Enabled flag so - /// a REAL (non-stubbed) child is spawned (mirrors - /// DriverHostActorWriteRoutingTests.SeedDeploymentWithEquipmentTags). - /// - private static DeploymentId SeedDeploymentWithEquipmentTags( - IDbContextFactory db, RevisionHash rev, - params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags) - { - var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray(); - - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = driverIds.Select(d => new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = d, - Name = d, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }).ToArray(), - Tags = tags.Select((t, i) => new - { - TagId = $"tag-{i}", - EquipmentId = t.Equip, - DriverInstanceId = t.Driver, - Name = t.Name, - FolderPath = t.Folder, - DataType = "Double", - TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }), - }).ToArray(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds an equipment to a driver via the - /// Equipment row's DriverInstanceId (the - /// projection) but carries NO authored equipment tags — so the equipment can only be resolved from - /// EquipmentNodes, not EquipmentTags. A DriverInstances row (non-Windows-only - /// "Modbus", Enabled) is included so a REAL (non-stubbed) child is - /// spawned for the driver even though it has zero tags. - /// - private static DeploymentId SeedDeploymentWithTagLessEquipment( - IDbContextFactory db, RevisionHash rev, string equipmentId, string driverId) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // The equipment binds to the driver via DriverInstanceId — the NEW resolution path — with no tags. - Equipment = new[] - { - new - { - EquipmentId = equipmentId, - Name = equipmentId, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = (string?)null, - }, - }, - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// - /// Seeds a Sealed deployment whose artifact binds ONE driver to MULTIPLE equipments, each via a - /// distinct Device row whose DeviceConfig carries a HostAddress — so each - /// resolves a distinct (the shape - /// the multi-device FixedTree partition keys on). No authored tags (tag-less): the equipments are - /// resolved purely from EquipmentNodes. The driver row is non-Windows-only ("Modbus", Enabled) - /// so a REAL (non-stubbed) child is spawned. - /// - private static DeploymentId SeedDeploymentWithMultiDeviceEquipments( - IDbContextFactory db, RevisionHash rev, string driverId, - params (string Equip, string DeviceId, string Host)[] equipments) - { - var artifact = JsonSerializer.SerializeToUtf8Bytes(new - { - Namespaces = new[] - { - new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0 - }, - DriverInstances = new[] - { - new - { - DriverInstanceRowId = Guid.NewGuid(), - DriverInstanceId = driverId, - Name = driverId, - DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed) - Enabled = true, - DriverConfig = "{}", - NamespaceId = "ns-eq", - }, - }, - // Each device carries a HostAddress so EquipmentNode.DeviceHost resolves (via the shared extractor). - Devices = equipments.Select(e => new - { - DeviceId = e.DeviceId, - DriverInstanceId = driverId, - Name = e.DeviceId, - DeviceConfig = JsonSerializer.Serialize(new { HostAddress = e.Host }), - }).ToArray(), - // Each equipment binds to the driver AND a device — the multi-device resolution path. - Equipment = equipments.Select(e => new - { - EquipmentId = e.Equip, - Name = e.Equip, - UnsLineId = (string?)null, - DriverInstanceId = driverId, - DeviceId = e.DeviceId, - }).ToArray(), - Tags = Array.Empty(), - }); - - var id = DeploymentId.NewId(); - using var ctx = db.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id.Value, - RevisionHash = rev.Value, - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; - } - - /// Factory producing a single shared for the supported - /// type, exposing its most-recent subscribed reference set for assertions (mirrors - /// DriverHostActorWriteRoutingTests.RecordingDriverFactory, but the driver is - /// so the merged subscription is observable). - private sealed class SubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private readonly SubscribableStubDriver _driver = new(); - public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// The reference set passed to the driver's most recent SubscribeAsync call. - public IReadOnlyList? LastSubscribedRefs => _driver.LastSubscribedRefs; - - /// Number of SubscribeAsync calls so far — lets a test prove a redundant re-apply did - /// NOT force a (drop-then-)re-subscribe of the whole handle. - public int SubscribeCount => _driver.SubscribeCount; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// Factory producing a single shared for the supported - /// type — a real (non-stubbed) child that exposes , - /// so the host's post-connect discovery loop populates the discovered-node cache AND a - /// re-runs discovery (the seam the rebind re-trigger - /// test asserts through). Exposes the driver's pass count so a test can observe the trigger landing. - private sealed class DiscoverableSubscribingDriverFactory : IDriverFactory - { - private readonly string _supportedType; - private DiscoverableSubscribingDriver? _driver; - public DiscoverableSubscribingDriverFactory(string supportedType) { _supportedType = supportedType; } - - /// Number of DiscoverAsync passes the child has driven (advances on every - /// connect-time pass and every -driven pass). - public int DiscoverCount => _driver?.DiscoverCount ?? 0; - - /// - public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => - string.Equals(driverType, _supportedType, StringComparison.Ordinal) - ? _driver ??= new DiscoverableSubscribingDriver(driverInstanceId) - : null; - - /// - public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; - } - - /// A that is BOTH (so the host's subscribe - /// path is exercised) and with policy - /// — exactly one post-connect pass, re-runnable by . Each - /// pass streams an ever-growing FixedTree (pass N → N nodes, refs "ft-ref-1".."ft-ref-N", none shadowing the - /// authored "40001"), so a re-triggered pass yields a grown set the host re-applies — and the public - /// makes the trigger observable at the host level. Re-implements - /// (the base hardcodes "stub-driver-1") so the - /// spawned child reports the SPEC's id — otherwise its auto-sent - /// would carry the wrong driver id and resolve no equipment. - private sealed class DiscoverableSubscribingDriver : StubDriver, IDriver, ISubscribable, ITagDiscovery - { - private int _passCount; - private readonly string _driverInstanceId; - private readonly StubHandle _handle = new(); - - public DiscoverableSubscribingDriver(string driverInstanceId) => _driverInstanceId = driverInstanceId; - - /// The spec's driver instance id (re-mapped from the base "stub-driver-1"). - public new string DriverInstanceId => _driverInstanceId; - - /// Single post-connect pass per (re)kick — re-runnable by TriggerRediscovery. - public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; - - /// Number of DiscoverAsync passes driven so far. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Never raised (the test asserts on discovery + materialise, not data changes); explicit - /// empty accessors satisfy the interface without a never-used backing field (no CS0067). - public event EventHandler? OnDataChange { add { } remove { } } - - /// - public Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) - => Task.FromResult(_handle); - - /// - public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) - => Task.CompletedTask; - - /// Streams an ever-growing FixedTree (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"ft-ref-{i + 1}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs deleted file mode 100644 index 3ae48247..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs +++ /dev/null @@ -1,583 +0,0 @@ -using Akka.Actor; -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Core.Abstractions; -using ZB.MOM.WW.OtOpcUa.Core.Resilience; -using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; -using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; - -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; - -/// -/// Covers the bounded post-connect re-discovery loop: when an driver -/// reaches Connected, runs repeated discovery passes (FOCAS-style: -/// the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each -/// pass's captured nodes to its parent as . The -/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not -/// spin forever. A driver that does not implement produces no passes at all. -/// -[Trait("Category", "Unit")] -public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase -{ - /// - /// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third - /// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty - /// set repeats. The final carries the 3 nodes - /// and no further passes arrive — proving the loop is bounded. - /// - [Fact] - public void Discovery_retries_until_set_stabilises_then_stops() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny interval so the bounded retry runs in well under a second (no real-time waits). - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - // Drive Connecting → Connected; the Connected entry kicks discovery. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4 - // (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops. - var msgs = new List(); - for (var i = 0; i < 4; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // The loop must STOP once the non-empty set has stabilised — no fifth pass. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // Early passes were empty (FixedTree cache still populating). - msgs[0].Nodes.Count.ShouldBe(0); - msgs[1].Nodes.Count.ShouldBe(0); - // The set then appears and stabilises at 3 nodes. - msgs[2].Nodes.Count.ShouldBe(3); - var final = msgs[^1]; - final.Nodes.Count.ShouldBe(3); - final.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" }); - - // The driver was asked exactly as many times as messages published — no extra zombie pass. - driver.DiscoverCount.ShouldBe(4); - } - - /// A driver that does not implement produces no discovery passes — - /// the Connected entry's discovery kick is a no-op, so the parent receives no - /// . - [Fact] - public void Driver_without_ITagDiscovery_produces_no_discovery() - { - var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // No discovery capability ⇒ never any DiscoveredNodesReady to the parent. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// - /// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a - /// drives the actor through Reconnecting and - /// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use), - /// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's - /// capabilities changed across the reconnect. The new init bumps the generation, so any - /// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled - /// here, so none are in flight). - /// - [Fact] - public void Discovery_reruns_after_reconnect() - { - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped. - for (var i = 0; i < 4; i++) - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeReconnect = driver.DiscoverCount; // 4 - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees - // the stable 3-node set immediately. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(3); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // The driver was discovered again — proves a fresh loop ran, not a replay of the old one. - driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect); - } - - /// - /// Regression for the Critical: a driver whose DiscoverAsync completes ASYNCHRONOUSLY (off the - /// actor thread) must still ship . The handler - /// touches Context.Parent + Timers AFTER awaiting discovery; if it awaited with - /// ConfigureAwait(false) the continuation would resume off the actor context and those calls - /// would throw NotSupportedException("no active ActorContext") — the handler would fault and no - /// message would arrive. Synchronous (Task.CompletedTask) stubs mask the bug; this one forces a - /// genuine off-context resume (modelled on SubscribableStubDriver.UnsubscribeYields). - /// - [Fact] - public void Async_completing_discovery_resumes_on_actor_context_and_publishes() - { - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets - // a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out. - var published = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the - /// REAL — whose Polly pipeline is awaited with - /// ConfigureAwait(false) internally. This is the one path the pass-through - /// NullDriverCapabilityInvoker (used by the sibling test) cannot cover: it returns the - /// call-site task directly, so it never exercises the invoker's internal off-context await. If that - /// internal ConfigureAwait(false) leaked to the actor's own await _invoker.ExecuteAsync(…), - /// the post-await Context.Parent.Tell in HandleRediscoverAsync would fault with - /// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives - /// the real resilience pipeline. - /// - [Fact] - public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context() - { - var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults - var invoker = new CapabilityInvoker( - pipelineBuilder, - driverInstanceId: "yielding-discoverable", - optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A }, - driverType: "Stub"); - var driver = new YieldingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var published = parent.ExpectMsg(TimeSpan.FromSeconds(5)); - published.Nodes.Count.ShouldBe(3); - published.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - } - - /// - /// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING - /// (1,2,3,…) never repeats its signature, so the loop is stopped only by - /// rediscoverMaxAttempts. With a cap of 3, exactly 3 passes are published, then the stream stops. - /// - [Fact] - public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap() - { - var driver = new GrowingDiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - var msgs = new List(); - for (var i = 0; i < 3; i++) - msgs.Add(parent.ExpectMsg(TimeSpan.FromSeconds(2))); - - // Cap reached — no fourth pass even though the set never stabilised. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - - // The set genuinely kept growing across the capped passes (1,2,3 nodes). - msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 }); - driver.DiscoverCount.ShouldBe(3); - } - - /// - /// A driver whose is - /// opts out of post-connect discovery entirely: the - /// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never - /// asked to discover and the parent receives no . - /// - [Fact] - public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - // Connect happened (the discovery decision is made on the Connected entry)... - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // ...but policy=Never ⇒ no discovery pass is ever run and nothing is published. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// A driver whose is - /// runs EXACTLY one post-connect pass even when its - /// discovered set would keep growing forever — under UntilStable the never-repeating signature - /// would retry to the attempt cap. Exactly one - /// is published and no further RediscoverTick is scheduled. - /// - [Fact] - public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Exactly one pass is published (the first, growing set → 1 node)... - var only = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - only.Nodes.Count.ShouldBe(1); - only.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // ...and NO second tick is scheduled, even though the set would keep growing under UntilStable. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(1); - } - - /// - /// means one pass PER (re)connect cycle — not one pass - /// ever. After the initial single pass settles, a - /// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and - /// StartDiscovery re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt - /// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the - /// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves - /// Once is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path - /// the follow-on TriggerRediscovery task touches. - /// - [Fact] - public void Discovery_policy_Once_reruns_one_pass_on_reconnect() - { - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - // Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is - // visibly more than the one pass Once must run per (re)connect. - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromMilliseconds(50), - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverMaxAttempts: 3)); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more. - var first = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - first.Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - driver.DiscoverCount.ShouldBe(1); - - // Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - - // Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap. - // The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes. - var afterReconnect = parent.ExpectMsg(TimeSpan.FromSeconds(3)); - afterReconnect.Nodes.Count.ShouldBe(2); - afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(2); - } - - /// - /// The per-pass discovery timeout is injectable via so tests - /// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving). - /// Wiring is verified by constructing via Props with a custom value and confirming the actor starts - /// and begins discovery normally. - /// - [Fact] - public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value() - { - // The constant must exist and preserve the pre-refactor 30 s literal. - DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30)); - - // Props must accept the new optional parameter — no throw and actor starts normally. - var driver = new DiscoverableStubDriver(); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - rediscoverInterval: TimeSpan.FromMilliseconds(20), - rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Actor starts and discovery publishes — confirms the custom timeout was wired without error. - parent.ExpectMsg(TimeSpan.FromSeconds(2)); - } - - /// - /// received while Connected re-kicks the - /// post-connect discovery loop: after the initial discovery has settled, sending the message drives a - /// FRESH discovery pass — the driver's DiscoverCount advances and a new - /// is published. This is the message - /// uses to re-run discovery after rebinding the driver to a new equipment. - /// - [Fact] - public void TriggerRediscovery_when_Connected_reruns_discovery() - { - // Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely - // one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg - // (no second settling pass to drain, and no stale-tick double pass alongside the fresh one). - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - - // Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles. - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesBeforeTrigger = driver.DiscoverCount; // 1 - - // Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes). - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - var afterTrigger = parent.ExpectMsg(TimeSpan.FromSeconds(2)); - afterTrigger.Nodes.Count.ShouldBe(2); - afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId); - - // Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived. - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1); - } - - /// - /// on a driver whose - /// is does - /// NOT re-discover: the handler calls StartDiscovery, which returns early for Never, so - /// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out. - /// - [Fact] - public void TriggerRediscovery_with_policy_Never_does_not_rediscover() - { - var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, rediscoverInterval: TimeSpan.FromMilliseconds(20))); - - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - driver.DiscoverCount.ShouldBe(0); - } - - /// - /// received while NOT Connected is a clean silent - /// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor - /// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both Connecting - /// (before init completes) and Reconnecting (after a , - /// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed. - /// - [Fact] - public void TriggerRediscovery_when_not_Connected_is_a_silent_noop() - { - // Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state); - // a long reconnect interval so the actor parks in Reconnecting deterministically within the test window. - var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once); - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, - reconnectInterval: TimeSpan.FromSeconds(30), - rediscoverInterval: TimeSpan.FromMilliseconds(20))); - Watch(actor); - - // (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it - // is handled in that non-Connected state. - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - // No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor). - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); - driver.DiscoverCount.ShouldBe(0); - - // Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - parent.ExpectMsg(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - var passesAfterConnect = driver.DiscoverCount; // 1 - - // (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto - // reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both - // messages are processed in order, so the trigger is handled while Reconnecting. - actor.Tell(new DriverInstanceActor.ForceReconnect()); - actor.Tell(new DriverInstanceActor.TriggerRediscovery()); - - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated - driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting - } - - /// - /// A that also exposes . Each DiscoverAsync - /// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set — - /// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put. - /// - private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing UntilStable tests are unaffected. - public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams a growing-then-stable node set into the builder (0,0,3,3,…). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var count = pass >= 3 ? 3 : 0; - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < count; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } - - /// - /// A discoverable driver whose DiscoverAsync genuinely SUSPENDS and resumes on a fresh - /// thread-pool thread that carries NO Akka actor cell — modelled on - /// SubscribableStubDriver.UnsubscribeYields. This forces the actor's await DiscoverAsync(...) - /// continuation to resume off-context unless the handler omits ConfigureAwait(false), so it is a - /// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass. - /// - private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - /// Suspends on a TCS completed from a background thread, then streams 3 nodes. - public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _ = Task.Run(() => tcs.SetResult(), cancellationToken); - await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell) - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < 3; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - } - } - - /// - /// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the - /// full-reference signature differs every pass and the loop can only be bounded by the attempt cap. - /// - private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery - { - private int _passCount; - - /// Constructs the fake reporting the given ; - /// defaults to (the interface default) so the - /// existing attempt-cap test is unaffected. With the - /// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying). - public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable) - => RediscoverPolicy = policy; - - /// The post-connect re-discovery policy this fake reports to the actor. - public DiscoveryRediscoverPolicy RediscoverPolicy { get; } - - /// Number of passes the actor has driven. - public int DiscoverCount => Volatile.Read(ref _passCount); - - /// Streams an ever-growing node set (pass N → N nodes). - public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) - { - var pass = Interlocked.Increment(ref _passCount); // 1-based pass number - var fixedTree = builder.Folder("FixedTree", "FixedTree"); - for (var i = 0; i < pass; i++) - { - fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo( - FullName: $"m.fixed.v{i}", - DriverDataType: DriverDataType.Float64, - IsArray: false, - ArrayDim: null, - SecurityClass: SecurityClassification.ViewOnly, - IsHistorized: false)); - } - return Task.CompletedTask; - } - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index a6f10ef3..3190713e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -120,35 +120,6 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); } - /// Verifies that forwards to the - /// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a - /// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path - /// end to end (mirrors the real-applier-over-recording-sink harness in - /// OpcUaPublishActorRebuildTests). - [Fact] - public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); - - var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") }; - var variables = new[] - { - new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", - Writable: false, IsArray: false, ArrayLength: null), - }; - - actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables)); - - AwaitAssert(() => - { - sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes")); - sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false)); - sink.ModelChanges.ShouldContain("EQ-1"); - }, duration: PresenceBudget); - } - /// Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level. [Fact] public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()