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>
This commit is contained in:
Leonid Pershin
2026-08-13 19:11:51 +03:00
co-authored by Claude Opus 5
parent 85656e70b0
commit 44fb0d3a5f
31 changed files with 1349 additions and 41 deletions
@@ -0,0 +1,105 @@
using AvParser.Core.Proxies;
namespace AvParser.Core.Tests.Proxies;
public class ProxyPoolWarmUpTests
{
private static readonly ProxyOptions Options = new() { ProbeConcurrency = 1 };
private static ProxyPool Build(out FakeProxyProbe probe, out FakeTimeProvider clock, params string[] hosts)
{
var source = new FakeProxySource(ProxySourceKind.Feed);
source.Endpoints.AddRange(hosts.Select(host => ProxyFactory.Endpoint(host)));
probe = new FakeProxyProbe();
clock = new FakeTimeProvider();
return new ProxyPool([source], probe, Options, clock);
}
[Fact]
public async Task What_worked_last_time_is_tried_first()
{
// The whole point of remembering: a second launch should confirm the known-good ones
// rather than walking a list of a few thousand addresses from the top.
var pool = Build(out var probe, out var clock, "cold-a", "known-good", "cold-b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries.Single(entry => entry.Endpoint.Host == "known-good")
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(30), 12, 1, clock.GetUtcNow());
pool.WarmUpOrder()[0].Endpoint.Host.ShouldBe("known-good");
// Asking for two: one is already known live, so the warm-up has a reason to probe at all.
probe.DefaultAlive = true;
await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
probe.Probed[0].ShouldContain("known-good");
}
[Fact]
public async Task The_faster_of_two_remembered_proxies_goes_first()
{
var pool = Build(out _, out var clock, "slow", "fast");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries.Single(entry => entry.Endpoint.Host == "slow")
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(900), 3, 0, clock.GetUtcNow());
pool.Entries.Single(entry => entry.Endpoint.Host == "fast")
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(40), 3, 0, clock.GetUtcNow());
pool.WarmUpOrder().Select(entry => entry.Endpoint.Host).ShouldBe(["fast", "slow"]);
}
[Fact]
public async Task Warming_up_stops_once_the_target_is_met()
{
// Probing all 200 when 2 were asked for would turn every launch into a full sweep.
var pool = Build(out var probe, out _, [.. Enumerable.Range(0, 200).Select(i => $"h{i}")]);
await pool.RefreshAsync(TestContext.Current.CancellationToken);
probe.DefaultAlive = true;
var live = await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
live.ShouldBeGreaterThanOrEqualTo(2);
probe.ProbeCount.ShouldBeLessThan(200);
}
[Fact]
public async Task Warming_up_probes_nothing_when_enough_are_already_live()
{
var pool = Build(out var probe, out var clock, "a", "b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
foreach (var entry in pool.Entries)
{
entry.RecordSuccess(clock.GetUtcNow(), TimeSpan.FromMilliseconds(10));
}
(await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(2);
probe.ProbeCount.ShouldBe(0);
}
[Fact]
public async Task A_quarantined_proxy_is_not_warmed_up()
{
var pool = Build(out _, out var clock, "a", "b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
pool.Entries.Single(entry => entry.Endpoint.Host == "a")
.RecordFailure(clock.GetUtcNow(), TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), 1);
pool.WarmUpOrder().Select(entry => entry.Endpoint.Host).ShouldBe(["b"]);
}
[Fact]
public async Task Nothing_live_reads_as_nothing_live()
{
var pool = Build(out var probe, out _, "a", "b");
await pool.RefreshAsync(TestContext.Current.CancellationToken);
probe.DefaultAlive = false;
(await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(0);
pool.LiveCount.ShouldBe(0);
}
}
@@ -6,12 +6,15 @@ namespace AvParser.Infrastructure.Tests;
public class ProxyPoolLoaderTests
{
private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool)
private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool) =>
Build(out source, out pool, new MemoryStateStore());
private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool, IProxyStateStore stateStore)
{
source = new CountingSource();
pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions());
return new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance);
return new ProxyPoolLoader(pool, stateStore, NullLogger<ProxyPoolLoader>.Instance);
}
[Fact]
@@ -19,11 +22,42 @@ public class ProxyPoolLoaderTests
{
var loader = Build(out _, out var pool);
(await loader.EnsureLoadedAsync()).ShouldBe(2);
(await loader.EnsureLoadedAsync()).Total.ShouldBe(2);
pool.Entries.Count.ShouldBe(2);
loader.IsLoaded.ShouldBeTrue();
}
[Fact]
public async Task What_the_previous_run_learned_is_restored_onto_the_fresh_list()
{
// The feed republishes the same addresses every few minutes; the point of remembering is
// that a proxy known to work is not re-discovered from scratch on every launch.
var stateStore = new MemoryStateStore
{
State =
{
["http://1.2.3.4:8080"] = new ProxyStateRecord("http://1.2.3.4:8080", Alive: true, LatencyMs: 120),
},
};
var loader = Build(out _, out _, stateStore);
var result = await loader.EnsureLoadedAsync();
result.Restored.ShouldBe(1);
}
[Fact]
public async Task The_pool_state_is_written_back_after_a_load()
{
var stateStore = new MemoryStateStore();
var loader = Build(out _, out _, stateStore);
await loader.EnsureLoadedAsync();
stateStore.Saves.ShouldBeGreaterThan(0);
}
[Fact]
public async Task The_feed_is_fetched_once_however_many_callers_there_are()
{
@@ -52,10 +86,27 @@ public class ProxyPoolLoaderTests
public async Task A_source_that_throws_does_not_take_startup_down()
{
var pool = new ProxyPool([new ThrowingSource()], new NeverProbe(), new ProxyOptions());
var loader = new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance);
var loader = new ProxyPoolLoader(pool, new MemoryStateStore(), NullLogger<ProxyPoolLoader>.Instance);
// The app has to start whether or not a public list is reachable.
(await loader.EnsureLoadedAsync()).ShouldBe(0);
(await loader.EnsureLoadedAsync()).Total.ShouldBe(0);
}
private sealed class MemoryStateStore : IProxyStateStore
{
public Dictionary<string, ProxyStateRecord> State { get; } = new(StringComparer.Ordinal);
public int Saves { get; private set; }
public Task<IReadOnlyDictionary<string, ProxyStateRecord>> LoadAsync(
CancellationToken cancellationToken = default
) => Task.FromResult<IReadOnlyDictionary<string, ProxyStateRecord>>(State);
public Task SaveAsync(IEnumerable<ProxyEntry> entries, CancellationToken cancellationToken = default)
{
Saves++;
return Task.CompletedTask;
}
}
private sealed class CountingSource : IProxySource
@@ -0,0 +1,166 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests;
public sealed class ProxyStateStoreTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"AvParserTests",
Guid.NewGuid().ToString("N")
);
private ProxyStateStore Create() => new(new AppPaths(_directory), NullLogger<ProxyStateStore>.Instance);
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
private static ProxyEntry Entry(string address, ProxySourceKind kind = ProxySourceKind.Feed)
{
ProxyEndpoint.TryParse(address, out var endpoint).ShouldBeTrue();
return new ProxyEntry(endpoint!, kind);
}
[Fact]
public async Task An_absent_file_reads_as_nothing_remembered()
{
var store = Create();
(await store.LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task A_working_proxy_survives_a_round_trip()
{
var alive = Entry("http://1.2.3.4:8080");
alive.RecordSuccess(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(250));
await Create().SaveAsync([alive], TestContext.Current.CancellationToken);
var state = await Create().LoadAsync(TestContext.Current.CancellationToken);
state.ShouldContainKey(alive.Endpoint.Key);
state[alive.Endpoint.Key].Alive.ShouldBeTrue();
state[alive.Endpoint.Key].LatencyMs.ShouldBe(250d);
}
[Fact]
public async Task Proxies_that_never_answered_are_not_remembered()
{
// The feed republishes a few thousand dead addresses every few minutes; carrying them over
// would bloat the file to save re-testing entries whose staleness tells us nothing.
var untried = Entry("http://1.2.3.4:8080");
var dead = Entry("http://5.6.7.8:3128");
dead.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout");
await Create().SaveAsync([untried, dead], TestContext.Current.CancellationToken);
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public void Applying_remembered_state_restores_the_matching_entries_only()
{
var known = Entry("http://1.2.3.4:8080");
var unknown = Entry("http://9.9.9.9:8080");
var state = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
{
[known.Endpoint.Key] = new(
known.Endpoint.ToString(),
Alive: true,
LatencyMs: 120,
SuccessCount: 7,
FailureCount: 2
),
};
ProxyStateStore.Apply([known, unknown], state).ShouldBe(1);
known.WasAliveOnLastRun.ShouldBeTrue();
known.Latency.ShouldBe(TimeSpan.FromMilliseconds(120));
known.SuccessCount.ShouldBe(7);
unknown.WasAliveOnLastRun.ShouldBeFalse();
}
[Fact]
public void A_remembered_proxy_is_not_reported_live_until_it_answers_again()
{
// Otherwise a launch a week later would open the parser gate on week-old evidence, and the
// warm-up would skip the very proxies it was supposed to re-check.
var entry = Entry("http://1.2.3.4:8080");
var state = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
{
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
};
ProxyStateStore.Apply([entry], state);
entry.Health.ShouldBe(ProxyHealthState.Unknown);
entry.IsBelievedAlive.ShouldBeTrue();
}
[Fact]
public async Task A_remembered_proxy_that_was_never_re_checked_is_still_remembered()
{
// The warm-up stops early, so most remembered entries end a session unprobed. Dropping
// them on save would erode the remembered set to nothing over a few launches.
var entry = Entry("http://1.2.3.4:8080");
ProxyStateStore.Apply(
[entry],
new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
{
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
}
);
await Create().SaveAsync([entry], TestContext.Current.CancellationToken);
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldContainKey(entry.Endpoint.Key);
}
[Fact]
public async Task A_remembered_proxy_that_fails_its_re_check_is_forgotten()
{
var entry = Entry("http://1.2.3.4:8080");
ProxyStateStore.Apply(
[entry],
new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
{
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
}
);
entry.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout");
await Create().SaveAsync([entry], TestContext.Current.CancellationToken);
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public void A_remembered_proxy_is_never_restored_into_a_quarantine()
{
// The window is wall-clock; a restart may be days later, so an expired sideline must not
// be resurrected — the whole point of remembering is to start from the good ones.
var entry = Entry("http://1.2.3.4:8080");
entry.RecordFailure(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), 1);
entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeTrue();
var restored = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
{
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
};
ProxyStateStore.Apply([entry], restored);
entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeFalse();
}
}
+46
View File
@@ -1,5 +1,6 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
using ReactiveUI.Primitives.Signals;
@@ -41,6 +42,22 @@ internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : ITh
public void Dispose() => _current.Dispose();
}
/// <summary>In-memory settings, so tests never touch the developer's real profile.</summary>
internal sealed class FakeSettingsService(AppSettings? initial = null) : ISettingsService, IDisposable
{
private readonly BehaviorSignal<AppSettings> _current = new(initial ?? new AppSettings());
public AppSettings Current => _current.Value;
public IObservable<AppSettings> Changes => _current;
public void Update(Func<AppSettings, AppSettings> mutate) => _current.OnNext(mutate(_current.Value));
public Task FlushAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public void Dispose() => _current.Dispose();
}
/// <summary>An editable proxy list held in memory.</summary>
internal sealed class FakeMutableProxySource : IMutableProxySource
{
@@ -80,3 +97,32 @@ internal sealed class FakeProxyProbe : IProxyProbe
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not probed in tests"));
}
/// <summary>A remembered-state store that keeps everything in memory.</summary>
internal sealed class FakeProxyStateStore : IProxyStateStore
{
public Dictionary<string, ProxyStateRecord> State { get; } = new(StringComparer.Ordinal);
public Task<IReadOnlyDictionary<string, ProxyStateRecord>> LoadAsync(
CancellationToken cancellationToken = default
) => Task.FromResult<IReadOnlyDictionary<string, ProxyStateRecord>>(State);
public Task SaveAsync(IEnumerable<ProxyEntry> entries, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
/// <summary>
/// A loader that does nothing.
/// </summary>
/// <remarks>
/// The real one probes on startup, which would race these view tests: the load is fired from the
/// page constructor and would mark every fake proxy dead partway through an assertion.
/// </remarks>
internal sealed class FakeProxyPoolLoader : IProxyPoolLoader
{
public bool IsLoaded => true;
public Task<ProxyPoolLoadResult> EnsureLoadedAsync() => Task.FromResult(new ProxyPoolLoadResult(0, 0, 0));
public Task SaveStateAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
}
@@ -0,0 +1,121 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class ParseViewTests
{
private static (ParseView View, ParseViewModel ViewModel, Window Window) ShowPage(params ITextParser[] extra)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser(), .. extra]);
var lastParser = extra.Length > 0 ? extra[0].Id : null;
var viewModel = new ParseViewModel(
catalog,
new FakeSettingsService(new AppSettings { LastParserId = lastParser }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new EmptyServiceProvider(),
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
);
var view = new ParseView { DataContext = viewModel };
var window = new Window
{
Width = 1400,
Height = 900,
Content = view,
};
window.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel, window);
}
private static Border Banner(ParseView view) => view.FindControl<Border>("ProxyGateBanner").ShouldNotBeNull();
[AvaloniaFact]
public void No_banner_is_shown_for_a_parser_that_needs_no_network()
{
var (view, viewModel, _) = ShowPage();
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_network_parser_puts_the_banner_on_screen()
{
// Rendered rather than asserted on the view model: an IsVisible binding that never fires
// leaves the page silently unhelpful, which is exactly the failure this guards.
var (view, viewModel, _) = ShowPage(new NetworkParser());
Dispatcher.UIThread.RunJobs();
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
Banner(view).IsEffectivelyVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void The_banner_offers_a_way_to_the_proxies_page()
{
var (view, viewModel, _) = ShowPage(new NetworkParser());
Dispatcher.UIThread.RunJobs();
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
button.Command.ShouldBeSameAs(viewModel.GoToProxiesCommand);
}
[AvaloniaFact]
public void A_blocked_page_will_not_run_the_parser()
{
var (view, viewModel, _) = ShowPage(new NetworkParser());
viewModel.InputText = "anything";
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
.OfType<Button>()
.First(candidate => ReferenceEquals(candidate.Command, viewModel.ParseCommand));
run.IsEffectivelyEnabled.ShouldBeFalse();
}
private sealed class EmptyServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
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;
}
}
}
@@ -24,7 +24,7 @@ public class ProxiesViewTests
var viewModel = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
new FakeProxyPoolLoader(),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -72,6 +72,8 @@ public class ViewLocatorTests
public string CustomProxiesFile => Path.Combine(DataDirectory, "proxies.custom.json");
public string ProxyStateFile => Path.Combine(DataDirectory, "proxies.state.json");
public string LogDirectory => Path.Combine(DataDirectory, "logs");
}
}
@@ -1,7 +1,26 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
namespace AvParser.UI.Tests.Fakes;
/// <summary>A remembered-state store that keeps everything in memory.</summary>
internal sealed class FakeProxyStateStore : IProxyStateStore
{
public Dictionary<string, ProxyStateRecord> State { get; } = new(StringComparer.Ordinal);
public int Saves { get; private set; }
public Task<IReadOnlyDictionary<string, ProxyStateRecord>> LoadAsync(
CancellationToken cancellationToken = default
) => Task.FromResult<IReadOnlyDictionary<string, ProxyStateRecord>>(State);
public Task SaveAsync(IEnumerable<ProxyEntry> entries, CancellationToken cancellationToken = default)
{
Saves++;
return Task.CompletedTask;
}
}
/// <summary>A feed whose contents the test supplies.</summary>
internal sealed class FakeProxySource(IEnumerable<ProxyEndpoint>? endpoints = null) : IProxySource
{
+113 -2
View File
@@ -1,5 +1,6 @@
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;
@@ -11,15 +12,21 @@ namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(AppSettings? settings = null)
private static (ParseViewModel Page, FakeSettingsService Settings) Build(
AppSettings? settings = null,
IProxyPool? proxyPool = null,
params ITextParser[] extraParsers
)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser()]);
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
),
@@ -27,6 +34,36 @@ public class ParseViewModelTests
);
}
/// <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>
@@ -67,6 +104,80 @@ public class ParseViewModelTests
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)]
@@ -24,7 +24,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
new ProxyPoolLoader(pool, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -110,7 +110,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
new ProxyPoolLoader(pool, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -192,7 +192,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
new ProxyPoolLoader(pool, new FakeProxyStateStore(), NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);