fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only. A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> 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
This commit is contained in:
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
|
||||
{
|
||||
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
|
||||
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
|
||||
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
|
||||
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
|
||||
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
|
||||
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
|
||||
/// routing: a raw <c>s=<RawPath></c> and a UNS <c>s=<Area/Line/Equip/Eff></c> can collide as bare
|
||||
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
|
||||
/// operator write route to the WRONG driver ref.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task resolving to the device-write outcome.</returns>
|
||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
|
||||
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
|
||||
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
public static readonly NullOpcUaNodeWriteGateway Instance = new();
|
||||
private NullOpcUaNodeWriteGateway() { }
|
||||
/// <inheritdoc />
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="value">The new value to write.</param>
|
||||
/// <param name="quality">The OPC UA quality status code.</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
|
||||
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
|
||||
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
|
||||
/// <param name="state">The full condition state to project onto the node.</param>
|
||||
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
|
||||
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
|
||||
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
|
||||
/// notes — a script alarm carries no High/Low limits to populate.</para>
|
||||
/// </remarks>
|
||||
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 <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
|
||||
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
|
||||
/// <param name="folderNodeId">The node identifier of the folder.</param>
|
||||
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
|
||||
/// <param name="displayName">The display name of the folder.</param>
|
||||
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
|
||||
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
|
||||
/// <param name="displayName">The new display name to apply.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
|
||||
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
|
||||
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
|
||||
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
|
||||
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
|
||||
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
|
||||
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
|
||||
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
|
||||
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
|
||||
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
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
|
||||
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
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
|
||||
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
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
|
||||
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
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
|
||||
EnsureAddressSpaceCreated();
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
@@ -38,7 +38,7 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct)
|
||||
public async Task<NodeWriteOutcome> 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<DriverHostActor.NodeWriteResult>(
|
||||
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);
|
||||
|
||||
@@ -128,19 +128,22 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
|
||||
|
||||
/// <summary>
|
||||
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>materialised value NodeId (BARE <c>s=</c> id) →
|
||||
/// (DriverInstanceId, RawPath)</c>. 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
|
||||
/// <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c> ∪
|
||||
/// <c>UnsReferenceVariables</c>, and resolved by <see cref="HandleRouteNodeWrite"/> 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
|
||||
/// (<c>node.NodeId.ToString()</c>); <see cref="HandleRouteNodeWrite"/> 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 <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
|
||||
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
|
||||
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
|
||||
/// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
|
||||
/// composition's <c>RawTags</c> ∪ <c>UnsReferenceVariables</c>, and resolved by
|
||||
/// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
|
||||
/// owning driver child as a write of its wire-ref RawPath.
|
||||
/// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=<RawPath></c> and a UNS
|
||||
/// <c>s=<Area/Line/Equip/Eff></c> 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 (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
|
||||
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
|
||||
/// namespace disambiguation the ns-qualified id carries.</para>
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
|
||||
|
||||
/// <summary>(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
|
||||
/// </summary>
|
||||
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
|
||||
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
|
||||
public sealed record RouteNodeWrite(string NodeId, object? Value);
|
||||
public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
|
||||
|
||||
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
|
||||
/// (or a gate/lookup failure that never reached the driver).</summary>
|
||||
@@ -654,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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=<id>"). 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=<id>") 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<NodeRealmRef>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
|
||||
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
|
||||
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
|
||||
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
|
||||
@@ -57,7 +57,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
|
||||
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
|
||||
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
|
||||
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);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
|
||||
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
|
||||
///
|
||||
/// <para>
|
||||
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
|
||||
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
|
||||
/// materialises against — so the value always lands on a NodeId that exists.
|
||||
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
|
||||
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
|
||||
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
|
||||
/// always lands on a NodeId that exists.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost)
|
||||
=> await driverHost.Ask<DriverHostActor.NodeWriteResult>(
|
||||
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);
|
||||
|
||||
@@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var captured = new List<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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<AlarmCommand>();
|
||||
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();
|
||||
|
||||
|
||||
+11
-10
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+14
-13
@@ -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
|
||||
|
||||
+9
-8
@@ -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<double>();
|
||||
@@ -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<double>();
|
||||
@@ -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<double>();
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-6
@@ -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();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
|
||||
try
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500));
|
||||
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
+25
-24
@@ -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();
|
||||
}
|
||||
|
||||
+19
-19
@@ -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);
|
||||
|
||||
@@ -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 ───────────────────
|
||||
|
||||
/// <summary>(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.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>(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.</summary>
|
||||
[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(
|
||||
|
||||
@@ -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<DriverHostActor.RouteNodeWrite>(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<DriverHostActor.RouteNodeWrite>(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");
|
||||
|
||||
@@ -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.";
|
||||
|
||||
/// <summary>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 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
|
||||
/// 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
|
||||
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
|
||||
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.";
|
||||
|
||||
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
||||
public const string EquipmentDeviceBindingRetired =
|
||||
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
|
||||
|
||||
+84
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam
|
||||
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) and the live compose seam
|
||||
/// (<see cref="AddressSpaceComposer.Compose"/>) must produce BYTE-IDENTICAL <b>Raw</b> and <b>UNS</b> 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
|
||||
/// <c>DeploymentArtifactEquipRefParityTests</c> covers <c>{{equip}}</c> script-path parity; this covers the
|
||||
/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths,
|
||||
/// writable + historian + array shape).
|
||||
/// </summary>
|
||||
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<ScriptedAlarm>(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
|
||||
/// overwrote the BadWaitingForInitialData seed.
|
||||
/// </summary>
|
||||
[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
|
||||
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
|
||||
/// </summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
||||
public void Discovered_node_and_value_survive_a_redeploy()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
|
||||
+43
-16
@@ -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);
|
||||
|
||||
/// <summary>v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
|
||||
/// A driver reports a FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>, but the host must
|
||||
/// NOT materialise anything (no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> reaches the publish
|
||||
/// side) — discovered raw tags are authored via the <c>/raw</c> browse-commit flow, not injected at runtime.
|
||||
/// The skipped v2 injection scenarios below are the counterpart of this guard.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
|
||||
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
|
||||
/// 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).</summary>
|
||||
[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
|
||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> 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".</summary>
|
||||
[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 <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
|
||||
[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).</summary>
|
||||
[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 <c>loglevel = WARNING</c> 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 <c>PlansRoutingEqual</c>.)</summary>
|
||||
[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
|
||||
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
|
||||
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
|
||||
/// are materialised.</summary>
|
||||
[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.</summary>
|
||||
[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.</summary>
|
||||
[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
|
||||
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
|
||||
[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 <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
|
||||
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
|
||||
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
|
||||
[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 <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
|
||||
/// under the new equipment EQ-2.</summary>
|
||||
[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 <c>droppedAny</c> on a non-drop path). The
|
||||
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
|
||||
/// pass.</summary>
|
||||
[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 <see cref="DriverInstanceActor"/>): 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".)</summary>
|
||||
[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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
|
||||
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — 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.</summary>
|
||||
[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 <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> 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 <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
|
||||
[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 <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
|
||||
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
|
||||
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
|
||||
[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();
|
||||
|
||||
+6
-5
@@ -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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
|
||||
|
||||
AwaitAssert(() =>
|
||||
|
||||
+2
-1
@@ -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<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
|
||||
// Assert no dead-letter carrying an OpcUaProbeResult was published.
|
||||
|
||||
+127
-5
@@ -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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -149,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
localRoles: new HashSet<string> { "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<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -157,6 +158,46 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
result.Reason!.ShouldContain("stale");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
|
||||
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
|
||||
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
|
||||
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
|
||||
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
|
||||
/// </summary>
|
||||
[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<DriverHostActor.NodeWriteResult>(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<DriverHostActor.NodeWriteResult>(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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
@@ -214,6 +255,52 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
|
||||
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
|
||||
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
|
||||
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> 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<object>(),
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
|
||||
/// into the Stale path.</summary>
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
@@ -223,6 +310,41 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
throw new InvalidOperationException("config DB unreachable (test stub)");
|
||||
}
|
||||
|
||||
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
|
||||
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
|
||||
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
|
||||
{
|
||||
private readonly string _supportedType;
|
||||
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||
|
||||
/// <summary>The writes the given driver instance received.</summary>
|
||||
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
|
||||
{
|
||||
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
||||
}
|
||||
|
||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
|
||||
private sealed class RecordingDriverFactory : IDriverFactory
|
||||
{
|
||||
|
||||
+1
-1
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
+4
-4
@@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[Fact]
|
||||
public void Dependency_change_above_threshold_activates_alarm()
|
||||
@@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
|
||||
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
|
||||
/// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared").</summary>
|
||||
[Fact]
|
||||
public void Dependency_change_below_threshold_clears_alarm()
|
||||
{
|
||||
@@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[Fact]
|
||||
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
|
||||
@@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[Fact]
|
||||
public void Inbound_AlarmCommand_is_processed_regardless_of_role()
|
||||
|
||||
Reference in New Issue
Block a user