using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// #143 block-read coalescing: with MaxReadGap > 0 the driver merges nearby tags into a /// single FC03/FC04 read. Coverage focuses on the planner output (PDU count + quantity) /// rather than wire bytes — those are tested by ModbusDriverTests. /// [Trait("Category", "Unit")] public sealed class ModbusCoalescingTests { private sealed class CountingTransport : IModbusTransport { public readonly List<(byte Unit, byte Fc, ushort Address, ushort Quantity)> Reads = new(); public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); if (pdu[0] is 0x03 or 0x04) Reads.Add((unitId, pdu[0], addr, qty)); switch (pdu[0]) { case 0x03: case 0x04: { var resp = new byte[2 + qty * 2]; resp[0] = pdu[0]; resp[1] = (byte)(qty * 2); return Task.FromResult(resp); } default: return Task.FromResult(new byte[] { pdu[0], 0, 0 }); } } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } [Fact] public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads() { var fake = new CountingTransport(); var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 0, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2"], CancellationToken.None); // With coalescing off, expect 2 separate FC03 reads. var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); fc03Reads.Count.ShouldBe(2); } [Fact] public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read() { var fake = new CountingTransport(); // Three tags within 5 registers: T1@100, T2@102, T3@104. Gaps: 1, 1. MaxReadGap=2 → 1 block. var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 2, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None); var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); fc03Reads.Count.ShouldBe(1); fc03Reads[0].Address.ShouldBe((ushort)100); fc03Reads[0].Quantity.ShouldBe((ushort)5); // 100..104 } [Fact] public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold() { var fake = new CountingTransport(); // T1@100, T2@102 (gap 1, joins block), T3@200 (gap 97 → exceeds gap=10 → second block). var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None); var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); fc03Reads.Count.ShouldBe(2); // T1+T2 coalesced; T3 alone } [Fact] public async Task CoalesceProhibited_Tag_Reads_Alone() { var fake = new CountingTransport(); var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, CoalesceProhibited: true); var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None); var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); // T2 read alone (CoalesceProhibited). T1 and T3 coalesce (gap = 3 within MaxReadGap=10). // Expect 2 reads total. fc03Reads.Count.ShouldBe(2); } [Fact] public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries() { var fake = new CountingTransport(); // Same Region + adjacent addresses but different UnitIds → must NOT coalesce. var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16, UnitId: 1); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, UnitId: 2); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 100, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2"], CancellationToken.None); var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); fc03Reads.Count.ShouldBe(2); fc03Reads.Select(r => r.Unit).Distinct().Count().ShouldBe(2); } [Fact] public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead() { var fake = new CountingTransport(); // T1@0, T2@200 with MaxReadGap=300 would naturally form one block of 201 registers, // but MaxRegistersPerRead=125 caps it. The planner should NOT coalesce because the // resulting span exceeds the cap — it falls back to two separate reads. var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 300, MaxRegistersPerRead = 125, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T1", "T2"], CancellationToken.None); var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList(); fc03Reads.Count.ShouldBe(2); } [Fact] public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently() { // Sanity check: after coalescing the per-tag values must still be correct (no // index-shift bugs in the slice math). var fake = new CountingTransport(); var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); var values = await drv.ReadAsync(["T1", "T2"], CancellationToken.None); values.Count.ShouldBe(2); values[0].StatusCode.ShouldBe(0u); values[1].StatusCode.ShouldBe(0u); // The fake returns zeros for our values; the assertion is on quality + that the slice // didn't mis-index (a bug there would surface as IndexOutOfRange / wrong type). } }