cf62007678
Review r0 of Integration-W3: - I1: the effective FC16 cap is min(configured maxRegistersPerWrite, 123). The driver trusts a larger configured cap and sends an over-ceiling FC16 (past 127 registers its byte-count byte wraps), which every conformant device refuses, so such an array is never advertised writable. - I2: a BitInRegister[] write reads its span first in one unchunked FC03, so it is also bounded by min(configured maxRegistersPerRead, 125) (absent/0 = 125). - m2: every span (registers and coils) must end at or before address 65535. - m1: the String-node wording says it converts only when the text parses, which the server cannot promise (DateTime never converts). - m4: the parity device now enforces FC01/02 2000, FC03/04 125 (or a smaller device read limit), FC15 1968, FC16 123, FC23 121/125, the FC15/FC16 byte-count field (exception 03) and the address-space end (exception 02). Tests: parity rows for each ceiling/cap/span boundary against the real write path, and Core.Abstractions row tests. The configured-cap test that asserted a cap of 200 lifts the ceiling now asserts the opposite. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
369 lines
28 KiB
C#
369 lines
28 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
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, 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\"" + Arr2 + "}", true)]
|
|
[InlineData("{\"region\":\"Coils\",\"address\":0" + Arr2 + "}", true)] // absent dataType ⇒ Bool
|
|
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int16\"" + Arr2 + "}", true)] // a bit region COERCES any type to Bool
|
|
[InlineData("{\"region\":\"Coils\",\"address\":0,\"dataType\":\"Int17\"" + Arr2 + "}", false)] // ...but a typo refuses the tag
|
|
[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 + "}", true)] // #741 U3
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":15" + Arr2 + "}", true)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"BitInRegister\",\"bitIndex\":16" + Arr2 + "}", false)] // factory refuses
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4" + Arr2 + "}", true)] // #741 U3
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\"" + Arr2 + "}", false)] // no stringLength ⇒ refused
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":0" + Arr2 + "}", false)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"Int16\",\"byteOrder\":\"Sideways\"" + Arr2 + "}", false)] // strict enum
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"String\",\"stringLength\":4,\"stringByteOrder\":\"Middle\"" + Arr2 + "}", false)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65536,\"dataType\":\"Int16\"" + Arr2 + "}", false)] // not a register address
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":1.5,\"dataType\":\"Int16\"" + Arr2 + "}", false)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\",\"isArray\":true,\"arrayLength\":1}", true)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"BitInRegister\"" + Arr2 + "}", false)] // span runs past 65535
|
|
[InlineData("{\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // string-only: family unknown at compose time
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"addressString\":\"40001:S::2\"" + Arr2 + "}", false)] // region alone is not the keys
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":0,\"addressString\":\"30001\",\"dataType\":\"Int16\"" + Arr2 + "}", true)] // keys win
|
|
[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_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 configured cap above the FC16 protocol ceiling (123) never lifts it: a conformant device refuses 124
|
|
// registers (and past 127 the driver's one-byte byte-count field wraps), so the write always fails.
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 123), "{\"maxRegistersPerWrite\":200}")
|
|
.ShouldBeTrue();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 124), "{\"maxRegistersPerWrite\":200}")
|
|
.ShouldBeFalse();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("BitInRegister", 124), "{\"maxRegistersPerWrite\":200}")
|
|
.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>A BitInRegister[] write is a read-modify-write: one unchunked FC03 of the whole span first, so its
|
|
/// length is also bounded by <c>maxRegistersPerRead</c> (smallest positive value across the configs; absent / 0 =>
|
|
/// 125, and never above FC03's protocol ceiling of 125). The other register shapes never read on write.</summary>
|
|
[Fact]
|
|
public void Modbus_bit_in_register_arrays_respect_the_read_cap()
|
|
{
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 64), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":64}").ShouldBeFalse();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 65), "{\"maxRegistersPerRead\":100}", "{\"MaxRegistersPerRead\":64}")
|
|
.ShouldBeFalse();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 123), "{\"maxRegistersPerRead\":0}").ShouldBeTrue();
|
|
// A numeric array of the same length is not read on write, so the read cap does not apply to it.
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", RegisterArray("Int16", 65), "{\"maxRegistersPerRead\":64}").ShouldBeTrue();
|
|
// With the write cap lifted, the read cap is the bound.
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 100),
|
|
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeTrue();
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Boolean", RegisterArray("BitInRegister", 101),
|
|
"{\"maxRegistersPerRead\":100,\"maxRegistersPerWrite\":200}").ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>Every shape's span must end at or before address 65535 - a conformant device refuses a write past it
|
|
/// (exception 02).</summary>
|
|
[Theory]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":6}", true)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65530,\"dataType\":\"Int16\",\"isArray\":true,\"arrayLength\":7}", false)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65532,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", true)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65533,\"dataType\":\"Int32\",\"isArray\":true,\"arrayLength\":2}", false)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65534,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", true)]
|
|
[InlineData("{\"region\":\"HoldingRegisters\",\"address\":65535,\"dataType\":\"String\",\"stringLength\":4,\"isArray\":true,\"arrayLength\":1}", false)]
|
|
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":536}", true)]
|
|
[InlineData("{\"region\":\"Coils\",\"address\":65000,\"isArray\":true,\"arrayLength\":537}", false)]
|
|
public void Modbus_array_spans_end_within_the_address_space(string tagConfig, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected);
|
|
|
|
/// <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();
|
|
}
|
|
|
|
/// <summary>#741 U3 — String[] and BitInRegister[] arrays ride the same FC16 cap: <c>(stringLength+1)/2</c> registers
|
|
/// per String element, one per BitInRegister element.</summary>
|
|
[Theory]
|
|
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 61, true)] // 122 registers
|
|
[InlineData("\"dataType\":\"String\",\"stringLength\":4", 62, false)] // 124
|
|
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 41, true)] // 3 registers each: 123
|
|
[InlineData("\"dataType\":\"String\",\"stringLength\":5", 42, false)] // 126
|
|
[InlineData("\"dataType\":\"BitInRegister\"", 123, true)]
|
|
[InlineData("\"dataType\":\"BitInRegister\"", 124, false)]
|
|
public void Modbus_string_and_bit_arrays_respect_the_write_cap(string typeKeys, int length, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16",
|
|
$"{{\"region\":\"HoldingRegisters\",\"address\":0,{typeKeys},\"isArray\":true,\"arrayLength\":{length}}}").ShouldBe(expected);
|
|
|
|
/// <summary>Elements CONVERT (ScadaBridge's <c>int[]</c> to an Int16 node), so the Modbus row keys on no exact node
|
|
/// DataType; it leaves out only a String or DateTime node over a numeric / bit element (a DateTime never converts; a String converts only when its text happens to
|
|
/// parse, which the server cannot promise). A
|
|
/// String element takes any node type (each element is <c>Convert.ToString</c>'d).</summary>
|
|
[Theory]
|
|
[InlineData("Int16", "Int16", true)]
|
|
[InlineData("Int16", "Int32", true)]
|
|
[InlineData("Int16", "Double", true)]
|
|
[InlineData("Int16", "Boolean", true)]
|
|
[InlineData("Int16", "String", false)]
|
|
[InlineData("Int16", "DateTime", false)]
|
|
[InlineData("BitInRegister", "Boolean", true)]
|
|
[InlineData("BitInRegister", "String", false)]
|
|
[InlineData("String", "String", true)]
|
|
[InlineData("String", "Int16", true)]
|
|
[InlineData("String", "DateTime", true)]
|
|
[InlineData("String", null, true)]
|
|
public void Modbus_rows_leave_out_only_node_types_that_never_convert(string element, string? nodeType, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, nodeType,
|
|
$"{{\"region\":\"HoldingRegisters\",\"address\":0,\"dataType\":\"{element}\",\"stringLength\":4" + Arr2 + "}").ShouldBe(expected);
|
|
|
|
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)]
|
|
[InlineData("UInt16", true)]
|
|
[InlineData("Int32", true)]
|
|
[InlineData("UInt32", true)]
|
|
[InlineData("Float32", true)]
|
|
[InlineData("Bool", false)]
|
|
[InlineData("Int64", false)]
|
|
[InlineData("UInt64", false)]
|
|
[InlineData("Float64", false)]
|
|
[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}\"{Arr2}}}")
|
|
.ShouldBe(expected);
|
|
|
|
[Fact]
|
|
public void S7_without_a_dataType_key_is_not_an_S7_tag() =>
|
|
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)]
|
|
[InlineData("Int32", true)]
|
|
[InlineData("Int64", true)]
|
|
[InlineData("Float", true)]
|
|
[InlineData("Double", true)]
|
|
[InlineData("String", true)]
|
|
[InlineData("DateTime", true)]
|
|
[InlineData("Int16", false)]
|
|
[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\"" + 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() =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.OpcUaClient, "UInt16", "{\"nodeId\":\"ns=2;s=x\"}").ShouldBeTrue();
|
|
|
|
// ---- AbCip / AbLegacy / TwinCAT (review 07 U10 / U9: their array encoders landed in #737 / #738 / #739) ----
|
|
|
|
/// <summary>AbCip: the atomic element types <c>AbCipArrayCodec</c> encodes. BOOL (bit-packed DWORDs), STRING and
|
|
/// structure arrays stay read-only, as do a safety tag, an undeclared length and a node type no element converts
|
|
/// from.</summary>
|
|
[Theory]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", true)]
|
|
[InlineData("{\"tagPath\":\"Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"int\"" + Arr2 + "}", "Int16", true)] // case-insensitive, like the factory
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"LReal\"" + Arr2 + "}", "Double", true)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Dt\"" + Arr2 + "}", "Int32", true)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInnt\"" + Arr2 + "}", "Int32", false)] // typo ⇒ the factory refuses the tag
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"safetyTag\":true" + Arr2 + "}", "Int32", false)]
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\",\"isArray\":true}", "Int32", false)] // no length ⇒ scalar
|
|
[InlineData("{\"dataType\":\"DInt\"" + Arr2 + "}", "Int32", false)] // no tagPath ⇒ not an AbCip tag
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "DateTime", false)] // DateTime elements never convert
|
|
[InlineData("{\"tagPath\":\"Arr\",\"dataType\":\"DInt\"" + Arr2 + "}", "String", false)] // text converts only when numeric
|
|
public void AbCip_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.AbCip, dataType, tagConfig).ShouldBe(expected);
|
|
|
|
/// <summary>AbLegacy: Int / AnalogInt / Long / Float on a plain address of the MATCHING data file. Every shape the
|
|
/// write path refuses stays read-only.</summary>
|
|
[Theory]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
|
[InlineData("{\"address\":\"N7:0\"" + Arr2 + "}", "Int16", true)] // dataType ⇒ Int
|
|
[InlineData("{\"address\":\" n7:10 \",\"dataType\":\"analogint\"" + Arr2 + "}", "Int16", true)] // trimmed, case-insensitive
|
|
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
|
[InlineData("{\"address\":\"A9:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
|
[InlineData("{\"address\":\"L9:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", true)]
|
|
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Float\"" + Arr2 + "}", "Float", true)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":256}", "Int16", true)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true,\"arrayLength\":257}", "Int16", false)] // the driver clamps to 256
|
|
[InlineData("{\"address\":\"B3:0\",\"dataType\":\"Bit\"" + Arr2 + "}", "Boolean", false)]
|
|
[InlineData("{\"address\":\"ST9:0\",\"dataType\":\"String\"" + Arr2 + "}", "String", false)]
|
|
[InlineData("{\"address\":\"T4:0\",\"dataType\":\"TimerElement\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"N7:0/3\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"T4:0.ACC\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"N7:[N7:0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"F8:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // type / file mismatch
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Long\"" + Arr2 + "}", "Int32", false)]
|
|
[InlineData("{\"address\":\"S:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)] // libplctag refuses a translated name
|
|
[InlineData("{\"address\":\"O:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Innt\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "Boolean", false)] // bool elements are refused
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
|
[InlineData("{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
|
public void AbLegacy_rows_follow_the_element_type_and_data_file(string tagConfig, string dataType, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, dataType, tagConfig).ShouldBe(expected);
|
|
|
|
/// <summary>The native transport refuses every PLC-5 write before the wire, so a native PLC-5 device keeps its
|
|
/// arrays read-only, wherever the transport and the family are configured. libplctag PLC-5 is the control.</summary>
|
|
[Theory]
|
|
[InlineData("{\"transport\":\"Native\",\"devices\":[{\"hostAddress\":\"ab://h/1,0\",\"plcFamily\":\"Plc5\"}]}", "{}", false)]
|
|
[InlineData("{\"Transport\":\"native\"}", "{\"PlcFamily\":\"plc5\"}", false)]
|
|
[InlineData("{\"transport\":\"Native\"}", "{\"plcFamily\":\"Slc500\"}", true)]
|
|
[InlineData("{\"transport\":\"Libplctag\"}", "{\"plcFamily\":\"Plc5\"}", true)]
|
|
[InlineData("{}", "{\"plcFamily\":\"Plc5\"}", true)] // absent ⇒ libplctag
|
|
public void AbLegacy_native_PLC5_stays_read_only(string driverConfig, string deviceConfig, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16",
|
|
"{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}", driverConfig, deviceConfig).ShouldBe(expected);
|
|
|
|
/// <summary>TwinCAT: every element type <c>TwinCATArrayCodec</c> encodes (the factory refuses Structure tags). A
|
|
/// BOOL bit-in-word path writes a scalar bit and is refused for an array value.</summary>
|
|
[Theory]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\"" + Arr2 + "}", "Int32", true)] // dataType ⇒ DInt
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "String", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "String", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "Double", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"DateTime\"" + Arr2 + "}", "DateTime", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Grid[0,0]\",\"dataType\":\"Int\"" + Arr2 + "}", "Int16", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Structure\"" + Arr2 + "}", "Int32", false)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Word.3\",\"dataType\":\"Bool\"" + Arr2 + "}", "Boolean", false)] // bit-in-word
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\",\"isArray\":true}", "Int16", false)]
|
|
[InlineData("{\"dataType\":\"Int\"" + Arr2 + "}", "Int16", false)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "DateTime", false)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Time\"" + Arr2 + "}", "DateTime", false)] // a duration, not a date
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}", "String", false)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"WString\"" + Arr2 + "}", "DateTime", true)]
|
|
[InlineData("{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"String\"" + Arr2 + "}", "DateTime", false)] // culture-dependent text vs strict Windows-1252
|
|
public void TwinCAT_rows_follow_the_element_type(string tagConfig, string dataType, bool expected) =>
|
|
DriverArrayWriteSupport.Supports(DriverTypeNames.TwinCAT, dataType, tagConfig).ShouldBe(expected);
|
|
|
|
/// <summary>ScadaBridge writes CLR <c>int[]</c> / <c>List<int></c> to Int16 nodes, and an unknown DataType
|
|
/// name (served as BaseDataType) hands the driver whatever the client sent. The element CONVERTS in all three
|
|
/// encoders, so neither case may make the row read-only.</summary>
|
|
[Theory]
|
|
[InlineData(DriverTypeNames.AbCip, "{\"tagPath\":\"Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
|
[InlineData(DriverTypeNames.AbLegacy, "{\"address\":\"N7:0\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
|
[InlineData(DriverTypeNames.TwinCAT, "{\"symbolPath\":\"MAIN.Arr\",\"dataType\":\"Int\"" + Arr2 + "}")]
|
|
public void Converting_drivers_do_not_key_on_an_exact_node_type(string driverType, string tagConfig)
|
|
{
|
|
foreach (var dataType in new[] { "Int16", "Int32", "Int64", "UInt16", "Double", "Float32", "Reference" })
|
|
DriverArrayWriteSupport.Supports(driverType, dataType, tagConfig).ShouldBeTrue($"{driverType} {dataType}");
|
|
DriverArrayWriteSupport.Supports(driverType, null, tagConfig).ShouldBeTrue($"{driverType} <null>");
|
|
}
|
|
|
|
/// <summary>Every driver type NOT in the table — and an unknown or absent one — stays read-only.</summary>
|
|
[Fact]
|
|
public void Every_other_driver_type_is_not_array_writable()
|
|
{
|
|
var listed = new[]
|
|
{
|
|
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy,
|
|
DriverTypeNames.AbCip, DriverTypeNames.AbLegacy, DriverTypeNames.TwinCAT,
|
|
};
|
|
var others = DriverTypeNames.All.Except(listed).ToList();
|
|
others.ShouldNotBeEmpty();
|
|
// 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))
|
|
{
|
|
DriverArrayWriteSupport.Supports(driverType, "Int32", blob).ShouldBeFalse(driverType ?? "<null>");
|
|
}
|
|
}
|
|
|
|
/// <summary>#610 review R6 MINOR-1 — only Modbus and S7 have a scalar encoder that cannot take an array value;
|
|
/// every other driver (the pass-through OpcUaClient and Galaxy above all) keeps dispatching an array written to
|
|
/// a scalar-shaped tag, as before #610.</summary>
|
|
[Fact]
|
|
public void Only_modbus_and_s7_refuse_an_array_value_on_a_scalar_tag()
|
|
{
|
|
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.Modbus).ShouldBeTrue();
|
|
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(DriverTypeNames.S7).ShouldBeTrue();
|
|
foreach (var driverType in DriverTypeNames.All
|
|
.Except([DriverTypeNames.Modbus, DriverTypeNames.S7]).Append("NoSuchDriver").Append(null))
|
|
{
|
|
DriverArrayWriteSupport.RefusesArrayValueOnScalarTag(driverType).ShouldBeFalse(driverType ?? "<null>");
|
|
}
|
|
}
|
|
}
|