diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs index fa34f1ee..535d0ae4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs @@ -551,7 +551,8 @@ public static class Phase7Composer /// 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. The length is honoured ONLY when isArray is true AND the prop is a - /// JSON number that fits uint (else null ⇒ unbounded, resolved later). Mirrors + /// JSON number that fits uint (else null ⇒ unbounded 1-D array, + /// ArrayDimensions=[0] at materialisation). Mirrors /// exactly in structure + null/blank/non-object/malformed-JSON /// tolerance. Never throws. The artifact-decode side /// (DeploymentArtifact.ExtractTagArray) MUST parse identically (byte-parity). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs index ece237b3..2a96ad1d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagArrayTests.cs @@ -39,6 +39,10 @@ public class ExtractTagArrayTests [InlineData("{\"isArray\":true,\"arrayLength\":\"16\"}", true, null)] // Negative arrayLength does not fit uint ⇒ length null, flag still honoured. [InlineData("{\"isArray\":true,\"arrayLength\":-1}", true, null)] + // Float arrayLength (16.5) is not an exact uint ⇒ TryGetUInt32 rejects it ⇒ length null. + [InlineData("{\"isArray\":true,\"arrayLength\":16.5}", true, null)] + // Overflow arrayLength (uint.MaxValue + 1 = 4294967296) does not fit uint ⇒ length null. + [InlineData("{\"isArray\":true,\"arrayLength\":4294967296}", true, null)] public void ExtractTagArray_parses_or_returns_defaults(string? cfg, bool expectedIsArray, uint? expectedLength) { var (isArray, arrayLength) = Phase7Composer.ExtractTagArray(cfg); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 14925d81..bebfef41 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -323,6 +323,61 @@ public sealed class Phase7ApplierTests call.HistorianTagname.ShouldBe("40001"); } + /// Array-support Task 2 — an with IsArray: true, + /// ArrayLength: 16 flowing through must + /// forward BOTH flags verbatim to the sink's EnsureVariable. Guards against arg-order swaps or + /// accidental drops in the wire-through. + [Fact] + public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", + FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + var call = sink.ArrayCalls.ShouldHaveSingleItem(); + call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer")); + call.IsArray.ShouldBeTrue(); + call.ArrayLength.ShouldBe(16u); + } + + /// Array-support Task 2 — a scalar (IsArray: false, + /// ArrayLength: null) must pass isArray == false through to the sink. Guards against a + /// default flip that would silently materialise scalar tags as 1-D arrays. + [Fact] + public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentTags = new[] + { + new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", + FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), + }, + }; + + applier.MaterialiseEquipmentTags(composition); + + var call = sink.ArrayCalls.ShouldHaveSingleItem(); + call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed")); + call.IsArray.ShouldBeFalse(); + call.ArrayLength.ShouldBeNull(); + } + /// Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create @@ -719,6 +774,9 @@ public sealed class Phase7ApplierTests /// Gets the queue of the historian-tagname arg captured per EnsureVariable call, /// keyed by NodeId (null ⇒ that call passed not-historized). public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new(); + /// Gets the queue of the isArray/arrayLength args captured per EnsureVariable + /// call, keyed by NodeId. + public ConcurrentQueue<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayQueue { get; } = new(); /// Gets the queue of alarm-condition materialise calls. public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new(); /// Gets the number of rebuild calls made on this sink. @@ -732,6 +790,8 @@ public sealed class Phase7ApplierTests public List<(string NodeId, string? Parent, string DisplayName, string DataType, bool Writable)> VariableCalls => VariableQueue.ToList(); /// Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call. public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList(); + /// Gets the list of recorded (NodeId, isArray, arrayLength) triples captured per EnsureVariable call. + public List<(string NodeId, bool IsArray, uint? ArrayLength)> ArrayCalls => ArrayQueue.ToList(); /// Gets the list of recorded alarm-condition materialise calls. public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList(); @@ -772,6 +832,7 @@ public sealed class Phase7ApplierTests { VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable)); HistorianQueue.Enqueue((variableNodeId, historianTagname)); + ArrayQueue.Enqueue((variableNodeId, isArray, arrayLength)); } /// Records a rebuild address space call. public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);