Files
av-parser/tests/AvParser.UI.Tests/GalleryViewModelTests.cs
T
Leonid Pershin 0aa3a7cf11 Implement media rule editor and enhance gallery functionality
- 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.
2026-08-15 16:03:02 +03:00

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);
}
}