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; /// /// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes. /// lights up the ns=Raw device tree /// (containers as folders + tags as variables, each keyed by its RawPath, all in /// ); lights /// up the ns=UNS reference variables (each under its equipment folder in /// ) and wires an Organizes 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. /// public sealed class AddressSpaceApplierRawUnsTests { private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) => new(sink, NullLogger.Instance); [Fact] public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm() { var sink = new RealmRecordingSink(); var composition = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { 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()), }, }; 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(), Array.Empty(), Array.Empty()) { 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(), 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(), 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(), Array.Empty(), Array.Empty()) { 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()), }, }; 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(); // No referencing equipment ⇒ no multi-notifier wiring. sink.NotifierWirings.ShouldBeEmpty(); } [Fact] public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder() { var sink = new RealmRecordingSink(); // UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the // EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag. var composition = new AddressSpaceComposition( new[] { new UnsAreaProjection("a1", "filling") }, new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") }, new[] { new EquipmentNode("EQ-1", "station1", "l1"), new EquipmentNode("EQ-2", "station2", "l2"), }, Array.Empty(), Array.Empty()) { RawTags = new[] { new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700), // The composer emits Area/Line/Equipment NAME paths here. ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }), }, }; NewApplier(sink).MaterialiseRawSubtree(composition); // The single condition is materialised ONCE at the RawPath (Raw realm)... var c = sink.Conditions.ShouldHaveSingleItem(); c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); c.Realm.ShouldBe(AddressSpaceRealm.Raw); // ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the // logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root. var w = sink.NotifierWirings.ShouldHaveSingleItem(); w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp"); w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw); w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns); w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true); } [Fact] public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian() { var sink = new RealmRecordingSink(); var composition = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { 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"); } /// A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges. 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 List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { 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 WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) => NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm)); 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) { } } }