using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusCapTests
{
///
/// Records every PDU sent so tests can assert request-count and per-request quantity —
/// the only observable behaviour of the auto-chunking path.
///
private sealed class RecordingTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[1024];
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
if (fc == 0x03)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc03Requests.Add((addr, qty));
var byteCount = (byte)(qty * 2);
var resp = new byte[2 + byteCount];
resp[0] = 0x03;
resp[1] = byteCount;
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
}
return Task.FromResult(resp);
}
if (fc == 0x10)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc16Requests.Add((addr, qty));
for (var i = 0; i < qty; i++)
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
}
return Task.FromException(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_within_cap_issues_single_FC03_request()
{
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs — fits in default cap (125).
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(1);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
}
[Fact]
public async Task Read_above_cap_splits_into_two_FC03_requests()
{
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
StringLength: 240);
var transport = new RecordingTransport();
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
for (ushort i = 100; i < 100 + 120; i++)
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
var opts = new ModbusDriverOptions
{
Host = "fake",
Tags = [tag],
MaxRegistersPerRead = 100,
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
var s = (string)results[0].Value!;
s.Length.ShouldBeGreaterThan(0);
s[0].ShouldBe('A'); // register[100] high byte
}
[Fact]
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
{
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 200);
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(2);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
}
[Fact]
public async Task Write_exceeding_cap_throws_instead_of_splitting()
{
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
// caller knows their tag definition is incompatible with the device cap rather than silently
// writing half a string and crashing between chunks.
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 220); // 110 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("LongStringWrite", new string('A', 220))],
TestContext.Current.CancellationToken);
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
// list must still be empty because nothing was sent.
results[0].StatusCode.ShouldNotBe(0u);
transport.Fc16Requests.Count.ShouldBe(0);
}
[Fact]
public async Task Write_within_cap_proceeds_normally()
{
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("ShortStringWrite", "HELLO")],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc16Requests.Count.ShouldBe(1);
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
}
}