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:
@@ -76,6 +76,30 @@
|
||||
повторный рендер, который тихо разъедется с шаблоном. Ctrl+C без выделения копирует весь журнал.
|
||||
В 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/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
всего набора — то есть из пары тысяч непроверенных адресов, и почти каждый запрос платил полный
|
||||
connect-таймаут, чтобы это выяснить. Теперь «живых N» на странице и то, куда реально ходит сбор, —
|
||||
одно и то же число.
|
||||
- **`AcquireAsync(excludedCountries)` — для правила «попробовать из другой страны».** Записи, у
|
||||
которых фид не назвал страну, остаются в игре: неизвестная страна — не совпадение, и отбрасывать их
|
||||
значит сужать пул из-за пустого поля.
|
||||
- **Фолбэк на всё доступное, когда живых нет.** Непроверенная ≠ мёртвая: на холодном пуле строгий
|
||||
фильтр не выдал бы ничего и сбор просто встал бы, не попробовав. Ответившая на этом пути прокси
|
||||
сама себя переводит в живые.
|
||||
|
||||
@@ -44,7 +44,10 @@ public interface IProxyPool
|
||||
/// stated stay eligible: an unknown country is not a known match, and refusing them would turn a
|
||||
/// missing field into a smaller pool.
|
||||
/// </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>
|
||||
/// <returns>How many answered.</returns>
|
||||
|
||||
@@ -140,8 +140,7 @@ public sealed class MediaFetcher(
|
||||
foreach (var address in candidate.Addresses)
|
||||
{
|
||||
var attempt = address == candidate.Url ? candidate : candidate with { Url = address, Alternatives = [] };
|
||||
var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken)
|
||||
.ConfigureAwait(false);
|
||||
var result = await FetchWithRulesAsync(attempt, options, stopwatch, token, userToken).ConfigureAwait(false);
|
||||
|
||||
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>
|
||||
private static FetchResult GeoBlocked(Uri url, Stopwatch stopwatch, HashSet<string> tried) =>
|
||||
Failure(
|
||||
url,
|
||||
SeenOutcome.Failed,
|
||||
"GeoBlocked",
|
||||
tried.Count == 0 ? null : string.Join(", ", tried),
|
||||
stopwatch
|
||||
);
|
||||
Failure(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>
|
||||
private static bool IsMissing(SeenOutcome outcome) =>
|
||||
@@ -601,7 +594,15 @@ public sealed class MediaFetcher(
|
||||
var outcome =
|
||||
rule == MediaRuleAction.Skip
|
||||
? 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);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ public static class UiServiceCollectionExtensions
|
||||
));
|
||||
services.AddSingleton<GalleryViewModel>(static sp => new GalleryViewModel(
|
||||
sp.GetRequiredService<IMediaStore>(),
|
||||
sp.GetRequiredService<IMediaSourceCatalog>(),
|
||||
sp.GetRequiredService<IThumbnailCache>(),
|
||||
sp.GetRequiredService<ILogger<GalleryViewModel>>()
|
||||
));
|
||||
|
||||
@@ -814,6 +814,45 @@
|
||||
<data name="Gallery.Hash" xml:space="preserve">
|
||||
<value>CONTENT HASH</value>
|
||||
</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">
|
||||
<value>Close</value>
|
||||
</data>
|
||||
|
||||
@@ -814,6 +814,45 @@
|
||||
<data name="Gallery.Hash" xml:space="preserve">
|
||||
<value>ХЕШ СОДЕРЖИМОГО</value>
|
||||
</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">
|
||||
<value>Закрыть</value>
|
||||
</data>
|
||||
|
||||
@@ -94,6 +94,7 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
/// <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="logger">Diagnostics.</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),
|
||||
_mainThread
|
||||
);
|
||||
CancelRuleCommand = ReactiveCommand.Create(() => IsRuleEditorOpen = false, editing, _mainThread);
|
||||
CancelRuleCommand = ReactiveCommand.Create(
|
||||
() =>
|
||||
{
|
||||
IsRuleEditorOpen = false;
|
||||
},
|
||||
editing,
|
||||
_mainThread
|
||||
);
|
||||
|
||||
EditRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
||||
SaveRuleCommand.ThrownExceptions.Subscribe(OnRuleFailed);
|
||||
@@ -218,6 +226,31 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
||||
/// <summary>Closes the viewer.</summary>
|
||||
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>
|
||||
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;
|
||||
|
||||
/// <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 />
|
||||
protected override void OnLanguageChanged()
|
||||
{
|
||||
@@ -371,6 +512,11 @@ public partial class GalleryViewModel : PageViewModel, IDisposable
|
||||
loading.Dispose();
|
||||
}
|
||||
|
||||
foreach (var source in RuleSources)
|
||||
{
|
||||
source.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -198,12 +198,78 @@
|
||||
Margin="0,0,32,32"
|
||||
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}">
|
||||
<TextBlock Text="{l:Loc Gallery.Close}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</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>
|
||||
|
||||
<!-- ===== Pager ===== -->
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user