From 0a747c343d893af656ab91e52961ad3ff936223c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:40:22 -0400 Subject: [PATCH] feat(runtime): decode IsArray/ArrayLength byte-parity in DeploymentArtifact --- .../Drivers/DeploymentArtifact.cs | 34 ++- .../DeploymentArtifactArrayParityTests.cs | 196 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 6e91f144..d0dc203c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -438,6 +438,7 @@ public static class DeploymentArtifact if (!equipmentNamespaces.Contains(nsId)) continue; var (isHistorized, historianTagname) = ExtractTagHistorize(tagConfig); + var (isArray, arrayLength) = ExtractTagArray(tagConfig); result.Add(new EquipmentTagPlan( TagId: tagId!, EquipmentId: equipmentId!, @@ -449,7 +450,9 @@ public static class DeploymentArtifact Writable: writable, Alarm: ExtractTagAlarm(tagConfig), IsHistorized: isHistorized, - HistorianTagname: historianTagname)); + HistorianTagname: historianTagname, + IsArray: isArray, + ArrayLength: arrayLength)); } result.Sort((a, b) => @@ -719,6 +722,35 @@ public static class DeploymentArtifact catch (JsonException) { return (false, null); } } + /// Parses the optional array intent from a tag's TagConfig JSON: the isArray + /// bool (absent / not a bool / non-object root / blank / malformed ⇒ false) and the optional + /// arrayLength uint (honoured ONLY when isArray is true AND the prop is a JSON number + /// that fits uint; else null). Mirrors in structure + + /// null/blank/non-object/malformed-JSON tolerance. Never throws. The live-edit composer side + /// (Phase7Composer.ExtractTagArray) MUST parse identically (byte-parity). + private static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig) + { + if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null); + try + { + using var doc = JsonDocument.Parse(tagConfig); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return (false, null); + var isArray = doc.RootElement.TryGetProperty("isArray", out var aEl) + && (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False) + && aEl.GetBoolean(); + uint? arrayLength = null; + if (isArray + && doc.RootElement.TryGetProperty("arrayLength", out var lEl) + && lEl.ValueKind == JsonValueKind.Number + && lEl.TryGetUInt32(out var len)) + { + arrayLength = len; + } + return (isArray, arrayLength); + } + catch (JsonException) { return (false, null); } + } + private static IReadOnlyList ReadArray(JsonElement root, string propertyName, Func reader) where T : class { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs new file mode 100644 index 00000000..61c5f511 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactArrayParityTests.cs @@ -0,0 +1,196 @@ +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, + }; +}