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.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(); } [Fact] public async Task Disposing_twice_is_safe() { // Not hypothetical: every page is registered under its own type and under PageViewModel, so // the container disposes it once per registration. The second call used to cancel an // already-disposed token source and bring the process down on every exit. var (page, _, _) = Build(Media("https://a.test/1.png")); await page.LoadAsync(0, TestContext.Current.CancellationToken); page.Dispose(); Should.NotThrow(page.Dispose); } }