diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs
index 95d66d2..2a4ae7a 100644
--- a/src/AvParser.Core/Settings/AppSettings.cs
+++ b/src/AvParser.Core/Settings/AppSettings.cs
@@ -19,57 +19,54 @@ public enum AppTheme
/// Everything the app remembers between runs. Persisted verbatim as JSON.
///
///
-/// A mutable record with defaults on every property: a settings file written by an older
-/// version must still deserialise, so no property may be required.
+///
+/// Defaults live on the primary constructor parameters, not on property initialisers.
+/// That is not a style choice: the source-generated serialiser constructs the object without
+/// running property initialisers, so a settings file written by an older build came back with
+/// default(T) for every setting added since — which silently switched the proxy feed off
+/// and zeroed the probe timeout. Constructor parameter defaults are applied for absent JSON
+/// members, so an old file now upgrades cleanly.
+///
+///
+/// No property may be required, for the same reason: an old file must still deserialise.
+///
///
-public sealed record AppSettings
+/// Chosen theme variant.
+/// Chosen UI language.
+/// Id of the parser selected last time; resolved leniently on load.
+/// Last main-window width in device-independent pixels.
+/// Last main-window height in device-independent pixels.
+/// Whether the main window was maximised on exit.
+/// Minimum Serilog level, as a Serilog level name.
+/// How the pool picks the next proxy.
+/// When proxy liveness is verified.
+/// Whether the remote proxy feed is consulted.
+/// Protocols accepted when loading proxy sources.
+/// URL fetched to decide whether a proxy works.
+/// Per-proxy probe timeout, in seconds.
+/// How many probes run at once during a pool sweep.
+public sealed record AppSettings(
+ AppTheme Theme = AppTheme.System,
+ AppLanguage Language = AppLanguage.System,
+ string? LastParserId = null,
+ double WindowWidth = 1280,
+ double WindowHeight = 800,
+ bool WindowMaximized = false,
+ string MinimumLogLevel = "Information",
+ ProxyRotation ProxyRotation = ProxyRotation.Sticky,
+ ProxyHealthCheck ProxyHealthCheck = ProxyHealthCheck.Pool,
+ bool ProxyUseFeed = true,
+ ProxyProtocolFilter ProxyProtocols = ProxyProtocolFilter.All,
+ string ProxyProbeUrl = "http://www.gstatic.com/generate_204",
+ int ProxyProbeTimeoutSeconds = 8,
+ int ProxyProbeConcurrency = 64
+)
{
- /// Chosen theme variant.
- public AppTheme Theme { get; init; } = AppTheme.System;
-
- /// Chosen UI language.
- public AppLanguage Language { get; init; } = AppLanguage.System;
-
- /// Id of the parser selected last time; resolved leniently on load.
- public string? LastParserId { get; init; }
-
- /// Last main-window width in device-independent pixels.
- public double WindowWidth { get; init; } = 1280;
-
- /// Last main-window height in device-independent pixels.
- public double WindowHeight { get; init; } = 800;
-
- /// Whether the main window was maximised on exit.
- public bool WindowMaximized { get; init; }
-
- /// Minimum Serilog level, as a Serilog level name.
- public string MinimumLogLevel { get; init; } = "Information";
-
- /// How the pool picks the next proxy.
- public ProxyRotation ProxyRotation { get; init; } = ProxyRotation.Sticky;
-
- /// When proxy liveness is verified.
- public ProxyHealthCheck ProxyHealthCheck { get; init; } = ProxyHealthCheck.Pool;
-
- /// Whether the remote proxy feed is consulted.
- public bool ProxyUseFeed { get; init; } = true;
-
- /// Protocols accepted when loading proxy sources.
- public ProxyProtocolFilter ProxyProtocols { get; init; } = ProxyProtocolFilter.All;
-
- /// URL fetched to decide whether a proxy works.
- public string ProxyProbeUrl { get; init; } = "http://www.gstatic.com/generate_204";
-
- /// Per-proxy probe timeout, in seconds.
- public int ProxyProbeTimeoutSeconds { get; init; } = 8;
-
- /// How many probes run at once during a pool sweep.
- public int ProxyProbeConcurrency { get; init; } = 64;
-
/// Projects the proxy-related settings onto .
///
/// Settings are persisted as primitives so an old file still deserialises; the pool wants a
- /// validated options object. This is the single place that bridges the two.
+ /// validated options object. This is the single place that bridges the two, and it clamps
+ /// rather than throws so a hand-edited file cannot stop the app from starting.
///
public ProxyOptions ToProxyOptions()
{
@@ -82,7 +79,7 @@ public sealed record AppSettings
Rotation = ProxyRotation,
HealthCheck = ProxyHealthCheck,
UseFeed = ProxyUseFeed,
- Protocols = ProxyProtocols,
+ Protocols = ProxyProtocols == ProxyProtocolFilter.None ? ProxyProtocolFilter.All : ProxyProtocols,
ProbeUrl = probeUrl,
ProbeTimeout = TimeSpan.FromSeconds(Math.Clamp(ProxyProbeTimeoutSeconds, 1, 120)),
ProbeConcurrency = Math.Clamp(ProxyProbeConcurrency, 1, 512),
diff --git a/src/AvParser.Desktop/App.axaml.cs b/src/AvParser.Desktop/App.axaml.cs
index ed2c1ac..08adb36 100644
--- a/src/AvParser.Desktop/App.axaml.cs
+++ b/src/AvParser.Desktop/App.axaml.cs
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using AvParser.Core.Settings;
+using AvParser.Infrastructure.Proxies;
using AvParser.UI;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
@@ -57,6 +58,11 @@ public partial class App : Application
desktop.ShutdownRequested += (_, _) => settings.FlushAsync().GetAwaiter().GetResult();
}
+ // Start filling the proxy pool as soon as the window is up. Not awaited on purpose: the
+ // load is a network round trip, and blocking startup on a public list being reachable
+ // would be the wrong trade. It never throws, so there is nothing to observe.
+ _ = _services.GetRequiredService().EnsureLoadedAsync();
+
base.OnFrameworkInitializationCompleted();
}
diff --git a/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
index 926851d..b0dee02 100644
--- a/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
+++ b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs
@@ -75,6 +75,7 @@ public static class InfrastructureServiceCollectionExtensions
));
services.AddSingleton();
+ services.AddSingleton();
return services;
}
diff --git a/src/AvParser.Infrastructure/Logging/AppLogging.cs b/src/AvParser.Infrastructure/Logging/AppLogging.cs
index 60a41d8..79fb6cb 100644
--- a/src/AvParser.Infrastructure/Logging/AppLogging.cs
+++ b/src/AvParser.Infrastructure/Logging/AppLogging.cs
@@ -30,6 +30,9 @@ public static class AppLogging
var logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
+ // IHttpClientFactory logs four Information lines per request. At Information that
+ // buries everything the app itself says, and the proxy sweep makes thousands of them.
+ .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
diff --git a/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs b/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs
new file mode 100644
index 0000000..e838361
--- /dev/null
+++ b/src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs
@@ -0,0 +1,75 @@
+using AvParser.Core.Proxies;
+using Microsoft.Extensions.Logging;
+
+namespace AvParser.Infrastructure.Proxies;
+
+/// Fills the pool once at startup, so nothing has to be loaded by hand.
+public interface IProxyPoolLoader
+{
+ ///
+ /// Loads every source, once per process. Later callers get the same operation rather than a
+ /// second download.
+ ///
+ /// How many proxies the pool holds afterwards.
+ /// Never throws: a source that is down leaves the pool as it was.
+ Task EnsureLoadedAsync();
+
+ /// Whether the initial load has finished.
+ bool IsLoaded { get; }
+}
+
+///
+public sealed class ProxyPoolLoader(IProxyPool pool, ILogger logger) : IProxyPoolLoader
+{
+ private readonly IProxyPool _pool = pool ?? throw new ArgumentNullException(nameof(pool));
+ private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ private readonly Lock _gate = new();
+
+ private Task? _load;
+
+ ///
+ public bool IsLoaded => _load is { IsCompleted: true };
+
+ ///
+ public Task EnsureLoadedAsync()
+ {
+ if (_load is { } started)
+ {
+ return started;
+ }
+
+ lock (_gate)
+ {
+ // Deliberately not taking the caller's CancellationToken: the task is shared between
+ // the startup path and the proxy page, and one caller giving up must not cancel the
+ // load for the other.
+ _load ??= LoadAsync();
+ return _load;
+ }
+ }
+
+ private async Task LoadAsync()
+ {
+ try
+ {
+ // Worth logging: an empty pool is almost always a filter or a switched-off feed
+ // rather than a network problem, and without this it looks identical to both.
+ _logger.LogInformation(
+ "Loading proxy pool (feed: {UseFeed}, protocols: {Protocols})",
+ _pool.Options.UseFeed,
+ _pool.Options.Protocols
+ );
+
+ var count = await _pool.RefreshAsync(CancellationToken.None).ConfigureAwait(false);
+ _logger.LogInformation("Proxy pool loaded with {Count} entries", count);
+ return count;
+ }
+ catch (Exception ex)
+ {
+ // Startup must not fail because a public list is unreachable; the user can retry from
+ // the Proxies page, and the app works without proxies in the meantime.
+ _logger.LogWarning(ex, "Could not load the proxy pool at startup");
+ return _pool.Entries.Count;
+ }
+ }
+}
diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs
index cc632e5..0acf5a8 100644
--- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs
+++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using AvParser.Core.Parsing;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
+using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using AvParser.UI.Navigation;
using AvParser.UI.Services;
@@ -46,6 +47,7 @@ public static class UiServiceCollectionExtensions
services.AddSingleton(static sp => new ProxiesViewModel(
sp.GetRequiredService(),
sp.GetRequiredService(),
+ sp.GetRequiredService(),
sp.GetRequiredService>()
));
services.AddSingleton();
diff --git a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs
index 94f6ad9..518ae33 100644
--- a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs
+++ b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs
@@ -91,11 +91,13 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// Creates the page.
/// The pool being managed.
/// The user's editable list.
+ /// The shared startup load, joined so the page can report its outcome.
/// Diagnostics.
/// Scheduler for UI-affine updates; tests pass an immediate one.
public ProxiesViewModel(
IProxyPool pool,
IMutableProxySource customSource,
+ IProxyPoolLoader loader,
ILogger logger,
ISequencer? mainThread = null
)
@@ -104,6 +106,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
_customSource = customSource ?? throw new ArgumentNullException(nameof(customSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
+ ArgumentNullException.ThrowIfNull(loader);
SearchText = string.Empty;
NewProxies = string.Empty;
@@ -145,6 +148,11 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed);
Rebuild();
+
+ // The pool is normally already loading by the time this page is built, but joining the
+ // same operation means the page reports the outcome whether it is opened during the load
+ // or long after it. Not awaited — a constructor cannot be, and the task never throws.
+ _ = ReportInitialLoadAsync(loader);
}
///
@@ -185,6 +193,19 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// Empties the custom list.
public ReactiveCommand ClearCustomCommand { get; }
+ private async Task ReportInitialLoadAsync(IProxyPoolLoader loader)
+ {
+ var count = await loader.EnsureLoadedAsync().ConfigureAwait(false);
+ var message = Localizer.Instance.Format(
+ "Proxies.Status.PoolHolds",
+ Localizer.Instance.Plural("Proxies.Count.Proxies", count)
+ );
+
+ // Anything the user has done since — an add, a check — is more interesting than the
+ // startup count, so do not overwrite it.
+ OnUi(() => StatusMessage ??= message);
+ }
+
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
diff --git a/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs
new file mode 100644
index 0000000..0919135
--- /dev/null
+++ b/tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs
@@ -0,0 +1,142 @@
+using AvParser.Core.Proxies;
+using AvParser.Core.Settings;
+using AvParser.Infrastructure.Settings;
+using AvParser.Infrastructure.Storage;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AvParser.Infrastructure.Tests;
+
+public sealed class JsonSettingsServiceTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(),
+ "AvParserTests",
+ Guid.NewGuid().ToString("N")
+ );
+
+ private JsonSettingsService Create() => new(new AppPaths(_directory), NullLogger.Instance);
+
+ private void WriteSettings(string json)
+ {
+ Directory.CreateDirectory(_directory);
+ File.WriteAllText(Path.Combine(_directory, "settings.json"), json);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void An_absent_file_yields_the_defaults()
+ {
+ using var service = Create();
+
+ service.Current.ShouldBe(new AppSettings());
+ }
+
+ ///
+ /// A settings file written before a setting existed must not zero that setting.
+ ///
+ ///
+ /// This is a regression test with a real bug behind it. Defaults used to live on property
+ /// initialisers, which the source-generated deserialiser does not run — so an older file came
+ /// back with default(T) everywhere, switching the proxy feed off, clearing the protocol
+ /// filter and zeroing the probe timeout. The app then loaded an empty pool and looked as if
+ /// the network had failed.
+ ///
+ [Fact]
+ public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates()
+ {
+ WriteSettings(
+ """
+ {
+ "theme": "System",
+ "lastParserId": "delimited",
+ "windowWidth": 1280,
+ "windowHeight": 800,
+ "windowMaximized": false,
+ "minimumLogLevel": "Information"
+ }
+ """
+ );
+
+ using var service = Create();
+ var settings = service.Current;
+
+ settings.ProxyUseFeed.ShouldBeTrue();
+ settings.ProxyProtocols.ShouldBe(ProxyProtocolFilter.All);
+ settings.ProxyProbeTimeoutSeconds.ShouldBe(8);
+ settings.ProxyProbeConcurrency.ShouldBe(64);
+ settings.ProxyProbeUrl.ShouldNotBeNullOrEmpty();
+ settings.Language.ShouldBe(AppLanguage.System);
+
+ // And the values the file did carry must survive.
+ settings.LastParserId.ShouldBe("delimited");
+ settings.MinimumLogLevel.ShouldBe("Information");
+ }
+
+ [Fact]
+ public void Options_built_from_an_older_file_still_consult_the_feed()
+ {
+ WriteSettings("""{ "theme": "Dark" }""");
+
+ using var service = Create();
+ var options = service.Current.ToProxyOptions();
+
+ options.UseFeed.ShouldBeTrue();
+ options.Protocols.ShouldBe(ProxyProtocolFilter.All);
+ }
+
+ [Fact]
+ public void An_empty_protocol_filter_is_treated_as_all()
+ {
+ // Nothing in the UI can produce None, but a hand-edited file can — and a pool that
+ // silently matches nothing is the least useful possible reading of it.
+ var options = new AppSettings(ProxyProtocols: ProxyProtocolFilter.None).ToProxyOptions();
+
+ options.Protocols.ShouldBe(ProxyProtocolFilter.All);
+ }
+
+ [Fact]
+ public void Out_of_range_values_are_clamped_rather_than_thrown()
+ {
+ var options = new AppSettings(
+ ProxyProbeTimeoutSeconds: 0,
+ ProxyProbeConcurrency: 100_000,
+ ProxyProbeUrl: "not a url"
+ ).ToProxyOptions();
+
+ options.ProbeTimeout.ShouldBe(TimeSpan.FromSeconds(1));
+ options.ProbeConcurrency.ShouldBe(512);
+ options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl);
+ }
+
+ [Fact]
+ public void A_corrupt_file_falls_back_to_the_defaults_instead_of_failing_to_start()
+ {
+ WriteSettings("{ this is not json");
+
+ using var service = Create();
+
+ service.Current.ShouldBe(new AppSettings());
+ }
+
+ [Fact]
+ public async Task Updates_round_trip_through_the_file()
+ {
+ using (var service = Create())
+ {
+ service.Update(current => current with { Theme = AppTheme.Dark, ProxyUseFeed = false });
+ await service.FlushAsync(TestContext.Current.CancellationToken);
+ }
+
+ using var reopened = Create();
+
+ reopened.Current.Theme.ShouldBe(AppTheme.Dark);
+ reopened.Current.ProxyUseFeed.ShouldBeFalse();
+ }
+}
diff --git a/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs b/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs
new file mode 100644
index 0000000..f73a123
--- /dev/null
+++ b/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs
@@ -0,0 +1,102 @@
+using AvParser.Core.Proxies;
+using AvParser.Infrastructure.Proxies;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AvParser.Infrastructure.Tests;
+
+public class ProxyPoolLoaderTests
+{
+ private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool)
+ {
+ source = new CountingSource();
+ pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions());
+
+ return new ProxyPoolLoader(pool, NullLogger.Instance);
+ }
+
+ [Fact]
+ public async Task Loading_fills_the_pool()
+ {
+ var loader = Build(out _, out var pool);
+
+ (await loader.EnsureLoadedAsync()).ShouldBe(2);
+ pool.Entries.Count.ShouldBe(2);
+ loader.IsLoaded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task The_feed_is_fetched_once_however_many_callers_there_are()
+ {
+ var loader = Build(out var source, out _);
+
+ // Startup and the proxy page both ask; a second download of a 600 KB list would be waste.
+ await Task.WhenAll(loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync());
+ await loader.EnsureLoadedAsync();
+
+ source.Calls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Concurrent_callers_share_one_operation()
+ {
+ var loader = Build(out _, out _);
+
+ var first = loader.EnsureLoadedAsync();
+ var second = loader.EnsureLoadedAsync();
+
+ first.ShouldBeSameAs(second);
+ await first;
+ }
+
+ [Fact]
+ 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.Instance);
+
+ // The app has to start whether or not a public list is reachable.
+ (await loader.EnsureLoadedAsync()).ShouldBe(0);
+ }
+
+ private sealed class CountingSource : IProxySource
+ {
+ public int Calls { get; private set; }
+
+ public string Id => "counting";
+
+ public string DisplayName => "Counting source";
+
+ public ProxySourceKind Kind => ProxySourceKind.Feed;
+
+ public Task> GetProxiesAsync(CancellationToken cancellationToken = default)
+ {
+ Calls++;
+
+ return Task.FromResult>([
+ new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080),
+ new ProxyEndpoint(ProxyProtocol.Socks5, "5.6.7.8", 1080),
+ ]);
+ }
+ }
+
+ private sealed class ThrowingSource : IProxySource
+ {
+ public string Id => "throwing";
+
+ public string DisplayName => "Throwing source";
+
+ public ProxySourceKind Kind => ProxySourceKind.Feed;
+
+ public Task> GetProxiesAsync(CancellationToken cancellationToken = default) =>
+ throw new HttpRequestException("upstream is down");
+ }
+
+ private sealed class NeverProbe : IProxyProbe
+ {
+ public Task ProbeAsync(
+ ProxyEndpoint endpoint,
+ ProxyOptions options,
+ CancellationToken cancellationToken = default
+ ) => Task.FromResult(ProxyProbeResult.Failure("not used"));
+ }
+}
diff --git a/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs b/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs
index 64367a8..2d6e103 100644
--- a/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs
+++ b/tests/AvParser.UI.HeadlessTests/ProxiesViewTests.cs
@@ -3,6 +3,7 @@ using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Proxies;
+using AvParser.Infrastructure.Proxies;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,6 +24,7 @@ public class ProxiesViewTests
var viewModel = new ProxiesViewModel(
pool,
custom,
+ new ProxyPoolLoader(pool, NullLogger.Instance),
NullLogger.Instance,
ImmediateSequencer.Instance
);
diff --git a/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs b/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs
index f5502fe..9cede20 100644
--- a/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs
+++ b/tests/AvParser.UI.Tests/ProxiesViewModelTests.cs
@@ -1,4 +1,5 @@
using AvParser.Core.Proxies;
+using AvParser.Infrastructure.Proxies;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,6 +24,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
+ new ProxyPoolLoader(pool, NullLogger.Instance),
NullLogger.Instance,
ImmediateSequencer.Instance
);
@@ -37,11 +39,40 @@ public class ProxiesViewModelTests
private static Task Run(ReactiveUI.ReactiveCommand command) =>
command.Execute().ToTask(TestContext.Current.CancellationToken);
+ /// Polls until the throttled rebuild has caught up, or gives up.
+ ///
+ /// 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.
+ ///
+ private static async Task WaitUntil(Func condition)
+ {
+ for (var attempt = 0; attempt < 50 && !condition(); attempt++)
+ {
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+ }
+
[Fact]
- public void Starts_empty()
+ 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);
}
@@ -79,6 +110,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
+ new ProxyPoolLoader(pool, NullLogger.Instance),
NullLogger.Instance,
ImmediateSequencer.Instance
);
@@ -160,6 +192,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
+ new ProxyPoolLoader(pool, NullLogger.Instance),
NullLogger.Instance,
ImmediateSequencer.Instance
);