using AvParser.Core.Collecting; using AvParser.Core.Collecting.Sources; 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, }; /// An in-memory catalog, so a rule can be scoped to a source in tests. private sealed class FakeUserSourceStore(IEnumerable seed) : IUserSourceStore { private readonly List _configs = [.. seed]; public event EventHandler? Changed { add { } remove { } } public IReadOnlyList List() => [.. _configs]; public Task AddAsync( PatternSourceConfig config, CancellationToken cancellationToken = default ) => Task.FromResult(config); public Task UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default) => Task.FromResult(false); public Task RemoveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(false); } private static PatternSourceConfig Config(string id) { PatternSourceConfig.TryCreate( id, "https://imgtest.example/x/", 6, 8, IdAlphabet.Alphanumeric, null, ".jpg", allowDirectConnection: false, out var config, id ); return config!; } 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, new MediaSourceCatalog(new FakeUserSourceStore([Config("url-list"), Config("other")])), 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 A_new_rule_starts_scoped_to_the_source_the_picture_came_from() { var (page, _, _) = Build(Media("https://a.test/1.png")); await page.LoadAsync(0, TestContext.Current.CancellationToken); page.Selected = page.Items[0]; await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.IsRuleEditorOpen.ShouldBeTrue(); page.HasRule.ShouldBeFalse(); page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("url-list"); page.RuleAction.Value.ShouldBe(MediaRuleAction.Skip); } [Fact] public async Task Saving_a_skip_rule_records_it_and_drops_the_copy_that_prompted_it() { var (page, store, _) = Build(Media("https://a.test/1.png")); await page.LoadAsync(0, TestContext.Current.CancellationToken); page.Selected = page.Items[0]; var hash = page.Selected.Media.Sha256; await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); var rule = store.Rules.ShouldHaveSingleItem(); rule.Sha256.ShouldBe(hash); rule.Action.ShouldBe(MediaRuleAction.Skip); rule.AppliesTo("url-list").ShouldBeTrue(); rule.AppliesTo("other").ShouldBeFalse(); // The thing the rule was made about is gone, so the viewer cannot keep showing it. page.IsRuleEditorOpen.ShouldBeFalse(); page.Selected.ShouldBeNull(); page.StatusMessage.ShouldNotBeNull().ShouldContain("Rule saved"); } [Fact] public async Task A_rule_with_no_source_ticked_covers_every_source() { var (page, store, _) = Build(Media("https://a.test/1.png")); await page.LoadAsync(0, TestContext.Current.CancellationToken); page.Selected = page.Items[0]; await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); foreach (var source in page.RuleSources) { source.IsSelected = false; } page.RuleAction = page.RuleActions.Single(option => option.Value == MediaRuleAction.RetryElsewhere); await page.SaveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); var rule = store.Rules.ShouldHaveSingleItem(); rule.SourceIds.ShouldBeNull(); rule.AppliesTo("anything-at-all").ShouldBeTrue(); rule.Action.ShouldBe(MediaRuleAction.RetryElsewhere); } [Fact] public async Task An_existing_rule_comes_back_into_the_editor() { var (page, store, _) = Build(Media("https://a.test/1.png")); await page.LoadAsync(0, TestContext.Current.CancellationToken); page.Selected = page.Items[0]; store.Rules.Add( new MediaRule(page.Selected.Media.Sha256, MediaRuleAction.RetryElsewhere) { SourceIds = "other" } ); await page.EditRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); page.HasRule.ShouldBeTrue(); page.RuleAction.Value.ShouldBe(MediaRuleAction.RetryElsewhere); page.RuleSources.Single(source => source.IsSelected).Id.ShouldBe("other"); await page.RemoveRuleCommand.Execute().ToTask(TestContext.Current.CancellationToken); store.Rules.ShouldBeEmpty(); page.HasRule.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); } }