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
+24
View File
@@ -76,6 +76,30 @@
повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал. повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал.
В Avalonia 12 `SetTextAsync` — расширение из `Avalonia.Input.Platform`, а не член `IClipboard`. В Avalonia 12 `SetTextAsync` — расширение из `Avalonia.Input.Platform`, а не член `IClipboard`.
## Правила на картинку
Пользователь открывает картинку в галерее и говорит, что делать, когда она попадётся снова:
`MediaRule` (SHA-256 → действие + список источников), таблица `media_rule`, редактор — оверлей
`x:Name="RuleEditor"` в `GalleryView`.
- **Опознание по байтам, и только.** Ни адрес, ни размер не отличают «удалено» и «недоступно в вашей
стране» от настоящей картинки — сервис отдаёт и то и другое честным 200. Перцептивных хешей здесь
нет намеренно, поэтому пережатая версия того же баннера — другое правило; притворяться иначе значит
тихо терять настоящие находки.
- **Область — источники.** Одни и те же байты у одного хоста заглушка, у другого обычная картинка.
Пусто = все источники. Новое правило открывается отмеченным на источнике этой картинки.
- **`Skip` удаляет и уже собранную копию.** Правило ставят, глядя на мусор в галерее; оставить его на
диске значит выполнить половину просьбы. Удаление идёт через существующий tombstone — он один умеет
снимать копии вместе со ссылками showcase.
- **`RetryElsewhere` перезапрашивает тот же адрес через другую страну**, до `MaxCountryAttempts`, и
**требует прокси даже у источника, которому разрешено прямое подключение**: смысл в том, чтобы
прийти откуда-то ещё, а свой адрес — ровно то место, где баннер уже видели.
- **Некуда идти — ничего не сохраняем, адрес остаётся повторяемым** (`GeoBlocked`, нетерминальный).
Баннер это не картинка, а записать его как решённый исход значит сжечь id навсегда из-за
сегодняшнего состава пула. Прокси без страны в фиде исключить не из чего — это тоже конец попыток.
- **Старые tombstone приезжают в ту же карту как безобластной `Skip`**: у фетчера один поиск на
скачивание, а правило по тому же хешу перебивает, как более позднее и более конкретное.
## Хранилище медиа ## Хранилище медиа
- **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на - **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
+3
View File
@@ -24,6 +24,9 @@
всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный
connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, — connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, —
одно и то же число. одно и то же число.
- **`AcquireAsync(excludedCountries)` — для правила «попробовать из другой страны».** Записи, у
которых фид не назвал страну, остаются в игре: неизвестная страна — не совпадение, и отбрасывать их
значит сужать пул из-за пустого поля.
- **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий - **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий
фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси
сама себя переводит в живые. сама себя переводит в живые.
+4 -1
View File
@@ -44,7 +44,10 @@ public interface IProxyPool
/// stated stay eligible: an unknown country is not a known match, and refusing them would turn a /// stated stay eligible: an unknown country is not a known match, and refusing them would turn a
/// missing field into a smaller pool. /// missing field into a smaller pool.
/// </remarks> /// </remarks>
Task<ProxyLease?> AcquireAsync(IReadOnlySet<string>? excludedCountries, CancellationToken cancellationToken = default); Task<ProxyLease?> AcquireAsync(
IReadOnlySet<string>? excludedCountries,
CancellationToken cancellationToken = default
);
/// <summary>Probes every entry in parallel and updates their health.</summary> /// <summary>Probes every entry in parallel and updates their health.</summary>
/// <returns>How many answered.</returns> /// <returns>How many answered.</returns>
@@ -140,8 +140,7 @@ public sealed class MediaFetcher(
foreach (var address in candidate.Addresses) foreach (var address in candidate.Addresses)
{ {
var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] }; var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] };
var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken) var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken).ConfigureAwait(false);
.ConfigureAwait(false);
if (!IsMissing(result.Outcome)) if (!IsMissing(result.Outcome))
{ {
@@ -224,13 +223,7 @@ public sealed class MediaFetcher(
/// <summary>The verdict when the picture is a known geo-block and there is nowhere else to be.</summary> /// <summary>The verdict when the picture is a known geo-block and there is nowhere else to be.</summary>
private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet<string> tried) => private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet<string> tried) =>
Failure( Failure(url, SeenOutcome.Failed, "GeoBlocked", tried.Count == 0 ? null : string.Join(", ", tried), stopwatch);
url,
SeenOutcome.Failed,
"GeoBlocked",
tried.Count == 0 ? null : string.Join(", ", tried),
stopwatch
);
/// <summary>Whether the address simply held nothing, which is the only reason to try the next one.</summary> /// <summary>Whether the address simply held nothing, which is the only reason to try the next one.</summary>
private static bool IsMissing(SeenOutcome outcome) => private static bool IsMissing(SeenOutcome outcome) =>
@@ -601,7 +594,15 @@ public sealed class MediaFetcher(
var outcome = var outcome =
rule == MediaRuleAction.Skip rule == MediaRuleAction.Skip
? Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status) ? Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status)
: Failure(url, SeenOutcome.Failed, RetryElsewhereCode, null, stopwatch, leased.ProxyKey, status); : Failure(
url,
SeenOutcome.Failed,
RetryElsewhereCode,
null,
stopwatch,
leased.ProxyKey,
status
);
return (outcome with { ProxyCountry = leased.ProxyCountry }, true, null); return (outcome with { ProxyCountry = leased.ProxyCountry }, true, null);
} }
@@ -63,6 +63,7 @@ public static class UiServiceCollectionExtensions
)); ));
services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel( services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel(
sp.GetRequiredService<IMediaStore>(), sp.GetRequiredService<IMediaStore>(),
sp.GetRequiredService<IMediaSourceCatalog>(),
sp.GetRequiredService<IThumbnailCache>(), sp.GetRequiredService<IThumbnailCache>(),
sp.GetRequiredService<ILogger<GalleryViewModel>>() sp.GetRequiredService<ILogger<GalleryViewModel>>()
)); ));
+39
View File
@@ -814,6 +814,45 @@
<data name="Gallery.Hash" xml:space="preserve"> <data name="Gallery.Hash" xml:space="preserve">
<value>CONTENT HASH</value> <value>CONTENT HASH</value>
</data> </data>
<data name="Gallery.Rule.Edit" xml:space="preserve">
<value>When found again…</value>
</data>
<data name="Gallery.Rule.Title" xml:space="preserve">
<value>When this picture turns up again</value>
</data>
<data name="Gallery.Rule.Hint" xml:space="preserve">
<value>Matched by content, byte for byte: a re-encoded or resized version of the same picture is a different one.</value>
</data>
<data name="Gallery.Rule.Action" xml:space="preserve">
<value>ACTION</value>
</data>
<data name="Gallery.Rule.Sources" xml:space="preserve">
<value>SOURCES</value>
</data>
<data name="Gallery.Rule.SourcesHint" xml:space="preserve">
<value>Nothing ticked means every source. The same bytes can be a placeholder on one host and an ordinary picture on another.</value>
</data>
<data name="Gallery.Rule.Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="Gallery.Rule.Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="Gallery.Rule.Remove" xml:space="preserve">
<value>Remove the rule</value>
</data>
<data name="Gallery.Rule.Saved" xml:space="preserve">
<value>Rule saved: {0}. Removed {1} copy(ies).</value>
</data>
<data name="Gallery.Rule.Removed" xml:space="preserve">
<value>Rule removed. What it deleted does not come back.</value>
</data>
<data name="Enum.MediaRuleAction.Skip" xml:space="preserve">
<value>Skip: never keep this picture</value>
</data>
<data name="Enum.MediaRuleAction.RetryElsewhere" xml:space="preserve">
<value>Try again through another country</value>
</data>
<data name="Gallery.Close" xml:space="preserve"> <data name="Gallery.Close" xml:space="preserve">
<value>Close</value> <value>Close</value>
</data> </data>
@@ -814,6 +814,45 @@
<data name="Gallery.Hash" xml:space="preserve"> <data name="Gallery.Hash" xml:space="preserve">
<value>ХЕШ СОДЕРЖИМОГО</value> <value>ХЕШ СОДЕРЖИМОГО</value>
</data> </data>
<data name="Gallery.Rule.Edit" xml:space="preserve">
<value>Действие при находке…</value>
</data>
<data name="Gallery.Rule.Title" xml:space="preserve">
<value>Когда эта картинка попадётся снова</value>
</data>
<data name="Gallery.Rule.Hint" xml:space="preserve">
<value>Сравнение по содержимому, байт в байт: пережатая или изменённая в размере версия той же картинки — уже другая.</value>
</data>
<data name="Gallery.Rule.Action" xml:space="preserve">
<value>ДЕЙСТВИЕ</value>
</data>
<data name="Gallery.Rule.Sources" xml:space="preserve">
<value>ИСТОЧНИКИ</value>
</data>
<data name="Gallery.Rule.SourcesHint" xml:space="preserve">
<value>Ничего не отмечено — значит для всех источников. Одни и те же байты у одного хоста заглушка, у другого обычная картинка.</value>
</data>
<data name="Gallery.Rule.Save" xml:space="preserve">
<value>Сохранить</value>
</data>
<data name="Gallery.Rule.Cancel" xml:space="preserve">
<value>Отмена</value>
</data>
<data name="Gallery.Rule.Remove" xml:space="preserve">
<value>Удалить правило</value>
</data>
<data name="Gallery.Rule.Saved" xml:space="preserve">
<value>Правило сохранено: {0}. Удалено копий: {1}.</value>
</data>
<data name="Gallery.Rule.Removed" xml:space="preserve">
<value>Правило удалено. То, что оно стёрло, не возвращается.</value>
</data>
<data name="Enum.MediaRuleAction.Skip" xml:space="preserve">
<value>Пропускать: никогда не сохранять</value>
</data>
<data name="Enum.MediaRuleAction.RetryElsewhere" xml:space="preserve">
<value>Пробовать снова через другую страну</value>
</data>
<data name="Gallery.Close" xml:space="preserve"> <data name="Gallery.Close" xml:space="preserve">
<value>Закрыть</value> <value>Закрыть</value>
</data> </data>
+147 -1
View File
@@ -94,6 +94,7 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
/// <summary>Creates the page.</summary> /// <summary>Creates the page.</summary>
/// <param name="store">Where the content is.</param> /// <param name="store">Where the content is.</param>
/// <param name="catalog">Sources a rule can be scoped to.</param>
/// <param name="thumbnails">Decodes and caches tile bitmaps.</param> /// <param name="thumbnails">Decodes and caches tile bitmaps.</param>
/// <param name="logger">Diagnostics.</param> /// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param> /// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
@@ -148,7 +149,14 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
this.WhenAnyValue(x => x.HasRule), this.WhenAnyValue(x => x.HasRule),
_mainThread _mainThread
); );
CancelRuleCommand = ReactiveCommand.Create(() => IsRuleEditorOpen = false, editing, _mainThread); CancelRuleCommand = ReactiveCommand.Create(
() =>
{
IsRuleEditorOpen = false;
},
editing,
_mainThread
);
EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed); EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed); SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
@@ -218,6 +226,31 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
/// <summary>Closes the viewer.</summary> /// <summary>Closes the viewer.</summary>
public ReactiveCommand<RxVoid, GalleryItemViewModel?> CloseViewerCommand { get; } public ReactiveCommand<RxVoid, GalleryItemViewModel?> CloseViewerCommand { get; }
/// <summary>Opens the "what to do when this turns up again" panel for the open picture.</summary>
public ReactiveCommand<RxVoid, RxVoid> EditRuleCommand { get; }
/// <summary>Saves the rule for the open picture.</summary>
public ReactiveCommand<RxVoid, RxVoid> SaveRuleCommand { get; }
/// <summary>Drops the rule for the open picture.</summary>
public ReactiveCommand<RxVoid, RxVoid> RemoveRuleCommand { get; }
/// <summary>Closes the panel without saving.</summary>
public ReactiveCommand<RxVoid, RxVoid> CancelRuleCommand { get; }
/// <summary>Actions the editor offers.</summary>
public IReadOnlyList<LocalizedOption<MediaRuleAction>> RuleActions { get; }
/// <summary>
/// Sources the rule may cover, each with its own tick.
/// </summary>
/// <remarks>
/// Ticked rather than a single choice because the same bytes mean different things per service:
/// one host's "removed" placeholder is another host's ordinary picture. Nothing ticked means
/// every source, which is what a user who does not care about the distinction will leave it as.
/// </remarks>
public ObservableCollection<MediaSourceViewModel> RuleSources { get; }
/// <summary>Whether the viewer is open.</summary> /// <summary>Whether the viewer is open.</summary>
public bool IsViewerOpen => Selected is not null; public bool IsViewerOpen => Selected is not null;
@@ -339,6 +372,114 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total; private static bool HasNextPage(int page, int total, bool loading) => !loading && (page + 1) * PageSize < total;
/// <summary>Fills the editor from whatever rule the open picture already has.</summary>
private async Task OpenRuleEditorAsync()
{
if (Selected is not { } item)
{
return;
}
var rules = await _store.LoadRulesAsync().ConfigureAwait(false);
var existing = rules.FirstOrDefault(rule =>
string.Equals(rule.Sha256, item.Media.Sha256, StringComparison.Ordinal)
);
var scoped = new HashSet<string>(existing?.Sources ?? [], StringComparer.OrdinalIgnoreCase);
OnUi(() =>
{
foreach (var source in RuleSources)
{
source.Dispose();
}
RuleSources.Clear();
foreach (var source in _catalog.Sources)
{
RuleSources.Add(new MediaSourceViewModel(source) { IsSelected = scoped.Contains(source.Id) });
}
// A new rule starts on the source this picture came from: it is the one the user is
// looking at, and scoping to it is nearly always what they mean.
if (existing is null)
{
foreach (var source in RuleSources.Where(source => source.Id == item.Media.SourceId))
{
source.IsSelected = true;
}
}
RuleAction = RuleActions.First(option => option.Value == (existing?.Action ?? MediaRuleAction.Skip));
HasRule = existing is not null;
IsRuleEditorOpen = true;
});
}
private async Task SaveRuleAsync()
{
if (Selected is not { } item)
{
return;
}
var rule = new MediaRule(item.Media.Sha256, RuleAction.Value)
{
SourceIds = MediaRule.JoinSources(
RuleSources.Where(source => source.IsSelected).Select(source => source.Id)
),
};
var removed = await _store.SaveRuleAsync(rule).ConfigureAwait(false);
var message = Localizer.Instance.Format("Gallery.Rule.Saved", RuleAction.Label, removed);
OnUi(() =>
{
IsRuleEditorOpen = false;
HasRule = true;
// A skip rule deletes the copy that prompted it, so the page behind the viewer is now
// showing something that is gone.
if (removed > 0)
{
Selected = null;
}
});
if (removed > 0)
{
await LoadAsync(PageIndex).ConfigureAwait(false);
}
// After the reload, not before: reading a page rewrites the status line, so saying it first
// would leave the user with an empty toolbar and no sign anything happened.
OnUi(() => StatusMessage = message);
}
private async Task RemoveRuleAsync()
{
if (Selected is not { } item)
{
return;
}
await _store.RemoveRuleAsync(item.Media.Sha256).ConfigureAwait(false);
OnUi(() =>
{
HasRule = false;
IsRuleEditorOpen = false;
StatusMessage = Localizer.Instance["Gallery.Rule.Removed"];
});
}
private void OnRuleFailed(Exception exception)
{
_logger.LogError(exception, "The gallery rule could not be applied");
OnUi(() => StatusMessage = Localizer.Instance.Format("Gallery.Failed", exception.Message));
}
/// <inheritdoc /> /// <inheritdoc />
protected override void OnLanguageChanged() protected override void OnLanguageChanged()
{ {
@@ -371,6 +512,11 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
loading.Dispose(); loading.Dispose();
} }
foreach (var source in RuleSources)
{
source.Dispose();
}
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
+66
View File
@@ -198,12 +198,78 @@
Margin="0,0,32,32" Margin="0,0,32,32"
Spacing="8" Spacing="8"
> >
<Button Command="{Binding EditRuleCommand}" ToolTip.Tip="{l:Loc Gallery.Rule.Hint}">
<TextBlock Text="{l:Loc Gallery.Rule.Edit}" />
</Button>
<Button Classes="primary" Command="{Binding CloseViewerCommand}"> <Button Classes="primary" Command="{Binding CloseViewerCommand}">
<TextBlock Text="{l:Loc Gallery.Close}" /> <TextBlock Text="{l:Loc Gallery.Close}" />
</Button> </Button>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
<!-- ===== Rule editor ===== -->
<Border
x:Name="RuleEditor"
Background="{DynamicResource AppSurfaceSunkenBrush}"
IsVisible="{Binding IsRuleEditorOpen}"
>
<Border Classes="card" MaxWidth="520" VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Classes="subtitle" Text="{l:Loc Gallery.Rule.Title}" />
<TextBlock Classes="muted" Text="{l:Loc Gallery.Rule.Hint}" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="caption" Text="{l:Loc Gallery.Rule.Action}" />
<ComboBox
ItemsSource="{Binding RuleActions}"
SelectedItem="{Binding RuleAction}"
HorizontalAlignment="Stretch"
>
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="l:LocalizedOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="caption" Text="{l:Loc Gallery.Rule.Sources}" />
<Border
BorderThickness="1"
BorderBrush="{DynamicResource AppBorderBrush}"
CornerRadius="4"
MaxHeight="160"
>
<ItemsControl x:Name="RuleSourceList" ItemsSource="{Binding RuleSources}" Margin="10">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MediaSourceViewModel">
<CheckBox IsChecked="{Binding IsSelected}" Content="{Binding Name}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<TextBlock Classes="muted caption" Text="{l:Loc Gallery.Rule.SourcesHint}" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Classes="destructive" Command="{Binding RemoveRuleCommand}">
<TextBlock Text="{l:Loc Gallery.Rule.Remove}" />
</Button>
<Button Command="{Binding CancelRuleCommand}">
<TextBlock Text="{l:Loc Gallery.Rule.Cancel}" />
</Button>
<Button Classes="primary" Command="{Binding SaveRuleCommand}">
<TextBlock Text="{l:Loc Gallery.Rule.Save}" />
</Button>
</StackPanel>
</StackPanel>
</Border>
</Border>
</Grid> </Grid>
<!-- ===== Pager ===== --> <!-- ===== Pager ===== -->
@@ -1,4 +1,5 @@
using AvParser.Core.Collecting; using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
using AvParser.UI.Tests.Fakes; using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels; using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -25,6 +26,49 @@ public class GalleryViewModelTests
IsAnimated = animated, 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( private static (GalleryViewModel Page, FakeMediaStore Store, FakeThumbnailCache Thumbnails) Build(
params StoredMedia[] items params StoredMedia[] items
) )
@@ -36,6 +80,7 @@ public class GalleryViewModelTests
var thumbnails = new FakeThumbnailCache(); var thumbnails = new FakeThumbnailCache();
var page = new GalleryViewModel( var page = new GalleryViewModel(
store, store,
new MediaSourceCatalog(new FakeUserSourceStore([Config("url-list"), Config("other")])),
thumbnails, thumbnails,
NullLogger<GalleryViewModel>.Instance, NullLogger<GalleryViewModel>.Instance,
ImmediateSequencer.Instance ImmediateSequencer.Instance
@@ -171,6 +216,90 @@ public class GalleryViewModelTests
page.IsLoading.ShouldBeFalse(); 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] [Fact]
public async Task Disposing_twice_is_safe() public async Task Disposing_twice_is_safe()
{ {