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");
}
}