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 page = new CollectViewModel(
catalog,
settingsService,
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
runner,
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 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");
}
}