Files
av-parser/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs
T
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00

233 lines
7.5 KiB
C#

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, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
return (page, pool, custom);
}
private static ProxyEndpoint Endpoint(string host, ProxyProtocol protocol = ProxyProtocol.Http) =>
new(protocol, host, 8080);
/// <summary>Runs a command to completion under the ambient test cancellation token.</summary>
private static Task Run<TResult>(ReactiveUI.ReactiveCommand<RxVoid, TResult> command) =>
command.Execute().ToTask(TestContext.Current.CancellationToken);
/// <summary>Polls until the throttled rebuild has caught up, or gives up.</summary>
/// <remarks>
/// 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.
/// </remarks>
private static async Task WaitUntil(Func<bool> 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, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.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, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.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()));
}
}