fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)

MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 08:31:46 -04:00
parent a702875d41
commit 52708bff52
27 changed files with 509 additions and 97 deletions
@@ -34,6 +34,7 @@ public sealed class ArrayWriteWireTests
private const string S7ArrayUns = "filling/line-1/station-1/Levels";
private const string S7ScalarRaw = "Plant/S7a/plc1/Setpoint";
private const string GalaxyArrayRaw = "Plant/Gx1/gx/Recipe";
private const string UaScalarRaw = "Plant/Ua1/up/Vector";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{
@@ -73,6 +74,20 @@ public sealed class ArrayWriteWireTests
Name = "Gx1", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
};
var gxDevice = new Device { DeviceId = "dev-3", DriverInstanceId = "drv-gx", Name = "gx", DeviceConfig = "{}" };
// An upstream array variable committed from the /raw browser: the commit sets the DataType but never
// isArray, so it materialises SCALAR while the driver publishes an array into it (R6 MINOR-1).
var ua = new DriverInstance
{
DriverInstanceId = "drv-ua", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Ua1", DriverType = "OpcUaClient", DriverConfig = "{}",
};
var uaDevice = new Device { DeviceId = "dev-4", DriverInstanceId = "drv-ua", Name = "up", DeviceConfig = "{}" };
var vector = new Tag
{
TagId = "t-vector", DeviceId = "dev-4", Name = "Vector", DataType = "Int32",
AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"nodeId\":\"ns=2;s=Vector\"}",
};
var recipe = new Tag
{
TagId = "t-recipe", DeviceId = "dev-3", Name = "Recipe", DataType = "Int32",
@@ -103,9 +118,9 @@ public sealed class ArrayWriteWireTests
var reference = new UnsTagReference { UnsTagReferenceId = "ref-levels", EquipmentId = "eq-1", TagId = "t-levels" };
var composition = AddressSpaceComposer.Compose(
[area], [line], [equip], [s7, ab, gx], Array.Empty<ScriptedAlarm>(),
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint],
rawFolders: [folder], devices: [plc1, plc2, gxDevice]);
[area], [line], [equip], [s7, ab, gx, ua], Array.Empty<ScriptedAlarm>(),
unsTagReferences: [reference], tags: [levels, counts, recipe, setpoint, vector],
rawFolders: [folder], devices: [plc1, plc2, gxDevice, uaDevice]);
var applier = new AddressSpaceApplier(rig.Sink, NullLogger<AddressSpaceApplier>.Instance);
applier.MaterialiseHierarchy(composition).ShouldBe(0);
@@ -118,6 +133,7 @@ public sealed class ArrayWriteWireTests
rig.Sink.WriteValue(AbArrayRaw, new[] { 5, 6, 7, 8 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(GalaxyArrayRaw, new[] { 11, 12 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(S7ScalarRaw, (short)5, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(UaScalarRaw, new[] { 1, 2 }, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
return rig;
}
@@ -247,9 +263,10 @@ public sealed class ArrayWriteWireTests
/// <summary>
/// #610 review MAJOR-1 — the SDK runs no type check of its own on a node with an <c>OnWriteValue</c> handler,
/// so the node manager must refuse a mistyped array before dispatch: an <c>Int32[]</c> to an S7 Int16 array
/// (else the node serves an int[] under an Int16 DataType), and a <c>UInt32[]</c> to a Galaxy Int32 array
/// (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as -294,967,296, Good).
/// so the node manager must refuse an array its node type cannot represent before dispatch: an <c>Int32[]</c>
/// element outside Int16 to an S7 Int16 array, and a <c>UInt32[]</c> element above Int32.MaxValue to a Galaxy
/// Int32 array (else MxValueEncoder's int[] arm takes it by covariance and writes 4,000,000,000 as
/// -294,967,296, Good).
/// </summary>
[Fact]
public async Task A_mistyped_array_write_is_refused_inline_and_never_reaches_the_driver()
@@ -264,7 +281,7 @@ public sealed class ArrayWriteWireTests
(await ReadAccessLevelAsync(session, galaxy, ct) & AccessLevels.CurrentWrite).ShouldBe(AccessLevels.CurrentWrite);
(await WriteAsync(session, s7, new[] { 1, 2, 3, 4 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "Int32[] to an Int16 array");
(await WriteAsync(session, s7, new[] { 1, 2, 3, 70_000 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "an Int32 element outside Int16");
(await WriteAsync(session, galaxy, new uint[] { 4_000_000_000, 1 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch, "UInt32[] to an Int32 array");
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 1, 2, 3, 4 });
((int[])(await session.ReadValueAsync(galaxy, ct)).Value).ShouldBe(new[] { 11, 12 });
@@ -275,7 +292,53 @@ public sealed class ArrayWriteWireTests
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 21, 22 });
}
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node is refused before dispatch.</summary>
/// <summary>
/// #610 review R6 MAJOR-1 — ScadaBridge writes the CLR list a List attribute decodes to (here Int32, the only
/// integer element type it has) to an Int16 array node. The write is converted, checked, to the node's
/// element type: the client sees Good, the node serves <c>short[]</c>, and the driver receives <c>short[]</c>.
/// </summary>
[Fact]
public async Task A_numeric_array_of_another_element_type_is_converted_to_the_node_type()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-convert", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
var s7 = Raw(session, S7ArrayRaw);
(await WriteAsync(session, s7, new[] { 10, -20, 30, 32_767 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
(await ReadShortsAsync(session, s7, ct)).ShouldBe(new short[] { 10, -20, 30, 32_767 });
lock (gateway.Writes)
{
var dispatched = gateway.Writes.ShouldHaveSingleItem().Value.ShouldBeOfType<short[]>();
dispatched.ShouldBe(new short[] { 10, -20, 30, 32_767 });
}
}
/// <summary>
/// #610 review R6 MINOR-1 — a pass-through driver's scalar-shaped node (an upstream array variable committed
/// from <c>/raw</c> without <c>isArray</c>) still dispatches an array write, as it did before #610: the
/// upstream server arbitrates the shape. The S7 scalar's refusal is the other side of the same rule.
/// </summary>
[Fact]
public async Task An_array_value_to_a_pass_through_scalar_node_is_still_dispatched()
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartAsync("array-to-ua-scalar", ct);
var gateway = new RecordingWriteGateway(succeed: true);
rig.Nm.NodeWriteGateway = gateway;
using var session = await rig.ConnectAsync(anonymous: false, ct);
(await WriteAsync(session, Raw(session, UaScalarRaw), new[] { 7, 8, 9 }, ct)).ShouldBe((StatusCode)StatusCodes.Good);
lock (gateway.Writes) gateway.Writes.ShouldHaveSingleItem().Value.ShouldBe(new[] { 7, 8, 9 });
(await WriteAsync(session, Raw(session, S7ScalarRaw), new short[] { 9 }, ct)).ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
gateway.Count.ShouldBe(1);
}
/// <summary>#610 review MINOR-5 — an array value to a SCALAR node of a driver whose scalar encoder cannot take
/// one (S7) is refused before dispatch.</summary>
[Fact]
public async Task An_array_value_to_a_scalar_node_is_refused_inline()
{
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
}
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
}
@@ -350,7 +350,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
@@ -486,7 +486,7 @@ public sealed class AddressSpaceApplierRawUnsTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm, isArray, arrayLength));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
@@ -2033,7 +2033,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2077,7 +2077,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -2128,7 +2128,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary>
@@ -306,6 +306,14 @@ public sealed class AddressSpaceComposerDualNamespaceTests
c.RawTags.Single(t => t.Name == "Counts").ArrayWritable.ShouldBeFalse(); // AbCip: no array encoder
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingArrayWritable.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingArrayWritable.ShouldBeFalse();
// #610 review R6 MINOR-1: the array-on-scalar refusal follows the owning DRIVER (S7 yes, AbCip no — AbCip
// keeps pre-#610 dispatch), is not gated on the tag's shape, and reaches the UNS mirror.
c.RawTags.Single(t => t.Name == "Level").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Levels").ScalarRefusesArrayValue.ShouldBeTrue();
c.RawTags.Single(t => t.Name == "Counts").ScalarRefusesArrayValue.ShouldBeFalse();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r1").BackingScalarRefusesArrayValue.ShouldBeTrue();
c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "r3").BackingScalarRefusesArrayValue.ShouldBeFalse();
}
/// <summary>#610 review MINOR-1 — the composer hands the table the owning driver's <c>DriverConfig</c> AND the
@@ -84,6 +84,22 @@ public sealed class DeferredAddressSpaceSinkTests
call.HistorianTagname.ShouldBe("MyTag.PV");
}
/// <summary>#610 review R6 — the array-on-scalar refusal flag reaches the inner sink. Actors inject THIS wrapper,
/// so a dropped flag would leave every Modbus/S7 scalar node dispatching array writes (the reflection guard
/// only proves the call arrives, not its arguments).</summary>
[Fact]
public void EnsureVariable_forwards_refuseArrayOnScalar_to_inner_sink()
{
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.EnsureVariable("v-1", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw, refuseArrayOnScalar: true);
deferred.EnsureVariable("v-2", null, "MyVar", "Int16", writable: true, AddressSpaceRealm.Raw);
inner.RefuseArrayOnScalarCalls.ShouldBe(new[] { ("v-1", true), ("v-2", false) });
}
/// <summary>F10b regression: the deferred wrapper MUST forward the surgical capability to a
/// surgical inner sink — otherwise <c>AddressSpaceApplier</c> (which injects THIS wrapper on every
/// driver-role host, not the inner <c>SdkAddressSpaceSink</c>) never sees the capability and the
@@ -226,6 +242,10 @@ public sealed class DeferredAddressSpaceSinkTests
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// <summary>Gets the list of (NodeId, HistorianTagname) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <summary>Gets the (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public ConcurrentQueue<(string NodeId, bool Refuse)> RefuseArrayOnScalarQueue { get; } = new();
/// <summary>Gets the list of (NodeId, refuseArrayOnScalar) pairs captured per EnsureVariable call.</summary>
public List<(string NodeId, bool Refuse)> RefuseArrayOnScalarCalls => RefuseArrayOnScalarQueue.ToList();
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
@@ -241,10 +261,11 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname));
RefuseArrayOnScalarQueue.Enqueue((variableNodeId, refuseArrayOnScalar));
}
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
@@ -297,7 +318,7 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
/// <inheritdoc />
@@ -214,9 +214,10 @@ public sealed class EquipmentWriteGateTests
}
/// <summary>#610 review MAJOR-1 — the SDK skips its own type check when <c>OnWriteValue</c> is set, so an array
/// write's ELEMENT type must match the node's DataType exactly here: no numeric widening for arrays (a
/// <c>uint[]</c> to an Int32 node would reach MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret
/// its bits). The matching element type is the positive control.</summary>
/// write's ELEMENT type is checked here: a non-numeric mismatch, an unclassifiable element type, or a numeric
/// element the node's type cannot represent exactly (a <c>uint[]</c> element above Int32.MaxValue would reach
/// MxValueEncoder's <c>int[]</c> arm by covariance and reinterpret its bits) is refused. The matching element
/// type is the positive control.</summary>
[Theory]
[MemberData(nameof(MistypedArrays))]
public void Structure_array_node_requires_the_exact_element_type(NodeId dataType, Array matching, Array mistyped)
@@ -229,28 +230,103 @@ public sealed class EquipmentWriteGateTests
public static TheoryData<NodeId, Array, Array> MistypedArrays() => new()
{
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 2 } }, // Int32[] → Int16
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1, 70_000 } }, // Int32[] → Int16, out of range
{ DataTypeIds.Int32, new[] { 1, 2 }, new uint[] { 4_000_000_000, 2 } }, // UInt32[] → Int32 (Galaxy)
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { 1, 2 } }, // UInt64[] → Int64 (Galaxy)
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 2d } }, // Double[] → Float
{ DataTypeIds.Int64, new long[] { 1, 2 }, new ulong[] { ulong.MaxValue, 2 } }, // UInt64[] → Int64 (Galaxy)
{ DataTypeIds.Float, new[] { 1f, 2f }, new[] { 1d, 1e300 } }, // Double[] → Float, overflows
{ DataTypeIds.Int16, new short[] { 1, 2 }, new[] { 1.5d, 2d } }, // fractional → integer
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { double.NaN, 2d } }, // NaN → integer
{ DataTypeIds.Int64, new long[] { 1, 2 }, new[] { double.PositiveInfinity, 2d } },// ∞ → integer
{ DataTypeIds.UInt16, new ushort[] { 1, 2 }, new[] { -1, 2 } }, // negative → unsigned
{ DataTypeIds.Int32, new[] { 1, 2 }, new[] { true, false } }, // Boolean[] → Int32
{ DataTypeIds.String, new[] { "a", "b" }, new[] { 1, 2 } }, // Int32[] → String
{ DataTypeIds.Boolean, new[] { true, false }, new[] { 1, 0 } }, // Int32[] → Boolean
{ DataTypeIds.Int16, new short[] { 1, 2 }, new object[] { (short)1, (short)2 } }, // unclassifiable ⇒ refused
};
/// <summary>#610 review R6 MAJOR-1 — ScadaBridge writes the CLR list it decoded from a List attribute, whose
/// element type can only be Boolean/Int32/Float/Double/String/DateTime. A numeric array of another element type
/// is CONVERTED (checked) to the node's element type, and the converted array — not the client's — is what the
/// production overload hands back to be stored and dispatched.</summary>
[Theory]
[MemberData(nameof(ConvertibleArrays))]
public void Structure_numeric_array_of_another_element_type_is_converted_to_the_node_type(NodeId dataType, Array written, Array expected)
{
var node = ArrayNode(dataType, length: (uint)written.Length);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldNotBeNull().GetType().ShouldBe(expected.GetType());
coerced.ShouldBe(expected);
}
public static TheoryData<NodeId, Array, Array> ConvertibleArrays() => new()
{
{ DataTypeIds.Int16, new[] { 1, -2, 32_767 }, new short[] { 1, -2, 32_767 } }, // Int32 list → Modbus default
{ DataTypeIds.Float, new[] { 1.5d, -2.25d }, new[] { 1.5f, -2.25f } }, // Double list → Float array
{ DataTypeIds.Int64, new[] { 1, -2 }, new long[] { 1, -2 } }, // Int32 list → Galaxy Int64
{ DataTypeIds.UInt16, new[] { 0, 65_535 }, new ushort[] { 0, 65_535 } },
{ DataTypeIds.UInt32, new[] { 0, int.MaxValue }, new uint[] { 0, int.MaxValue } },
{ DataTypeIds.Byte, new[] { 0, 255 }, new byte[] { 0, 255 } },
{ DataTypeIds.Int16, new[] { 2.0d, -3.0d }, new short[] { 2, -3 } }, // integral doubles convert
{ DataTypeIds.Int32, new uint[] { 5, 2_147_483_647 }, new[] { 5, int.MaxValue } }, // in-range UInt32 → Int32
{ DataTypeIds.Double, new[] { 1f, 2.5f }, new[] { 1d, 2.5d } },
};
/// <summary>An exact element-type match is passed through unconverted (the same instance).</summary>
[Fact]
public void Structure_matching_array_is_passed_through_unchanged()
{
var written = new short[] { 1, 2 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, ArrayNode(DataTypeIds.Int16, 2), refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
}
/// <summary>A refused write hands back no value to store.</summary>
[Fact]
public void Structure_refused_array_returns_no_coerced_value()
{
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { 70_000 }, ArrayNode(DataTypeIds.Int16, 1), refuseArrayOnScalar: true, out var coerced)
.ShouldNotBeNull();
coerced.ShouldBeNull();
}
/// <summary>A Byte array decodes as <c>byte[]</c>, which classifies as ByteString — the one equivalence the SDK
/// itself honours for array nodes.</summary>
/// itself honours for array nodes. An SByte array is numeric, so it converts when every element fits (R6) and is
/// refused when one does not.</summary>
[Fact]
public void Structure_byte_array_node_takes_a_byte_array()
{
var node = ArrayNode(DataTypeIds.Byte, length: 2);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new byte[] { 1, 2 }, node).ShouldBeNull();
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node)!.StatusCode
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { 1, 2 }, node, refuseArrayOnScalar: true, out var coerced)
.ShouldBeNull();
coerced.ShouldBe(new byte[] { 1, 2 });
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new sbyte[] { -1, 2 }, node)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
}
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node is refused up front. A
/// byte[] (a ByteString on the wire) is a scalar and still reaches the type check; an unresolved-DataType node
/// defers as before.</summary>
/// <summary>#610 review R6 MINOR-1 — a scalar node whose driver passes the value through (OpcUaClient, Galaxy:
/// a browse-committed array attribute committed as a scalar tag) keeps the pre-#610 behaviour: an array value is
/// dispatched and the driver decides. The Modbus/S7 refusal is the control.</summary>
[Fact]
public void Structure_scalar_node_of_a_pass_through_driver_accepts_an_array_value()
{
var node = ValueNode(DataTypeIds.Int32);
var written = new[] { 1, 2, 3 };
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: false, out var coerced)
.ShouldBeNull();
coerced.ShouldBeSameAs(written);
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(written, node, refuseArrayOnScalar: true, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
// The element-type check still applies on the pass-through path: a String[] to an Int32 node is refused.
OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(new[] { "a" }, node, refuseArrayOnScalar: false, out _)!.StatusCode
.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
}
/// <summary>#610 review MINOR-5 — the mirror case: an array value to a scalar node of a driver that cannot take
/// one (Modbus / S7 — the two-argument overload's assumption) is refused up front. A byte[] (a ByteString on the
/// wire) is a scalar and still reaches the type check; an unresolved-DataType node defers as before.</summary>
[Fact]
public void Structure_scalar_node_refuses_an_array_value()
{
@@ -220,7 +220,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
@@ -138,7 +138,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -153,7 +153,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -172,7 +172,7 @@ public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -1044,7 +1044,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
{
if (string.Equals(FailEnsureVariableFor, variableNodeId, StringComparison.Ordinal))
throw new InvalidOperationException($"simulated EnsureVariable fault for {variableNodeId}");
@@ -146,7 +146,7 @@ public sealed class OpcUaPublishActorStatusBatchTests : RuntimeActorTestBase
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
@@ -772,7 +772,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) =>
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild call.</summary>