Files
av-parser/tests/AvParser.UI.Tests/ParseViewModelTests.cs
T
Leonid PershinandClaude Opus 5 9bf2ea5532 Add a proxy pool with rotation, liveness checks and a management page
The parser will need to move between proxies, so this adds the module it will
sit on: pluggable sources, a pool that hands proxies out and learns from the
outcome, three rotation strategies, and a page to drive it.

Sources are IProxySource implementations. The public proxifly/free-proxy-list
feed is fetched as the combined all/data.json through jsDelivr and filtered
locally — one conditional request beats four per-protocol ones that can disagree
mid-publish — and cached for the five minutes upstream takes to regenerate. A
feed that is down keeps serving its last payload rather than emptying the pool.
The user's own list lives in proxies.custom.json beside the settings, takes a
pasted blob, and names the lines it could not parse instead of quietly dropping
them.

Both knobs the pool exposes are settings, as asked: rotation is Sticky (default,
the only one that keeps site sessions coherent), RoundRobin or WeightedRandom;
liveness is either a parallel sweep of the whole pool or a probe at hand-out
time. Free lists are a few percent alive, so skipping verification entirely
means mostly waiting on timeouts.

Two invariants worth keeping, both of which cost a bug to find:

Availability is decided by the quarantine, never by Health. Excluding everything
that has ever failed made the quarantine window dead code and discarded proxies
permanently on their first hiccup, which is exactly wrong for addresses that
flap constantly. Health only orders the candidates now.

A probe verdict does not touch the success/failure counters. Those are about
real requests, and letting a sweep over a few thousand proxies rewrite them
would drown the evidence weighted selection reads.

SOCKS needs no extra package — .NET resolves socks4/socks4a/socks5 in WebProxy —
but a proxifly record with "protocol": "https" is still an HTTP proxy reached
over http:// with CONNECT, not an https:// scheme.

115 new tests. Also fixes a pre-existing flake: a command gated on another
command's IsExecuting cannot be driven straight after its Execute() completes,
because IsExecuting is published on the output scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:22:30 +03:00

168 lines
5.0 KiB
C#

using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Settings;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(AppSettings? settings = null)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser()]);
var settingsService = new FakeSettingsService(settings);
return (
new ParseViewModel(
catalog,
settingsService,
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
),
settingsService
);
}
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
/// <summary>
/// Waits until a command's gate opens.
/// </summary>
/// <remarks>
/// <c>Execute()</c> completing and <c>IsExecuting</c> going false are not the same instant:
/// the latter is published on the output scheduler. Commands gated on another command's
/// IsExecuting therefore need the gate observed, not assumed — asserting straight after the
/// await failed intermittently under load.
/// </remarks>
private static Task WhenExecutable<TParam, TResult>(ReactiveUI.ReactiveCommand<TParam, TResult> command) =>
command.CanExecute.Where(static can => can).Take(1).ToTask(TestContext.Current.CancellationToken);
[Fact]
public void Restores_the_last_used_parser()
{
var (page, _) = Build(new AppSettings { LastParserId = "key-value" });
page.SelectedParser.Id.ShouldBe("key-value");
}
[Fact]
public void Falls_back_to_the_default_parser_for_an_unknown_id()
{
var (page, _) = Build(new AppSettings { LastParserId = "removed-in-a-past-version" });
page.SelectedParser.Id.ShouldBe("delimited");
}
[Fact]
public void Remembers_the_selected_parser()
{
var (page, settings) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
settings.Current.LastParserId.ShouldBe("key-value");
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("id,name\n1,Ada", true)]
public void Parsing_requires_non_blank_input(string input, bool expected)
{
var (page, _) = Build();
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.InputText = input;
canExecute.ShouldBe(expected);
}
[Fact]
public async Task Parsing_fills_the_records_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(2);
page.Errors.ShouldBeEmpty();
page.Progress.ShouldBe(1d);
page.StatusMessage!.ShouldContain("2 records");
}
[Fact]
public async Task Bad_lines_land_in_the_errors_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
page.Errors.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("1 error");
}
[Fact]
public async Task A_second_run_replaces_the_previous_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
}
[Fact]
public async Task Cancelling_stops_the_run_and_says_so()
{
var (page, _) = Build();
page.InputText = string.Join(
'\n',
Enumerable.Range(0, 200_000).Select(i => i == 0 ? "id,name" : $"{i},row{i}")
);
var run = RunAsync(page);
page.CancelCommand.Execute().Subscribe(_ => { });
await run;
page.StatusMessage!.ShouldStartWith("Cancelled");
}
[Fact]
public void Loading_the_sample_matches_the_selected_parser()
{
var (page, _) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
page.LoadSampleCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldContain("host = localhost");
}
[Fact]
public async Task Clearing_empties_the_input_and_the_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
await WhenExecutable(page.ClearCommand);
await page.ClearCommand.Execute().ToTask(TestContext.Current.CancellationToken);
page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty();
page.StatusMessage.ShouldBeNull();
page.Progress.ShouldBe(0d);
}
}