Show the collected content: thumbnails and a gallery
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f8744c930a
commit
ceacec79e2
@@ -71,6 +71,7 @@ public class CollectViewTests
|
||||
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
new IdleRunner(),
|
||||
new FakeMediaStore(),
|
||||
new FakeThumbnailCache(),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
|
||||
@@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
|
||||
public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0);
|
||||
|
||||
public List<StoredMedia> Browse { get; } = [];
|
||||
|
||||
public List<string> SourceIds { get; } = [];
|
||||
|
||||
public MediaBrowseQuery? LastBrowse { get; private set; }
|
||||
|
||||
public int? TotalOverride { get; set; }
|
||||
|
||||
public bool BrowseThrows { get; set; }
|
||||
|
||||
public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096);
|
||||
|
||||
public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink;
|
||||
@@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats);
|
||||
|
||||
public Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastBrowse = query;
|
||||
|
||||
return BrowseThrows
|
||||
? throw new InvalidOperationException("the index is unreadable")
|
||||
: Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(SourceIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Media;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>
|
||||
/// A cache that decodes nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returning null is exactly what the real cache does for a video or a missing blob, so the view
|
||||
/// models are already required to cope with it — which is what makes this an honest stand-in
|
||||
/// rather than a convenient one. It records what was asked for, so tests can assert that a page
|
||||
/// requested previews at all.
|
||||
/// </remarks>
|
||||
internal sealed class FakeThumbnailCache : IThumbnailCache
|
||||
{
|
||||
public List<(string Sha256, int Width)> Requested { get; } = [];
|
||||
|
||||
public Task<Bitmap?> GetAsync(
|
||||
string sha256,
|
||||
string extension,
|
||||
MediaKind kind,
|
||||
int width,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
lock (Requested)
|
||||
{
|
||||
Requested.Add((sha256, width));
|
||||
}
|
||||
|
||||
return Task.FromResult<Bitmap?>(null);
|
||||
}
|
||||
|
||||
public void Clear() => Requested.Clear();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace AvParser.UI.HeadlessTests;
|
||||
|
||||
/// <summary>
|
||||
/// Every resource key the XAML asks for must actually resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>DynamicResource</c> naming a key that does not exist fails silently: the property simply
|
||||
/// keeps its default, so a background never paints and a brush is transparent. That shipped twice —
|
||||
/// most recently as a viewer overlay you could see straight through, caught only by looking at a
|
||||
/// screenshot. Both themes are checked, because a key can exist in one dictionary and not the other.
|
||||
/// </remarks>
|
||||
public partial class ResourceKeyTests
|
||||
{
|
||||
[GeneratedRegex(@"\{(?:Dynamic|Static)Resource\s+([A-Za-z0-9_.]+)\s*\}", RegexOptions.Compiled)]
|
||||
private static partial Regex ResourceMarkup();
|
||||
|
||||
/// <summary>Keys defined inside a view's own <c>Resources</c> block rather than in a theme.</summary>
|
||||
private static readonly HashSet<string> Local = new(StringComparer.Ordinal) { "LocalizedOptionTemplate" };
|
||||
|
||||
private static DirectoryInfo RepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AvParser.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory ?? throw new InvalidOperationException("Could not find the repository root.");
|
||||
}
|
||||
|
||||
[AvaloniaTheory]
|
||||
[InlineData("Light")]
|
||||
[InlineData("Dark")]
|
||||
public void Every_resource_key_used_in_xaml_resolves(string theme)
|
||||
{
|
||||
var application = Application.Current.ShouldNotBeNull();
|
||||
application.RequestedThemeVariant = theme == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
|
||||
|
||||
var views = Path.Combine(RepositoryRoot().FullName, "src", "AvParser.UI");
|
||||
var files = Directory.GetFiles(views, "*.axaml", SearchOption.AllDirectories);
|
||||
|
||||
files.ShouldNotBeEmpty();
|
||||
|
||||
var missing = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
foreach (Match match in ResourceMarkup().Matches(File.ReadAllText(file)))
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
|
||||
if (Local.Contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!application.TryGetResource(key, application.ActualThemeVariant, out _))
|
||||
{
|
||||
missing.Add($"{key} ({Path.GetFileName(file)})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
missing.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ public class CollectViewModelTests
|
||||
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
runner,
|
||||
store,
|
||||
new FakeThumbnailCache(),
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
|
||||
@@ -9,6 +9,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
|
||||
public MediaStoreStats Stats { get; set; } = new(0, 0, 0, 0);
|
||||
|
||||
public List<StoredMedia> Browse { get; } = [];
|
||||
|
||||
public List<string> SourceIds { get; } = [];
|
||||
|
||||
public MediaBrowseQuery? LastBrowse { get; private set; }
|
||||
|
||||
public int? TotalOverride { get; set; }
|
||||
|
||||
public bool BrowseThrows { get; set; }
|
||||
|
||||
public PurgeResult PurgeResult { get; set; } = new(3, 2, 4096);
|
||||
|
||||
public ShowcaseMode Mode { get; private set; } = ShowcaseMode.HardLink;
|
||||
@@ -61,4 +71,16 @@ internal sealed class FakeMediaStore : IMediaStore
|
||||
Task.FromResult(0);
|
||||
|
||||
public Task<MediaStoreStats> GetStatsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Stats);
|
||||
|
||||
public Task<MediaPage> BrowseAsync(MediaBrowseQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastBrowse = query;
|
||||
|
||||
return BrowseThrows
|
||||
? throw new InvalidOperationException("the index is unreadable")
|
||||
: Task.FromResult(new MediaPage(Browse, TotalOverride ?? Browse.Count));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetSourceIdsAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<string>>(SourceIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Media;
|
||||
|
||||
namespace AvParser.UI.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// A cache that decodes nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returning null is exactly what the real cache does for a video or a missing blob, so the view
|
||||
/// models are already required to cope with it — which is what makes this an honest stand-in
|
||||
/// rather than a convenient one. It records what was asked for, so tests can assert that a page
|
||||
/// requested previews at all.
|
||||
/// </remarks>
|
||||
internal sealed class FakeThumbnailCache : IThumbnailCache
|
||||
{
|
||||
public List<(string Sha256, int Width)> Requested { get; } = [];
|
||||
|
||||
public Task<Bitmap?> GetAsync(
|
||||
string sha256,
|
||||
string extension,
|
||||
MediaKind kind,
|
||||
int width,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
lock (Requested)
|
||||
{
|
||||
Requested.Add((sha256, width));
|
||||
}
|
||||
|
||||
return Task.FromResult<Bitmap?>(null);
|
||||
}
|
||||
|
||||
public void Clear() => Requested.Clear();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using AvParser.Core.Collecting;
|
||||
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 GalleryViewModelTests
|
||||
{
|
||||
private static StoredMedia Media(string url, MediaKind kind = MediaKind.Png, bool animated = false) =>
|
||||
new(
|
||||
Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"),
|
||||
kind,
|
||||
MediaKinds.ExtensionFor(kind),
|
||||
4096,
|
||||
"url-list",
|
||||
url,
|
||||
DateTimeOffset.UtcNow
|
||||
)
|
||||
{
|
||||
Width = 800,
|
||||
Height = 600,
|
||||
IsAnimated = animated,
|
||||
};
|
||||
|
||||
private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build(
|
||||
params StoredMedia[] items
|
||||
)
|
||||
{
|
||||
var store = new FakeMediaStore();
|
||||
store.Browse.AddRange(items);
|
||||
store.SourceIds.Add("url-list");
|
||||
|
||||
var thumbnails = new FakeThumbnailCache();
|
||||
var page = new GalleryViewModel(
|
||||
store,
|
||||
thumbnails,
|
||||
NullLogger<GalleryViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
return (page, store, thumbnails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task What_the_store_holds_becomes_tiles()
|
||||
{
|
||||
var (page, _, _) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.gif", MediaKind.Gif));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Items.Count.ShouldBe(2);
|
||||
page.TotalCount.ShouldBe(2);
|
||||
page.StatusMessage.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_tile_asks_for_a_thumbnail()
|
||||
{
|
||||
var (page, _, thumbnails) = Build(Media("https://a.test/1.png"), Media("https://a.test/2.png"));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
// By distinct hash rather than call count: the page also loads once on construction, and
|
||||
// what matters is that every tile got asked for, at the tile's own width.
|
||||
thumbnails.Requested.Select(r => r.Sha256).Distinct().Count().ShouldBe(2);
|
||||
thumbnails.Requested.ShouldAllBe(r => r.Width == GalleryItemViewModel.ThumbnailWidth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_nothing_can_decode_is_flagged_rather_than_left_blank()
|
||||
{
|
||||
// The fake decodes nothing, which is exactly what the real cache does for a video.
|
||||
var (page, _, _) = Build(Media("https://a.test/clip.mp4", MediaKind.Mp4));
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
var tile = page.Items.ShouldHaveSingleItem();
|
||||
tile.Thumbnail.ShouldBeNull();
|
||||
tile.HasNoPreview.ShouldBeTrue();
|
||||
tile.KindText.ShouldBe("MP4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_empty_store_says_so_instead_of_showing_a_blank_page()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.Items.ShouldBeEmpty();
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldContain("Nothing here yet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_filters_reach_the_query()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
|
||||
page.SelectedSourceId = "url-list";
|
||||
page.AnimatedOnly = true;
|
||||
page.SearchText = "kitten";
|
||||
page.SelectedKinds = page.KindFilters.Single(option => option.Value == MediaKindFilter.Images);
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
var query = store.LastBrowse.ShouldNotBeNull();
|
||||
query.SourceId.ShouldBe("url-list");
|
||||
query.AnimatedOnly.ShouldBeTrue();
|
||||
query.Search.ShouldBe("kitten");
|
||||
query.Kinds.ShouldBe(MediaKindFilter.Images);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unset_source_filter_means_all_of_them()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
|
||||
page.SelectedSourceId = " ";
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
store.LastBrowse.ShouldNotBeNull().SourceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paging_moves_the_offset_and_stops_at_the_ends()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
store.TotalOverride = 250;
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
page.PageText.ShouldContain("1");
|
||||
|
||||
await page.LoadAsync(1, TestContext.Current.CancellationToken);
|
||||
store.LastBrowse!.Skip.ShouldBe(120);
|
||||
|
||||
// A negative page is a clamp, not a crash.
|
||||
await page.LoadAsync(-3, TestContext.Current.CancellationToken);
|
||||
page.PageIndex.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_a_tile_opens_the_viewer_and_closing_it_clears_the_selection()
|
||||
{
|
||||
var (page, _, _) = Build(Media("https://a.test/1.png"));
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.IsViewerOpen.ShouldBeFalse();
|
||||
|
||||
page.Selected = page.Items[0];
|
||||
page.IsViewerOpen.ShouldBeTrue();
|
||||
|
||||
await page.CloseViewerCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.Selected.ShouldBeNull();
|
||||
page.IsViewerOpen.ShouldBeFalse();
|
||||
page.Preview.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_store_that_throws_reports_it_rather_than_taking_the_page_down()
|
||||
{
|
||||
var (page, store, _) = Build(Media("https://a.test/1.png"));
|
||||
store.BrowseThrows = true;
|
||||
|
||||
await page.LoadAsync(0, TestContext.Current.CancellationToken);
|
||||
|
||||
page.StatusMessage.ShouldNotBeNull().ShouldContain("Could not read the store");
|
||||
page.IsLoading.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Media;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The cache's refusals.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the paths that answer before touching a decoder are covered here: decoding needs an
|
||||
/// Avalonia rendering platform, which this project deliberately does not start. The decode itself
|
||||
/// is one framework call; what is worth pinning is that the cache never reaches it for content it
|
||||
/// cannot handle, because doing so would mean an exception per tile.
|
||||
/// </remarks>
|
||||
public sealed class ThumbnailCacheTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N"));
|
||||
private readonly ThumbnailCache _cache;
|
||||
|
||||
public ThumbnailCacheTests()
|
||||
{
|
||||
var paths = new AppPaths(_root);
|
||||
paths.EnsureCreated();
|
||||
|
||||
_cache = new ThumbnailCache(paths, NullLogger<ThumbnailCache>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MediaKind.Mp4)]
|
||||
[InlineData(MediaKind.WebM)]
|
||||
public async Task A_video_is_refused_without_looking_at_the_disk(MediaKind kind)
|
||||
{
|
||||
var result = await _cache.GetAsync(
|
||||
new string('a', 64),
|
||||
".mp4",
|
||||
kind,
|
||||
220,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_blob_is_a_blank_tile_rather_than_a_throw()
|
||||
{
|
||||
var result = await _cache.GetAsync(
|
||||
new string('b', 64),
|
||||
".png",
|
||||
MediaKind.Png,
|
||||
220,
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearing_an_empty_cache_is_harmless() => _cache.Clear();
|
||||
|
||||
[Fact]
|
||||
public void The_capacity_leaves_room_for_more_than_one_screenful()
|
||||
{
|
||||
// The cache disposes what it evicts, so a capacity near the page size would dispose
|
||||
// bitmaps that are still on screen.
|
||||
ThumbnailCache.Capacity.ShouldBeGreaterThan(120 * 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user