Files
av-parser/tests/AvParser.UI.Tests/CollectViewModelTests.cs
T
Leonid Pershin eb5061ee23 Refactor media source handling and update collection options
- Updated `IMediaSourceCatalog` to support user-added media sources, allowing dynamic editing and management of sources.
- Removed the `UrlListSource` class as its functionality is now integrated into the new catalog structure.
- Enhanced `CollectOptions` to default `RequireProxy` to true, ensuring stricter handling of proxy requirements.
- Improved error handling in `ParseError` to include a `Subject` field for better context on failures.
- Adjusted dependency injection to reflect changes in media source management, removing old source registrations.
- Introduced background proxy checks to ensure a more robust proxy pool management during collection processes.

These changes streamline the media collection process and improve the overall user experience by providing clearer error reporting and more flexible source management.
2026-08-15 14:20:06 +03:00

585 lines
19 KiB
C#

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
{
/// <summary>A runner that returns a canned stream instead of touching a network.</summary>
private sealed class FakeRunner : ICollectRunner
{
private readonly Lock _gate = new();
public List<ParseOutcome<CollectedItem>> Results { get; } = [];
/// <summary>Ids the runner was asked to run, one entry per call.</summary>
public List<string> RunSourceIds { get; } = [];
public CollectOptions? LastOptions { get; private set; }
public MediaQuery? LastQuery { get; private set; }
public TimeSpan Delay { get; set; }
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
IMediaSource source,
MediaQuery query,
CollectOptions options,
IProgress<ParseProgress>? 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));
}
}
/// <summary>In-memory source list standing in for the persisted store.</summary>
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig>? seed = null) : IUserSourceStore
{
private readonly List<PatternSourceConfig> _configs = seed?.ToList() ?? [];
public event EventHandler? Changed;
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
public Task<PatternSourceConfig> 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<bool> 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<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
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<PatternSourceConfig>? 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<CollectViewModel>.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<CollectedItem>.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<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
ParseOutcome<CollectedItem>.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<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
ParseOutcome<CollectedItem>.Success(Item("https://a.test/2.png", CollectStatus.Duplicate)),
ParseOutcome<CollectedItem>.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<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
ParseOutcome<CollectedItem>.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<CollectedItem>.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<CollectedItem>.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<CollectedItem>.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");
}
}