using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; /// /// 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 (*ArrayWriteSupportParityTests); these /// tests pin the table's own reading of TagConfig (defaults, numeric enums, malformed blobs, declared length, /// write caps). /// 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)] // 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_element_type_and_declared_length(string tagConfig, bool expected) => DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", tagConfig).ShouldBe(expected); /// #610 review MINOR-1 — the driver refuses (does not chunk) an FC16 over maxRegistersPerWrite /// (default 123), so an array whose registers cannot fit is never advertised writable. [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); /// 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. [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(); } /// FC15 carries at most 1968 coils and the driver sends one request, so a longer coil array is not /// writable. [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)] [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(); /// #610 review MINOR-2 — S7 maps isArray without a positive integer arrayLength to a SCALAR tag. [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); /// #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. [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(); /// Every driver type NOT in the table — and an unknown or absent one — stays read-only. [Fact] public void Every_other_driver_type_is_not_array_writable() { var listed = new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy }; 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 ?? ""); } } }