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(); } [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 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) { } } }