using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
///
/// Transport self-heal (review 07 P3), single in-gate connect attempt + failed-client disposal (L3)
/// and the response-PDU length check (H9).
///
[Trait("Category", "Unit")]
public sealed class ModbusTransportSelfHealTests
{
private static readonly byte[] ReadHr0 = [0x03, 0x00, 0x00, 0x00, 0x01];
///
/// P3: two lost replies in a row leave the socket torn down. The NEXT call must reconnect on its own
/// instead of failing fast with "Transport not connected" until the 90 s comms-loss watchdog.
///
[Fact]
public async Task After_two_consecutive_timeouts_the_next_call_reconnects()
{
await using var server = new ScriptedModbusTcpServer
{
Script = i => i < 2 ? ScriptedModbusTcpServer.Action.Stall : ScriptedModbusTcpServer.Action.Answer,
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(500));
await transport.ConnectAsync(TestContext.Current.CancellationToken);
// First call: stall → teardown → reconnect → resend → stall again → propagates.
await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
// Second call: the socket is gone; the transport must dial again and succeed.
var resp = await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
resp[0].ShouldBe((byte)0x03);
server.AcceptedConnections.ShouldBe(3);
}
///
/// P3 throttle: after a FAILED connect, calls inside the backoff window fail fast with
/// and make no connect attempt; once the window has
/// passed the next call dials again. Positive control: the device is back before the throttled call,
/// so an un-throttled transport would have reconnected.
///
[Fact]
public async Task Failed_connect_is_throttled_then_retried_after_the_window()
{
await using var server = new ScriptedModbusTcpServer();
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
var now = 1_000_000L;
transport.Lifecycle.NowMs = () => now;
await transport.ConnectAsync(TestContext.Current.CancellationToken);
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
server.Stop();
// Dead socket → EOF → teardown → ONE reconnect attempt → refused.
var first = await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ModbusSocketLifecycle.IsSocketLevelFailure(first).ShouldBeTrue();
server.Start();
var acceptedBefore = server.AcceptedConnections;
await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
server.AcceptedConnections.ShouldBe(acceptedBefore, "a throttled call must not dial");
now += 60_000; // past any window (MaxDelay default 30 s)
(await transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken))[0].ShouldBe((byte)0x03);
server.AcceptedConnections.ShouldBe(acceptedBefore + 1);
}
///
/// L3: the reconnect never sleeps inside the transport gate. With a 30 s InitialDelay the old
/// ConnectWithBackoffAsync held the gate for 30 s before even dialling; now one attempt is made
/// and the call returns at once.
///
[Fact]
public async Task Reconnect_after_drop_does_not_sleep_inside_the_gate()
{
await using var server = new ScriptedModbusTcpServer();
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2),
reconnect: new ModbusReconnectOptions { InitialDelay = TimeSpan.FromSeconds(30) });
await transport.ConnectAsync(TestContext.Current.CancellationToken);
server.Stop();
var call = transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync(() => call.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
ex.ShouldNotBeOfType("the send must not wait out the 30 s backoff inside the gate");
}
/// H9: an MBAP frame carrying no PDU is a truncated-frame desync, not an IndexOutOfRangeException.
[Fact]
public async Task Empty_response_pdu_is_a_truncated_frame_desync()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, []),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// H9: an exception frame missing its exception code is a truncated-frame desync.
[Fact]
public async Task Exception_frame_without_code_is_a_truncated_frame_desync()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83]),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// Positive control for the two H9 tests: a well-formed exception frame still surfaces as a
/// carrying its code.
[Fact]
public async Task Well_formed_exception_frame_still_surfaces_as_ModbusException()
{
await using var server = new ScriptedModbusTcpServer
{
RawReply = (_, header, _) => ScriptedModbusTcpServer.Adu(header, [0x83, 0x02]),
};
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync(
() => transport.SendAsync(1, ReadHr0, TestContext.Current.CancellationToken));
ex.ExceptionCode.ShouldBe((byte)0x02);
}
}