From 85656e70b03f0f54bfbaa18572e3f2ce05d8b279 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 13 Aug 2026 18:33:41 +0300 Subject: [PATCH] Load the proxy pool at startup, and stop old settings files zeroing new defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool was empty until someone pressed Refresh, which also meant the parser would have had nothing to work with. A ProxyPoolLoader now fills it once at launch; startup does not await it, because blocking on a public list being reachable would be the wrong trade, and it never throws. The proxy page joins the same operation rather than starting a second download, so it reports the outcome whether it is opened during the load or long after. That change surfaced a worse bug underneath. The first run still loaded zero entries with no error logged at all, which turned out to be the feed source never being asked: options said UseFeed=False and Protocols=None. Neither is reachable from the UI — both are default(T). The cause is that AppSettings kept its defaults on property initialisers, and the source-generated deserialiser does not run them. Reflection-based deserialisation of "{}" keeps them; the generated context does not. So a settings.json written before a setting existed came back with default(T) for it: the proxy feed switched off, the protocol filter empty, the probe timeout zero and the probe URL blank — and the app looked like the network had failed. Defaults now live on primary constructor parameters, which STJ applies for absent JSON members on both paths, so an older file upgrades cleanly. The regression test writes a settings file from before the proxy settings existed and asserts each one comes back at its default. ToProxyOptions also treats an empty protocol filter as "all", since a hand-edited file that matches nothing is the least useful possible reading of it. Also quietens IHttpClientFactory to Warning: four Information lines per request buried everything the app said, and a proxy sweep makes thousands of them. Co-Authored-By: Claude Opus 5 --- src/AvParser.Core/Settings/AppSettings.cs | 91 ++++++----- src/AvParser.Desktop/App.axaml.cs | 6 + ...frastructureServiceCollectionExtensions.cs | 1 + .../Logging/AppLogging.cs | 3 + .../Proxies/ProxyPoolLoader.cs | 75 +++++++++ .../UiServiceCollectionExtensions.cs | 2 + .../ViewModels/ProxiesViewModel.cs | 21 +++ .../JsonSettingsServiceTests.cs | 142 ++++++++++++++++++ .../ProxyPoolLoaderTests.cs | 102 +++++++++++++ .../ProxiesViewTests.cs | 2 + .../ProxiesViewModelTests.cs | 35 ++++- 11 files changed, 432 insertions(+), 48 deletions(-) create mode 100644 src/AvParser.Infrastructure/Proxies/ProxyPoolLoader.cs create mode 100644 tests/AvParser.Infrastructure.Tests/JsonSettingsServiceTests.cs create mode 100644 tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs 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 );