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.
This commit is contained in:
Leonid Pershin
2026-08-15 16:03:02 +03:00
parent 0fa8fb89f6
commit 0aa3a7cf11
10 changed files with 463 additions and 12 deletions
@@ -1,4 +1,5 @@
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
@@ -25,6 +26,49 @@ public class GalleryViewModelTests
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
)
@@ -36,6 +80,7 @@ public class GalleryViewModelTests
var thumbnails = new FakeThumbnailCache();
var page = new GalleryViewModel(
store,
new MediaSourceCatalog(new FakeUserSourceStore([Config("url-list"), Config("other")])),
thumbnails,
NullLogger<GalleryViewModel>.Instance,
ImmediateSequencer.Instance
@@ -171,6 +216,90 @@ public class GalleryViewModelTests
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()
{