From 2e0743ad2529a004016e0276337afe835205332d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 11:30:13 -0400 Subject: [PATCH] fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only. A raw s= and a UNS s= can collide as bare strings; the bare-only key let a colliding raw+UNS pair route to the WRONG driver ref (last-writer-wins). The realm the node manager resolves (RealmOf) is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite -> _driverRefByNodeId keyed by (realm, bareId). New regression test: Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm. M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes hard-short-circuits (single enforcement point; _discoveredByDriver never populates so the re-inject tail is inert too), with a clear log pointing at the /raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3; the 16+2 v2 injection scenarios re-pointed to an accurate skip reason (DiscoveryInjectionDormantV3). M2 (MEDIUM): realm-qualified dual-node self-correction tests — Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched + Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped). L1: removed the = AddressSpaceRealm.Uns defaults from the consequential node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/ EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/ RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit realm; read-only accessors + internal builders retain their defaults. L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the retired EquipmentNodeIds.Variable). Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox --- .../OpcUa/IOpcUaNodeWriteGateway.cs | 12 +- .../OtOpcUaNodeManager.cs | 26 ++-- .../SdkAddressSpaceSink.cs | 4 +- .../Drivers/ActorNodeWriteGateway.cs | 4 +- .../Drivers/DriverHostActor.cs | 67 ++++++--- .../OpcUa/OpcUaPublishActor.cs | 4 +- .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 4 +- .../VirtualTags/VirtualTagHostActor.cs | 10 +- .../OpcUa/NullOpcUaNodeWriteGatewayTests.cs | 2 +- .../PrimaryGateFailoverTests.cs | 3 +- .../AlarmCommandRouterTests.cs | 100 ++++++------- ...eManagerAlarmIdempotentMaterialiseTests.cs | 21 +-- .../NodeManagerArrayTests.cs | 10 +- .../NodeManagerHistorizeTests.cs | 11 +- .../NodeManagerHistoryReadConcurrencyTests.cs | 3 +- .../NodeManagerHistoryReadEventsTests.cs | 27 ++-- .../NodeManagerHistoryReadPagingTests.cs | 17 +-- .../NodeManagerHistoryReadTests.cs | 23 +-- .../NodeManagerModelChangeOnAddTests.cs | 12 +- .../NodeManagerPreStartGuardTests.cs | 8 +- .../NodeManagerSurgicalRemoveTests.cs | 49 +++---- .../NodeManagerSurgicalShapeUpdateTests.cs | 38 ++--- .../NodeManagerWriteRevertTests.cs | 92 ++++++++++-- .../Drivers/ActorNodeWriteGatewayTests.cs | 9 +- .../Drivers/DarkAddressSpaceReasons.cs | 12 ++ .../DeploymentArtifactRawUnsParityTests.cs | 84 +++++++++++ .../DiscoveryInjectionEndToEndTests.cs | 4 +- .../Drivers/DriverHostActorDiscoveryTests.cs | 59 +++++--- .../DriverHostActorPrimaryGateTests.cs | 11 +- .../DriverHostActorProbeResultDropTests.cs | 3 +- .../DriverHostActorWriteRoutingTests.cs | 132 +++++++++++++++++- .../OtOpcUaTelemetryHookTests.cs | 2 +- .../OpcUa/OpcUaPublishActorTests.cs | 10 +- .../ScriptedAlarmHostActorTests.cs | 8 +- 34 files changed, 616 insertions(+), 265 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs index 4469005f..17a10b80 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaNodeWriteGateway.cs @@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway { /// Route a write of to the driver backing node /// ; the returned task resolves with the device-write outcome. - /// The folder-scoped equipment-variable node id being written. + /// The full ns-qualified node id being written (node.NodeId.ToString()). /// The value the client wrote. + /// Which of the two v3 namespaces ( device tree or + /// equipment tree) lives in — the node + /// manager resolves it from the node's namespace index (RealmOf). It is REQUIRED for correct + /// routing: a raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare + /// strings, so the routing map is keyed by (realm, bareId) — dropping the realm would let an + /// operator write route to the WRONG driver ref. /// Cancellation token. /// A task resolving to the device-write outcome. - Task WriteAsync(string nodeId, object? value, CancellationToken ct); + Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct); } /// Outcome of routing an inbound node write to the backing driver. @@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway public static readonly NullOpcUaNodeWriteGateway Instance = new(); private NullOpcUaNodeWriteGateway() { } /// - public Task WriteAsync(string nodeId, object? value, CancellationToken ct) => + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(false, "writes unavailable")); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 5f8b6169..e0f3d357 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -349,7 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. - public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); @@ -386,7 +386,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The timestamp of the alarm state change in UTC. - public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentNullException.ThrowIfNull(state); @@ -678,7 +678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// . LimitAlarm deliberately falls back to base per the T13 /// notes — a script alarm carries no High/Low limits to populate. /// - public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1000,7 +1000,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never // re-enters lock (Lock) while CustomNodeManager2.Write still holds it. - _ = gateway.WriteAsync(nodeKey, optimisticValue, CancellationToken.None) + _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None) .ContinueWith( t => { @@ -1206,7 +1206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// from to populate the audit event's ClientUserId; null when unknown. internal void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, - string? clientUserId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + string? clientUserId, AddressSpaceRealm realm) { // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. AuditWriteUpdateEventState? auditEvent = null; @@ -1398,7 +1398,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the folder. - public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1443,7 +1443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node identifier of the folder to update in place. /// The new display name to apply. /// True when the in-place update was applied; false when the folder id is unknown. - public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1485,7 +1485,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// — rank + dimensions carry the array-ness. /// Phase 4c: the declared length of the 1-D array when /// is true; ignored for scalars. Null ⇒ length 0. - public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); @@ -1583,7 +1583,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar. /// The declared length of the 1-D array when is true; ignored for scalars. /// True when the in-place update was applied; false when the node id is unknown. - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType) @@ -1712,7 +1712,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// /// The folder-scoped node id of the parent under which nodes were added. - public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(affectedNodeId); GeneralModelChangeEventState e; @@ -2004,7 +2004,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); EnsureAddressSpaceCreated(); @@ -2028,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); EnsureAddressSpaceCreated(); @@ -2053,7 +2053,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) { ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId); EnsureAddressSpaceCreated(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 87927ae7..084ba026 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -30,7 +30,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) - => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative, realm); + => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) @@ -38,7 +38,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre /// public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) - => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength, realm); + => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); /// public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs index ccb91771..7c6cb37e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/ActorNodeWriteGateway.cs @@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway } /// - public async Task WriteAsync(string nodeId, object? value, CancellationToken ct) + public async Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) { var driverHost = _resolveDriverHost(); if (driverHost is null) @@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway try { var result = await driverHost.Ask( - new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false); + new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false); if (!result.Success) _logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason); return new NodeWriteOutcome(result.Success, result.Reason); 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 46e99fbf..7723c662 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -128,19 +128,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet> _nodeIdByDriverRef = new(); /// - /// Inverse of : materialised value NodeId (BARE s= id) → - /// (DriverInstanceId, RawPath). v3 Batch 4: keyed by BOTH the raw NodeId AND every referencing UNS - /// NodeId (all mapping to the same driver ref — the single value source). Rebuilt every apply by - /// from the composition's RawTags ∪ - /// UnsReferenceVariables, and resolved by so an inbound - /// operator write to EITHER NodeId is forwarded to the owning driver child as a write of its wire-ref - /// RawPath. Keyed by the BARE id: the node manager's write hook passes the full ns-qualified id - /// (node.NodeId.ToString()); normalises it to the bare id - /// before lookup, so a raw write and a UNS write both resolve to the same driver ref (they share it — - /// a UNS reference's backing RawPath IS the raw node's, so no cross-realm ambiguity is possible). + /// Inverse of : (Realm, BARE s= id) → (DriverInstanceId, + /// RawPath). v3 Batch 4: keyed by BOTH the raw NodeId () AND every + /// referencing UNS NodeId () — all mapping to the same driver ref + /// (the single value source). Rebuilt every apply by from the + /// composition's RawTagsUnsReferenceVariables, and resolved by + /// so an inbound operator write to EITHER NodeId is forwarded to the + /// owning driver child as a write of its wire-ref RawPath. + /// The realm is part of the key (Wave B review H1): a raw s=<RawPath> and a UNS + /// s=<Area/Line/Equip/Eff> can collide as bare strings (folder/driver/device names and + /// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route + /// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full + /// ns-qualified id PLUS the realm it resolved (RealmOf); + /// normalises the id to the bare form and looks up (realm, bareId), preserving exactly the + /// namespace disambiguation the ns-qualified id carries. /// - private readonly Dictionary _driverRefByNodeId = - new(StringComparer.Ordinal); + private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new(); /// (DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged). /// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing @@ -241,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// /// The folder-scoped equipment-variable NodeId the operator wrote to. /// The value to write (the driver coerces it to the attribute's data type). - public sealed record RouteNodeWrite(string NodeId, object? Value); + public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm); /// Reply to : the outcome of forwarding the write to the driver /// (or a gate/lookup failure that never reached the driver). @@ -654,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// 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", @@ -728,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _discoveredByDriver[msg.DriverInstanceId] = newPlans; ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans); +#pragma warning restore CS0162 } /// @@ -933,7 +952,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // 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[nodeId] = key; + _driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key; } _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes( equipmentId, plan.Folders, plan.Variables)); @@ -1101,15 +1120,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers return; } - // v3 Batch 4: the node manager's write hook passes the FULL ns-qualified NodeId string - // (node.NodeId.ToString(), e.g. "ns=3;s="). The routing map is keyed by the BARE s= identifier - // (the raw RawPath or the UNS path). Normalise before lookup so a write to EITHER a raw NodeId or a - // referencing UNS NodeId resolves to the SAME driver ref (they share it — a UNS reference's backing - // RawPath IS the raw node's, so there is no cross-realm ambiguity). + // v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string + // (node.NodeId.ToString(), e.g. "ns=3;s=") PLUS the realm it resolved from the namespace index. + // The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look + // up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a + // bare string, so a write always routes to its OWN driver ref (never a colliding sibling's). var bareNodeId = BareNodeId(msg.NodeId); - if (!_driverRefByNodeId.TryGetValue(bareNodeId, out var target)) + if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target)) { - Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}")); + Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})")); return; } @@ -1564,14 +1583,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers if (!_nodeIdByDriverRef.TryGetValue(key, out var set)) _nodeIdByDriverRef[key] = set = new HashSet(); set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw)); - _driverRefByNodeId[t.NodeId] = key; + _driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key; if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs)) { foreach (var v in refs) { set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns)); - _driverRefByNodeId[v.NodeId] = key; // a UNS write resolves to the same driver ref + // A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never + // collide with a raw NodeId that shares its bare string. + _driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key; } } } 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 b49b623e..15e0fe51 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -45,7 +45,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// referencing UNS node in ) with identical value/quality/timestamp. /// defaults to so pre-v3 callers (VirtualTag /// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node. - public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm); /// Carries the full Part 9 condition state for a scripted alarm to the sink. The /// snapshot is the Commons projection the Runtime host maps from the engine's @@ -57,7 +57,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The source timestamp of the transition in UTC. /// The namespace realm the condition lives in — for /// scripted alarms (default), for v3 native raw conditions. - public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm = AddressSpaceRealm.Uns); + public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm); /// /// Triggers an address-space rebuild. is the deployment /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index a8d790b8..0edea434 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor // Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/ // shelving/severity/message) onto the materialised condition node via the Commons snapshot. // e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId). + // Scripted alarms are per-equipment condition nodes in the UNS realm. _publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate( AlarmNodeId: e.AlarmId, State: ToSnapshot(e), - TimestampUtc: e.TimestampUtc)); + TimestampUtc: e.TimestampUtc, + Realm: AddressSpaceRealm.Uns)); // Publish the transition to the cluster `alerts` topic — the single historization + live // fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs index 961f2fad..4229d68d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs @@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value. /// /// -/// The published NodeId is computed by the shared — -/// the single source of truth AddressSpaceApplier.MaterialiseEquipmentVirtualTags also -/// materialises against — so the value always lands on a NodeId that exists. +/// The published NodeId is computed via (the equipment-anchored +/// slash path {EquipmentId}[/{FolderPath}]/{Name}) — byte-identical to the NodeId +/// AddressSpaceApplier.MaterialiseEquipmentVirtualTags materialises against — so the value +/// always lands on a NodeId that exists. /// /// public sealed class VirtualTagHostActor : ReceiveActor @@ -192,8 +193,9 @@ public sealed class VirtualTagHostActor : ReceiveActor // 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script // failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the // sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good. + // VirtualTags materialise as equipment nodes in the UNS realm — publish there. _publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate( - nodeId, result.Value, result.Quality, result.TimestampUtc)); + nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns)); // Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so // no parallel map. The historian path key is the SAME folder-scoped NodeId we just published diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs index dc7a2899..60cc02d7 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/NullOpcUaNodeWriteGatewayTests.cs @@ -9,7 +9,7 @@ public class NullOpcUaNodeWriteGatewayTests [Fact] public async Task NullGateway_returns_writes_unavailable() { - var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, TestContext.Current.CancellationToken); + var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, AddressSpaceRealm.Uns, TestContext.Current.CancellationToken); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldBe("writes unavailable"); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs index c9ca2b92..d32e42b9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/PrimaryGateFailoverTests.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.Extensions.DependencyInjection; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; @@ -68,7 +69,7 @@ public sealed class PrimaryGateFailoverTests private static async Task RouteProbeWriteAsync(IActorRef driverHost) => await driverHost.Ask( - new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0), TimeSpan.FromSeconds(10)); + new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0, AddressSpaceRealm.Uns), TimeSpan.FromSeconds(10)); private static bool IsGateReject(DriverHostActor.NodeWriteResult r) => !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs index e5f9f928..210c15c2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs @@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); - nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-2"); condition.ShouldNotBeNull(); @@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3"); - nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-3"); condition.ShouldNotBeNull(); @@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C"); - nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-c"); condition.ShouldNotBeNull(); condition!.OnConfirm.ShouldNotBeNull(); @@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC"); - nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ac"); condition.ShouldNotBeNull(); condition!.OnAddComment.ShouldNotBeNull(); @@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1"); - nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s1"); condition.ShouldNotBeNull(); condition!.OnShelve.ShouldNotBeNull(); @@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2"); - nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s2"); condition.ShouldNotBeNull(); @@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3"); - nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s3"); condition.ShouldNotBeNull(); @@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4"); - nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-s4"); condition.ShouldNotBeNull(); @@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU"); - nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-tu"); condition.ShouldNotBeNull(); condition!.OnTimedUnshelve.ShouldNotBeNull(); @@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1"); - nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed1"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2"); - nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed2"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3"); - nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed3"); condition.ShouldNotBeNull(); @@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var captured = new List(); nm.AlarmCommandRouter = captured.Add; - nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4"); - nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-ed4"); condition.ShouldNotBeNull(); condition!.OnEnableDisable.ShouldNotBeNull(); @@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1"); - nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak1"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2"); - nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false); + nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak2"); condition.ShouldNotBeNull(); condition!.OnAcknowledge.ShouldNotBeNull(); @@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4"); - nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak4"); condition.ShouldNotBeNull(); @@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable nm.AlarmCommandRouter = scripted.Add; nm.NativeAlarmAckRouter = native.Add; - nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3"); - nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true); + nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nak3"); condition.ShouldNotBeNull(); @@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var nm = server.NodeManager!; // No router set (default null). - nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR"); - nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var condition = nm.TryGetAlarmCondition("alm-nr"); condition.ShouldNotBeNull(); @@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); @@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a2").ShouldBeFalse(); @@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured // before the same id can be re-materialised (ResolveParentFolder needs the parent back). nm.RebuildAddressSpace(); - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); @@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeFalse(); nm.RebuildAddressSpace(); - nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.IsNativeAlarmNode("a1").ShouldBeTrue(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs index c34a3151..6b62c032 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAlarmIdempotentMaterialiseTests.cs @@ -1,4 +1,5 @@ using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; @@ -27,14 +28,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var first = nm.TryGetAlarmCondition("alm-1"); first.ShouldNotBeNull(); // Same id + same kind ⇒ skip-if-present: the existing instance is kept. - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var second = nm.TryGetAlarmCondition("alm-1"); second.ShouldBeSameAs(first); @@ -50,14 +51,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var scripted = nm.TryGetAlarmCondition("alm-1"); nm.IsNativeAlarmNode("alm-1").ShouldBeFalse(); // Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set. - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); var native = nm.TryGetAlarmCondition("alm-1"); native.ShouldNotBeSameAs(scripted); @@ -75,16 +76,16 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var before = nm.TryGetAlarmCondition("alm-1"); nm.RebuildAddressSpace(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns); var after = nm.TryGetAlarmCondition("alm-1"); after.ShouldNotBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs index 2dfce4d1..642fa81f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs @@ -32,7 +32,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: 8); + writable: false, historianTagname: null, isArray: true, arrayLength: 8, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arr"); variable.ShouldNotBeNull(); @@ -52,7 +52,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: null); + writable: false, historianTagname: null, isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arr-unfixed"); variable.ShouldNotBeNull(); @@ -72,7 +72,7 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32", - writable: false); + writable: false, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/scalar"); variable.ShouldNotBeNull(); @@ -91,10 +91,10 @@ public sealed class NodeManagerArrayTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32", - writable: false, historianTagname: null, isArray: true, arrayLength: 3); + writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns); var payload = new[] { 1, 2, 3 }; - nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow); + nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/arrwrite"); variable.ShouldNotBeNull(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs index d4e4f8a1..8e236777 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistorizeTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Shouldly; using Xunit; @@ -29,7 +30,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Tag"); + writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/temp"); variable.ShouldNotBeNull(); @@ -56,9 +57,9 @@ public sealed class NodeManagerHistorizeTests : IDisposable // Explicit null and the defaulted-param form both mean "not historized". nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", - writable: false, historianTagname: null); + writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns); nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32", - writable: false); + writable: false, realm: AddressSpaceRealm.Uns); foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" }) { @@ -83,7 +84,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float", - writable: true, historianTagname: "WW.Setpoint"); + writable: true, historianTagname: "WW.Setpoint", realm: AddressSpaceRealm.Uns); var variable = nm.TryGetVariable("eq-1/setpoint"); variable.ShouldNotBeNull(); @@ -109,7 +110,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Tag"); + writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns); nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue(); nm.RebuildAddressSpace(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs index f8e97331..a8658918 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Server; @@ -228,7 +229,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable { var key = $"eq/tag{i}"; nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float", - writable: false, historianTagname: $"WW.Tag{i}"); + writable: false, historianTagname: $"WW.Tag{i}", realm: AddressSpaceRealm.Uns); ids[i] = nm.TryGetVariable(key)!.NodeId; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 20a14ccf..28bba442 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -42,8 +43,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-evt"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc); @@ -107,8 +108,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-unbounded"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; fake.EventsResult = new HistoricalEventsResult( @@ -143,8 +144,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-unsupported"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; fake.EventsResult = new HistoricalEventsResult( @@ -187,8 +188,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-empty"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails @@ -221,8 +222,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable // Materialise the alarm while the source is still the Null default — the folder is promoted to // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. const string equipmentId = "eq-nosrc"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. @@ -271,7 +272,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable // A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but // EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs. nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Temp"); + writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var details = new ReadEventDetails @@ -303,8 +304,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable nm.HistorianDataSource = fake; const string equipmentId = "eq-boom"; - nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment"); - nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900); + nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; var details = new ReadEventDetails diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index 16ddb42e..16d8bf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -42,7 +43,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double", - writable: false, historianTagname: "WW.PV"); + writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId; var collected = new List(); @@ -84,7 +85,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1)); nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double", - writable: false, historianTagname: "WW.Exact"); + writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId; var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); @@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double", - writable: false, historianTagname: "WW.Tie"); + writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId; var collected = new List(); @@ -167,7 +168,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double", - writable: false, historianTagname: "WW.Burst"); + writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId; var collected = new List(); @@ -211,7 +212,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double", - writable: false, historianTagname: "WW.Absurd"); + writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId; // Page 1: a full page of the first 2 ties, with a continuation point. @@ -241,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double", - writable: false, historianTagname: "WW.All"); + writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId; var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null); @@ -265,7 +266,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double", - writable: false, historianTagname: "WW.BadCp"); + writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId; fake.ResetReadCount(); @@ -291,7 +292,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double", - writable: false, historianTagname: "WW.Rel"); + writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId; // Page 1 — get a CP. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs index 48ee37f5..12b4b9d8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; @@ -39,7 +40,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float", - writable: false, historianTagname: "WW.Temp"); + writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId; var src = DateTime.UtcNow.AddSeconds(-5); @@ -91,7 +92,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV". nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double", - writable: false, historianTagname: "Plant.Flow.PV"); + writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId; var details = new ReadRawModifiedDetails @@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float", - writable: false, historianTagname: "WW.Empty"); + writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId; var details = new ReadRawModifiedDetails @@ -156,7 +157,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Plain (non-historized) variable — no HistoryRead access bit. nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32", - writable: false, historianTagname: null); + writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId; var details = new ReadRawModifiedDetails @@ -185,7 +186,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float", - writable: false, historianTagname: "WW.Mod"); + writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId; var details = new ReadRawModifiedDetails @@ -215,7 +216,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float", - writable: false, historianTagname: "WW.Avg"); + writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId; var details = new ReadProcessedDetails @@ -247,7 +248,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float", - writable: false, historianTagname: "WW.Sd"); + writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId; var details = new ReadProcessedDetails @@ -277,7 +278,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float", - writable: false, historianTagname: "WW.At"); + writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId; var t1 = DateTime.UtcNow.AddMinutes(-2); @@ -309,9 +310,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable // Materialise two distinct historized variables under separate equipment folders. nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float", - writable: false, historianTagname: "A.PV"); + writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns); nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float", - writable: false, historianTagname: "B.PV"); + writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns); var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId; var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId; @@ -368,7 +369,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float", - writable: false, historianTagname: "WW.Boom"); + writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId; var details = new ReadRawModifiedDetails diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs index 7b942b55..085112e4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerModelChangeOnAddTests.cs @@ -43,8 +43,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7"); - nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false); + nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); var parent = nm.TryGetFolder("eq-7")!; var e = nm.BuildNodesAddedModelChange("eq-7"); @@ -95,13 +95,13 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable var nm = server.NodeManager!; // Before any nodes exist under the parent — must not throw. - Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns)); - nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); - nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false); + nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); // After the nodes are materialised — still must not throw. - Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9")); + Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns)); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs index 1f2490fd..2c8e5bcf 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPreStartGuardTests.cs @@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { var ex = Should.Throw(() => - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment")); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns)); ex.Message.ShouldContain("address space has not been created"); } finally @@ -55,7 +55,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable { Should.Throw(() => nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", - dataType: "Float", writable: false)); + dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns)); } finally { @@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { Should.Throw(() => - nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow)); + nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns)); } finally { @@ -85,7 +85,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable try { Should.Throw(() => - nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500)); + nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns)); } finally { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs index fd225779..b064ddf0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalRemoveTests.cs @@ -1,4 +1,5 @@ using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Shouldly; using Xunit; @@ -31,13 +32,13 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); - nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false, realm: AddressSpaceRealm.Uns); var countBefore = nm.VariableCount; nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue(); - nm.RemoveVariableNode("eq-1/A").ShouldBeTrue(); + nm.RemoveVariableNode("eq-1/A", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetVariable("eq-1/A").ShouldBeNull(); nm.VariableCount.ShouldBe(countBefore - 1); @@ -56,7 +57,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse(); + nm.RemoveVariableNode("eq-1/nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); await host.DisposeAsync(); } @@ -68,8 +69,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, realm: AddressSpaceRealm.Uns); var e = nm.BuildNodesRemovedModelChange("eq-1/A"); @@ -92,17 +93,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue(); - nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue(); + nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull(); nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse(); - nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false + nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); // already gone ⇒ false await host.DisposeAsync(); } @@ -114,10 +115,10 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false, realm: AddressSpaceRealm.Uns); - nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue(); + nm.RemoveAlarmConditionNode("alm-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); nm.TryGetAlarmCondition("alm-1").ShouldBeNull(); await host.DisposeAsync(); @@ -136,17 +137,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var nm = server.NodeManager!; // Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition. - nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); - nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag"); - nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A"); - nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false); - nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true); + nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns); + nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns); // Sibling equipment eq-2 that must survive untouched. - nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2"); - nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false); + nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns); - nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue(); + nm.RemoveEquipmentSubtree("eq-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue(); // Every eq-1 descendant is gone from every map. nm.TryGetFolder("eq-1").ShouldBeNull(); @@ -163,7 +164,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable // Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the // eq-1 demotion) — proves no orphaned root-notifier ref broke the event path. - Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false)); + Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false, realm: AddressSpaceRealm.Uns)); await host.DisposeAsync(); } @@ -176,7 +177,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse(); + nm.RemoveEquipmentSubtree("eq-nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); await host.DisposeAsync(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs index d064246c..9205bf10 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerSurgicalShapeUpdateTests.cs @@ -44,13 +44,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); - nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null, - dataType: "Int32", isArray: false, arrayLength: null); + dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place @@ -69,13 +69,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); - nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: 8u); + dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); @@ -96,13 +96,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", - writable: false, historianTagname: null, isArray: true, arrayLength: 4u); - nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); + writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ArrayDimensions![0].ShouldBe(4u); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: 8u); + dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ArrayDimensions[0].ShouldBe(8u); @@ -121,13 +121,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var nm = server.NodeManager!; nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", - writable: false, historianTagname: null, isArray: true, arrayLength: 4u); - nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow); + writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: false, arrayLength: null); + dataType: "Int16", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.Scalar); @@ -146,11 +146,11 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false); + nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/buf")!; var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null, - dataType: "Int16", isArray: true, arrayLength: null); + dataType: "Int16", isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.ValueRank.ShouldBe(ValueRanks.OneDimension); @@ -171,13 +171,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); - nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; // Same DataType ("Float") + same scalar shape — only Writable flips false → true. var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null, - dataType: "Float", isArray: false, arrayLength: null); + dataType: "Float", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns); applied.ShouldBeTrue(); node.Value.ShouldBe(7.0f); // value preserved (NOT reset) @@ -199,7 +199,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable bool result = true; Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null, - dataType: "Int32", isArray: false, arrayLength: null)); + dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns)); result.ShouldBeFalse(); await host.DisposeAsync(); @@ -215,7 +215,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var e = nm.BuildNodeShapeChangedEvent(node); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs index 264a67dd..303b8827 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteRevertTests.cs @@ -50,9 +50,9 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); // Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject). - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; // simulate the SDK's optimistic apply of the rejected write @@ -62,7 +62,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, - clientUserId: "op"); + clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(7); // settled back to prior value node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad) @@ -78,15 +78,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 42; nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(true, null), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(42); // success ⇒ optimistic value stands @@ -101,15 +101,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42) nm.RevertOptimisticWriteIfNeeded( "eq-1/sp", outcome: new NodeWriteOutcome(false, "device rejected"), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); node.Value.ShouldBe(99); // poll value preserved — not reverted to 7 @@ -127,7 +127,71 @@ public sealed class NodeManagerWriteRevertTests : IDisposable Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded( "eq-1/gone", outcome: new NodeWriteOutcome(false, "device rejected"), - optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op")); + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns)); + + await host.DisposeAsync(); + } + + // ─────────────────── v3 Batch 4 (review M2) — realm-qualified dual-node self-correction ─────────────────── + + /// (M2a) A failed write driven through a UNS NodeId reverts ONLY the UNS node back to its prior + /// value; the raw node that shares the SAME bare id (but lives in the Raw realm) is NEVER touched and keeps + /// its driver value. The revert routes by (realm, bareId), so it can't clobber the sibling node. + [Fact] + public async Task Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + // Same bare id "A/B/C/D" in BOTH realms — the raw device node and the UNS reference node. + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + var now = DateTime.UtcNow; + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value + var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!; + var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!; + + uns.Value = 42; // the SDK optimistically applied the client's UNS write + + nm.RevertOptimisticWriteIfNeeded( + "A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op", + realm: AddressSpaceRealm.Uns); + + uns.Value.ShouldBe(10); // UNS node reverted to prior + raw.Value.ShouldBe(10); // raw node NEVER changed (realm-qualified revert) + + await host.DisposeAsync(); + } + + /// (M2b) A raw-realm revert reverts the RAW node and leaves the same-bare-id UNS node untouched. + /// This locks the realm arg: were it omitted (defaulting to Uns) the revert would target the UNS node — whose + /// value is not the optimistic one, so the still-holds-optimistic guard skips it — leaving the raw node stuck + /// at the rejected value and FAILING the raw-reverts assertion below. + [Fact] + public async Task Raw_realm_revert_reverts_raw_node_only() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + var now = DateTime.UtcNow; + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); + nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); + var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!; + var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!; + + raw.Value = 42; // optimistic write to the RAW node + + nm.RevertOptimisticWriteIfNeeded( + "A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"), + optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op", + realm: AddressSpaceRealm.Raw); + + raw.Value.ShouldBe(10); // raw reverted (would stay 42 if the realm were dropped to Uns) + uns.Value.ShouldBe(10); // UNS node untouched await host.DisposeAsync(); } @@ -143,8 +207,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( @@ -174,8 +238,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable var (host, server) = await BootAsync(); var nm = server.NodeManager!; - nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true); - nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow); + nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns); var node = nm.TryGetVariable("eq-1/sp")!; var audit = nm.BuildWriteFailureAuditEvent( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs index d70484bc..c168cf88 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ActorNodeWriteGatewayTests.cs @@ -1,6 +1,7 @@ using Akka.Actor; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; @@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase var probe = CreateTestProbe(); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); - var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); var routed = probe.ExpectMsg(TimeSpan.FromSeconds(5)); routed.NodeId.ShouldBe("eq-1/speed"); @@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase var probe = CreateTestProbe(); var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance); - var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); probe.ExpectMsg(TimeSpan.FromSeconds(5)); probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary")); @@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase resolveDriverHost: () => probe.Ref, NullLogger.Instance, askTimeout: TimeSpan.FromMilliseconds(200)); - var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldNotBeNull(); @@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase { var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance); - var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None); + var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None); outcome.Success.ShouldBeFalse(); outcome.Reason.ShouldBe("writes unavailable"); 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 cd44ab42..c8d5ca33 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,6 +18,18 @@ 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."; + /// Equipment↔device host binding is architecturally retired in v3. public const string EquipmentDeviceBindingRetired = "v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs new file mode 100644 index 00000000..fe2cc5ce --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactRawUnsParityTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam +/// () and the live compose seam +/// () must produce BYTE-IDENTICAL Raw and UNS node +/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently +/// diverge the deployed dual-namespace address space from what was authored/validated. The existing +/// DeploymentArtifactEquipRefParityTests covers {{equip}} script-path parity; this covers the +/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths, +/// writable + historian + array shape). +/// +public sealed class DeploymentArtifactRawUnsParityTests +{ + [Fact] + public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode() + { + // Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override, + // one plain read-only), an area/line/equipment, and a UNS reference with a display-name override. + var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null }; + var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" }; + var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" }; + var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null }; + var speed = new Tag + { + TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", + AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}", + }; + var run = new Tag + { + TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean", + AccessLevel = TagAccessLevel.Read, TagConfig = "{}", + }; + var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" }; + var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" }; + var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" }; + var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" }; + + var composed = AddressSpaceComposer.Compose( + new[] { area }, new[] { line }, new[] { equip }, + new[] { driver }, Array.Empty(), + unsTagReferences: new[] { reference }, tags: new[] { speed, run }, + rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group }); + + // Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0). + var blob = JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } }, + TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } }, + Tags = new object[] + { + new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" }, + new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" }, + }, + UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } }, + UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } }, + Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } }, + }); + + var decoded = DeploymentArtifact.ParseComposition(blob); + + // Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised). + composed.RawTags.Count.ShouldBe(2); + composed.UnsReferenceVariables.Count.ShouldBe(1); + composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed"); + composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed"); + + // Byte-parity: the three dual-namespace node sets are record-equal between the two seams. + decoded.RawContainers.ShouldBe(composed.RawContainers); + decoded.RawTags.ShouldBe(composed.RawTags); + decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables); + } +} 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 index 33509853..bdbeab4c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DiscoveryInjectionEndToEndTests.cs @@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// — proving the live value routed end-to-end and (in production) /// overwrote the BadWaitingForInitialData seed. /// - [Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good() { var db = NewInMemoryDbFactory(); @@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase /// (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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_and_value_survive_a_redeploy() { var db = NewInMemoryDbFactory(); 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 index 085fee91..2054eb51 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs @@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription() { var db = NewInMemoryDbFactory(); @@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes() { var db = NewInMemoryDbFactory(); @@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment() { var db = NewInMemoryDbFactory(); @@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft() { var db = NewInMemoryDbFactory(); @@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet() { var db = NewInMemoryDbFactory(); @@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_node_shadowing_an_authored_ref_is_not_injected() { var db = NewInMemoryDbFactory(); @@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does() { var db = NewInMemoryDbFactory(); @@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_survive_a_redeploy_rebuild() { var db = NewInMemoryDbFactory(); @@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// (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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_dropped_when_equipment_no_longer_resolves() { var db = NewInMemoryDbFactory(); @@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment() { var db = NewInMemoryDbFactory(); @@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Config_unchanged_rebind_re_triggers_discovery_on_the_child() { var db = NewInMemoryDbFactory(); @@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void No_drop_redeploy_does_not_re_trigger_discovery() { var db = NewInMemoryDbFactory(); @@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription() { var db = NewInMemoryDbFactory(); @@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback() { var db = NewInMemoryDbFactory(); @@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription() { var db = NewInMemoryDbFactory(); @@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase /// 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.EquipmentTagsDarkBatch4)] + [Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)] public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing() { var db = NewInMemoryDbFactory(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs index cce53094..24f10784 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorPrimaryGateTests.cs @@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe; using Akka.TestKit; using Microsoft.EntityFrameworkCore; using Shouldly; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; @@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref); var res = asker.ExpectMsg(Timeout); res.Reason.ShouldNotBe("not primary"); res.Reason.ShouldNotBe("not primary (role unknown)"); @@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase AwaitAssert(() => { var asker1 = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref); var res = asker1.ExpectMsg(Timeout); res.Reason.ShouldNotBe("not primary"); res.Success.ShouldBeTrue(res.Reason); @@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase // Secondary snapshot ⇒ denied with the steady-state reason. TellRole(actor, RedundancyRole.Secondary); var asker2 = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref); var denied = asker2.ExpectMsg(Timeout); denied.Success.ShouldBeFalse(); denied.Reason.ShouldBe("not primary"); @@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeFalse(); AwaitAssert(() => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs index a9c7df56..6d5a211f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorProbeResultDropTests.cs @@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; using Microsoft.EntityFrameworkCore; using Xunit; +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.Runtime.Drivers; @@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase // drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled // OpcUaProbeResult would already be on the EventStream before we assert. var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout); // Assert no dead-letter carrying an OpcUaProbeResult was published. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs index 3f9c1527..df53cfa6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorWriteRoutingTests.cs @@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; +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; @@ -56,7 +57,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var asker = CreateTestProbe(); // The node manager passes the FULL ns-qualified id; the host must normalise + resolve it. - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => @@ -79,7 +80,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref); asker.ExpectMsg(Timeout).Success.ShouldBeTrue(); AwaitAssert(() => @@ -110,7 +111,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase CorrelationId.NewId())); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -129,7 +130,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase var actor = SpawnHostAndApply(db, deploymentId, recorder); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(Timeout); result.Success.ShouldBeFalse(); @@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase localRoles: new HashSet { "driver" })); var asker = CreateTestProbe(); - actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref); + actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref); var result = asker.ExpectMsg(TimeSpan.FromSeconds(2)); result.Success.ShouldBeFalse(); @@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase result.Reason!.ShouldContain("stale"); } + /// + /// Review H1 regression — a raw RawPath and a UNS path that share a BARE s= id but back + /// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag A/B/C/D on + /// drv-1 and a UnsTagReference whose UNS NodeId is ALSO A/B/C/D (Area A / Line B / Equip C + /// / effective name D) but which backs a raw tag X/Y/Z/W on drv-2. A bare-only routing key + /// would collide (last-writer-wins → wrong device); the (realm, bareId) key keeps them distinct. + /// + [Fact] + public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm() + { + var db = NewInMemoryDbFactory(); + var factory = new PerInstanceRecordingDriverFactory("Modbus"); + var deploymentId = SeedCollisionDeployment(db, RevA); + + var actor = SpawnHostAndApply(db, deploymentId, factory); + + // Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D. + var asker1 = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref); + asker1.ExpectMsg(Timeout).Success.ShouldBeTrue(); + + // Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W. + var asker2 = CreateTestProbe(); + actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref); + asker2.ExpectMsg(Timeout).Success.ShouldBeTrue(); + + AwaitAssert(() => + { + // drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222). + var w1 = factory.WritesFor("drv-1"); + var w2 = factory.WritesFor("drv-2"); + w1.Count.ShouldBe(1); + w1[0].FullReference.ShouldBe("A/B/C/D"); + w1[0].Value.ShouldBe(111.0); + w2.Count.ShouldBe(1); + w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id + w2[0].Value.ShouldBe(222.0); + }, duration: Timeout); + } + /// Spawns the host with the recording driver factory, dispatches the deployment, and waits for /// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a /// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor. @@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase return id; } + /// Seeds the review-H1 collision config: raw tag A/B/C/D on drv-1, and a UnsTagReference + /// (Area A / Line B / Equip C, effective name D → UNS NodeId A/B/C/D) backing raw tag X/Y/Z/W + /// on drv-2. Both drivers ENABLED (Modbus) so both children spawn. + private static DeploymentId SeedCollisionDeployment(IDbContextFactory db, RevisionHash rev) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + RawFolders = new[] + { + new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" }, + new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" }, + }, + DriverInstances = new[] + { + new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, + new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true }, + }, + Devices = new[] + { + new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" }, + new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" }, + }, + TagGroups = Array.Empty(), + Tags = new[] + { + new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, + new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" }, + }, + UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } }, + UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } }, + Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } }, + // Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2. + UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } }, + }); + + 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; + } + /// An whose CreateDbContext always throws — drives the host /// into the Stale path. private sealed class ThrowingDbFactory : IDbContextFactory @@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase throw new InvalidOperationException("config DB unreachable (test stub)"); } + /// Factory producing a SEPARATE per driver instance id, so a test + /// with two drivers can assert which instance received a given write (used by the H1 collision test). + private sealed class PerInstanceRecordingDriverFactory : IDriverFactory + { + private readonly string _supportedType; + private readonly Dictionary _byId = new(StringComparer.Ordinal); + private readonly object _lock = new(); + public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; } + + /// The writes the given driver instance received. + public IReadOnlyList WritesFor(string driverInstanceId) + { + lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty(); + } + + /// + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null; + lock (_lock) + { + if (!_byId.TryGetValue(driverInstanceId, out var d)) + { + d = new RecordingDriver(); + d.Bind(driverInstanceId, driverType); + _byId[driverInstanceId] = d; + } + return d; + } + } + + /// + public IReadOnlyCollection SupportedTypes => new[] { _supportedType }; + } + /// Factory producing a single for the supported type. private sealed class RecordingDriverFactory : IDriverFactory { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index bf3fe3a4..a38b2319 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase NodeId: "ns=2;s=tag-1", Value: 42, Quality: OpcUaQuality.Good, - TimestampUtc: DateTime.UtcNow)); + TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssertion(() => { 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 1f9e0ba6..3f73107a 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 @@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase public void Accepts_message_contracts_without_pinned_dispatcher_in_tests() { var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests()); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow)); - actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240)); @@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase var sink = new RecordingSink(); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow)); - actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssert(() => { @@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink)); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate( - "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow)); + "ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns)); AwaitAssert(() => { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 2bd182bd..7fb9d2f4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase } /// Activation path: with the alarm loaded, pushing a value above the threshold drives an - /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an + /// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an /// AlarmTransitionEvent("Activated") on the alerts topic. [Fact] public void Dependency_change_above_threshold_activates_alarm() @@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase } /// Clear path: after activating, pushing a value below the threshold drives Active→Inactive - /// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared"). + /// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared"). [Fact] public void Dependency_change_below_threshold_clears_alarm() { @@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase /// Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is /// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting - /// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic + /// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic /// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User). [Fact] public void AlarmCommand_acknowledge_drives_engine_with_mapped_args() @@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase /// Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound /// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active - /// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true) + /// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) /// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent). [Fact] public void Inbound_AlarmCommand_is_processed_regardless_of_role()