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); } }