using System.Diagnostics;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
///
/// Unit tests for — the wall-clock ceiling that bounds every
/// S7 async wire op (R2-01 read-leg gate). Async S7.Net reads/writes ignore the socket
/// ReadTimeout/WriteTimeout, so a frozen-but-established peer would otherwise block for minutes;
/// these pin the deadline behaviour without a live PLC.
///
[Trait("Category", "Unit")]
public sealed class S7OperationDeadlineTests
{
private static readonly TimeSpan ShortTimeout = TimeSpan.FromMilliseconds(150);
/// An operation that completes within the deadline returns its value untouched.
[Fact]
public async Task Completes_within_deadline_returns_value()
{
var result = await S7OperationDeadline.RunAsync(
_ => Task.FromResult(42), ShortTimeout, "read", TestContext.Current.CancellationToken);
result.ShouldBe(42);
}
///
/// An operation that ignores its token and never completes is cut at the deadline and
/// surfaces a — the frozen-established-peer shape. Uses a bare
/// that never completes, so the token is genuinely ignored.
///
[Fact]
public async Task Token_ignoring_hang_times_out()
{
var neverCompletes = new TaskCompletionSource().Task;
var sw = Stopwatch.StartNew();
var ex = await Should.ThrowAsync(async () =>
await S7OperationDeadline.RunAsync(
_ => neverCompletes, ShortTimeout, "read of 'DB1.DBW0'", TestContext.Current.CancellationToken));
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5));
ex.Message.ShouldContain("did not complete within 150 ms");
}
///
/// An operation that honours its token (the well-behaved async-read case) is cancelled at the
/// deadline; the resulting cancellation is normalised to a ,
/// NOT surfaced as an .
///
[Fact]
public async Task Token_honouring_hang_times_out_as_timeout_not_cancel()
{
await Should.ThrowAsync(async () =>
await S7OperationDeadline.RunAsync(
async token =>
{
await Task.Delay(Timeout.Infinite, token);
return 0;
},
ShortTimeout, "read", TestContext.Current.CancellationToken));
}
///
/// Caller cancellation (as opposed to the deadline firing) propagates as an
/// — the deadline wrapper must not swallow genuine
/// cancellation into a timeout.
///
[Fact]
public async Task Caller_cancellation_propagates_as_operation_cancelled()
{
using var cts = new CancellationTokenSource();
cts.CancelAfter(50);
// A long deadline so the CALLER token fires first, not the internal deadline.
await Should.ThrowAsync(async () =>
await S7OperationDeadline.RunAsync(
async token =>
{
await Task.Delay(Timeout.Infinite, token);
return 0;
},
TimeSpan.FromSeconds(30), "read", cts.Token));
}
/// The result-less overload also bounds a hang and surfaces a .
[Fact]
public async Task Resultless_overload_times_out()
{
var neverCompletes = new TaskCompletionSource().Task;
await Should.ThrowAsync(async () =>
await S7OperationDeadline.RunAsync(
_ => neverCompletes, ShortTimeout, "write of 'DB1.DBW0'", TestContext.Current.CancellationToken));
}
}