using GatewayKeepalive.Core; namespace GatewayKeepalive.Tests; internal static class SwitchPolicyTests { public static Task SuccessThresholdAndCooldownAsync() { var clock = new ManualTimeProvider(); var policy = new SwitchPolicy(clock); var settings = Fixtures.Settings() with { SuccessThreshold = 3, RecoveryCooldownSeconds = 10 }; var healthy = Fixtures.Health(true, true); policy.Switched(); Check.Equal(false, policy.Evaluate(settings, NetworkMode.Direct, healthy) is not null, "The first success must remain below threshold"); Check.Equal(1, policy.SuccessCount, "One success should be counted"); clock.AdvanceSeconds(5); Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The second success must remain below threshold"); clock.AdvanceSeconds(4); Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The recovery cooldown must still block at 9 seconds"); Check.Equal(3, policy.SuccessCount, "Success count should cap at the configured threshold"); clock.AdvanceSeconds(1); Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "The switch must fire exactly when the recovery cooldown expires"); var resetting = new SwitchPolicy(clock); Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First healthy observation should not switch"); Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second healthy observation should not switch"); Check.True(resetting.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(false, true)) is null, "An unhealthy observation must reset consecutive success"); Check.Equal(0, resetting.SuccessCount, "Unhealthy observation should reset success count"); Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First post-reset success must not switch"); Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second post-reset success must not switch"); Check.Equal(2, resetting.SuccessCount, "Only consecutive successes should count"); return Task.CompletedTask; } public static Task FailureThresholdAndCooldownAsync() { var clock = new ManualTimeProvider(); var policy = new SwitchPolicy(clock); var settings = Fixtures.Settings() with { FailureThreshold = 3, FailureCooldownSeconds = 5, RecoveryCooldownSeconds = 3600, }; policy.Switched(); var unhealthy = Fixtures.Health(true, false); Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "First failed probe must not switch"); clock.AdvanceSeconds(1); Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Second failed probe must not switch"); clock.AdvanceSeconds(3); Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Failure cooldown must block before 5 seconds"); Check.Equal(3, policy.FailureCount, "Failure count should cap at threshold"); clock.AdvanceSeconds(1); Check.Equal(false, policy.Evaluate(settings, NetworkMode.Proxy, unhealthy), "Proxy must fail over to direct at its own cooldown"); var recoveryClock = new ManualTimeProvider(); var recovery = new SwitchPolicy(recoveryClock); var recoverySettings = settings with { SuccessThreshold = 1, RecoveryCooldownSeconds = 40, FailureCooldownSeconds = 3600 }; recovery.Switched(); recoveryClock.AdvanceSeconds(39); Check.True(recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)) is null, "Recovery cooldown must be independent of failure cooldown"); recoveryClock.AdvanceSeconds(1); Check.Equal(true, recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)), "Direct should recover at the configured recovery cooldown"); var resetting = new SwitchPolicy(new ManualTimeProvider()); resetting.Switched(); resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); Check.Equal(2, resetting.FailureCount, "Failure fixture should be consecutive"); Check.True(resetting.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(true, true)) is null, "A healthy proxy check must not switch"); Check.Equal(0, resetting.FailureCount, "A healthy proxy check must reset failure count"); resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy); Check.Equal(1, resetting.FailureCount, "Only consecutive failures should count after a healthy check"); return Task.CompletedTask; } public static Task FailureRetryUsesThirtySecondFloorAsync() { var clock = new ManualTimeProvider(); var policy = new SwitchPolicy(clock); var settings = Fixtures.Settings() with { SuccessThreshold = 1, RecoveryCooldownSeconds = 0 }; var healthy = Fixtures.Health(true, true); policy.Switched(); clock.AdvanceSeconds(1); policy.Failed(); Check.Equal(0, policy.SuccessCount, "Failed() should reset success count"); Check.Equal(0, policy.FailureCount, "Failed() should reset failure count"); clock.AdvanceSeconds(29); Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Recovery must remain blocked 29 seconds after a failure"); Check.Equal(1, policy.SuccessCount, "The blocked attempt should still be observed"); clock.AdvanceSeconds(1); Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "Recovery must become available at the 30 second failure backoff"); var failureClock = new ManualTimeProvider(); var failurePolicy = new SwitchPolicy(failureClock); var failureSettings = settings with { FailureThreshold = 1, FailureCooldownSeconds = 0 }; failurePolicy.Failed(); failureClock.AdvanceSeconds(29); Check.True(failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)) is null, "Failover must also wait 29 seconds"); failureClock.AdvanceSeconds(1); Check.Equal(false, failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)), "Failover must be allowed at the 30 second floor"); return Task.CompletedTask; } public static Task NonAutoModesResetCountsAsync() { var policy = new SwitchPolicy(new ManualTimeProvider()); var settings = Fixtures.Settings(); var healthy = Fixtures.Health(true, true); policy.Evaluate(settings, NetworkMode.Direct, healthy); policy.Evaluate(settings, NetworkMode.Direct, healthy); Check.Equal(2, policy.SuccessCount, "Fixture should accumulate success before changing mode"); foreach (var mode in new[] { ControlPolicy.Paused, ControlPolicy.ForceDirect, ControlPolicy.ForceProxy }) { Check.True(policy.Evaluate(settings with { Policy = mode }, NetworkMode.Direct, healthy) is null, $"{mode} must request no automatic switch"); Check.Equal(0, policy.SuccessCount, $"{mode} must clear success count"); Check.Equal(0, policy.FailureCount, $"{mode} must clear failure count"); } policy.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(false, true)); Check.True(policy.Evaluate(settings, NetworkMode.Degraded, healthy) is null, "Observe-only network modes must not switch"); Check.Equal(0, policy.FailureCount, "Observe-only modes must clear failure count"); return Task.CompletedTask; } public static Task CountsResetAfterEventsAsync() { var clock = new ManualTimeProvider(); var policy = new SwitchPolicy(clock); var settings = Fixtures.Settings(); var sick = Fixtures.Health(true, false); policy.Evaluate(settings, NetworkMode.Proxy, sick); policy.Evaluate(settings, NetworkMode.Proxy, sick); Check.Equal(2, policy.FailureCount, "Failure count fixture"); policy.Switched(); Check.Equal(0, policy.FailureCount, "Switched() should clear failure count"); Check.Equal(0, policy.SuccessCount, "Switched() should clear success count"); policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); policy.Failed(); Check.Equal(0, policy.SuccessCount, "Failed() should clear success count"); Check.Equal(0, policy.FailureCount, "Failed() should clear failure count"); policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true)); policy.Reset(); Check.Equal(0, policy.SuccessCount, "Reset() should clear success count"); Check.Equal(0, policy.FailureCount, "Reset() should clear failure count"); return Task.CompletedTask; } }