fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)
MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an OnWriteValue handler (every writable equipment node), and the node manager's fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure now matches an ARRAY write's element type exactly (Byte[]/ByteString the only equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and the Galaxy parity test's false "the SDK refuses" premise is corrected (the UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep numeric widening by decision: consumers write CLR-typed values (Double to a Float node) and every driver's scalar encoder range-checks. MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig / the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and requires region + numeric address; Modbus/S7/Galaxy require isArray + integer arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the attribute); an array value to a scalar node is refused inline. Docs: Uns.md array-write rules + upgrade note (ReadWrite array tags become client-writable on first boot), Modbus.md, Galaxy.md. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+92
-20
@@ -6,27 +6,78 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
/// <summary>
|
||||
/// Gitea #610 — the per-driver array-write capability table. The table's agreement with each listed driver's
|
||||
/// real encoder is pinned in that driver's own test project (<c>*ArrayWriteSupportParityTests</c>); these
|
||||
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs).
|
||||
/// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs, declared length,
|
||||
/// write caps).
|
||||
/// </summary>
|
||||
public sealed class DriverArrayWriteSupportTests
|
||||
{
|
||||
private const string Arr2 = ",\"isArray\":true,\"arrayLength\":2";
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"}", true)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0}", true)] // FC15 ignores dataType
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"}", true)]
|
||||
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"}", true)] // case-insensitive, like the driver
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0}", true)] // dataType ⇒ Int16
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"}", false)]
|
||||
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"}", false)] // read-only region
|
||||
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0}", false)]
|
||||
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", true)]
|
||||
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // FC15 ignores dataType
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Float32\"" + Arr2 + "}", true)]
|
||||
[InlineData("{\"region\":\"holdingregisters\",\"address\":0,\"dataType\":\"bcd16\"" + Arr2 + "}", true)] // case-insensitive, like the driver
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0" + Arr2 + "}", true)] // dataType ⇒ Int16
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Bool\"" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":\"InputRegisters\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // read-only region
|
||||
[InlineData("{\"region\":\"DiscreteInputs\",\"address\":0" + Arr2 + "}", false)]
|
||||
[InlineData("{\"region\":0,\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // numeric ⇒ absent ⇒ HoldingRegisters (driver reads it that way)
|
||||
[InlineData("{\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no region ⇒ the factory refuses the tag
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"dataType\":\"Int16\"" + Arr2 + "}", false)] // no address ⇒ likewise
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true}", false)] // no length ⇒ scalar to the driver
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":0}", false)]
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":\"4\"}", false)] // non-numeric length
|
||||
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":1}", true)] // 1-element array is valid
|
||||
[InlineData("not json", false)]
|
||||
[InlineData("", false)]
|
||||
public void Modbus_rows_follow_region_and_element_type(string tagConfig, bool expected) =>
|
||||
public void Modbus_rows_follow_region_element_type_and_declared_length(string tagConfig, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
||||
|
||||
/// <summary>#610 review MINOR-1 — the driver refuses (does not chunk) an FC16 over <c>maxRegistersPerWrite</c>
|
||||
/// (default 123), so an array whose registers cannot fit is never advertised writable.</summary>
|
||||
[Theory]
|
||||
[InlineData("Int16", 123, true)]
|
||||
[InlineData("Int16", 124, false)]
|
||||
[InlineData("Int32", 61, true)] // 122 registers
|
||||
[InlineData("Int32", 62, false)] // 124 registers
|
||||
[InlineData("Float64", 30, true)] // 120 registers
|
||||
[InlineData("Float64", 31, false)] // 124 registers
|
||||
public void Modbus_register_arrays_respect_the_default_write_cap(string dataType, int length, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray(dataType, length)).ShouldBe(expected);
|
||||
|
||||
/// <summary>A configured cap (driver- or device-level, case-insensitive) is honoured; when both are visible the
|
||||
/// smaller wins, and a non-positive value means the driver default.</summary>
|
||||
[Fact]
|
||||
public void Modbus_register_arrays_respect_a_configured_write_cap()
|
||||
{
|
||||
var tag = RegisterArray("Int16", 10);
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":10}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"MaxRegistersPerWrite\":9}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":100}", "{\"maxRegistersPerWrite\":9}")
|
||||
.ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tag, "{\"maxRegistersPerWrite\":0}", null, "not json").ShouldBeTrue();
|
||||
// A larger configured cap lifts the default: 124 registers fit under 200.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not
|
||||
/// writable.</summary>
|
||||
[Fact]
|
||||
public void Modbus_coil_arrays_respect_the_FC15_ceiling()
|
||||
{
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
|
||||
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1968}").ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean",
|
||||
"{\"region\":\"Coils\",\"address\":0,\"isArray\":true,\"arrayLength\":1969}").ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static string RegisterArray(string dataType, int length) =>
|
||||
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{dataType}\",\"isArray\":true,\"arrayLength\":{length}}}";
|
||||
|
||||
[Theory]
|
||||
[InlineData("Byte", true)]
|
||||
[InlineData("Int16", true)]
|
||||
@@ -41,12 +92,23 @@ public sealed class DriverArrayWriteSupportTests
|
||||
[InlineData("String", false)]
|
||||
[InlineData("DateTime", false)]
|
||||
public void S7_rows_follow_the_element_type(string s7Type, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"}}")
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"{s7Type}\"{Arr2}}}")
|
||||
.ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", "{\"address\":\"DB1.DBW0\"" + Arr2 + "}").ShouldBeFalse();
|
||||
|
||||
/// <summary>#610 review MINOR-2 — S7 maps isArray without a positive integer arrayLength to a SCALAR tag.</summary>
|
||||
[Theory]
|
||||
[InlineData("", false)]
|
||||
[InlineData(",\"isArray\":true", false)]
|
||||
[InlineData(",\"isArray\":true,\"arrayLength\":0", false)]
|
||||
[InlineData(",\"isArray\":false,\"arrayLength\":4", false)]
|
||||
[InlineData(",\"isArray\":true,\"arrayLength\":4", true)]
|
||||
public void S7_requires_a_declared_length(string shape, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int16", $"{{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"{shape}}}")
|
||||
.ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Boolean", true)]
|
||||
@@ -60,7 +122,17 @@ public sealed class DriverArrayWriteSupportTests
|
||||
[InlineData("UInt32", false)]
|
||||
[InlineData("Byte", false)]
|
||||
public void Galaxy_rows_follow_the_opc_ua_data_type(string dataType, bool expected) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"}").ShouldBe(expected);
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, dataType, "{\"FullName\":\"Obj.Attr\"" + Arr2 + "}").ShouldBe(expected);
|
||||
|
||||
/// <summary>#610 review MINOR-4 — with no declared length the node is unbounded and a short whole-array write
|
||||
/// would RESIZE the Galaxy attribute, so an undeclared-length Galaxy array stays read-only.</summary>
|
||||
[Fact]
|
||||
public void Galaxy_requires_a_declared_length()
|
||||
{
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":0}").ShouldBeFalse();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", "{\"FullName\":\"Obj.Attr\",\"isArray\":true,\"arrayLength\":3}").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaClient_forwards_every_array() =>
|
||||
@@ -73,15 +145,15 @@ public sealed class DriverArrayWriteSupportTests
|
||||
var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy };
|
||||
var others = DriverTypeNames.All.Except(listed).ToList();
|
||||
others.ShouldNotBeEmpty();
|
||||
const string blob = "{\"region\":\"Coils\",\"dataType\":\"Int32\"}";
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
const string blob = "{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int32\"" + Arr2 + "}";
|
||||
// Positive control: the same blob IS array-writable under the listed drivers that read it.
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32", blob).ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.S7, "Int32", blob).ShouldBeTrue();
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.Galaxy, "Int32", blob).ShouldBeTrue();
|
||||
foreach (var driverType in others.Append("NoSuchDriver").Append(null))
|
||||
{
|
||||
// A blob every listed driver would accept, so only the driver type can make the answer false.
|
||||
DriverArrayWriteSupport.Supports(driverType, "Int32", "{\"region\":\"Coils\",\"dataType\":\"Int32\"}")
|
||||
.ShouldBeFalse(driverType ?? "<null>");
|
||||
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user