using System.Runtime.CompilerServices; using AvParser.Core.Collecting; using AvParser.Core.Collecting.Sources; 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 { private readonly Lock _gate = new(); public List> Results { get; } = []; /// Ids the runner was asked to run, one entry per call. public List RunSourceIds { get; } = []; 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 ) { lock (_gate) { RunSourceIds.Add(source.Id); } 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)); } } /// In-memory source list standing in for the persisted store. private sealed class FakeUserSourceStore(IEnumerable? seed = null) : IUserSourceStore { private readonly List _configs = seed?.ToList() ?? []; public event EventHandler? Changed; public IReadOnlyList List() => [.. _configs]; public Task AddAsync( PatternSourceConfig config, CancellationToken cancellationToken = default ) { _configs.RemoveAll(c => c.Id == config.Id); _configs.Add(config); Changed?.Invoke(this, EventArgs.Empty); return Task.FromResult(config); } public Task UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default) { var index = _configs.FindIndex(c => c.Id == config.Id); if (index < 0) { return Task.FromResult(false); } _configs[index] = config; Changed?.Invoke(this, EventArgs.Empty); return Task.FromResult(true); } public Task RemoveAsync(string id, CancellationToken cancellationToken = default) { var removed = _configs.RemoveAll(c => c.Id == id) > 0; if (removed) { Changed?.Invoke(this, EventArgs.Empty); } return Task.FromResult(removed); } } private sealed class EmptyServiceProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } /// /// A source that may run without a proxy, which is what most of these tests need: the gate is a /// per-source setting now, so a config that requires one would block every run. /// private static PatternSourceConfig Config( string id, string name = "Test", string url = "https://imgtest.example/test1/", bool allowDirect = true ) { PatternSourceConfig.TryCreate( name, url, 6, 8, IdAlphabet.Alphanumeric, null, ".jpg", allowDirect, out var config, id ); return config!; } private static CollectedItem Item(string url, CollectStatus status, long length = 4096) => new( new MediaCandidate(new Uri(url)) { SourceId = "s1", 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, IEnumerable? configs = null ) { var store = new FakeUserSourceStore(configs ?? [Config("s1")]); var catalog = new MediaSourceCatalog(store); var settingsService = new FakeSettingsService(settings ?? new AppSettings()); var runner = new FakeRunner(); var page = new CollectViewModel( catalog, settingsService, proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), runner, new FakeMediaStore(), 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 An_empty_catalog_selects_nothing_and_offers_to_add_one() { var (page, _, _) = Build(configs: []); page.SelectedSource.ShouldBeNull(); page.HasSources.ShouldBeFalse(); var canExecute = true; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); canExecute.ShouldBeFalse(); } [Fact] public void The_last_used_source_is_restored() { var (page, _, _) = Build( new AppSettings { LastSourceId = "s2" }, configs: [Config("s1", "Alpha"), Config("s2", "Beta")] ); page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s2"); } [Fact] public void An_unknown_remembered_source_falls_back_instead_of_throwing() { var (page, _, _) = Build(new AppSettings { LastSourceId = "gone" }); page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s1"); } [Fact] public void Choosing_a_source_remembers_it() { var (page, _, settings) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]); page.SelectedSource = page.Sources.Single(source => source.Id == "s2"); settings.Current.LastSourceId.ShouldBe("s2"); } [Fact] public void Collecting_needs_at_least_one_ticked_source() { var (page, _, _) = Build(); var canExecute = false; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); canExecute.ShouldBeTrue(); page.Sources.Single().IsSelected = false; canExecute.ShouldBeFalse(); } [Fact] public void An_unlimited_budget_does_not_block_the_button() { // Zero used to mean "nothing to do"; it now means "until stopped", which is a running state, // not a disabled one. var (page, _, _) = Build(); var canExecute = false; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); page.AttemptBudget = 0; page.TargetCount = 0; canExecute.ShouldBeTrue(); } [Fact] public async Task An_unlimited_budget_reaches_the_source_as_no_limit() { var (page, runner, settings) = Build(); page.AttemptBudget = 0; await RunAsync(page); runner.LastQuery!.HasLimit.ShouldBeFalse(); settings.Current.CollectAttemptBudget.ShouldBe(0); } [Fact] public async Task Every_ticked_source_runs_in_the_same_collection() { var (page, runner, _) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]); foreach (var source in page.Sources) { source.IsSelected = true; } runner.Results.Add(ParseOutcome.Success(Item("https://a.test/1.png", CollectStatus.Stored))); await RunAsync(page); runner.RunSourceIds.Order().ShouldBe(["s1", "s2"]); page.Items.Count.ShouldBe(2); } [Fact] public void The_ticked_set_is_remembered() { var (page, _, settings) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]); page.Sources.Single(source => source.Id == "s2").IsSelected = true; AppSettings.SplitSourceIds(settings.Current.CollectSourceIds).Order().ShouldBe(["s1", "s2"]); } [Fact] public void A_remembered_ticked_set_is_restored() { var (page, _, _) = Build( new AppSettings { CollectSourceIds = "s2" }, configs: [Config("s1", "Alpha"), Config("s2", "Beta")] ); page.RunnableSources().ShouldHaveSingleItem().Id.ShouldBe("s2"); } [Fact] public async Task The_log_records_what_happened_as_it_happens() { 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")), ]); await RunAsync(page); page.ErrorCount.ShouldBe(1); page.Log.Select(entry => entry.Text).ShouldContain(text => text.Contains("https://a.test/1.png")); page.Log.Select(entry => entry.Text).ShouldContain("Larger than the size limit."); page.Log[^1].Text.ShouldStartWith("Run finished."); } [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)), ]); await RunAsync(page); page.Items.Count.ShouldBe(3); page.ErrorCount.ShouldBe(0); 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")), ]); await RunAsync(page); page.Items.ShouldHaveSingleItem(); page.Log.Single(entry => entry.IsError).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.ForceRefetch = true; await RunAsync(page); runner.LastOptions!.ForceRefetch.ShouldBeTrue(); } [Fact] public async Task The_attempt_budget_becomes_the_query_limit() { var (page, runner, _) = Build(); page.AttemptBudget = 250; await RunAsync(page); runner.LastQuery!.Limit.ShouldBe(250); } [Fact] public async Task Collecting_stops_once_the_target_is_reached() { var (page, runner, _) = Build(); for (var i = 0; i < 5; i++) { runner.Results.Add( ParseOutcome.Success(Item($"https://a.test/{i}.png", CollectStatus.Stored)) ); } page.TargetCount = 2; await RunAsync(page); page.Items.Count.ShouldBe(2); // Reaching the target is a clean finish, not a stop: the summary must not read "Stopped". page.StatusMessage.ShouldNotBeNull().ShouldNotContain("Stopped"); } [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))); await RunAsync(page); await RunAsync(page); page.Items.ShouldHaveSingleItem(); } [Fact] public void A_network_source_is_blocked_while_nothing_is_live() { var (page, _, _) = Build(configs: [Config("s1", allowDirect: false)]); page.IsBlockedWithoutProxy.ShouldBeTrue(); var canExecute = true; using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value); canExecute.ShouldBeFalse(); } [Fact] public void A_source_allowed_to_go_direct_lifts_the_gate_for_itself() { var (page, _, _) = Build(configs: [Config("s1", allowDirect: true)]); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public void One_gated_source_in_the_run_blocks_the_whole_run() { // The permissive source cannot vouch for the strict one: the request the user did not want // leaving their own address would leave it anyway. var (page, _, _) = Build( configs: [Config("s1", "Open", allowDirect: true), Config("s2", "Strict", allowDirect: false)] ); page.Sources.Single(source => source.Id == "s2").IsSelected = true; page.IsBlockedWithoutProxy.ShouldBeTrue(); } [Fact] public async Task Each_source_carries_its_own_proxy_policy_into_the_run() { var (page, runner, _) = Build(configs: [Config("s1", allowDirect: true)]); await RunAsync(page); // The gate is only half of it: the fetcher has to be told too, or a source that should be // blocked would simply go direct with nobody the wiser. runner.LastOptions.ShouldNotBeNull().RequireProxy.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(proxyPool: pool, configs: [Config("s1", allowDirect: false)]); page.IsBlockedWithoutProxy.ShouldBeTrue(); await pool.RefreshAsync(TestContext.Current.CancellationToken); await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken); page.RefreshProxyGate(); page.IsBlockedWithoutProxy.ShouldBeFalse(); } [Fact] public async Task Adding_a_source_stores_it_and_selects_it() { var (page, _, _) = Build(configs: []); page.AddSourceCommand.Execute().Subscribe(); page.EditorName = "New one"; page.EditorBaseUrl = "https://imgtest.example/test2/"; page.EditorMinLength = 8; page.EditorMaxLength = 12; page.EditorAlphabet = page.AlphabetOptions.Single(o => o.Value == IdAlphabet.Digits); await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.IsEditorOpen.ShouldBeFalse(); page.Sources.ShouldHaveSingleItem(); page.SelectedSource.ShouldNotBeNull().Name.ShouldBe("New one"); } [Fact] public async Task An_invalid_source_reports_an_error_and_is_not_added() { var (page, _, _) = Build(configs: []); page.AddSourceCommand.Execute().Subscribe(); page.EditorName = "Bad"; page.EditorBaseUrl = "not a url"; await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.EditorError.ShouldNotBeNull(); page.IsEditorOpen.ShouldBeTrue(); page.Sources.ShouldBeEmpty(); } [Fact] public async Task Editing_a_source_updates_it_in_place() { var (page, _, _) = Build(configs: [Config("s1", "Before")]); page.EditSourceCommand.Execute().Subscribe(); page.EditorName = "After"; await page.SaveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.Sources.ShouldHaveSingleItem().Name.ShouldBe("After"); page.SelectedSource.ShouldNotBeNull().Id.ShouldBe("s1"); } [Fact] public async Task Removing_a_source_drops_it() { var (page, _, _) = Build(configs: [Config("s1", "Alpha"), Config("s2", "Beta")]); page.SelectedSource = page.Sources.Single(source => source.Id == "s2"); await page.RemoveSourceCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.Sources.ShouldHaveSingleItem().Id.ShouldBe("s1"); } [Fact] public async Task Purging_removes_only_the_selected_source() { 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 async Task Log_lines_copy_as_the_text_that_is_on_screen() { var (page, runner, _) = Build(); runner.Results.Add(ParseOutcome.Success(Item("https://a.test/1.png", CollectStatus.Stored))); await RunAsync(page); var line = page.Log.First(entry => entry.IsSuccess).ToString(); line.ShouldContain("https://a.test/1.png"); line.ShouldContain("Test"); // the source name, as shown in the chip line.ShouldStartWith(page.Log[0].TimeText[..2]); // a timestamp, not a bare message var text = CollectLogEntryViewModel.ToText(page.Log); text.Split(Environment.NewLine).Length.ShouldBe(page.Log.Count); } [Fact] public void Disposing_twice_is_safe() { // The container disposes each page once per registration — its own type and PageViewModel. var (page, _, _) = Build(); page.Dispose(); Should.NotThrow(page.Dispose); } [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"); } }