3efcf8014b
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
178 lines
9.3 KiB
C#
178 lines
9.3 KiB
C#
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) { }
|
|
}
|
|
}
|