using AvParser.Core.Proxies; using AvParser.Core.Proxies.Selection; namespace AvParser.Core.Tests.Proxies; public class ProxySelectionTests { private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); [Fact] public void Sticky_keeps_returning_the_same_proxy() { var strategy = new StickyProxySelection(); ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")]; var first = strategy.Pick(candidates); strategy.Pick(candidates).ShouldBeSameAs(first); strategy.Pick(candidates).ShouldBeSameAs(first); } [Fact] public void Sticky_moves_on_after_a_failure() { var strategy = new StickyProxySelection(); ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")]; var first = strategy.Pick(candidates)!; strategy.Report(first, success: false); var remaining = candidates.Where(entry => !ReferenceEquals(entry, first)).ToArray(); strategy.Pick(remaining).ShouldNotBeSameAs(first); } [Fact] public void Sticky_lets_go_when_its_pick_leaves_the_candidate_set() { var strategy = new StickyProxySelection(); var a = ProxyFactory.Entry("a"); var b = ProxyFactory.Entry("b"); strategy.Pick([a, b]).ShouldBeSameAs(a); // A refresh or a quarantine can drop the current pick under us. strategy.Pick([b]).ShouldBeSameAs(b); } [Fact] public void Sticky_survives_a_success_report() { var strategy = new StickyProxySelection(); ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b")]; var first = strategy.Pick(candidates)!; strategy.Report(first, success: true); strategy.Pick(candidates).ShouldBeSameAs(first); } [Fact] public void RoundRobin_cycles_through_every_candidate() { var strategy = new RoundRobinProxySelection(); ProxyEntry[] candidates = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")]; var picked = Enumerable.Range(0, 6).Select(_ => strategy.Pick(candidates)!.Endpoint.Host).ToArray(); picked.ShouldBe(["a", "b", "c", "a", "b", "c"]); } [Fact] public void RoundRobin_handles_a_shrinking_candidate_list() { var strategy = new RoundRobinProxySelection(); ProxyEntry[] three = [ProxyFactory.Entry("a"), ProxyFactory.Entry("b"), ProxyFactory.Entry("c")]; strategy.Pick(three); strategy.Pick(three); strategy.Pick(three); // The cursor is now past the end of the smaller list; this must not throw. Should.NotThrow(() => strategy.Pick([three[0]])); } [Fact] public void Every_strategy_returns_null_for_an_empty_pool() { foreach (var rotation in Enum.GetValues()) { ProxySelectionStrategyFactory.Create(rotation, new Random(1)).Pick([]).ShouldBeNull(); } } [Fact] public void Weighted_selection_favours_proxies_that_have_worked() { var good = ProxyFactory.Entry("good", score: 5); var bad = ProxyFactory.Entry("bad", score: 5); for (var i = 0; i < 20; i++) { good.RecordSuccess(Now); bad.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue); } var strategy = new WeightedRandomProxySelection(new Random(20260813)); var picks = Enumerable.Range(0, 400).Count(_ => strategy.Pick([good, bad])!.Endpoint.Host == "good"); // Not asserting an exact split — this is a random draw. The point is the bias exists and // is decisive, not that it hits a particular number. picks.ShouldBeGreaterThan(340); } [Fact] public void Weight_never_drops_to_zero_so_a_bad_proxy_can_recover() { var hopeless = ProxyFactory.Entry("hopeless"); for (var i = 0; i < 50; i++) { hopeless.RecordFailure(Now, TimeSpan.Zero, TimeSpan.Zero, failuresBeforeQuarantine: int.MaxValue); } WeightedRandomProxySelection.WeightOf(hopeless).ShouldBeGreaterThan(0d); } [Fact] public void An_unproven_proxy_starts_at_even_odds() { // Before any evidence, success rate is 0.5 rather than 0 — otherwise a freshly loaded // pool would have every weight pinned to the floor and selection would be arbitrary. ProxyFactory.Entry("fresh").SuccessRate.ShouldBe(0.5d); } [Fact] public void The_factory_builds_what_it_was_asked_for() { foreach (var rotation in Enum.GetValues()) { ProxySelectionStrategyFactory.Create(rotation).Kind.ShouldBe(rotation); } } }