Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusArrayWriteSupportParityTests.cs
T
Joseph Doherty dabd8eb955 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
2026-10-08 08:07:36 -04:00

105 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}