using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; public sealed class ModbusRtuFramingTests { private static MemoryStream Canned(params byte[] frame) => new(frame); [Fact] public void BuildAdu_prefixes_unit_and_appends_crc() { var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }); adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A }); } [Fact] public async Task ReadResponse_FC03_returns_bare_pdu() { // addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi) var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A }; var frame = ModbusCrc.Append(body); await using var s = Canned(frame); var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken); pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC } [Fact] public async Task ReadResponse_exception_frame_throws_ModbusException() { // addr=01 fc=0x83 exc=0x02 + CRC var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 }); await using var s = Canned(frame); var ex = await Should.ThrowAsync( ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken)); ex.FunctionCode.ShouldBe((byte)0x03); ex.ExceptionCode.ShouldBe((byte)0x02); } [Fact] public async Task ReadResponse_bad_crc_throws_desync() { var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A }); frame[^1] ^= 0xFF; // corrupt CRC high byte await using var s = Canned(frame); await Should.ThrowAsync( ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken)); } [Fact] public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu() { // addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A }); await using var s = Canned(frame); var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken); pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A }); } [Fact] public async Task ReadResponse_wrong_unit_address_throws_desync() { // CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync. var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A }); await using var s = Canned(frame); await Should.ThrowAsync( ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken)); } [Fact] public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles() { // Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves // the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads. var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A }); await using var s = new DribbleStream(frame, chunk: 2); var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken); pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); } [Fact] public async Task ReadResponse_stream_ends_early_throws_desync() { // Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0) // -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse. var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A }); var truncated = frame[..4]; await using var s = new DribbleStream(truncated, chunk: 2); await Should.ThrowAsync( ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken)); } /// /// A read-only test double that dribbles its backing bytes out at most chunk at a time /// per call, then returns 0 (EOF) /// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync /// path that a single-shot never reaches. /// private sealed class DribbleStream(byte[] data, int chunk) : Stream { private int _pos; public override int Read(byte[] buffer, int offset, int count) { var n = Math.Min(Math.Min(chunk, count), data.Length - _pos); if (n <= 0) return 0; Array.Copy(data, _pos, buffer, offset, n); _pos += n; return n; } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos); if (n <= 0) return ValueTask.FromResult(0); data.AsSpan(_pos, n).CopyTo(buffer.Span); _pos += n; return ValueTask.FromResult(n); } public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => data.Length; public override long Position { get => _pos; set => throw new NotSupportedException(); } public override void Flush() { } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } }