1adc9d65e1
U3: both array shapes read fine but every write failed BadInternalError. String[] now encodes each element at its register width (StringLength chars, 2 per register); BitInRegister[] reads the span once, flips the addressed bit of each register and writes the span back with FC16 (per-register RMW locks taken in ascending order). A length mismatch is BadOutOfRange and sends nothing. DriverArrayWriteSupport (outside this lane) still advertises these nodes read-only - recorded as follow-up F1 in the parity test. Refs #741 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
136 lines
7.7 KiB
C#
136 lines
7.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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Shapes where the driver has moved ahead of the server-side table (review 07 P6 / U3, Modbus lane) and
|
||
/// <see cref="DriverArrayWriteSupport"/> — owned outside the Modbus lane — has not followed yet
|
||
/// (follow-up F1): the tag factory now REFUSES a Coils tag with a non-Bool type (so the driver serves no
|
||
/// such tag), and the driver now encodes String[] and BitInRegister[] holding-register writes.
|
||
/// <see cref="Pending_F1_divergences_still_exist"/> fails the moment the table follows, so this list
|
||
/// is removed with it rather than left to rot.
|
||
/// </summary>
|
||
private static bool PendingF1(ModbusRegion region, ModbusDataType dataType) =>
|
||
(region == ModbusRegion.Coils && dataType != ModbusDataType.Bool)
|
||
|| (region == ModbusRegion.HoldingRegisters && dataType is ModbusDataType.String or ModbusDataType.BitInRegister);
|
||
|
||
|
||
public static TheoryData<ModbusRegion, ModbusDataType> PendingShapes()
|
||
{
|
||
var data = new TheoryData<ModbusRegion, ModbusDataType>();
|
||
foreach (var (region, dt) in Shapes().Where(s => PendingF1(s.Region, s.DataType))) data.Add(region, dt);
|
||
return data;
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(PendingShapes))]
|
||
public void Pending_F1_divergences_still_exist(ModbusRegion region, ModbusDataType dataType)
|
||
{
|
||
var json = $$"""{"region":"{{region}}","address":10,"dataType":"{{dataType}}","stringLength":4,"isArray":true,"arrayLength":2}""";
|
||
var driverAccepts = region == ModbusRegion.HoldingRegisters; // String[] / BitInRegister[] encode; mistyped Coils are refused
|
||
DriverArrayWriteSupport.Supports(DriverTypeNames.Modbus, "Int16", json).ShouldNotBe(driverAccepts,
|
||
"F1 landed — DriverArrayWriteSupport now agrees with the driver for this shape; delete it from PendingF1");
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(AllShapes))]
|
||
public async Task Table_agrees_with_the_driver_write_path(ModbusRegion region, ModbusDataType dataType)
|
||
{
|
||
if (PendingF1(region, dataType)) return; // covered by Pending_F1_divergences_still_exist
|
||
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();
|
||
}
|
||
}
|