Implement theme management and settings dialog in PLib video library manager. Add theme service for light/dark/system themes, integrate settings dialog for user preferences, and update app configuration to include appearance options. Enhance thumbnail caching with new methods for cache size and clearing. Update README.md to reflect new features and settings.

This commit is contained in:
Leonid Pershin
2026-08-08 12:04:53 +03:00
parent 625eae7ead
commit 37c5feb2a6
24 changed files with 1099 additions and 185 deletions
+2
View File
@@ -12,10 +12,12 @@
<PackageReference Include="xunit.v3" />
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PLib.Application\PLib.Application.csproj" />
<ProjectReference Include="..\..\src\PLib.Desktop\PLib.Desktop.csproj" />
<ProjectReference Include="..\..\src\PLib.Domain\PLib.Domain.csproj" />
</ItemGroup>
@@ -0,0 +1,134 @@
using Microsoft.Extensions.Configuration;
using PLib.Application.Library;
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,
};
await new JsonAppSettingsStore(_paths).SaveAsync(settings, Token);
var (library, appearance) = 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);
}
[Fact]
public async Task Removing_a_folder_actually_shortens_the_stored_list()
{
var store = new JsonAppSettingsStore(_paths);
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 new JsonAppSettingsStore(_paths).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);
}
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,
};
private IConfigurationRoot Build() => new ConfigurationBuilder()
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
.Build();
private (LibraryOptions Library, AppearanceOptions Appearance) Reload()
{
var configuration = Build();
var library = new LibraryOptions();
configuration.GetSection(LibraryOptions.SectionName).Bind(library);
var appearance = new AppearanceOptions();
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
return (library, appearance);
}
private sealed class TempPaths : IAppPaths, IDisposable
{
public TempPaths()
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(ThumbnailDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string DatabaseFile { get; }
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
}
}