using System.Runtime.CompilerServices; using AvParser.Core.Collecting; using AvParser.Core.Parsing; 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 CollectViewModelTests { /// A runner that returns a canned stream instead of touching a network. private sealed class FakeRunner : ICollectRunner { public List> Results { get; } = []; public int Runs { get; private set; } public CollectOptions? LastOptions { get; private set; } public MediaQuery? LastQuery { get; private set; } public TimeSpan Delay { get; set; } public async IAsyncEnumerable> RunAsync( IMediaSource source, MediaQuery query, CollectOptions options, IProgress? progress, [EnumeratorCancellation] CancellationToken cancellationToken ) { Runs++; LastOptions = options; LastQuery = query; foreach (var result in Results) { cancellationToken.ThrowIfCancellationRequested(); if (Delay > TimeSpan.Zero) { await Task.Delay(Delay, cancellationToken); } yield return result; } progress?.Report(new ParseProgress(Results.Count, Results.Count)); } } private sealed class StubSource(string id, string name, bool network) : IMediaSource { public string Id => id; public string DisplayName => name; public string Description => string.Empty; public bool RequiresNetwork => network; public bool CanParse(MediaQuery input) => true; public async IAsyncEnumerable> ParseAsync( MediaQuery input, IProgress? progress, [EnumeratorCancellation] CancellationToken cancellationToken ) { await Task.Yield(); yield break; } } private sealed class EmptyServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } private static CollectedItem Item(string url, CollectStatus status, long length = 4096) => new( new MediaCandidate(new Uri(url)) { SourceId = "url-list", Ordinal = 1 }, MediaBlob.Create(new string('a', 64), MediaKind.Png, length), status ); private static (CollectViewModel Page, FakeRunner Runner, FakeSettingsService Settings) Build( AppSettings? settings = null, IProxyPool? proxyPool = null, bool includeNetworkSource = false ) { IMediaSource[] sources = includeNetworkSource ? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)] : [new StubSource("url-list", "URL list", false)]; var catalog = new MediaSourceCatalog(sources, "url-list"); var settingsService = new FakeSettingsService(settings); var runner = new FakeRunner(); var store = new FakeMediaStore(); var page = new CollectViewModel( catalog, settingsService, proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), runner, store, new FakeThumbnailCache(), new EmptyServiceProvider(), NullLogger.Instance, ImmediateSequencer.Instance ); return (page, runner, settingsService); } private static Task RunAsync(CollectViewModel page) => page.CollectCommand.Execute().ToTask(); [Fact] public void The_page_opens_on_the_source_that_needs_no_proxy() { // Otherwise the app lands behind the gate before the user has asked for anything. var (page, _, _) = Build(includeNetworkSource: true); page.SelectedSource.Id.ShouldBe("url-list"); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public void The_last_used_source_is_restored() { var (page, _, _) = Build( new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true }, includeNetworkSource: true ); page.SelectedSource.Id.ShouldBe("own-service"); } [Fact] public void An_unknown_remembered_source_falls_back_instead_of_throwing() { var (page, _, _) = Build(new AppSettings { LastSourceId = "removed-in-a-past-version" }); page.SelectedSource.Id.ShouldBe("url-list"); } [Fact] public void Choosing_a_source_remembers_it() { var (page, _, settings) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true); page.SelectedSource = page.Sources.Single(source => source.Id == "own-service"); settings.Current.LastSourceId.ShouldBe("own-service"); } [Fact] public void Collecting_needs_something_to_collect() { var (page, _, _) = Build(); var canExecute = true; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); canExecute.ShouldBeFalse(); page.InputText = "https://example.test/a.png"; canExecute.ShouldBeTrue(); page.InputText = " "; canExecute.ShouldBeFalse(); } [Fact] public void An_endpoint_source_wants_an_address_not_pasted_text() { var (page, _, _) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true); page.SelectedSource = page.Sources.Single(source => source.Id == "own-service"); var canExecute = true; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); page.InputText = "https://example.test/a.png"; canExecute.ShouldBeFalse(); page.EndpointText = "not an address"; canExecute.ShouldBeFalse(); page.EndpointText = "https://own.test/api/list"; canExecute.ShouldBeTrue(); } [Fact] public async Task Results_land_in_the_list_and_the_summary_counts_them() { var (page, runner, _) = Build(); runner.Results.AddRange([ ParseOutcome.Success(Item("https://a.test/1.png", CollectStatus.Stored)), ParseOutcome.Success(Item("https://a.test/2.png", CollectStatus.Duplicate)), ParseOutcome.Success(Item("https://a.test/3.png", CollectStatus.Skipped)), ]); page.InputText = "https://a.test/1.png"; await RunAsync(page); page.Items.Count.ShouldBe(3); page.Errors.ShouldBeEmpty(); var summary = page.StatusMessage.ShouldNotBeNull(); summary.ShouldContain("1 image"); summary.ShouldContain("already held"); summary.ShouldContain("skipped"); } [Fact] public async Task Failures_land_in_the_error_list_without_stopping_the_run() { var (page, runner, _) = Build(); runner.Results.AddRange([ ParseOutcome.Success(Item("https://a.test/1.png", CollectStatus.Stored)), ParseOutcome.Failure(ParseError.Create(2, "TooLarge", "too big")), ]); page.InputText = "https://a.test/1.png"; await RunAsync(page); page.Items.ShouldHaveSingleItem(); page.Errors.ShouldHaveSingleItem().Text.ShouldBe("Larger than the size limit."); page.StatusMessage!.ShouldContain("1 error"); } [Fact] public async Task The_force_refetch_switch_reaches_the_runner() { var (page, runner, _) = Build(); page.InputText = "https://a.test/1.png"; page.ForceRefetch = true; await RunAsync(page); runner.LastOptions!.ForceRefetch.ShouldBeTrue(); } [Fact] public async Task The_pasted_text_reaches_the_query() { var (page, runner, _) = Build(); page.InputText = "https://a.test/1.png\nhttps://a.test/2.png"; await RunAsync(page); runner.LastQuery!.Text.ShouldContain("2.png"); runner.LastQuery.Endpoint.ShouldBeNull(); } [Fact] public async Task A_second_run_replaces_the_previous_results() { var (page, runner, _) = Build(); runner.Results.Add(ParseOutcome.Success(Item("https://a.test/1.png", CollectStatus.Stored))); page.InputText = "https://a.test/1.png"; await RunAsync(page); await RunAsync(page); page.Items.ShouldHaveSingleItem(); } [Fact] public void A_local_source_runs_with_no_proxy_at_all() { var (page, _, _) = Build(); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public void A_network_source_is_blocked_while_nothing_is_live() { var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true); page.IsBlockedWithoutProxy.ShouldBeTrue(); var canExecute = true; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); page.EndpointText = "https://own.test/api/list"; canExecute.ShouldBeFalse(); } [Fact] public void Allowing_direct_connections_lifts_the_gate() { var (page, _, _) = Build( new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true }, includeNetworkSource: true ); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public async Task A_network_source_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 { LastSourceId = "own-service" }, pool, includeNetworkSource: true); 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 Switching_away_from_a_network_source_lifts_the_gate() { var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true); page.IsBlockedWithoutProxy.ShouldBeTrue(); page.SelectedSource = page.Sources.Single(source => source.Id == "url-list"); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public async Task Purging_removes_only_the_selected_source() { // Scoped rather than emptying the store: content another source also holds must survive, // which is exactly what the index's reference count is for. var (page, _, _) = Build(); await page.PurgeCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.StatusMessage.ShouldNotBeNull().ShouldContain("Removed"); } [Fact] public void The_store_totals_are_shown() { var (page, _, _) = Build(); page.StorageSummary.ShouldNotBeNull().ShouldContain("in the store"); } [Fact] public void Sizes_read_the_way_a_file_manager_shows_them() { CollectedItemViewModel.FormatSize(0).ShouldBeEmpty(); CollectedItemViewModel.FormatSize(512).ShouldBe("512 B"); CollectedItemViewModel.FormatSize(2048).ShouldBe("2 KB"); CollectedItemViewModel.FormatSize(1024 * 1024 * 3).ShouldBe("3 MB"); } }