using AvParser.Core.Proxies; using AvParser.Infrastructure.Proxies; using AvParser.UI.Tests.Fakes; using AvParser.UI.ViewModels; using Microsoft.Extensions.Logging.Abstractions; using ReactiveUI.Primitives.Concurrency; namespace AvParser.UI.Tests; public class ProxiesViewModelTests { private static (ProxiesViewModel Page, ProxyPool Pool, FakeMutableProxySource Custom) Build(params string[] hosts) { var custom = new FakeMutableProxySource(); var feed = new FakeProxySource(hosts.Select(host => Endpoint(host))); var pool = new ProxyPool( [feed, custom], new FakeProxyProbe(), new ProxyOptions(), timeProvider: null, random: new Random(1) ); var page = new ProxiesViewModel( pool, custom, new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); return (page, pool, custom); } private static ProxyEndpoint Endpoint(string host, ProxyProtocol protocol = ProxyProtocol.Http) => new(protocol, host, 8080); /// Runs a command to completion under the ambient test cancellation token. private static Task Run(ReactiveUI.ReactiveCommand command) => command.Execute().ToTask(TestContext.Current.CancellationToken); /// Polls until the throttled rebuild has caught up, or gives up. /// /// The page coalesces pool changes over 250 ms, so anything driven by the pool rather than by /// a command needs to be waited for rather than asserted on the next line. /// private static async Task WaitUntil(Func condition) { for (var attempt = 0; attempt < 50 && !condition(); attempt++) { await Task.Delay(20, TestContext.Current.CancellationToken); } } [Fact] public async Task The_pool_loads_without_the_user_pressing_refresh() { var (page, _, _) = Build("a", "b"); // Deliberately no RefreshCommand: opening the page joins the startup load, which is what // stops the list from being empty until someone presses the button. await WaitUntil(() => page.TotalCount == 2); page.TotalCount.ShouldBe(2); page.Proxies.Count.ShouldBe(2); page.StatusMessage.ShouldNotBeNull(); } [Fact] public async Task An_empty_pool_stays_empty() { var (page, _, _) = Build(); await WaitUntil(() => page.StatusMessage is not null); page.Proxies.ShouldBeEmpty(); page.TotalCount.ShouldBe(0); } [Fact] public async Task Refreshing_fills_the_list() { var (page, _, _) = Build("a", "b"); await Run(page.RefreshCommand); page.Proxies.Count.ShouldBe(2); page.TotalCount.ShouldBe(2); page.StatusMessage.ShouldNotBeNull(); } [Fact] public async Task The_search_box_filters_by_address() { var (page, _, _) = Build("10.0.0.1", "10.0.0.2"); await Run(page.RefreshCommand); page.SearchText = "10.0.0.2"; await Task.Delay(250, TestContext.Current.CancellationToken); page.Proxies.ShouldHaveSingleItem().Address.ShouldContain("10.0.0.2"); } [Fact] public async Task The_protocol_filter_narrows_the_list() { var custom = new FakeMutableProxySource(); var feed = new FakeProxySource([Endpoint("http-one"), Endpoint("socks-one", ProxyProtocol.Socks5)]); var pool = new ProxyPool([feed, custom], new FakeProxyProbe(), new ProxyOptions()); var page = new ProxiesViewModel( pool, custom, new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); await Run(page.RefreshCommand); page.ProtocolFilter = page.ProtocolFilters.Single(option => option.Value == ProxyProtocolFilter.Socks5); await Task.Delay(250, TestContext.Current.CancellationToken); page.Proxies.ShouldHaveSingleItem().Protocol.ShouldBe("SOCKS5"); } [Fact] public async Task Adding_custom_proxies_reports_what_it_could_not_parse() { var (page, _, custom) = Build(); page.NewProxies = "1.2.3.4:8080\nnot-a-proxy"; await Run(page.AddCustomCommand); custom.Endpoints.Count.ShouldBe(1); page.StatusMessage!.ShouldContain("not-a-proxy"); } [Fact] public async Task The_input_box_is_cleared_only_when_something_was_added() { var (page, _, _) = Build(); page.NewProxies = "not-a-proxy"; await Run(page.AddCustomCommand); page.NewProxies.ShouldBe("not-a-proxy"); page.NewProxies = "1.2.3.4:8080"; await Run(page.AddCustomCommand); page.NewProxies.ShouldBeEmpty(); } [Fact] public async Task Removing_is_offered_only_for_custom_entries() { var (page, _, _) = Build("feed-one"); await Run(page.RefreshCommand); var canRemove = true; using var subscription = page.RemoveSelectedCommand.CanExecute.Subscribe(value => canRemove = value); page.SelectedProxy = page.Proxies.Single(); // Feed entries are republished upstream; removing one locally would be undone on the // next refresh, so the command stays disabled. canRemove.ShouldBeFalse(); } [Fact] public async Task A_custom_entry_can_be_removed() { var (page, _, custom) = Build(); page.NewProxies = "1.2.3.4:8080"; await Run(page.AddCustomCommand); page.SelectedProxy = page.Proxies.Single(); page.SelectedProxy.IsCustom.ShouldBeTrue(); await Run(page.RemoveSelectedCommand); custom.Endpoints.ShouldBeEmpty(); page.Proxies.ShouldBeEmpty(); } [Fact] public async Task Sweeping_updates_the_alive_count() { var custom = new FakeMutableProxySource(); var feed = new FakeProxySource([Endpoint("good"), Endpoint("bad")]); var probe = new FakeProxyProbe(); probe.Set(Endpoint("good"), alive: true); var pool = new ProxyPool([feed, custom], probe, new ProxyOptions()); var page = new ProxiesViewModel( pool, custom, new ProxyPoolLoader(pool, NullLogger.Instance), NullLogger.Instance, ImmediateSequencer.Instance ); await Run(page.RefreshCommand); await Run(page.SweepCommand); page.AliveCount.ShouldBe(1); page.IsSweeping.ShouldBeFalse(); page.StatusMessage!.ShouldContain("1 of 2"); } [Fact] public async Task Clearing_empties_the_custom_list() { var (page, _, custom) = Build(); page.NewProxies = "1.2.3.4:8080\n5.6.7.8:1080"; await Run(page.AddCustomCommand); await Run(page.ClearCustomCommand); custom.Endpoints.ShouldBeEmpty(); page.Proxies.ShouldBeEmpty(); } [Fact] public void Disposing_detaches_from_the_pool() { var (page, pool, _) = Build("a"); page.Dispose(); // The pool is a singleton; a page that stayed subscribed would be kept alive forever // and would keep rebuilding its rows in the background. Should.NotThrow(() => pool.Configure(new ProxyOptions())); } }