dabd8eb955
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
105 lines
5.7 KiB
C#
105 lines
5.7 KiB
C#
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||
|
||
/// <summary>
|
||
/// Gitea #610 — pins <see cref="DriverArrayWriteSupport"/>'s Modbus row against the REAL write path. For every
|
||
/// region × element type, the server advertises an array node writable exactly when the driver accepts a
|
||
/// whole-array write for it. A driver change that gains or loses an array encoder fails here until the table
|
||
/// follows — so the server can never advertise a write this driver refuses (nor hide one it accepts).
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class ModbusArrayWriteSupportParityTests
|
||
{
|
||
private static IEnumerable<(ModbusRegion Region, ModbusDataType DataType)> Shapes() =>
|
||
from region in Enum.GetValues<ModbusRegion>()
|
||
from dt in Enum.GetValues<ModbusDataType>()
|
||
select (region, dt);
|
||
|
||
public static TheoryData<ModbusRegion, ModbusDataType> AllShapes()
|
||
{
|
||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||
foreach (var (region, dt) in Shapes()) data.Add(region, dt);
|
||
return data;
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(AllShapes))]
|
||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
|
||
{
|
||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||
var fake = new ModbusDriverTests.FakeTransport();
|
||
var drv = new ModbusDriver(
|
||
new ModbusDriverOptions { Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)] },
|
||
"modbus-array-parity", _ => fake);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
// A two-element value every element type can convert (Convert.ToBoolean / ToInt16 / ToSingle … of 1).
|
||
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", new object[] { 1, 1 })], CancellationToken.None);
|
||
var driverAccepts = result.Single().StatusCode == 0u;
|
||
|
||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldBe(
|
||
driverAccepts, $"region={region} dataType={dataType}: driver status 0x{result.Single().StatusCode:X8}");
|
||
}
|
||
|
||
/// <summary>Positive control: the theory above is not vacuously "everything refused".</summary>
|
||
[Fact]
|
||
public void The_matrix_contains_both_answers()
|
||
{
|
||
var answers = Shapes()
|
||
.Select(s => DriverArrayWriteSupport.Supports(
|
||
DriverTypeNames.Modbus, "Int16",
|
||
$$"""{"region":"{{s.Region}}","address":10,"dataType":"{{s.DataType}}","isArray":true,"arrayLength":2}"""))
|
||
.ToHashSet();
|
||
answers.ShouldBe(new[] { true, false }, ignoreOrder: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #610 review MINOR-1/-2 — the table must also agree with the driver on the SHAPE edges: the FC16 write cap
|
||
/// (the driver refuses rather than chunks an over-cap array; default 123 registers, or the configured
|
||
/// <c>MaxRegistersPerWrite</c>) and a missing / zero <c>arrayLength</c> (the factory maps it to a scalar tag,
|
||
/// whose scalar encoder refuses an array value).
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData("Int32", "\"arrayLength\":61", 0, 61)] // 122 registers — fits the default cap
|
||
[InlineData("Int32", "\"arrayLength\":62", 0, 62)] // 124 registers — over it
|
||
[InlineData("Float64", "\"arrayLength\":31", 0, 31)] // 124 registers
|
||
[InlineData("Int16", "\"arrayLength\":10", 10, 10)] // exactly a configured cap
|
||
[InlineData("Int16", "\"arrayLength\":11", 10, 11)] // one over it
|
||
[InlineData("Int16", "\"arrayLength\":0", 0, 2)] // declared length 0 ⇒ scalar to the driver
|
||
[InlineData("Int16", "\"isArrayLengthMissing\":true", 0, 2)]
|
||
public async Task Table_agrees_with_the_driver_on_write_cap_and_declared_length(
|
||
string dataType, string lengthProp, ushort maxRegistersPerWrite, int elements)
|
||
{
|
||
var json = $$"""{"region":"HoldingRegisters","address":10,"dataType":"{{dataType}}","isArray":true,{{lengthProp}}}""";
|
||
var driverConfig = maxRegistersPerWrite == 0 ? "{}" : $$"""{"maxRegistersPerWrite":{{maxRegistersPerWrite}}}""";
|
||
var fake = new ModbusDriverTests.FakeTransport();
|
||
var options = new ModbusDriverOptions
|
||
{
|
||
Host = "fake", RawTags = [new RawTagEntry("cell/modbus/arr", json, false)],
|
||
MaxRegistersPerWrite = maxRegistersPerWrite == 0 ? (ushort)123 : maxRegistersPerWrite,
|
||
};
|
||
var drv = new ModbusDriver(options, "modbus-array-cap-parity", _ => fake);
|
||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||
|
||
var value = Enumerable.Repeat<object>(1, elements).ToArray();
|
||
var result = await drv.WriteAsync([new WriteRequest("cell/modbus/arr", value)], CancellationToken.None);
|
||
var driverAccepts = result.Single().StatusCode == 0u;
|
||
|
||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json, driverConfig).ShouldBe(
|
||
driverAccepts, $"{dataType} {lengthProp} cap={maxRegistersPerWrite}: driver status 0x{result.Single().StatusCode:X8}");
|
||
}
|
||
|
||
/// <summary>Positive control for the theory above: both answers occur, so it is not vacuously all-refused.</summary>
|
||
[Fact]
|
||
public void The_shape_edges_contain_both_answers()
|
||
{
|
||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
|
||
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":61}""").ShouldBeTrue();
|
||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int32",
|
||
"""{"region":"HoldingRegisters","address":10,"dataType":"Int32","isArray":true,"arrayLength":62}""").ShouldBeFalse();
|
||
}
|
||
}
|