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>
174 lines
5.6 KiB
C#
174 lines
5.6 KiB
C#
using System.Runtime.CompilerServices;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless.XUnit;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using AvParser.Core.Collecting;
|
|
using AvParser.Core.Parsing;
|
|
using AvParser.Core.Proxies;
|
|
using AvParser.Core.Settings;
|
|
using AvParser.UI.ViewModels;
|
|
using AvParser.UI.Views;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ReactiveUI.Primitives.Concurrency;
|
|
|
|
namespace AvParser.UI.HeadlessTests;
|
|
|
|
public class CollectViewTests
|
|
{
|
|
private sealed class StubSource(string id, string name, bool network) : IMediaSource
|
|
{
|
|
public string Id => id;
|
|
|
|
public string DisplayName => name;
|
|
|
|
public string Description => "A source";
|
|
|
|
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 IdleRunner : ICollectRunner
|
|
{
|
|
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
|
|
IMediaSource source,
|
|
MediaQuery query,
|
|
CollectOptions options,
|
|
IProgress<ParseProgress>? progress,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken
|
|
)
|
|
{
|
|
await Task.Yield();
|
|
yield break;
|
|
}
|
|
}
|
|
|
|
private sealed class EmptyServiceProvider : IServiceProvider
|
|
{
|
|
public object? GetService(Type serviceType) => null;
|
|
}
|
|
|
|
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource)
|
|
{
|
|
IMediaSource[] sources = networkSource
|
|
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
|
|
: [new StubSource("url-list", "URL list", false)];
|
|
|
|
var viewModel = new CollectViewModel(
|
|
new MediaSourceCatalog(sources, networkSource ? "own-service" : "url-list"),
|
|
new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }),
|
|
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
|
new IdleRunner(),
|
|
new FakeMediaStore(),
|
|
new FakeThumbnailCache(),
|
|
new EmptyServiceProvider(),
|
|
NullLogger<CollectViewModel>.Instance,
|
|
ImmediateSequencer.Instance
|
|
);
|
|
|
|
var view = new CollectView { DataContext = viewModel };
|
|
var window = new Window
|
|
{
|
|
Width = 1400,
|
|
Height = 900,
|
|
Content = view,
|
|
};
|
|
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return (view, viewModel, window);
|
|
}
|
|
|
|
private static Border Banner(CollectView view) => view.FindControl<Border>("ProxyGateBanner").ShouldNotBeNull();
|
|
|
|
[AvaloniaFact]
|
|
public void The_page_renders()
|
|
{
|
|
var (view, _, _) = ShowPage(networkSource: false);
|
|
|
|
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void No_banner_is_shown_for_a_source_that_needs_no_network()
|
|
{
|
|
var (view, viewModel, _) = ShowPage(networkSource: false);
|
|
|
|
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
|
|
Banner(view).IsVisible.ShouldBeFalse();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void A_blocked_network_source_puts_the_banner_on_screen()
|
|
{
|
|
// Rendered rather than asserted on the view model: an IsVisible binding that never fires
|
|
// leaves the page silently unhelpful, which is exactly the failure this guards.
|
|
var (view, viewModel, _) = ShowPage(networkSource: true);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
|
|
Banner(view).IsEffectivelyVisible.ShouldBeTrue();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void The_banner_offers_a_way_to_the_proxies_page()
|
|
{
|
|
var (view, viewModel, _) = ShowPage(networkSource: true);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
|
|
|
|
button.Command.ShouldBeSameAs(viewModel.GoToProxiesCommand);
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void A_blocked_page_will_not_run_the_collector()
|
|
{
|
|
var (view, viewModel, _) = ShowPage(networkSource: true);
|
|
viewModel.EndpointText = "https://own.test/api/list";
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var run = view.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(candidate => ReferenceEquals(candidate.Command, viewModel.CollectCommand));
|
|
|
|
run.IsEffectivelyEnabled.ShouldBeFalse();
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void An_endpoint_source_shows_an_address_box_rather_than_a_paste_box()
|
|
{
|
|
var (view, _, _) = ShowPage(networkSource: true);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
|
|
|
|
boxes.ShouldHaveSingleItem();
|
|
boxes[0].PlaceholderText.ShouldNotBeNull().ShouldContain("api/list");
|
|
}
|
|
|
|
[AvaloniaFact]
|
|
public void A_pasted_list_source_shows_the_paste_box()
|
|
{
|
|
var (view, _, _) = ShowPage(networkSource: false);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
|
|
|
|
boxes.ShouldHaveSingleItem();
|
|
boxes[0].AcceptsReturn.ShouldBeTrue();
|
|
}
|
|
}
|