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 } /// /// 2026-08-15 surgical-deploys review — a UNS reference also carries its backing tag's HISTORIAN /// intent + ARRAY shape (Backing{IsHistorized,HistorianTagname,IsArray,ArrayLength}). They are /// on the record so a raw-side edit to any of them makes the reference record unequal, producing the /// coincident UnsReferenceDelta that repairs the mirror node; without them the UNS node kept the /// old historian tagname / scalar shape after a surgical raw edit. /// [Fact] public void Uns_reference_variables_carry_backing_historian_intent_and_array_shape() { var c = ComposeFullFixture(); // Backing tag historized WITH an override → both fields ride the reference verbatim. var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed"); speedRef.BackingIsHistorized.ShouldBeTrue(); speedRef.BackingHistorianTagname.ShouldBe("speed_hist"); speedRef.BackingIsArray.ShouldBeFalse(); speedRef.BackingArrayLength.ShouldBeNull(); // Backing tag neither historized nor array → the defaults. var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status"); statusRef.BackingIsHistorized.ShouldBeFalse(); statusRef.BackingHistorianTagname.ShouldBeNull(); statusRef.BackingIsArray.ShouldBeFalse(); statusRef.BackingArrayLength.ShouldBeNull(); } /// The array + historize-without-override branches of the same inheritance: an ARRAY backing tag /// projects its shape onto the reference (so the UNS mirror is created as an array, and therefore /// read-only), and a historized tag with a BLANK override leaves BackingHistorianTagname null — the /// applier resolves that to the BackingRawPath, exactly as the raw side defaults to its NodeId. [Fact] public void Uns_reference_variables_carry_array_shape_and_blank_historian_override() { var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = null, Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", }; var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" }; var levels = new Tag { TagId = "t-levels", DeviceId = "dev-1", Name = "Levels", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isArray\":true,\"arrayLength\":8,\"isHistorized\":true}", }; 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 reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" }; var c = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equip }, new[] { driver }, Array.Empty(), unsTagReferences: new[] { reference }, tags: new[] { levels }, rawFolders: Array.Empty(), devices: new[] { device }); var levelsRef = c.UnsReferenceVariables.ShouldHaveSingleItem(); levelsRef.BackingIsArray.ShouldBeTrue(); levelsRef.BackingArrayLength.ShouldBe(8u); levelsRef.BackingIsHistorized.ShouldBeTrue(); levelsRef.BackingHistorianTagname.ShouldBeNull(); // blank override ⇒ resolved to BackingRawPath later } /// 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); } /// Gitea #610: ArrayWritable is derived per OWNING DRIVER at compose time, and the UNS mirror /// inherits it as BackingArrayWritable. An S7 Int16 array is writable-capable; the identical shape on /// an AbCip driver (scalar-only write encoder) is not; a scalar is never flagged. [Fact] public void Array_write_capability_follows_the_owning_driver_and_reaches_the_uns_mirror() { var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" }; var s7 = new DriverInstance { DriverInstanceId = "drv-s7", ClusterId = "c1", RawFolderId = "f", Name = "S7a", DriverType = "S7", DriverConfig = "{}" }; var ab = new DriverInstance { DriverInstanceId = "drv-ab", ClusterId = "c1", RawFolderId = "f", Name = "Ab1", DriverType = "AbCip", DriverConfig = "{}" }; var d1 = new Device { DeviceId = "d1", DriverInstanceId = "drv-s7", Name = "plc1", DeviceConfig = "{}" }; var d2 = new Device { DeviceId = "d2", DriverInstanceId = "drv-ab", Name = "plc2", DeviceConfig = "{}" }; const string s7Array = "{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":4}"; var tags = new[] { new Tag { TagId = "t1", DeviceId = "d1", Name = "Levels", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = s7Array }, new Tag { TagId = "t2", DeviceId = "d1", Name = "Level", DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"address\":\"DB1.DBW8\",\"dataType\":\"Int16\"}" }, new Tag { TagId = "t3", DeviceId = "d2", Name = "Counts", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"tagPath\":\"Counts\",\"dataType\":\"DInt\",\"isArray\":true,\"arrayLength\":4}" }, }; var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" }; var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" }; var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" }; var refs = new[] { new UnsTagReference { UnsTagReferenceId = "r1", EquipmentId = "e", TagId = "t1" }, new UnsTagReference { UnsTagReferenceId = "r3", EquipmentId = "e", TagId = "t3" }, }; var c = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equip }, new[] { s7, ab }, Array.Empty(), unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { d1, d2 }); c.RawTags.Single(t => t.Name == "Levels").ArrayWritable.ShouldBeTrue(); c.RawTags.Single(t => t.Name == "Level").ArrayWritable.ShouldBeFalse(); // scalar: never flagged c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse(); // #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip // keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror. c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue(); c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue(); c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue(); c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse(); } /// #610 review MINOR-1 — the composer hands the table the owning driver's DriverConfig AND the /// tag's own device's DeviceConfig, so a Modbus write cap configured at either level keeps an array that /// can never fit read-only — on the raw node and its UNS mirror alike. The same tag under an uncapped device is /// the positive control. [Fact] public void Array_write_capability_honours_a_modbus_write_cap_from_driver_or_device_config() { var folder = new RawFolder { RawFolderId = "f", ClusterId = "c1", Name = "Plant" }; var mb = new DriverInstance { DriverInstanceId = "drv-mb", ClusterId = "c1", RawFolderId = "f", Name = "Mb", DriverType = "Modbus", DriverConfig = "{\"MaxRegistersPerWrite\":100}" }; var open = new Device { DeviceId = "d-open", DriverInstanceId = "drv-mb", Name = "open", DeviceConfig = "{}" }; var capped = new Device { DeviceId = "d-capped", DriverInstanceId = "drv-mb", Name = "capped", DeviceConfig = "{\"maxRegistersPerWrite\":8}" }; static Tag Arr(string id, string device, int length) => new() { TagId = id, DeviceId = device, Name = id, DataType = "Int16", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = $"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":{length}}}", }; var tags = new[] { Arr("fits", "d-open", 100), Arr("overDriverCap", "d-open", 101), Arr("overDeviceCap", "d-capped", 10) }; var area = new UnsArea { UnsAreaId = "a", ClusterId = "c1", Name = "filling" }; var line = new UnsLine { UnsLineId = "l", UnsAreaId = "a", Name = "line-1" }; var equip = new Equipment { EquipmentId = "e", UnsLineId = "l", Name = "station-1", MachineCode = "S1" }; var refs = new[] { new UnsTagReference { UnsTagReferenceId = "r", EquipmentId = "e", TagId = "overDeviceCap" } }; var c = AddressSpaceComposer.Compose( new[] { area }, new[] { line }, new[] { equip }, new[] { mb }, Array.Empty(), unsTagReferences: refs, tags: tags, rawFolders: new[] { folder }, devices: new[] { open, capped }); c.RawTags.Single(t => t.Name == "fits").ArrayWritable.ShouldBeTrue(); c.RawTags.Single(t => t.Name == "overDriverCap").ArrayWritable.ShouldBeFalse(); c.RawTags.Single(t => t.Name == "overDeviceCap").ArrayWritable.ShouldBeFalse(); c.UnsReferenceVariables.Single().BackingArrayWritable.ShouldBeFalse(); } }