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:
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user