Until now a collected image was a row of text, and after a restart it was not
visible at all. The collect list gains a row thumbnail, and a Gallery page
browses the whole store with filters by source, format and address, paged at
120 tiles, with a built-in viewer showing the full size beside its provenance.
Thumbnails decode straight to the width they are drawn at. That is the whole
memory story: a 4000x3000 JPEG is about 48 MB once decoded, so decoding full
size and scaling afterwards runs out of memory long before the user finishes
scrolling. The cache is bounded and owns its bitmaps, which means its capacity
has to comfortably exceed a page - a bitmap evicted while still on screen would
be disposed out from under the renderer.
Paged rather than infinite-scrolled for the same reason: how much to decode is a
decision the page should make, not one the archive's size makes for it.
Video is not previewed and will not be. Extracting a first frame means FFmpeg,
which is a media stack in exchange for one picture per tile; those tiles show a
format badge instead. The refusal happens before touching the disk, because
attempting it would be an exception per tile.
Rendering the page caught the viewer overlay being see-through: it named a brush
that does not exist, and an unresolved DynamicResource fails silently - the
property just keeps its default. That is the second silent-reference bug to
reach a screenshot, so both kinds now have guards: one resolves every
{DynamicResource} in the XAML against both themes, the other checks every
{l:Loc} key exists. Both were confirmed to fail before being kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
366 lines
12 KiB
C#
366 lines
12 KiB
C#
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
|
|
{
|
|
/// <summary>A runner that returns a canned stream instead of touching a network.</summary>
|
|
private sealed class FakeRunner : ICollectRunner
|
|
{
|
|
public List<ParseOutcome<CollectedItem>> 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<ParseOutcome<CollectedItem>> RunAsync(
|
|
IMediaSource source,
|
|
MediaQuery query,
|
|
CollectOptions options,
|
|
IProgress<ParseProgress>? 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<ParseOutcome<MediaCandidate>> ParseAsync(
|
|
MediaQuery input,
|
|
IProgress<ParseProgress>? 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<CollectViewModel>.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<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)),
|
|
]);
|
|
|
|
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<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
|
|
ParseOutcome<CollectedItem>.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<CollectedItem>.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");
|
|
}
|
|
}
|