- Introduced a rule editor in the gallery for users to define actions when a media item is found again, allowing for better management of media rules. - Updated `GalleryViewModel` to handle rule actions, including saving, editing, and removing rules, with appropriate UI bindings. - Enhanced UI components in `GalleryView.axaml` to support rule editing, including action selection and source management. - Added localization strings for new rule-related features in both English and Russian. - Improved unit tests to cover new rule management functionalities, ensuring robust behavior during rule creation and editing. These changes significantly enhance the user experience by providing a more interactive and flexible way to manage media items in the gallery.
317 lines
11 KiB
C#
317 lines
11 KiB
C#
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,
|
|
};
|
|
|
|
/// <summary>An in-memory catalog, so a rule can be scoped to a source in tests.</summary>
|
|
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig> seed) : IUserSourceStore
|
|
{
|
|
private readonly List<PatternSourceConfig> _configs = [.. seed];
|
|
|
|
public event EventHandler? Changed
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
|
|
|
|
public Task<PatternSourceConfig> AddAsync(
|
|
PatternSourceConfig config,
|
|
CancellationToken cancellationToken = default
|
|
) => Task.FromResult(config);
|
|
|
|
public Task<bool> UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(false);
|
|
|
|
public Task<bool> 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<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();
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|