using System.Linq; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Proves the Phase 4c array intent (isArray + optional arrayLength), which rides /// inside the raw TagConfig JSON blob, round-trips with byte-parity through both /// equipment-tag producers: the live-edit composer () and the /// artifact decoder (). /// A secondary/follower node decoding a serialized deployment artifact MUST materialise array tags /// identically to the primary, so the artifact side must derive IsArray / ArrayLength /// from the same blob the composer parses. The composer's ExtractTagArray is internal and not /// visible to this test assembly (InternalsVisibleTo only names the OpcUaServer.Tests /// project), so byte-parity is asserted via the public-surface round-trip — exactly as the sibling /// does. /// public sealed class DeploymentArtifactArrayParityTests { /// /// One draft consumed by both producers, exercising every ExtractTagArray branch: /// isArray:true,arrayLength:16(true, 16u); absent ⇒ (false, null); /// isArray:true with no length ⇒ (true, null); a non-number (string) length while /// isArray:true(true, null). The decoded EquipmentTags must equal the /// composer's element-wise (positional-record value equality) and in the same order, proving /// IsArray / ArrayLength are derived byte-identically on both seams. /// [Fact] public void Composer_and_artifact_agree_on_array_equipment_tags() { var ns = new Namespace { NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", }; var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", NamespaceId = "ns-eq", Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}", }; 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", DriverInstanceId = "drv-modbus", UnsLineId = "line-1", Name = "FillingPump", MachineCode = "FILLINGPUMP", }; // Array WITH an explicit bounded length → (true, 16u). var arrayBoundedTag = new Tag { TagId = "tag-array-bounded", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Recipe", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\",\"isArray\":true,\"arrayLength\":16}", }; // Plain scalar — no isArray flag → (false, null). var scalarTag = new Tag { TagId = "tag-scalar", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"FullName\":\"40002\"}", }; // Array WITHOUT a length → (true, null) ⇒ unbounded 1-D array at materialisation. var arrayUnboundedTag = new Tag { TagId = "tag-array-unbounded", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Trace", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40003\",\"isArray\":true}", }; // Array with a NON-number (string) length token → length ignored ⇒ (true, null). This well-formed // blob exercises the artifact-side private ExtractTagArray's number-guard branch through the real // round-trip (it can't be unit-tested directly because it's private). A truly malformed TagConfig // string would cause ExtractTagFullName to return "" and break the SequenceEqual on other fields, // so the throws/malformed path is covered by the composer unit test, not here. // NOTE (review M-1): only the string-type bad-length case is round-tripped here. The // negative/float/overflow arrayLength reject paths are covered on the composer side // (ExtractTagArrayTests) and do not need duplication in this artifact parity test. var arrayBadLengthTag = new Tag { TagId = "tag-array-badlen", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Buffer", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40004\",\"isArray\":true,\"arrayLength\":\"sixteen\"}", }; // I-1: isArray:false with a non-zero arrayLength — the artifact-side guard must gate arrayLength // under isArray, so the length is discarded and IsArray==false, ArrayLength==null on both sides. // Pins that a future removal of the isArray gate would be caught here. var arrayDisabledTag = new Tag { TagId = "tag-array-disabled", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Setpoint", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"FullName\":\"40005\",\"isArray\":false,\"arrayLength\":8}", }; // I-2: isArray:true with arrayLength:0 — zero is a legal explicit dimension bound (e.g. dynamic // array with declared-but-unset size). Must decode to IsArray==true, ArrayLength==0u on both sides. // Pins that a future change treating 0 as absent would be caught here. var arrayZeroLengthTag = new Tag { TagId = "tag-array-zerolen", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", FolderPath = null, Name = "Empty", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40006\",\"isArray\":true,\"arrayLength\":0}", }; var areas = new[] { area }; var lines = new[] { line }; var equipment = new[] { equip }; var drivers = new[] { driver }; var tags = new[] { arrayBoundedTag, scalarTag, arrayUnboundedTag, arrayBadLengthTag, arrayDisabledTag, arrayZeroLengthTag }; var namespaces = new[] { ns }; // ---- Side 1: the live-edit composer ---- var composed = Phase7Composer.Compose( areas, lines, equipment, drivers, Array.Empty(), tags, namespaces); // ---- Side 2: serialise the SAME draft to the artifact blob shape, then decode it ---- var blob = JsonSerializer.SerializeToUtf8Bytes(new { Namespaces = new[] { new { ns.NamespaceId, ns.ClusterId, Kind = (int)ns.Kind }, }, DriverInstances = new[] { new { driver.DriverInstanceId, driver.DriverType, driver.DriverConfig, driver.NamespaceId, driver.ClusterId }, }, Tags = new[] { ToSnapshot(arrayBoundedTag), ToSnapshot(scalarTag), ToSnapshot(arrayUnboundedTag), ToSnapshot(arrayBadLengthTag), ToSnapshot(arrayDisabledTag), ToSnapshot(arrayZeroLengthTag), }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // ---- Full byte-parity: every field, same order (positional-record value equality) ---- decoded.EquipmentTags.Count.ShouldBe(6); decoded.EquipmentTags.SequenceEqual(composed.EquipmentTags).ShouldBeTrue(); // Spell out the array fields per-tag so a divergence names the offending tag. var arrayBounded = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-bounded"); arrayBounded.IsArray.ShouldBeTrue(); arrayBounded.ArrayLength.ShouldBe(16u); composed.EquipmentTags.Single(t => t.TagId == "tag-array-bounded").IsArray.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-bounded").ArrayLength.ShouldBe(16u); var scalar = decoded.EquipmentTags.Single(t => t.TagId == "tag-scalar"); scalar.IsArray.ShouldBeFalse(); scalar.ArrayLength.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-scalar").IsArray.ShouldBeFalse(); composed.EquipmentTags.Single(t => t.TagId == "tag-scalar").ArrayLength.ShouldBeNull(); var arrayUnbounded = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded"); arrayUnbounded.IsArray.ShouldBeTrue(); arrayUnbounded.ArrayLength.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded").IsArray.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-unbounded").ArrayLength.ShouldBeNull(); // 4th tag: isArray:true with a string arrayLength ⇒ (true, null) on both sides. Exercises the // artifact-side private ExtractTagArray's number-guard branch. var arrayBadLength = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-badlen"); arrayBadLength.IsArray.ShouldBeTrue(); arrayBadLength.ArrayLength.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-badlen").IsArray.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-badlen").ArrayLength.ShouldBeNull(); // I-1: isArray:false with a non-zero arrayLength ⇒ (false, null) on both sides. // The artifact-side isArray gate must suppress arrayLength even when it is present in the blob. var arrayDisabled = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-disabled"); arrayDisabled.IsArray.ShouldBeFalse(); arrayDisabled.ArrayLength.ShouldBeNull(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-disabled").IsArray.ShouldBeFalse(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-disabled").ArrayLength.ShouldBeNull(); // I-2: isArray:true with arrayLength:0 ⇒ (true, 0u) on both sides. // Zero is a legal explicit dimension bound; it must not be treated as absent. var arrayZeroLength = decoded.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen"); arrayZeroLength.IsArray.ShouldBeTrue(); arrayZeroLength.ArrayLength.ShouldBe(0u); composed.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen").IsArray.ShouldBeTrue(); composed.EquipmentTags.Single(t => t.TagId == "tag-array-zerolen").ArrayLength.ShouldBe(0u); } /// The Pascal-case snapshot a EF entity serialises to in the artifact /// (matches ConfigComposer); the equipment-tag decoder re-parses these fields — including the raw /// TagConfig blob the array flags ride inside. private static object ToSnapshot(Tag t) => new { t.TagId, t.DriverInstanceId, t.EquipmentId, t.Name, t.FolderPath, t.DataType, AccessLevel = (int)t.AccessLevel, t.TagConfig, }; }