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
This commit is contained in:
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
// Must not throw.
|
||||
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
|
||||
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------- after SetSink — operations are forwarded ----------
|
||||
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(inner);
|
||||
|
||||
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
inner.WriteValueCalled.ShouldBeTrue();
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||
dataType: "Int32", isArray: false, arrayLength: null).ShouldBeFalse();
|
||||
dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,7 +84,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(surgical);
|
||||
|
||||
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null);
|
||||
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
surgical.UpdateCalled.ShouldBeTrue();
|
||||
@@ -97,7 +97,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse();
|
||||
sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,7 +108,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South");
|
||||
var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
surgical.FolderRenameCalled.ShouldBeTrue();
|
||||
@@ -122,9 +122,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(new SpySink());
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -134,9 +134,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(surgical);
|
||||
|
||||
sink.RemoveVariableNode("v-1").ShouldBeTrue();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
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();
|
||||
@@ -147,9 +147,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void Before_SetSink_remove_members_return_false()
|
||||
{
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
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 ----------
|
||||
@@ -163,7 +163,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(null); // revert
|
||||
|
||||
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
|
||||
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
|
||||
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -174,7 +174,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
sink.SetSink(inner);
|
||||
sink.SetSink(null); // revert
|
||||
|
||||
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
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");
|
||||
}
|
||||
@@ -190,9 +190,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
=> 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, 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) { }
|
||||
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, 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) { }
|
||||
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") { }
|
||||
@@ -205,9 +205,9 @@ public class DeferredAddressSpaceSinkTests
|
||||
|
||||
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, 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) { }
|
||||
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, 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) { }
|
||||
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") { }
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
|
||||
|
||||
public class EquipmentNodeIdsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Variable_with_no_folder_is_equipment_slash_name()
|
||||
=> EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed");
|
||||
|
||||
[Fact]
|
||||
public void Variable_with_null_folder_is_equipment_slash_name()
|
||||
=> EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed");
|
||||
|
||||
[Fact]
|
||||
public void Variable_with_folder_is_equipment_slash_folder_slash_name()
|
||||
=> EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed");
|
||||
|
||||
[Fact]
|
||||
public void Variable_with_whitespace_folder_is_equipment_slash_name()
|
||||
=> EquipmentNodeIds.Variable("eq-1", " ", "speed").ShouldBe("eq-1/speed");
|
||||
|
||||
[Fact]
|
||||
public void SubFolder_is_equipment_slash_folder()
|
||||
=> EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers");
|
||||
}
|
||||
+17
-16
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
@@ -65,8 +66,8 @@ public sealed class SubscriptionSurvivalTests
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
||||
applier.MaterialiseHierarchy(withA);
|
||||
applier.MaterialiseEquipmentTags(withA);
|
||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
// --- Open a client session + subscription + monitored item on A. ---
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
@@ -115,7 +116,7 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// --- A's monitored item must still be alive: a new write to A is delivered. ---
|
||||
lock (gate) received.Clear();
|
||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
|
||||
lock (gate)
|
||||
@@ -125,8 +126,8 @@ public sealed class SubscriptionSurvivalTests
|
||||
}
|
||||
|
||||
// --- B is browsable + readable (the add landed). ---
|
||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var bValue = await session.ReadValueAsync(nodeIdB, ct);
|
||||
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
|
||||
bValue.Value.ShouldBe(7);
|
||||
@@ -173,10 +174,10 @@ public sealed class SubscriptionSurvivalTests
|
||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
||||
var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
@@ -212,7 +213,7 @@ public sealed class SubscriptionSurvivalTests
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// A stays alive: a fresh write to A is delivered.
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
|
||||
@@ -261,8 +262,8 @@ public sealed class SubscriptionSurvivalTests
|
||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
@@ -295,18 +296,18 @@ public sealed class SubscriptionSurvivalTests
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
// A stays alive.
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
|
||||
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
|
||||
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
|
||||
async () => await session.ReadValueAsync(nodeIdB, ct));
|
||||
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// C is browsable + readable.
|
||||
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns);
|
||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
|
||||
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
|
||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var cRead = await session.ReadValueAsync(nodeIdC, ct);
|
||||
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
cRead.Value.ShouldBe(9);
|
||||
|
||||
+2
-2
@@ -168,7 +168,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
|
||||
}
|
||||
@@ -178,7 +178,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
|
||||
}
|
||||
|
||||
+2
-2
@@ -319,7 +319,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>Records a folder creation request.</summary>
|
||||
/// <param name="folderNodeId">The node ID of the folder.</param>
|
||||
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
|
||||
@@ -333,7 +333,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
|
||||
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
|
||||
|
||||
+15
-10
@@ -292,8 +292,8 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||
{
|
||||
RemovedEquipmentTags = new[] { removedTag },
|
||||
ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) },
|
||||
RemovedRawTags = new[] { removedTag },
|
||||
ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) },
|
||||
};
|
||||
|
||||
applier.Apply(plan);
|
||||
@@ -324,15 +324,20 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
|
||||
}
|
||||
|
||||
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
|
||||
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
|
||||
// v3 Batch 4: historized value tags are RAW tags — the mux ref + historian default are the RawPath
|
||||
// (== NodeId). The `fullName` param plays the role of the RawPath here (kept as a param name so the
|
||||
// existing assertions — HistorizedTagRef("ref"/"40001", …) — read unchanged).
|
||||
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||
IsHistorized: true, HistorianTagname: historianName);
|
||||
|
||||
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
|
||||
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
|
||||
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||
IsHistorized: false, HistorianTagname: null);
|
||||
|
||||
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
|
||||
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
@@ -343,6 +348,6 @@ public sealed class AddressSpaceApplierProvisioningTests
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||
{
|
||||
AddedEquipmentTags = tags,
|
||||
AddedRawTags = tags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
|
||||
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
|
||||
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
|
||||
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
|
||||
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
|
||||
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
|
||||
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
|
||||
/// its realm EXPLICITLY.
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceApplierRawUnsTests
|
||||
{
|
||||
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
|
||||
new(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawContainers = new[]
|
||||
{
|
||||
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
|
||||
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
|
||||
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
|
||||
},
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
|
||||
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
// Containers are folders in the Raw realm, parents-before-children.
|
||||
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
|
||||
{
|
||||
("Plant", AddressSpaceRealm.Raw),
|
||||
("Plant/Modbus", AddressSpaceRealm.Raw),
|
||||
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
|
||||
});
|
||||
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
|
||||
var v = sink.Variables.ShouldHaveSingleItem();
|
||||
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
v.Parent.ShouldBe("Plant/Modbus/dev1");
|
||||
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
v.Writable.ShouldBeTrue();
|
||||
v.HistorianTagname.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
// Historized, no override → historian tagname defaults to the RawPath.
|
||||
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
|
||||
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
|
||||
// Historized with override → the override wins.
|
||||
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
|
||||
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
var byId = sink.Variables.ToDictionary(v => v.NodeId);
|
||||
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
|
||||
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||
ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
|
||||
var c = sink.Conditions.ShouldHaveSingleItem();
|
||||
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
|
||||
c.Parent.ShouldBe("Plant/A/dev");
|
||||
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
c.IsNative.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
|
||||
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
|
||||
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
|
||||
IsHistorized: true, HistorianTagname: "WW.Speed"),
|
||||
},
|
||||
UnsReferenceVariables = new[]
|
||||
{
|
||||
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
|
||||
"Plant/A/dev/speed", "Float", Writable: true),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseUnsReferences(composition);
|
||||
|
||||
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
|
||||
var v = sink.Variables.ShouldHaveSingleItem();
|
||||
v.NodeId.ShouldBe("filling/line1/station1/speed");
|
||||
v.Parent.ShouldBe("EQ-1");
|
||||
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
v.Writable.ShouldBeTrue();
|
||||
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
|
||||
|
||||
// An Organizes reference UNS→Raw links the two.
|
||||
var r = sink.References.ShouldHaveSingleItem();
|
||||
r.Source.ShouldBe("filling/line1/station1/speed");
|
||||
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
r.Target.ShouldBe("Plant/A/dev/speed");
|
||||
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
r.ReferenceType.ShouldBe("Organizes");
|
||||
}
|
||||
|
||||
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
|
||||
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
||||
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
||||
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
|
||||
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
||||
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ public sealed class AddressSpaceApplierTests
|
||||
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
||||
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierTests
|
||||
// A Read plan threads Writable: false (the node stays CurrentRead).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
}
|
||||
|
||||
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
||||
@@ -228,13 +228,13 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
|
||||
var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
||||
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
|
||||
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
|
||||
|
||||
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
|
||||
// matching display/type/severity) and did NOT drive EnsureVariable.
|
||||
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||
// A native equipment-tag alarm: the call-site threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
|
||||
@@ -264,7 +264,7 @@ public sealed class AddressSpaceApplierTests
|
||||
// The sub-folder is still created for an alarm tag with a FolderPath.
|
||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
|
||||
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp");
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
|
||||
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
|
||||
@@ -300,9 +300,9 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
|
||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
||||
byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
||||
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
||||
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
||||
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
||||
}
|
||||
|
||||
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
|
||||
@@ -326,7 +326,7 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.HistorianCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.HistorianTagname.ShouldBe("40001");
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
|
||||
call.IsArray.ShouldBeTrue();
|
||||
call.ArrayLength.ShouldBe(16u);
|
||||
}
|
||||
@@ -380,7 +380,7 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.IsArray.ShouldBeFalse();
|
||||
call.ArrayLength.ShouldBeNull();
|
||||
}
|
||||
@@ -471,11 +471,11 @@ public sealed class AddressSpaceApplierTests
|
||||
// VirtualTags are computed outputs — always read-only (Writable: false).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
|
||||
}
|
||||
|
||||
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
||||
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — the
|
||||
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
|
||||
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
|
||||
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
||||
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
||||
@@ -507,10 +507,10 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
|
||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency"));
|
||||
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
|
||||
}
|
||||
|
||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||
@@ -760,7 +760,7 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
@@ -815,7 +815,7 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||
// Terminal Bad written to the removed variable BEFORE the removal.
|
||||
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
|
||||
@@ -842,7 +842,7 @@ public sealed class AddressSpaceApplierTests
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
|
||||
var write = sink.AlarmWrites.ShouldHaveSingleItem();
|
||||
write.NodeId.ShouldBe(nodeId);
|
||||
@@ -953,7 +953,7 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old")));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old")));
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
}
|
||||
@@ -967,7 +967,7 @@ public sealed class AddressSpaceApplierTests
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot");
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "Slot");
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
|
||||
@@ -1457,8 +1457,8 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted
|
||||
sink.RemoveCalls.Count.ShouldBe(2);
|
||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
|
||||
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
|
||||
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed")));
|
||||
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency")));
|
||||
}
|
||||
|
||||
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
||||
@@ -1490,7 +1490,7 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.Writable.ShouldBeTrue(); // the NEW Writable value
|
||||
call.Historian.ShouldBeNull(); // not historized
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
@@ -1584,7 +1584,7 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.DataType.ShouldBe("Int32"); // the NEW DataType
|
||||
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
|
||||
call.IsArray.ShouldBeFalse();
|
||||
@@ -1781,8 +1781,8 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.SurgicalCalls.Count.ShouldBe(2);
|
||||
|
||||
// Both expected node ids must appear (order is not guaranteed).
|
||||
var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed");
|
||||
var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure");
|
||||
var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
|
||||
var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
|
||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
|
||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
|
||||
|
||||
@@ -2040,7 +2040,7 @@ public sealed class AddressSpaceApplierTests
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
|
||||
}
|
||||
|
||||
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
|
||||
@@ -2101,7 +2101,7 @@ public sealed class AddressSpaceApplierTests
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
|
||||
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
|
||||
new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
|
||||
@@ -2276,7 +2276,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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)
|
||||
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
|
||||
/// <summary>Records a folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
@@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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)
|
||||
{
|
||||
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||
@@ -2323,11 +2323,11 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>No-op alarm condition write call.</summary>
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
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) { }
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <summary>No-op variable creation call.</summary>
|
||||
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) { }
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
@@ -2363,7 +2363,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
|
||||
@@ -2376,7 +2376,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
|
||||
/// <summary>No-op rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
|
||||
@@ -14,8 +14,8 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
|
||||
// No throw, no observable side effect.
|
||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
deferred.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var inner = new RecordingSink();
|
||||
deferred.SetSink(inner);
|
||||
|
||||
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow);
|
||||
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
deferred.RebuildAddressSpace();
|
||||
deferred.RaiseNodesAddedModelChange("eq-1");
|
||||
deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
|
||||
|
||||
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" });
|
||||
}
|
||||
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
var inner = new RecordingSink();
|
||||
deferred.SetSink(inner);
|
||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
inner.Calls.Count.ShouldBe(1);
|
||||
|
||||
deferred.SetSink(null);
|
||||
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped
|
||||
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
|
||||
inner.Calls.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var second = new RecordingSink();
|
||||
|
||||
deferred.SetSink(first);
|
||||
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
deferred.SetSink(second);
|
||||
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
first.Calls.Single().ShouldBe("WV:a");
|
||||
second.Calls.Single().ShouldBe("WV:b");
|
||||
@@ -77,7 +77,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var inner = new RecordingSink();
|
||||
deferred.SetSink(inner);
|
||||
|
||||
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV");
|
||||
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV", realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var call = inner.HistorianCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe("v-1");
|
||||
@@ -96,7 +96,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
deferred.SetSink(inner);
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
|
||||
dataType: "Int32", isArray: true, arrayLength: 8u)
|
||||
dataType: "Int32", isArray: true, arrayLength: 8u, AddressSpaceRealm.Uns)
|
||||
.ShouldBeTrue();
|
||||
|
||||
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
|
||||
@@ -119,7 +119,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -131,12 +131,12 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||
.ShouldBeFalse();
|
||||
|
||||
deferred.SetSink(new RecordingSink()); // a non-surgical inner
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
|
||||
dataType: "Float", isArray: false, arrayLength: null)
|
||||
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var inner = new SurgicalRecordingSink { Result = true };
|
||||
deferred.SetSink(inner);
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South")
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns)
|
||||
.ShouldBeTrue();
|
||||
|
||||
var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
|
||||
@@ -167,11 +167,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
// Inner reports the folder missing ⇒ forward returns false.
|
||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
|
||||
// Non-surgical inner (the null sink before swap-in) ⇒ false.
|
||||
deferred.SetSink(null);
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
|
||||
@@ -184,9 +184,9 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
var inner = new SurgicalRecordingSink { Result = true };
|
||||
deferred.SetSink(inner);
|
||||
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
|
||||
}
|
||||
@@ -198,14 +198,14 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
var deferred = new DeferredAddressSpaceSink();
|
||||
deferred.SetSink(new SurgicalRecordingSink { Result = false });
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
|
||||
deferred.SetSink(null);
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
|
||||
@@ -234,13 +234,13 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> CallQueue.Enqueue($"EF:{folderNodeId}");
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
{
|
||||
CallQueue.Enqueue($"EV:{variableNodeId}");
|
||||
HistorianQueue.Enqueue((variableNodeId, historianTagname));
|
||||
@@ -290,11 +290,11 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
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) { }
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
/// <inheritdoc />
|
||||
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) { }
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
|
||||
@@ -44,8 +44,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
|
||||
@@ -120,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
|
||||
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager!.VariableCount.ShouldBe(3);
|
||||
|
||||
sink.RebuildAddressSpace();
|
||||
server.NodeManager.VariableCount.ShouldBe(0);
|
||||
|
||||
// After rebuild, subsequent writes start fresh.
|
||||
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager.VariableCount.ShouldBe(1);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -142,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Sanity check that the F10 fallback still works — production callers default to
|
||||
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
|
||||
var sink = NullOpcUaAddressSpaceSink.Instance;
|
||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
|
||||
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.RebuildAddressSpace();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
@@ -161,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
|
||||
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
|
||||
|
||||
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
@@ -201,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
|
||||
// BaseDataVariable is minted for the alarm id.
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
@@ -209,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
nm.AlarmConditionCount.ShouldBe(1);
|
||||
|
||||
// RebuildAddressSpace clears the alarm dict too.
|
||||
@@ -228,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
|
||||
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-x");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -250,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
|
||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
|
||||
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-rich");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -267,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Shelving: AlarmShelvingKind.Timed,
|
||||
Severity: 850,
|
||||
Message: "Pressure above limit"),
|
||||
DateTime.UtcNow);
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
@@ -296,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
|
||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
|
||||
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-base");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -318,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Shelving: AlarmShelvingKind.OneShot,
|
||||
Severity: 500,
|
||||
Message: "still works"),
|
||||
DateTime.UtcNow));
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
|
||||
// Mandatory state still projected despite the missing optional child.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
@@ -346,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -357,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
|
||||
// First engine-driven transition → fires an event with a fresh EventId.
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
firstEventId.ShouldNotBeNull();
|
||||
@@ -369,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
}
|
||||
|
||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
secondEventId.ShouldNotBeNull();
|
||||
@@ -395,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-evt2");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -404,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Drive several transitions; each fires an event AND projects state. State must survive firing.
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
});
|
||||
|
||||
// Final projected state is intact after the last firing.
|
||||
@@ -434,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack");
|
||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-ack");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
|
||||
@@ -462,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
||||
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
||||
// no change ⇒ NO event fires.
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
||||
@@ -486,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
|
||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta");
|
||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
|
||||
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
|
||||
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var condition = nm.TryGetAlarmCondition("alm-delta");
|
||||
condition.ShouldNotBeNull();
|
||||
@@ -496,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
||||
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterFirst.ShouldNotBeNull();
|
||||
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
||||
|
||||
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
||||
// events (EventId unchanged from the first fire).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterSecond.ShouldBe(afterFirst); // suppressed
|
||||
|
||||
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
||||
afterThird.ShouldNotBe(afterSecond); // fired
|
||||
|
||||
|
||||
+2
-2
@@ -331,14 +331,14 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
|
||||
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
||||
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) { }
|
||||
|
||||
/// <summary>Records an EnsureFolder call.</summary>
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
|
||||
|
||||
/// <summary>Records an EnsureVariable call.</summary>
|
||||
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)
|
||||
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||
|
||||
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
|
||||
|
||||
+2
-2
@@ -238,10 +238,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
|
||||
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
|
||||
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
|
||||
// (a lowercase-only "h1" check would miss a leaked raw "H1").
|
||||
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
|
||||
eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
|
||||
eqANodeId.ShouldNotContain("H1");
|
||||
eqANodeId.ShouldNotContain("h1");
|
||||
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
|
||||
eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
|
||||
eqBNodeId.ShouldNotContain("h2");
|
||||
|
||||
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
|
||||
|
||||
+143
-84
@@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-tag <b>live-value routing</b> wired into <see cref="DriverHostActor"/>:
|
||||
/// a driver publishes a value keyed by its wire-ref <c>FullName</c>, but the equipment variable was
|
||||
/// materialised under a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an
|
||||
/// apply, the host's <c>_nodeIdByDriverRef</c> map resolves <c>(DriverInstanceId, FullName)</c> to
|
||||
/// that folder-scoped NodeId, so <c>ForwardToMux</c> lands the value on the right node (and still
|
||||
/// forwards the raw value to the dependency mux for VirtualTag inputs).
|
||||
///
|
||||
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
|
||||
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
|
||||
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
|
||||
/// <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> — resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
|
||||
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
|
||||
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
|
||||
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
|
||||
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
|
||||
/// <para>
|
||||
/// Drives a real apply through the existing harness: the seeded artifact carries the
|
||||
/// <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays that
|
||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, and a
|
||||
/// <c>DispatchDeployment</c> triggers the <c>ApplyAndAck → PushDesiredSubscriptions</c> pass
|
||||
/// that builds the map. The OPC UA sink and the dependency mux are injected as
|
||||
/// <see cref="Akka.TestKit.TestProbe"/>s.
|
||||
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
|
||||
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
|
||||
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
|
||||
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
|
||||
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
@@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped
|
||||
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Driver_value_routes_to_folder_scoped_equipment_NodeId()
|
||||
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
|
||||
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
|
||||
[Fact]
|
||||
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed".
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||||
|
||||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// The driver publishes by its wire-ref RawPath.
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "40001", 42.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
|
||||
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.NodeId.ShouldBe("eq-1/speed");
|
||||
update.Value.ShouldBe(42.0);
|
||||
update.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
update.TimestampUtc.ShouldBe(Ts);
|
||||
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
|
||||
|
||||
// The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName).
|
||||
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
|
||||
// Realm travels with each update so the sink resolves the right namespace.
|
||||
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
|
||||
foreach (var u in byId.Values)
|
||||
{
|
||||
u.Value.ShouldBe(42.0);
|
||||
u.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
u.TimestampUtc.ShouldBe(Ts);
|
||||
}
|
||||
|
||||
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
|
||||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||||
.FullReference.ShouldBe("40001");
|
||||
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
}
|
||||
|
||||
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one
|
||||
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
|
||||
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
|
||||
[Fact]
|
||||
public void Unreferenced_raw_tag_fans_to_raw_node_only()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines).
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"),
|
||||
(Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "40001", 7.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
|
||||
|
||||
// One publish → two updates. Assert the SET of NodeIds (order is not contractual).
|
||||
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
var second = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
new[] { first.NodeId, second.NodeId }.ShouldBe(
|
||||
new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true);
|
||||
first.Value.ShouldBe(7.0);
|
||||
second.Value.ShouldBe(7.0);
|
||||
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||||
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||||
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
|
||||
/// identical value) — the 1:N fan-out.</summary>
|
||||
[Fact]
|
||||
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||
unsRefs: new[]
|
||||
{
|
||||
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
|
||||
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
|
||||
});
|
||||
|
||||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
|
||||
|
||||
var updates = new[]
|
||||
{
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||||
};
|
||||
updates.Select(u => u.NodeId).ShouldBe(new[]
|
||||
{
|
||||
"Plant/Modbus/dev1/speed",
|
||||
"filling/line1/station1/speed",
|
||||
"filling/line1/station2/speed",
|
||||
}, ignoreOrder: true);
|
||||
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
|
||||
}
|
||||
|
||||
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
|
||||
@@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA,
|
||||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||||
unsRefs: Array.Empty<(string, string, string, string, string?)>());
|
||||
|
||||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||||
"drv-1", "59999", 99.0, OpcUaQuality.Good, Ts));
|
||||
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
|
||||
|
||||
// No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink.
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
// The raw publish still reaches the dependency mux.
|
||||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||||
.FullReference.ShouldBe("59999");
|
||||
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
|
||||
/// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed
|
||||
/// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't
|
||||
/// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost).</summary>
|
||||
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
|
||||
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
@@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
|
||||
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
|
||||
// ExpectMsg<AttributeValueUpdate> assertions see only value updates.
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||||
|
||||
return (actor, publish, mux);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
|
||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags:
|
||||
/// <c>Namespaces</c> (one Equipment-kind ns), <c>DriverInstances</c> (each driver bound to that
|
||||
/// ns), and <c>Tags</c> (each with a non-null EquipmentId + a TagConfig blob carrying FullName).
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
||||
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
|
||||
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
|
||||
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
|
||||
/// numerically (AccessLevel: ReadWrite = 1).</summary>
|
||||
private static DeploymentId SeedV3Deployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
||||
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||||
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
|
||||
{
|
||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var drivers = rawTags
|
||||
.Select(t => (t.Driver, t.DriverName))
|
||||
.Distinct()
|
||||
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
|
||||
.ToArray();
|
||||
var devices = rawTags
|
||||
.Select(t => (t.Driver, t.Device))
|
||||
.Distinct()
|
||||
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
|
||||
.ToArray();
|
||||
var tags = rawTags.Select(t => new
|
||||
{
|
||||
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
|
||||
DeviceId = $"{t.Driver}:{t.Device}",
|
||||
TagGroupId = (string?)null,
|
||||
Name = t.Tag,
|
||||
DataType = t.DataType,
|
||||
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
||||
TagConfig = "{}",
|
||||
}).ToArray();
|
||||
|
||||
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
||||
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
|
||||
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
|
||||
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
|
||||
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
|
||||
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
|
||||
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
|
||||
var unsTagReferences = unsRefs.Select((r, i) => new
|
||||
{
|
||||
UnsTagReferenceId = $"ref-{i}",
|
||||
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
|
||||
TagId = r.BackingTag,
|
||||
DisplayNameOverride = r.Effective,
|
||||
}).ToArray();
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
DriverInstances = driverIds.Select(d => new
|
||||
{
|
||||
DriverInstanceId = d,
|
||||
NamespaceId = "ns-eq",
|
||||
}).ToArray(),
|
||||
Tags = tags.Select((t, i) => new
|
||||
{
|
||||
TagId = $"tag-{i}",
|
||||
EquipmentId = t.Equip,
|
||||
DriverInstanceId = t.Driver,
|
||||
Name = t.Name,
|
||||
FolderPath = t.Folder,
|
||||
DataType = "Double",
|
||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
||||
}).ToArray(),
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = drivers,
|
||||
Devices = devices,
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = tags,
|
||||
UnsAreas = areas,
|
||||
UnsLines = lines,
|
||||
Equipment = equipment,
|
||||
UnsTagReferences = unsTagReferences,
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
|
||||
+81
-195
@@ -20,22 +20,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
|
||||
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
|
||||
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
|
||||
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
|
||||
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
|
||||
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
|
||||
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
|
||||
///
|
||||
/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
|
||||
/// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
|
||||
/// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
|
||||
/// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
|
||||
/// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
|
||||
/// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
|
||||
/// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
|
||||
/// both reach the SAME driver point (they share the driver ref).
|
||||
/// <para>
|
||||
/// Drives a real apply through the existing harness (same artifact shape as
|
||||
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
|
||||
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
|
||||
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
|
||||
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
|
||||
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
|
||||
/// harness allows.
|
||||
/// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
|
||||
/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
@@ -44,48 +39,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
|
||||
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
|
||||
// The single seeded raw tag + its UNS reference.
|
||||
private const string RawPath = "Plant/Modbus/dev1/speed";
|
||||
private const string UnsNodeId = "filling/line1/station1/speed";
|
||||
|
||||
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
|
||||
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
|
||||
[Fact]
|
||||
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
// 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);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe("40001");
|
||||
recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
|
||||
recorder.Writes[0].Value.ShouldBe(123.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
|
||||
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
|
||||
/// ref and forwards the write keyed by the RawPath.</summary>
|
||||
[Fact]
|
||||
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0), asker.Ref);
|
||||
|
||||
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe(RawPath);
|
||||
recorder.Writes[0].Value.ShouldBe(456.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
|
||||
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
|
||||
[Fact]
|
||||
public void Secondary_rejects_write_and_does_not_forward_to_driver()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Force this node Secondary so the primary gate rejects.
|
||||
actor.Tell(new RedundancyStateChanged(
|
||||
new[]
|
||||
{
|
||||
@@ -95,29 +110,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
CorrelationId.NewId()));
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldBe("not primary");
|
||||
|
||||
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
|
||||
recorder.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
|
||||
/// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
|
||||
[Fact]
|
||||
public void Unknown_node_id_replies_failure()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
||||
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
|
||||
var deploymentId = SeedV3Deployment(db, RevA);
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -125,49 +137,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
recorder.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
|
||||
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
|
||||
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
|
||||
/// <c>(DriverInstanceId, <raw-blob>)</c> and the driver receives that exact string as its
|
||||
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Primary_routes_write_for_raw_protocol_blob_tag()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingDriverFactory("Modbus");
|
||||
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
|
||||
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
|
||||
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
|
||||
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
|
||||
equip: "eq-2", driver: "drv-2", name: "torque");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
|
||||
result.Success.ShouldBeTrue();
|
||||
|
||||
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
|
||||
// there is no top-level "FullName" key in the blob.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Writes.Count.ShouldBe(1);
|
||||
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
|
||||
recorder.Writes[0].Value.ShouldBe(456.0);
|
||||
}, duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
|
||||
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
|
||||
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
|
||||
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
|
||||
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
|
||||
/// immediate negative reply mentioning "stale".</summary>
|
||||
[Fact]
|
||||
public void Stale_host_fast_fails_route_node_write()
|
||||
{
|
||||
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
|
||||
var db = new ThrowingDbFactory();
|
||||
var coordinator = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
@@ -175,7 +149,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
localRoles: new HashSet<string> { "driver" }));
|
||||
|
||||
var asker = CreateTestProbe();
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
|
||||
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0), asker.Ref);
|
||||
|
||||
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
|
||||
result.Success.ShouldBeFalse();
|
||||
@@ -183,10 +157,9 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
result.Reason!.ShouldContain("stale");
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
|
||||
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
|
||||
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
|
||||
/// only the write path, which doesn't depend on the publish actor.</summary>
|
||||
/// <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>
|
||||
private IActorRef SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
||||
{
|
||||
@@ -198,49 +171,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
|
||||
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
|
||||
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
|
||||
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
|
||||
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
||||
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
|
||||
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
|
||||
/// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
|
||||
/// <c>filling/line1/station1/speed</c>).</summary>
|
||||
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||
{
|
||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
||||
},
|
||||
DriverInstances = driverIds.Select(d => new
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = d,
|
||||
Name = d,
|
||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
}).ToArray(),
|
||||
Tags = tags.Select((t, i) => new
|
||||
{
|
||||
TagId = $"tag-{i}",
|
||||
EquipmentId = t.Equip,
|
||||
DriverInstanceId = t.Driver,
|
||||
Name = t.Name,
|
||||
FolderPath = t.Folder,
|
||||
DataType = "Double",
|
||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
||||
}).ToArray(),
|
||||
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
||||
},
|
||||
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
|
||||
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
|
||||
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
@@ -258,77 +214,8 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
|
||||
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
|
||||
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
|
||||
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
|
||||
/// returning the raw blob string verbatim — that raw string becomes the
|
||||
/// <c>(DriverInstanceId, <raw-blob>)</c> reverse-map key, and the driver receives it as
|
||||
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
|
||||
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
|
||||
/// </summary>
|
||||
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string equip, string driver, string name)
|
||||
{
|
||||
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
|
||||
var rawBlobString = JsonSerializer.Serialize(
|
||||
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = driver,
|
||||
Name = driver,
|
||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
},
|
||||
},
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-raw",
|
||||
EquipmentId = equip,
|
||||
DriverInstanceId = driver,
|
||||
Name = name,
|
||||
FolderPath = (string?)null,
|
||||
DataType = "Double",
|
||||
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
|
||||
TagConfig = rawBlobString,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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, rawBlobString);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
|
||||
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
|
||||
/// so a write can be routed at a Stale host.</summary>
|
||||
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
|
||||
/// into the Stale path.</summary>
|
||||
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -336,15 +223,14 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
throw new InvalidOperationException("config DB unreachable (test stub)");
|
||||
}
|
||||
|
||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
|
||||
/// recorded write list is exposed for assertions.</summary>
|
||||
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
|
||||
private sealed class RecordingDriverFactory : IDriverFactory
|
||||
{
|
||||
private readonly string _supportedType;
|
||||
private readonly RecordingDriver _driver = new();
|
||||
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
||||
|
||||
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
|
||||
/// <summary>The writes the spawned driver received.</summary>
|
||||
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -389,7 +275,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
|
||||
{
|
||||
foreach (var w in writes) _writes.Enqueue(w);
|
||||
return Task.FromResult<IReadOnlyList<WriteResult>>(
|
||||
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
|
||||
writes.Select(_ => new WriteResult(0u)).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -175,15 +175,19 @@ public sealed class AddHistorianProvisioningTests
|
||||
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
|
||||
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
|
||||
// v3 Batch 4: historized value tags are RAW tags (the applier provisions from AddedRawTags). NodeId plays
|
||||
// the RawPath role (== mux ref + historian default).
|
||||
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
|
||||
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||
IsHistorized: true, HistorianTagname: historianName);
|
||||
|
||||
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
|
||||
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
|
||||
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
|
||||
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
|
||||
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
|
||||
IsHistorized: false, HistorianTagname: null);
|
||||
|
||||
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
|
||||
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
@@ -194,6 +198,6 @@ public sealed class AddHistorianProvisioningTests
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||
{
|
||||
AddedEquipmentTags = tags,
|
||||
AddedRawTags = tags,
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -206,7 +206,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
/// <summary>Ensures folder exists (stub implementation).</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="parentNodeId">The parent folder node identifier.</param>
|
||||
@@ -219,7 +219,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) { }
|
||||
/// <summary>Rebuilds address space (recorded via span).</summary>
|
||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
|
||||
|
||||
+4
-4
@@ -120,9 +120,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
{
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
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) { }
|
||||
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, 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) { }
|
||||
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
@@ -133,9 +133,9 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
{
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
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) { }
|
||||
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, 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) { }
|
||||
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") { }
|
||||
|
||||
@@ -451,7 +451,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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)
|
||||
=> Calls.Enqueue($"MA:{alarmNodeId}");
|
||||
/// <summary>Records a folder ensure call.</summary>
|
||||
/// <param name="folderNodeId">The folder node ID.</param>
|
||||
@@ -466,7 +466,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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)
|
||||
=> Calls.Enqueue($"EV:{variableNodeId}");
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
|
||||
@@ -622,7 +622,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
/// <param name="displayName">The condition display name.</param>
|
||||
/// <param name="alarmType">The domain alarm type.</param>
|
||||
/// <param name="severity">The domain severity.</param>
|
||||
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) { }
|
||||
|
||||
/// <summary>Records a folder ensure call.</summary>
|
||||
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
|
||||
@@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
/// <param name="dataType">The OPC UA built-in type name.</param>
|
||||
/// <param name="writable">Whether the node is created read/write.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</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) =>
|
||||
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
||||
|
||||
/// <summary>Records a rebuild call.</summary>
|
||||
|
||||
Reference in New Issue
Block a user