feat: add resilient reconnect and catch-up replay

This commit is contained in:
Joseph Doherty
2026-03-17 11:04:19 -04:00
parent c278f98496
commit 2f04ec9d1d
29 changed files with 3746 additions and 95 deletions

View File

@@ -0,0 +1,52 @@
using SuiteLink.Client.Internal;
namespace SuiteLink.Client.Tests.Internal;
public sealed class SuiteLinkRetryDelayCalculatorTests
{
[Fact]
public void GetDelay_UsesImmediateThenExponentialCap()
{
var policy = new SuiteLinkRetryPolicy(
initialDelay: TimeSpan.FromSeconds(1),
multiplier: 2.0,
maxDelay: TimeSpan.FromSeconds(30),
useJitter: false);
Assert.Equal(TimeSpan.Zero, SuiteLinkRetryDelayCalculator.GetDelay(policy, 0));
Assert.Equal(TimeSpan.FromSeconds(1), SuiteLinkRetryDelayCalculator.GetDelay(policy, 1));
Assert.Equal(TimeSpan.FromSeconds(2), SuiteLinkRetryDelayCalculator.GetDelay(policy, 2));
Assert.Equal(TimeSpan.FromSeconds(4), SuiteLinkRetryDelayCalculator.GetDelay(policy, 3));
}
[Theory]
[InlineData(-1, 2.0, 30)]
[InlineData(1, 0.0, 30)]
[InlineData(1, -1.0, 30)]
[InlineData(1, 2.0, -1)]
public void RetryPolicy_InvalidArguments_Throw(
int initialDelaySeconds,
double multiplier,
int maxDelaySeconds)
{
Assert.ThrowsAny<ArgumentOutOfRangeException>(() => new SuiteLinkRetryPolicy(
initialDelay: TimeSpan.FromSeconds(initialDelaySeconds),
multiplier: multiplier,
maxDelay: TimeSpan.FromSeconds(maxDelaySeconds),
useJitter: false));
}
[Fact]
public void GetDelay_WithJitterEnabled_StaysWithinCap()
{
var policy = new SuiteLinkRetryPolicy(
initialDelay: TimeSpan.FromSeconds(2),
multiplier: 2.0,
maxDelay: TimeSpan.FromSeconds(10),
useJitter: true);
var delay = SuiteLinkRetryDelayCalculator.GetDelay(policy, 3, () => 0.5);
Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
}

View File

@@ -169,12 +169,56 @@ public sealed class SuiteLinkSessionTests
Assert.Equal("callback failure", callbackException.Message);
}
[Fact]
public void TryDispatchUpdate_WithExplicitSource_UsesProvidedSource()
{
var session = new SuiteLinkSession();
SuiteLinkTagUpdate? callbackUpdate = null;
session.RegisterSubscription("Pump001.Run", 0x1234, update => callbackUpdate = update);
var decoded = new DecodedUpdate(
TagId: 0x1234,
Quality: 0x00C0,
ElapsedMilliseconds: 10,
Value: SuiteLinkValue.FromBoolean(true));
var dispatched = session.TryDispatchUpdate(
decoded,
DateTimeOffset.UtcNow,
SuiteLinkUpdateSource.CatchUpReplay,
out var dispatchedUpdate,
out _);
Assert.True(dispatched);
Assert.NotNull(dispatchedUpdate);
Assert.Equal(SuiteLinkUpdateSource.CatchUpReplay, dispatchedUpdate.Source);
Assert.Equal(dispatchedUpdate, callbackUpdate);
}
[Fact]
public void ClearSubscriptions_RemovesAllMappings()
{
var session = new SuiteLinkSession();
session.RegisterSubscription("Pump001.Run", 0x1234, _ => { });
session.RegisterSubscription("Pump001.Speed", 0x5678, _ => { });
session.ClearSubscriptions();
Assert.False(session.TryGetTagId("Pump001.Run", out _));
Assert.False(session.TryGetTagId("Pump001.Speed", out _));
Assert.False(session.TryGetItemName(0x1234, out _));
Assert.False(session.TryGetItemName(0x5678, out _));
Assert.Equal(0, session.SubscriptionCount);
}
[Fact]
public void SetState_InvalidTransition_ThrowsInvalidOperationException()
{
var session = new SuiteLinkSession();
var ex = Assert.Throws<InvalidOperationException>(() => session.SetState(SuiteLinkSessionState.SessionConnected));
var ex = Assert.Throws<InvalidOperationException>(() => session.SetState(SuiteLinkSessionState.Ready));
Assert.Contains("Invalid state transition", ex.Message);
Assert.Equal(SuiteLinkSessionState.Disconnected, session.State);
@@ -191,4 +235,17 @@ public sealed class SuiteLinkSessionTests
Assert.False(session.TryTransitionState(SuiteLinkSessionState.Disconnected, SuiteLinkSessionState.HandshakeComplete));
Assert.Equal(SuiteLinkSessionState.TcpConnected, session.State);
}
[Fact]
public void SetState_ReconnectAttemptStartupFailure_CanReturnToReconnecting()
{
var session = new SuiteLinkSession();
session.SetState(SuiteLinkSessionState.TcpConnected);
session.SetState(SuiteLinkSessionState.HandshakeComplete);
session.SetState(SuiteLinkSessionState.ConnectSent);
session.SetState(SuiteLinkSessionState.Reconnecting);
Assert.Equal(SuiteLinkSessionState.Reconnecting, session.State);
}
}