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. 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\"}", }; var areas = new[] { area }; var lines = new[] { line }; var equipment = new[] { equip }; var drivers = new[] { driver }; var tags = new[] { arrayBoundedTag, scalarTag, arrayUnboundedTag, arrayBadLengthTag }; 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), }, }); var decoded = DeploymentArtifact.ParseComposition(blob); // ---- Full byte-parity: every field, same order (positional-record value equality) ---- decoded.EquipmentTags.Count.ShouldBe(4); 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(); } /// 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, }; }