using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; /// /// Covers — the shared capped-exponential backoff extracted /// from the S7 poll fork (05/STAB-8; the seam plan R2-01 wires into the S7 connect throttle). /// Two surfaces: the static schedule and the /// per-device attempt-throttle instance. /// [Trait("Category", "Unit")] public sealed class ConnectionBackoffTests { private static readonly TimeSpan Base = TimeSpan.FromSeconds(1); private static readonly TimeSpan Cap = TimeSpan.FromSeconds(30); /// Zero (or negative) consecutive failures returns the base interval unchanged. [Theory] [InlineData(0)] [InlineData(-1)] public void ComputeDelay_NoFailures_ReturnsBaseInterval(int failures) => ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Base); /// The delay doubles per consecutive failure (1×, 2×, 4×, 8×) until it saturates the cap. [Theory] [InlineData(1, 1)] [InlineData(2, 2)] [InlineData(3, 4)] [InlineData(4, 8)] [InlineData(5, 16)] public void ComputeDelay_DoublesPerFailure(int failures, int expectedSeconds) => ConnectionBackoff.ComputeDelay(Base, failures, Cap) .ShouldBe(TimeSpan.FromSeconds(expectedSeconds)); /// Growth saturates at the cap and never exceeds it, even at a large failure count (overflow guard). [Theory] [InlineData(6)] // 32s would exceed 30s cap [InlineData(30)] [InlineData(1000)] // shift saturates; ticks overflow guard returns cap public void ComputeDelay_SaturatesAtCap(int failures) => ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Cap); /// A fresh throttle permits the first attempt immediately. [Fact] public void ShouldAttempt_FreshInstance_AllowsImmediately() { var backoff = new ConnectionBackoff(Base, Cap); backoff.ShouldAttempt(DateTime.UtcNow).ShouldBeTrue(); } /// After a failure the throttle blocks inside the backoff window and reopens once it elapses. [Fact] public void RecordFailure_BlocksWithinWindow_ReopensAfter() { var backoff = new ConnectionBackoff(Base, Cap); var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); backoff.RecordFailure(t0); // window = 1s (first failure) backoff.ShouldAttempt(t0).ShouldBeFalse(); // still inside window backoff.ShouldAttempt(t0.AddMilliseconds(500)).ShouldBeFalse(); backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue(); // window elapsed } /// Consecutive failures widen the window (1s then 2s). [Fact] public void RecordFailure_ConsecutiveFailures_WidenWindow() { var backoff = new ConnectionBackoff(Base, Cap); var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); backoff.RecordFailure(t0); // 1s backoff.RecordFailure(t0.AddSeconds(1)); // 2nd failure → 2s window backoff.ShouldAttempt(t0.AddSeconds(2)).ShouldBeFalse(); // within the 2s window backoff.ShouldAttempt(t0.AddSeconds(3)).ShouldBeTrue(); } /// Success resets immediately — recovery is never delayed by a residual window. [Fact] public void RecordSuccess_ResetsWindowImmediately() { var backoff = new ConnectionBackoff(Base, Cap); var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); backoff.RecordFailure(t0); backoff.RecordFailure(t0); // deep in a widened window backoff.ShouldAttempt(t0).ShouldBeFalse(); backoff.RecordSuccess(); backoff.ShouldAttempt(t0).ShouldBeTrue(); // reset, no residual delay // And the schedule restarts from the base window on the next failure. backoff.RecordFailure(t0); backoff.ShouldAttempt(t0.AddMilliseconds(999)).ShouldBeFalse(); backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue(); } }