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);