using AvParser.Core.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, 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); [Fact] public void Starts_empty() { var (page, _, _) = Build(); 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, 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, 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())); } }