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>
151 lines
4.8 KiB
C#
151 lines
4.8 KiB
C#
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();
|
|
}
|
|
}
|