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>
279 lines
8.6 KiB
C#
279 lines
8.6 KiB
C#
using AvParser.Core.Parsing;
|
|
using AvParser.Core.Parsing.Samples;
|
|
using AvParser.Core.Proxies;
|
|
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,
|
|
IProxyPool? proxyPool = null,
|
|
params ITextParser[] extraParsers
|
|
)
|
|
{
|
|
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extraParsers]);
|
|
var settingsService = new FakeSettingsService(settings);
|
|
|
|
return (
|
|
new ParseViewModel(
|
|
catalog,
|
|
settingsService,
|
|
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
|
new EmptyServiceProvider(),
|
|
NullLogger<ParseViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
),
|
|
settingsService
|
|
);
|
|
}
|
|
|
|
/// <summary>A container that resolves nothing; the page only reaches for navigation on click.</summary>
|
|
private sealed class EmptyServiceProvider : IServiceProvider
|
|
{
|
|
public object? GetService(Type serviceType) => null;
|
|
}
|
|
|
|
/// <summary>Stands in for the kind of parser the proxy gate exists for.</summary>
|
|
private sealed class NetworkParser : ITextParser
|
|
{
|
|
public string Id => "network";
|
|
|
|
public string DisplayName => "Network parser";
|
|
|
|
public string Description => "Fetches something";
|
|
|
|
public bool RequiresNetwork => true;
|
|
|
|
public bool CanParse(string input) => true;
|
|
|
|
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
|
|
string input,
|
|
IProgress<ParseProgress>? progress,
|
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
|
|
)
|
|
{
|
|
await Task.Yield();
|
|
yield break;
|
|
}
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_local_parser_runs_even_with_no_proxy_at_all()
|
|
{
|
|
// Only network parsers are gated. Blocking a parser that works on pasted text would make
|
|
// the app unusable whenever the public lists are down, for no benefit at all.
|
|
var (page, _) = Build();
|
|
|
|
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_network_parser_is_blocked_while_nothing_is_live()
|
|
{
|
|
var (page, _) = Build(
|
|
new AppSettings { LastParserId = "network" },
|
|
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
|
new NetworkParser()
|
|
);
|
|
|
|
var canExecute = true;
|
|
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
|
|
|
|
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
|
page.InputText = "anything";
|
|
|
|
canExecute.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_network_parser_runs_once_a_proxy_answers()
|
|
{
|
|
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080);
|
|
var pool = new ProxyPool(
|
|
[new FakeProxySource([endpoint])],
|
|
new FakeProxyProbe().Set(endpoint, alive: true),
|
|
new ProxyOptions()
|
|
);
|
|
|
|
var (page, _) = Build(new AppSettings { LastParserId = "network" }, pool, new NetworkParser());
|
|
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
|
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken);
|
|
page.RefreshProxyGate();
|
|
|
|
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Allowing_direct_connections_lifts_the_gate()
|
|
{
|
|
var (page, _) = Build(
|
|
new AppSettings { LastParserId = "network", AllowDirectConnection = true },
|
|
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
|
new NetworkParser()
|
|
);
|
|
|
|
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Switching_away_from_a_network_parser_lifts_the_gate()
|
|
{
|
|
var (page, _) = Build(
|
|
new AppSettings { LastParserId = "network" },
|
|
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
|
new NetworkParser()
|
|
);
|
|
|
|
page.SelectedParser = page.Parsers.Single(parser => parser.Id == "delimited");
|
|
|
|
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|