Keep collected media beside the executable, and let it be moved
Two things were wrong with where media lived. It defaulted to the user profile, which is the wrong home for the thing the application exists to accumulate: the collection grows without bound and belongs with the installation, so copying that folder takes the archive with it. And the setting for changing it existed but had no way to be set - the Settings page showed the path as read-only text. Media now defaults to a "media" folder next to the executable, and the Settings page has a box, a folder picker and a reset. Configuration stays in the profile, because that is genuinely per-user and the OS has an opinion about it. Writability is probed with a real file, not just a directory creation: creating a directory can succeed where writing into it does not, which is exactly what an install under Program Files looks like. On failure it falls back to the profile rather than refusing to start, and the effective path is shown in Settings so the fallback is visible instead of mysterious. A change applies on the next launch and says so. Paths are resolved before the container exists - the media root is read straight out of settings.json to build them - so applying it live would mean reconnecting the index, the blob store and the thumbnail cache underneath a possibly-running collection. Writing somewhere other than the box claims would be the worse failure. Existing files are not moved either; relocating an archive is its own operation with its own risks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ceacec79e2
commit
a4a0ea9a6b
@@ -241,6 +241,17 @@ throttle 250 мс — пул дёргается на каждый исход л
|
||||
свежая CVE не роняла сборку кода, который никто не трогал.
|
||||
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
|
||||
|
||||
## Пути
|
||||
|
||||
- **Медиа лежит рядом с exe, настройки — в профиле.** Это осознанное расхождение: конфигурация
|
||||
пользовательская, а коллекция принадлежит установке и переезжает вместе с папкой.
|
||||
- **`DefaultMediaDirectory()` проверяет запись пробным файлом**, а не только созданием каталога:
|
||||
каталог создаётся и там, куда потом нельзя записать. При отказе — откат в профиль.
|
||||
- **Смена каталога требует перезапуска.** `AppPaths` строится до контейнера (медиа-корень читается
|
||||
из settings.json напрямую), так что применить его на лету нельзя без переподключения индекса,
|
||||
blob-хранилища и кэша миниатюр. Настройки честно это говорят и показывают действующий путь.
|
||||
- **Смена каталога не переносит файлы.** Перенос архива — отдельная операция с другой ценой ошибки.
|
||||
|
||||
## Отображение медиа
|
||||
|
||||
- **Миниатюры декодируются в нужную ширину**, а не декодируются целиком и потом масштабируются.
|
||||
|
||||
@@ -214,6 +214,21 @@ proxifly-запись с `"protocol": "https"` — это всё равно HTTP
|
||||
десяти адресам, занимает место один раз. Провенанс (откуда, когда, каким прогоном, через какую
|
||||
прокси) пишется отдельно.
|
||||
|
||||
### Где всё лежит
|
||||
|
||||
**Собранное — рядом с приложением**, в папке `media` возле исполняемого файла: коллекция и есть
|
||||
смысл программы, она растёт без предела, и копирование папки должно уносить архив с собой. Каталог
|
||||
меняется в настройках (кнопка «Выбрать…»), применяется после перезапуска — пути разбираются один
|
||||
раз, до того как собран контейнер, и писать не туда, куда показывает поле, было бы хуже, чем
|
||||
попросить перезапуск. Существующие файлы при смене **не переносятся**.
|
||||
|
||||
Если папка рядом с exe недоступна для записи — установка в Program Files, монтирование только для
|
||||
чтения — происходит откат в профиль пользователя. Действующий путь всегда виден в настройках, так
|
||||
что откат заметен, а не загадочен.
|
||||
|
||||
Настройки, логи и состояние прокси остаются в профиле (`%APPDATA%/AvParser`,
|
||||
`~/.config/AvParser`): это конфигурация, она пользовательская и лежит там, где положено по правилам ОС.
|
||||
|
||||
### Витрина
|
||||
|
||||
`blobs/ab/cd/<sha256>.png` не годится для просмотра глазами, поэтому рядом строится
|
||||
|
||||
@@ -59,16 +59,53 @@ public sealed class AppPaths : IAppPaths
|
||||
private const string FolderName = "AvParser";
|
||||
|
||||
/// <summary>Creates paths under the current user's application-data directory.</summary>
|
||||
/// <remarks>Media lands beside the executable instead; see <see cref="DefaultMediaDirectory"/>.</remarks>
|
||||
public AppPaths()
|
||||
: this(
|
||||
: this(ProfileDirectory(), DefaultMediaDirectory()) { }
|
||||
|
||||
/// <summary>Where settings, logs and proxy state live: the per-user profile.</summary>
|
||||
public static string ProfileDirectory() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolderOption.Create
|
||||
),
|
||||
FolderName
|
||||
)
|
||||
) { }
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Where collected media goes when the user has not said otherwise: beside the executable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not the profile directory. The collection is the point of the application and
|
||||
/// grows without bound, so it belongs where the application itself was put — copy the folder
|
||||
/// and the archive comes with it, which is what anyone running this from a drive expects.
|
||||
/// <para>
|
||||
/// That location is not always writable: an install under Program Files, or a read-only mount.
|
||||
/// Falling back to the profile is better than refusing to start, and the effective path is
|
||||
/// shown in Settings so the fallback is visible rather than mysterious.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string DefaultMediaDirectory()
|
||||
{
|
||||
var beside = Path.Combine(AppContext.BaseDirectory, "media");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(beside);
|
||||
|
||||
// Creating the directory can succeed where writing a file into it does not.
|
||||
var probe = Path.Combine(beside, ".write-probe");
|
||||
File.WriteAllText(probe, string.Empty);
|
||||
File.Delete(probe);
|
||||
|
||||
return beside;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
|
||||
{
|
||||
return Path.Combine(ProfileDirectory(), "media");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates paths under an explicit root. Used by tests.</summary>
|
||||
/// <param name="dataDirectory">Root for settings, logs and proxy state.</param>
|
||||
|
||||
@@ -31,6 +31,7 @@ public static class UiServiceCollectionExtensions
|
||||
sp.GetRequiredService<IAppPaths>(),
|
||||
sp.GetRequiredService<ILogger<ThumbnailCache>>()
|
||||
));
|
||||
services.AddSingleton<IFolderPicker, FolderPicker>();
|
||||
services.AddSingleton<IThemeService, ThemeService>();
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<INavigationService, NavigationService>();
|
||||
@@ -42,7 +43,8 @@ public static class UiServiceCollectionExtensions
|
||||
sp.GetRequiredService<IAppPaths>(),
|
||||
sp.GetRequiredService<LoggingLevelSwitch>(),
|
||||
sp.GetRequiredService<IProxyPool>(),
|
||||
sp.GetRequiredService<ILocalizationService>()
|
||||
sp.GetRequiredService<ILocalizationService>(),
|
||||
sp.GetRequiredService<IFolderPicker>()
|
||||
));
|
||||
services.AddSingleton<CollectViewModel>(static sp => new CollectViewModel(
|
||||
sp.GetRequiredService<IMediaSourceCatalog>(),
|
||||
|
||||
@@ -700,4 +700,19 @@
|
||||
<data name="Collect.Preview" xml:space="preserve">
|
||||
<value>PREVIEW</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryBrowse" xml:space="preserve">
|
||||
<value>Choose…</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryPick" xml:space="preserve">
|
||||
<value>Where to keep collected media</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryReset" xml:space="preserve">
|
||||
<value>Back to the default, beside the application</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryHint" xml:space="preserve">
|
||||
<value>Empty means the default: a "media" folder beside the application, so copying the folder takes the archive with it. Point it at another drive if the collection outgrows this one.</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryRestart" xml:space="preserve">
|
||||
<value>Applies after a restart. This run is still writing to:</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -700,4 +700,19 @@
|
||||
<data name="Collect.Preview" xml:space="preserve">
|
||||
<value>ПРЕВЬЮ</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryBrowse" xml:space="preserve">
|
||||
<value>Выбрать…</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryPick" xml:space="preserve">
|
||||
<value>Где хранить собранное</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryReset" xml:space="preserve">
|
||||
<value>Вернуть по умолчанию, рядом с приложением</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryHint" xml:space="preserve">
|
||||
<value>Пусто — значит по умолчанию: папка «media» рядом с приложением, так что архив переезжает вместе с ней. Укажите другой диск, если коллекция перерастёт этот.</value>
|
||||
</data>
|
||||
<data name="Settings.MediaDirectoryRestart" xml:space="preserve">
|
||||
<value>Применится после перезапуска. Этот запуск пишет сюда:</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace AvParser.UI.Services;
|
||||
|
||||
/// <summary>Asks the user for a directory.</summary>
|
||||
/// <remarks>
|
||||
/// An interface because the picker lives on the window, not on the view model: reaching for a
|
||||
/// <c>TopLevel</c> from a view model would make it untestable and would tie it to there being a
|
||||
/// window at all. Tests substitute a canned answer.
|
||||
/// </remarks>
|
||||
public interface IFolderPicker
|
||||
{
|
||||
/// <summary>Returns the chosen directory, or null when the user cancelled.</summary>
|
||||
Task<string?> PickAsync(string title, string? startAt = null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed class FolderPicker : IFolderPicker
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> PickAsync(string title, string? startAt = null)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var window = desktop.MainWindow;
|
||||
|
||||
if (window?.StorageProvider is not { CanPickFolder: true } storage)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new FolderPickerOpenOptions { Title = title, AllowMultiple = false };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(startAt) && Directory.Exists(startAt))
|
||||
{
|
||||
options.SuggestedStartLocation = await storage.TryGetFolderFromPathAsync(startAt).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
var chosen = await storage.OpenFolderPickerAsync(options).ConfigureAwait(true);
|
||||
|
||||
return chosen.Count == 0 ? null : chosen[0].TryGetLocalPath();
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,19 @@ public partial class SettingsViewModel : PageViewModel
|
||||
[Reactive]
|
||||
public partial LocalizedOption<ShowcaseMode> SelectedShowcaseMode { get; set; }
|
||||
|
||||
/// <summary>Configured media root; empty means the default beside the executable.</summary>
|
||||
[Reactive]
|
||||
public partial string MediaRootOverride { get; set; }
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
/// <param name="settings">Persisted settings.</param>
|
||||
/// <param name="theme">Applies the theme choice.</param>
|
||||
/// <param name="paths">Where the app writes; also the effective media root for this run.</param>
|
||||
/// <param name="levelSwitch">Serilog level, changed live.</param>
|
||||
/// <param name="proxyPool">Reconfigured when a proxy knob changes.</param>
|
||||
/// <param name="localization">Applies the language choice.</param>
|
||||
/// <param name="folders">Asks the user for a directory.</param>
|
||||
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
|
||||
public SettingsViewModel(
|
||||
ISettingsService settings,
|
||||
IThemeService theme,
|
||||
@@ -95,6 +107,7 @@ public partial class SettingsViewModel : PageViewModel
|
||||
LoggingLevelSwitch levelSwitch,
|
||||
IProxyPool proxyPool,
|
||||
ILocalizationService localization,
|
||||
IFolderPicker folders,
|
||||
ISequencer? mainThread = null
|
||||
)
|
||||
{
|
||||
@@ -129,6 +142,7 @@ public partial class SettingsViewModel : PageViewModel
|
||||
HostDelayMs = current.HostDelayMs;
|
||||
MaxItemMegabytes = (int)Math.Max(1, current.MaxItemBytes / (1024 * 1024));
|
||||
SelectedShowcaseMode = Option(ShowcaseModes, current.ShowcaseMode);
|
||||
MediaRootOverride = current.MediaRootOverride ?? string.Empty;
|
||||
|
||||
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value));
|
||||
|
||||
@@ -169,12 +183,38 @@ public partial class SettingsViewModel : PageViewModel
|
||||
x => x.HostDelayMs,
|
||||
x => x.MaxItemMegabytes,
|
||||
x => x.SelectedShowcaseMode,
|
||||
(_, _, _, _, _) => RxVoid.Default
|
||||
x => x.MediaRootOverride,
|
||||
(_, _, _, _, _, _) => RxVoid.Default
|
||||
)
|
||||
.Throttle(TimeSpan.FromMilliseconds(200), scheduler)
|
||||
.ObserveOn(scheduler)
|
||||
.Subscribe(_ => ApplySettings());
|
||||
|
||||
this.WhenAnyValue(x => x.MediaRootOverride)
|
||||
.Subscribe(_ => this.RaisePropertyChanged(nameof(MediaDirectoryNeedsRestart)));
|
||||
|
||||
BrowseMediaDirectoryCommand = ReactiveCommand.CreateFromTask(
|
||||
async () =>
|
||||
{
|
||||
var chosen = await folders
|
||||
.PickAsync(Localizer.Instance["Settings.MediaDirectoryPick"], MediaRootOverride)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(chosen))
|
||||
{
|
||||
MediaRootOverride = chosen;
|
||||
}
|
||||
},
|
||||
outputScheduler: scheduler
|
||||
);
|
||||
|
||||
ResetMediaDirectoryCommand = ReactiveCommand.Create(
|
||||
() => MediaRootOverride = string.Empty,
|
||||
outputScheduler: scheduler
|
||||
);
|
||||
|
||||
BrowseMediaDirectoryCommand.ThrownExceptions.Subscribe(static _ => { });
|
||||
|
||||
this.WhenAnyValue(x => x.SelectedRotation)
|
||||
.Subscribe(_ => this.RaisePropertyChanged(nameof(RotationDescription)));
|
||||
|
||||
@@ -229,9 +269,31 @@ public partial class SettingsViewModel : PageViewModel
|
||||
/// <summary>Directory holding rolling log files.</summary>
|
||||
public string LogDirectory { get; }
|
||||
|
||||
/// <summary>Root of the collected media. Shown rather than edited: changing it needs a restart.</summary>
|
||||
/// <summary>Where media is written right now. Fixed for this run; a change needs a restart.</summary>
|
||||
public string MediaDirectory { get; }
|
||||
|
||||
/// <summary>Chooses a new media root.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> BrowseMediaDirectoryCommand { get; }
|
||||
|
||||
/// <summary>Puts the media root back to its default, beside the executable.</summary>
|
||||
public ReactiveCommand<RxVoid, string> ResetMediaDirectoryCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the configured root differs from where this run is actually writing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The paths are resolved once, before the container exists, so a change cannot take effect
|
||||
/// until the next launch. Saying so is the honest option: silently writing somewhere other
|
||||
/// than the box says would be worse than asking for a restart.
|
||||
/// </remarks>
|
||||
public bool MediaDirectoryNeedsRestart =>
|
||||
!string.IsNullOrWhiteSpace(MediaRootOverride)
|
||||
&& !string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(MediaRootOverride.Trim()),
|
||||
Path.TrimEndingDirectorySeparator(MediaDirectory),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
/// <summary>Width in pixels at which the shell switches from compact to the icon rail.</summary>
|
||||
public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth;
|
||||
|
||||
@@ -289,6 +351,7 @@ public partial class SettingsViewModel : PageViewModel
|
||||
HostDelayMs = HostDelayMs,
|
||||
MaxItemBytes = (long)MaxItemMegabytes * 1024 * 1024,
|
||||
ShowcaseMode = SelectedShowcaseMode.Value,
|
||||
MediaRootOverride = string.IsNullOrWhiteSpace(MediaRootOverride) ? null : MediaRootOverride.Trim(),
|
||||
};
|
||||
|
||||
return applied;
|
||||
|
||||
@@ -206,7 +206,37 @@
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Settings.MediaDirectory}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding MediaDirectory}" TextWrapping="Wrap" />
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button
|
||||
DockPanel.Dock="Right"
|
||||
Margin="8,0,0,0"
|
||||
Command="{Binding ResetMediaDirectoryCommand}"
|
||||
ToolTip.Tip="{l:Loc Settings.MediaDirectoryReset}"
|
||||
>
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconRefresh}" />
|
||||
</Button>
|
||||
<Button
|
||||
DockPanel.Dock="Right"
|
||||
Margin="8,0,0,0"
|
||||
Command="{Binding BrowseMediaDirectoryCommand}"
|
||||
Content="{l:Loc Settings.MediaDirectoryBrowse}"
|
||||
/>
|
||||
<TextBox Text="{Binding MediaRootOverride}" Watermark="{Binding MediaDirectory}" />
|
||||
</DockPanel>
|
||||
<TextBlock Classes="muted" Text="{l:Loc Settings.MediaDirectoryHint}" TextWrapping="Wrap" />
|
||||
|
||||
<Border
|
||||
Classes="card"
|
||||
Background="{DynamicResource AppAccentSoftBrush}"
|
||||
BorderBrush="{DynamicResource AppAccentBrush}"
|
||||
Padding="12,10"
|
||||
IsVisible="{Binding MediaDirectoryNeedsRestart}"
|
||||
>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{l:Loc Settings.MediaDirectoryRestart}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono caption" Text="{Binding MediaDirectory}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using AvParser.Infrastructure.Storage;
|
||||
|
||||
namespace AvParser.Infrastructure.Tests;
|
||||
|
||||
public sealed class AppPathsTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Media_defaults_to_a_folder_beside_the_application()
|
||||
{
|
||||
// The collection is the point of the app and grows without bound, so it belongs where the
|
||||
// app was put: copy that folder and the archive travels with it.
|
||||
var media = AppPaths.DefaultMediaDirectory();
|
||||
|
||||
Path.GetFileName(media).ShouldBe("media");
|
||||
Path.GetDirectoryName(media).ShouldBe(Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_and_logs_stay_in_the_profile()
|
||||
{
|
||||
// Only media moves next to the executable. Configuration is per-user and belongs where the
|
||||
// operating system says it does.
|
||||
var paths = new AppPaths();
|
||||
|
||||
paths.SettingsFile.ShouldStartWith(AppPaths.ProfileDirectory());
|
||||
paths.LogDirectory.ShouldStartWith(AppPaths.ProfileDirectory());
|
||||
paths.MediaDirectory.ShouldNotStartWith(AppPaths.ProfileDirectory());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_explicit_media_root_wins()
|
||||
{
|
||||
var elsewhere = Path.Combine(_root, "archive");
|
||||
|
||||
var paths = new AppPaths(_root, elsewhere);
|
||||
|
||||
paths.MediaDirectory.ShouldBe(elsewhere);
|
||||
paths.BlobDirectory.ShouldStartWith(elsewhere);
|
||||
paths.MediaIndexFile.ShouldStartWith(elsewhere);
|
||||
paths.MediaTempDirectory.ShouldStartWith(elsewhere);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_staging_area_is_a_sibling_of_the_blobs()
|
||||
{
|
||||
// Promotion has to be a rename, not a cross-volume copy of tens of megabytes.
|
||||
var paths = new AppPaths(_root, Path.Combine(_root, "archive"));
|
||||
|
||||
Path.GetDirectoryName(paths.MediaTempDirectory).ShouldBe(Path.GetDirectoryName(paths.BlobDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_omitted_media_root_falls_back_to_the_data_directory()
|
||||
{
|
||||
// The two-argument form is what tests and the override path use; without a media root it
|
||||
// must stay inside the directory it was given rather than escaping to the real profile.
|
||||
var paths = new AppPaths(_root);
|
||||
|
||||
paths.MediaDirectory.ShouldBe(Path.Combine(_root, "media"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Creating_the_directories_is_idempotent()
|
||||
{
|
||||
var paths = new AppPaths(_root, Path.Combine(_root, "archive"));
|
||||
|
||||
paths.EnsureCreated();
|
||||
paths.EnsureCreated();
|
||||
|
||||
Directory.Exists(paths.BlobDirectory).ShouldBeTrue();
|
||||
Directory.Exists(paths.ShowcaseDirectory).ShouldBeTrue();
|
||||
Directory.Exists(paths.MediaTempDirectory).ShouldBeTrue();
|
||||
Directory.Exists(paths.LogDirectory).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Services;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class SettingsViewModelTests
|
||||
{
|
||||
/// <summary>Language service that applies nothing; these tests are not about translation.</summary>
|
||||
private sealed class FakeLocalizationService : ILocalizationService, IDisposable
|
||||
{
|
||||
private readonly ReactiveUI.Primitives.Signals.BehaviorSignal<AppLanguage> _current = new(AppLanguage.English);
|
||||
|
||||
public AppLanguage Current => _current.Value;
|
||||
|
||||
public IObservable<AppLanguage> Changes => _current;
|
||||
|
||||
public void Apply(AppLanguage language) => _current.OnNext(language);
|
||||
|
||||
public void Dispose() => _current.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A picker that answers with whatever the test decided, without a window.</summary>
|
||||
private sealed class FakeFolderPicker : IFolderPicker
|
||||
{
|
||||
public string? Answer { get; set; }
|
||||
|
||||
public string? StartedAt { get; private set; }
|
||||
|
||||
public Task<string?> PickAsync(string title, string? startAt = null)
|
||||
{
|
||||
StartedAt = startAt;
|
||||
|
||||
return Task.FromResult(Answer);
|
||||
}
|
||||
}
|
||||
|
||||
private static (SettingsViewModel Page, FakeSettingsService Settings, FakeFolderPicker Picker) Build(
|
||||
AppSettings? settings = null,
|
||||
string? mediaDirectory = null
|
||||
)
|
||||
{
|
||||
var settingsService = new FakeSettingsService(settings);
|
||||
var picker = new FakeFolderPicker();
|
||||
var paths = new AppPaths(
|
||||
Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N")),
|
||||
mediaDirectory
|
||||
);
|
||||
|
||||
var page = new SettingsViewModel(
|
||||
settingsService,
|
||||
new FakeThemeService(),
|
||||
paths,
|
||||
new LoggingLevelSwitch(),
|
||||
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
new FakeLocalizationService(),
|
||||
picker,
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
return (page, settingsService, picker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unset_media_root_shows_as_empty_so_the_watermark_can_speak()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
|
||||
page.MediaRootOverride.ShouldBeEmpty();
|
||||
page.MediaDirectoryNeedsRestart.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Choosing_a_directory_records_it()
|
||||
{
|
||||
var chosen = Path.Combine(Path.GetTempPath(), "AvParserArchive");
|
||||
var (page, settings, picker) = Build();
|
||||
picker.Answer = chosen;
|
||||
|
||||
await page.BrowseMediaDirectoryCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.MediaRootOverride.ShouldBe(chosen);
|
||||
settings.Current.MediaRootOverride.ShouldBe(chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_the_picker_changes_nothing()
|
||||
{
|
||||
var (page, _, picker) = Build();
|
||||
page.MediaRootOverride = "D:/archive";
|
||||
picker.Answer = null;
|
||||
|
||||
await page.BrowseMediaDirectoryCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.MediaRootOverride.ShouldBe("D:/archive");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resetting_clears_the_override_so_the_default_applies_again()
|
||||
{
|
||||
var (page, settings, _) = Build(new AppSettings { MediaRootOverride = "D:/archive" });
|
||||
|
||||
page.MediaRootOverride.ShouldBe("D:/archive");
|
||||
|
||||
await page.ResetMediaDirectoryCommand.Execute().ToTask(TestContext.Current.CancellationToken);
|
||||
|
||||
page.MediaRootOverride.ShouldBeEmpty();
|
||||
settings.Current.MediaRootOverride.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_root_that_differs_from_this_run_asks_for_a_restart()
|
||||
{
|
||||
// Paths are resolved before the container exists, so the change cannot take effect now.
|
||||
// Saying so beats writing somewhere other than the box claims.
|
||||
var (page, _, _) = Build(mediaDirectory: "D:/current-archive");
|
||||
|
||||
page.MediaRootOverride = "D:/somewhere-else";
|
||||
|
||||
page.MediaDirectoryNeedsRestart.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_root_that_matches_this_run_does_not()
|
||||
{
|
||||
var (page, _, _) = Build(mediaDirectory: "D:/current-archive");
|
||||
|
||||
page.MediaRootOverride = "D:/current-archive/";
|
||||
|
||||
page.MediaDirectoryNeedsRestart.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Whitespace_is_treated_as_no_override_at_all()
|
||||
{
|
||||
var (page, settings, _) = Build(new AppSettings { MediaRootOverride = "D:/archive" });
|
||||
|
||||
page.MediaRootOverride = " ";
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
settings.Current.MediaRootOverride.ShouldBeNull();
|
||||
page.MediaDirectoryNeedsRestart.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user