using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// Exercises 's send/receive orchestration, single-flight, /// and per-op deadline against an in-memory duplex fake injected through the ForTest seam — /// no real socket. The fake records the request ADU the transport writes and replays a canned RTU /// response on read, or blocks forever (honouring cancellation) when stall is set. /// [Trait("Category", "Unit")] public sealed class ModbusRtuOverTcpTransportTests { [Fact] public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu() { // Response the fake gateway will emit: FC03, 1 reg = 0x000A. var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A }); var fake = new CapturingDuplexStream(response); await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2)); var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }, TestContext.Current.CancellationToken); pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header. fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A }); } [Fact] public async Task SendAsync_exception_pdu_surfaces_ModbusException() { var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 }); var fake = new CapturingDuplexStream(response); await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2)); await Should.ThrowAsync( transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }, TestContext.Current.CancellationToken)); } [Fact] public async Task SendAsync_stalled_gateway_hits_per_op_deadline() { var fake = new CapturingDuplexStream(respondBytes: Array.Empty(), stall: true); await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200)); await Should.ThrowAsync( transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 }, TestContext.Current.CancellationToken)); } /// /// In-memory duplex test double: records everything written and replays /// respondBytes on read. When stall is set the read blocks until its cancellation /// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised. /// private sealed class CapturingDuplexStream : Stream { private readonly byte[] _response; private readonly bool _stall; private readonly MemoryStream _written = new(); private int _readPos; public CapturingDuplexStream(byte[] respondBytes, bool stall = false) { _response = respondBytes; _stall = stall; } /// Gets the bytes the transport wrote to this stream (the request ADU). public byte[] Written => _written.ToArray(); public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => true; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) { if (_stall) { // Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels. var tcs = new TaskCompletionSource(); await using (ct.Register(() => tcs.TrySetCanceled(ct))) await tcs.Task.ConfigureAwait(false); return 0; // unreachable — the await above always throws when cancelled } var remaining = _response.Length - _readPos; if (remaining <= 0) return 0; var n = Math.Min(remaining, buffer.Length); _response.AsSpan(_readPos, n).CopyTo(buffer.Span); _readPos += n; return n; } public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) { await _written.WriteAsync(buffer, ct).ConfigureAwait(false); } public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask; public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } }