using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree /// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed s=<RawPath>, /// realm ) and the UNS subtree (one Variable per /// keyed s=<Area>/<Line>/<Equipment>/<EffectiveName>, /// realm , carrying its backing RawPath for the Organizes ref + /// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths. /// public sealed class AddressSpaceComposerDualNamespaceTests { // A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags. // Speed sits under the group; Status directly under the device; Levels is an array tag. private static AddressSpaceComposition ComposeFullFixture() { var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" }; var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant", Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", }; var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" }; var speed = new Tag { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," + "\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}", }; var status = new Tag { TagId = "t-status", DeviceId = "dev-1", Name = "Status", DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}", }; var levels = new Tag { TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"isArray\":true,\"arrayLength\":8}", }; var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" }; var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" }; var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" }; var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" }; var refStatus = new UnsTagReference { UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status", DisplayNameOverride = "MachineStatus", }; return AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equip }, new[] { driver }, Array.Empty(), unsTagReferences: new[] { refSpeed, refStatus }, tags: new[] { speed, status, levels }, rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group }); } /// The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each /// realm=Raw, with parents-before-children ordering + correct parent NodeIds. [Fact] public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents() { var c = ComposeFullFixture(); c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw); var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder); folder.NodeId.ShouldBe("Plant"); folder.ParentNodeId.ShouldBeNull(); // cluster root folder.DisplayName.ShouldBe("Plant"); var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver); driver.NodeId.ShouldBe("Plant/Modbus1"); driver.ParentNodeId.ShouldBe("Plant"); var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device); device.NodeId.ShouldBe("Plant/Modbus1/PLC-A"); device.ParentNodeId.ShouldBe("Plant/Modbus1"); var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup); group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); // Sorted by NodeId ordinal ⇒ a parent always precedes each of its children. c.RawContainers.Select(n => n.NodeId) .ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" }); } /// Raw tags are Variables keyed s=<RawPath> (realm=Raw), carrying DataType / /// writable / historize + historian tagname / array shape, and hang under their group (or device). [Fact] public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes() { var c = ComposeFullFixture(); c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw); var speed = c.RawTags.Single(t => t.TagId == "t-speed"); speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors"); speed.DriverInstanceId.ShouldBe("drv-modbus"); speed.DataType.ShouldBe("Float"); speed.Writable.ShouldBeTrue(); speed.IsHistorized.ShouldBeTrue(); speed.HistorianTagname.ShouldBe("speed_hist"); var status = c.RawTags.Single(t => t.TagId == "t-status"); status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A"); status.Writable.ShouldBeFalse(); status.IsHistorized.ShouldBeFalse(); var levels = c.RawTags.Single(t => t.TagId == "t-levels"); levels.IsArray.ShouldBeTrue(); levels.ArrayLength.ShouldBe(8u); } /// A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list /// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment. [Fact] public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths() { var c = ComposeFullFixture(); var speed = c.RawTags.Single(t => t.TagId == "t-speed"); speed.Alarm.ShouldNotBeNull(); speed.Alarm!.AlarmType.ShouldBe("LimitAlarm"); speed.Alarm.Severity.ShouldBe(700); speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); // A non-alarm tag carries no condition + its own referencing-equipment list. var status = c.RawTags.Single(t => t.TagId == "t-status"); status.Alarm.ShouldBeNull(); status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" }); // An unreferenced tag has an empty referencing-equipment set. var levels = c.RawTags.Single(t => t.TagId == "t-levels"); levels.ReferencingEquipmentPaths.ShouldBeEmpty(); } /// Each UnsTagReference emits a UNS Variable keyed /// s=<Area>/<Line>/<Equipment>/<EffectiveName> (realm=Uns), carrying its backing /// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective /// name = DisplayNameOverride else the backing raw tag's Name. [Fact] public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access() { var c = ComposeFullFixture(); c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns); var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed"); speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed"); speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out speedRef.DataType.ShouldBe("Float"); speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status"); statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus"); statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status"); statusRef.DataType.ShouldBe("Boolean"); statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag } /// A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy /// convenience overloads + earlier callers keep working). [Fact] public void No_raw_or_uns_inputs_leaves_both_subtrees_empty() { var c = AddressSpaceComposer.Compose( equipment: Array.Empty(), driverInstances: Array.Empty(), scriptedAlarms: Array.Empty()); c.RawContainers.ShouldBeEmpty(); c.RawTags.ShouldBeEmpty(); c.UnsReferenceVariables.ShouldBeEmpty(); } /// The Raw + UNS emit is deterministic — repeated calls produce element-identical output /// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison). [Fact] public void Dual_subtree_emit_is_deterministic() { var a = ComposeFullFixture(); var b = ComposeFullFixture(); a.RawContainers.ShouldBe(b.RawContainers); a.RawTags.ShouldBe(b.RawTags); a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables); } }