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)); } }