47e9cd56ef
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
111 lines
5.1 KiB
C#
111 lines
5.1 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|
|
|
/// <summary>
|
|
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
|
|
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> 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 <c>stall</c> is set.
|
|
/// </summary>
|
|
[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<ModbusException>(
|
|
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<byte>(), stall: true);
|
|
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
|
|
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
|
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
|
TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
|
|
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
|
|
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
|
|
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<int> ReadAsync(Memory<byte> 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<byte> 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();
|
|
}
|
|
}
|