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; /// /// 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. /// 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 = [], }; /// /// The store composes from configuration, which these /// tests do not exercise — every case here supplies the snapshot it wants to write. /// private JsonAppSettingsStore CreateStore() => new( _paths, Monitor(new LibraryOptions()), Monitor(new AppearanceOptions()), Monitor(new PlaybackOptions()), Monitor(new MetadataOptions())); private static IOptionsMonitor Monitor(T value) { var monitor = Substitute.For>(); 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"); DatabaseFile = Path.Combine(DataDirectory, "library.db"); Directory.CreateDirectory(ThumbnailDirectory); Directory.CreateDirectory(PreviewDirectory); } public string DataDirectory { get; } public string ThumbnailDirectory { get; } public string PreviewDirectory { get; } public string DatabaseFile { get; } public void Dispose() => Directory.Delete(DataDirectory, recursive: true); } }