Files
PLib/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs

225 lines
7.8 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using PLib.Infrastructure.Storage;
using Shouldly;
namespace PLib.Tests.Settings;
/// <summary>
/// The settings file is not just storage — it is a live configuration source. These tests
/// close the loop: what the store writes has to be what the options binder reads back.
/// </summary>
public sealed class AppSettingsStoreTests : IDisposable
{
private readonly TempPaths _paths = new();
public void Dispose() => _paths.Dispose();
[Fact]
public async Task Saved_settings_are_readable_by_the_configuration_binder()
{
var settings = new AppSettings
{
Folders = [@"C:\videos", @"D:\more videos"],
ThumbnailWidth = 640,
ThumbnailPositionRatio = 0.25,
MaxIndexingConcurrency = 8,
MinimumFileSizeInBytes = 2_097_152,
Theme = ThemeMode.Light,
Volume = 0.35,
IsMuted = true,
MetadataSources = [],
};
await CreateStore().SaveAsync(settings, Token);
var (library, appearance, playback) = Reload();
library.Folders.ShouldBe(settings.Folders);
library.ThumbnailWidth.ShouldBe(640);
library.ThumbnailPositionRatio.ShouldBe(0.25);
library.MaxIndexingConcurrency.ShouldBe(8);
library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
appearance.Theme.ShouldBe(ThemeMode.Light);
playback.Volume.ShouldBe(0.35);
playback.IsMuted.ShouldBeTrue();
}
[Fact]
public async Task Removing_a_folder_actually_shortens_the_stored_list()
{
var store = CreateStore();
var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
await store.SaveAsync(settings, Token);
await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
// Configuration merges arrays by index, so a shorter list is the case most likely
// to leave a stale entry behind.
Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
}
[Fact]
public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
{
await File.WriteAllTextAsync(
Path.Combine(_paths.DataDirectory, "settings.json"),
"""{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
Token);
await CreateStore().SaveAsync(Sample, Token);
var configuration = Build();
configuration["Experimental:Flag"].ShouldBe("True");
configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
{
var changed = cosmeticOnly
? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
: Sample with { Folders = [@"C:\elsewhere"] };
changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
}
[Fact]
public async Task Metadata_sources_survive_the_trip_through_the_file()
{
var store = CreateStore();
await store.SaveAsync(
Sample with
{
MetadataSources =
[
new MetadataSourceOptions { Name = "StashDB", Endpoint = "https://stashdb.org/graphql", ApiKey = "секрет" },
new MetadataSourceOptions { Name = "Выключенный", Endpoint = "https://other/graphql", IsEnabled = false },
],
},
Token);
var metadata = new MetadataOptions();
Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
metadata.Sources.Select(source => source.Name).ShouldBe(["StashDB", "Выключенный"]);
metadata.Sources[0].ApiKey.ShouldBe("секрет");
metadata.Sources[0].IsUsable.ShouldBeTrue();
metadata.Sources[1].IsUsable.ShouldBeFalse();
}
[Fact]
public async Task Removing_a_source_actually_shortens_the_stored_list()
{
var store = CreateStore();
var two = Sample with
{
MetadataSources =
[
new MetadataSourceOptions { Name = "Первый", Endpoint = "https://a/graphql" },
new MetadataSourceOptions { Name = "Второй", Endpoint = "https://b/graphql" },
],
};
await store.SaveAsync(two, Token);
await store.SaveAsync(two with { MetadataSources = [two.MetadataSources[0]] }, Token);
// Configuration merges arrays by index, so the second entry is exactly what would be
// left behind by a save that wrote the list element by element.
var metadata = new MetadataOptions();
Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
metadata.Sources.Select(source => source.Name).ShouldBe(["Первый"]);
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static AppSettings Sample => new()
{
Folders = [@"C:\videos"],
ThumbnailWidth = 480,
ThumbnailPositionRatio = 0.15,
MaxIndexingConcurrency = 4,
MinimumFileSizeInBytes = 65_536,
Theme = ThemeMode.System,
Volume = 0.8,
IsMuted = false,
MetadataSources = [],
};
/// <summary>
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
/// tests do not exercise — every case here supplies the snapshot it wants to write.
/// </summary>
private JsonAppSettingsStore CreateStore() => new(
_paths,
Monitor(new LibraryOptions()),
Monitor(new AppearanceOptions()),
Monitor(new PlaybackOptions()),
Monitor(new MetadataOptions()));
private static IOptionsMonitor<T> Monitor<T>(T value)
{
var monitor = Substitute.For<IOptionsMonitor<T>>();
monitor.CurrentValue.Returns(value);
return monitor;
}
private IConfigurationRoot Build() => new ConfigurationBuilder()
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
.Build();
private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
{
var configuration = Build();
var library = new LibraryOptions();
configuration.GetSection(LibraryOptions.SectionName).Bind(library);
var appearance = new AppearanceOptions();
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
var playback = new PlaybackOptions();
configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
return (library, appearance, playback);
}
private sealed class TempPaths : IAppPaths, IDisposable
{
public TempPaths()
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "labels");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory);
Directory.CreateDirectory(RemoteImageDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; }
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
}
}