Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredAddressSpaceSinkTests.cs
T
Joseph Doherty 3efcf8014b feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 10:56:31 -04:00

236 lines
10 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
/// <summary>
/// Covers the deferred-sink forwarding invariants documented in Commons-003.
/// The DeferredAddressSpaceSink is a production-critical seam: a new optional capability
/// interface MUST be forwarded through it or the optimization is inert on every driver-role
/// host (DeferredAddressSpaceSink is what actors inject, not the inner sink directly).
/// </summary>
public class DeferredAddressSpaceSinkTests
{
// ---------- before SetSink — all calls are safe no-ops ----------
[Fact]
public void Before_SetSink_WriteValue_is_a_noop()
{
var sink = new DeferredAddressSpaceSink();
// Must not throw.
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
}
[Fact]
public void Before_SetSink_RebuildAddressSpace_is_a_noop()
{
var sink = new DeferredAddressSpaceSink();
sink.RebuildAddressSpace(); // Must not throw.
}
[Fact]
public void Before_SetSink_UpdateTagAttributes_returns_false()
{
var sink = new DeferredAddressSpaceSink();
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
// ---------- after SetSink — operations are forwarded ----------
[Fact]
public void After_SetSink_WriteValue_is_forwarded()
{
var inner = new SpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeTrue();
}
[Fact]
public void After_SetSink_RebuildAddressSpace_is_forwarded()
{
var inner = new SpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.RebuildAddressSpace();
inner.RebuildCalled.ShouldBeTrue();
}
// ---------- ISurgicalAddressSpaceSink forwarding ----------
[Fact]
public void UpdateTagAttributes_returns_false_for_non_surgical_inner()
{
// SpySink does NOT implement ISurgicalAddressSpaceSink.
var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink());
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
public void UpdateTagAttributes_returns_true_for_surgical_inner()
{
var surgical = new SpySurgicalSink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns);
result.ShouldBeTrue();
surgical.UpdateCalled.ShouldBeTrue();
}
[Fact]
public void UpdateFolderDisplayName_returns_false_for_non_surgical_inner()
{
// SpySink does NOT implement ISurgicalAddressSpaceSink.
var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink());
sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
public void UpdateFolderDisplayName_returns_true_for_surgical_inner()
{
// OpcUaServer-001: the deferred wrapper must forward the folder-rename capability to a surgical inner.
var surgical = new SpySurgicalSink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
result.ShouldBeTrue();
surgical.FolderRenameCalled.ShouldBeTrue();
}
// ---------- R2-07 Phase 2: surgical remove forwarding ----------
[Fact]
public void Remove_members_return_false_for_non_surgical_inner()
{
var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink());
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
public void Remove_members_forward_to_surgical_inner()
{
var surgical = new SpySurgicalSink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
surgical.RemoveVariableCalled.ShouldBeTrue();
surgical.RemoveAlarmCalled.ShouldBeTrue();
surgical.RemoveSubtreeCalled.ShouldBeTrue();
}
[Fact]
public void Before_SetSink_remove_members_return_false()
{
var sink = new DeferredAddressSpaceSink();
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
}
// ---------- SetSink(null) reverts to null sink ----------
[Fact]
public void SetSink_null_reverts_to_null_sink_and_UpdateTagAttributes_returns_false()
{
var surgical = new SpySurgicalSink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
sink.SetSink(null); // revert
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
public void SetSink_null_reverts_to_null_sink_and_WriteValue_is_noop()
{
var inner = new SpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.SetSink(null); // revert
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
}
// ---- test doubles ----
private sealed class SpySink : IOpcUaAddressSpaceSink
{
public bool WriteValueCalled { get; private set; }
public bool RebuildCalled { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> WriteValueCalled = true;
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, 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) { }
public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
{
public bool UpdateCalled { get; private set; }
public bool FolderRenameCalled { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, 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) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
UpdateCalled = true;
return true;
}
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameCalled = true;
return true;
}
public bool RemoveVariableCalled { get; private set; }
public bool RemoveAlarmCalled { get; private set; }
public bool RemoveSubtreeCalled { get; private set; }
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; }
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; }
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveSubtreeCalled = true; return true; }
}
}