diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index b137c6c1..70e71972 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -34,14 +34,20 @@ jobs: publish: needs: build-test name: Publish (${{ matrix.runtime }}) - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - runtime: - - win-x64 - - linux-x64 - - osx-arm64 + include: + - runtime: win-x64 + os: ubuntu-24.04 + artifact-path: artifacts/publish/framework-dependent/win-x64 + - runtime: linux-x64 + os: ubuntu-24.04 + artifact-path: artifacts/publish/framework-dependent/linux-x64 + - runtime: osx-arm64 + os: macos-latest + artifact-path: artifacts/publish/framework-dependent/osx-arm64/*.dmg steps: - name: Checkout @@ -62,5 +68,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: ChapterTool-Avalonia-${{ matrix.runtime }} - path: artifacts/publish/framework-dependent/${{ matrix.runtime }} + path: ${{ matrix.artifact-path }} if-no-files-found: error diff --git a/scripts/publish.sh b/scripts/publish.sh index db3c1ca1..cf1cdbab 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -5,7 +5,7 @@ # ./scripts/publish.sh # win-x64, framework-dependent # ./scripts/publish.sh -Runtime osx-arm64 # ./scripts/publish.sh -Runtime linux-x64 -SelfContained -# ./scripts/publish.sh -Runtime osx-arm64 -NoRestore -PublishSingleFile +# ./scripts/publish.sh -Runtime osx-arm64 -NoRestore -PublishSingleFile # creates .app and .dmg on macOS set -euo pipefail # ---- defaults ---- @@ -127,9 +127,10 @@ case "$Runtime" in exit 0 fi - app_name="ChapterTool.Avalonia" - app_bundle="$output/$app_name.app" - app_bundle_tmp="$output/.$app_name.app.tmp" + executable_name="ChapterTool.Avalonia" + app_display_name="ChapterTool" + app_bundle="$output/$app_display_name.app" + app_bundle_tmp="$output/.$app_display_name.app.tmp" contents_dir="$app_bundle_tmp/Contents" macos_dir="$contents_dir/MacOS" resources_dir="$contents_dir/Resources" @@ -161,7 +162,7 @@ case "$Runtime" in printf 'APPL????' > "$contents_dir/PkgInfo" # Mark the executable so the bundle is double-clickable. - exe_path="$macos_dir/$app_name" + exe_path="$macos_dir/$executable_name" if [[ ! -f "$exe_path" ]]; then echo "ERROR: expected executable '$exe_path' not found" >&2 exit 1 @@ -177,7 +178,25 @@ case "$Runtime" in # Refresh icon cache so Dock picks up the new art immediately during dev. touch "$app_bundle" + dmg_staging="$output/.dmg-staging" + dmg_output="$output/$app_display_name-$Runtime.dmg" + + rm -rf "$dmg_staging" "$dmg_output" + mkdir -p "$dmg_staging" + cp -R "$app_bundle" "$dmg_staging/" + ln -s /Applications "$dmg_staging/Applications" + + hdiutil create \ + -volname "$app_display_name" \ + -srcfolder "$dmg_staging" \ + -ov \ + -format UDZO \ + "$dmg_output" >/dev/null + + rm -rf "$dmg_staging" + echo "Created macOS app bundle at $app_bundle" + echo "Created macOS DMG at $dmg_output" ;; *) echo "Published ChapterTool Avalonia to $output" diff --git a/src/ChapterTool.Core/Exporting/ChapterExportService.cs b/src/ChapterTool.Core/Exporting/ChapterExportService.cs index 585eb869..197be3b5 100644 --- a/src/ChapterTool.Core/Exporting/ChapterExportService.cs +++ b/src/ChapterTool.Core/Exporting/ChapterExportService.cs @@ -2,8 +2,8 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; -using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using System.Xml.Linq; using ChapterTool.Core.Diagnostics; using ChapterTool.Core.Models; @@ -11,7 +11,7 @@ namespace ChapterTool.Core.Exporting; -public sealed class ChapterExportService +public sealed partial class ChapterExportService { private readonly IChapterTimeFormatter timeFormatter; private readonly ILuaExpressionScriptService luaExpressionService; @@ -209,7 +209,7 @@ private static ChapterExportResult Json(ChapterInfo info, ChapterExportOptions o } var payload = new JsonPayload(info.SourceType == "MPLS" ? $"{info.SourceName}.m2ts" : null, entries); - return Success(JsonSerializer.Serialize(payload, JsonOptions), ".json"); + return Success(JsonSerializer.Serialize(payload, ExportJsonSerializerContext.Default.JsonPayload), ".json"); } private static ChapterExportResult Lines(string extension, IEnumerable lines) => @@ -236,13 +236,14 @@ private static ChapterExportResult Success(string content, string extension) => private static ChapterExportResult Failure(string code, string message) => new(false, string.Empty, string.Empty, [new ChapterDiagnostic(DiagnosticSeverity.Error, code, message)]); - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; - private sealed record JsonPayload(string? SourceName, IReadOnlyList Chapter); private sealed record JsonChapter(string Name, double Time); + + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(JsonPayload))] + private sealed partial class ExportJsonSerializerContext : JsonSerializerContext + { + } } diff --git a/src/ChapterTool.Infrastructure/Configuration/AppJsonSerializerContext.cs b/src/ChapterTool.Infrastructure/Configuration/AppJsonSerializerContext.cs new file mode 100644 index 00000000..c4975a32 --- /dev/null +++ b/src/ChapterTool.Infrastructure/Configuration/AppJsonSerializerContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using ChapterTool.Infrastructure.Importing.Media; + +namespace ChapterTool.Infrastructure.Configuration; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true)] +[JsonSerializable(typeof(AppSettings))] +[JsonSerializable(typeof(ThemeColorSettings))] +[JsonSerializable(typeof(FfprobeChapterOutput))] +internal sealed partial class AppJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs b/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs index 0d629d8f..69116509 100644 --- a/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs +++ b/src/ChapterTool.Infrastructure/Configuration/AppSettingsStore.cs @@ -1,29 +1,24 @@ using System.Text.Json; -using System.Text.RegularExpressions; using ChapterTool.Core.Services; namespace ChapterTool.Infrastructure.Configuration; -public sealed partial class AppSettingsStore(string settingsDirectory, IReadOnlyList? legacyDirectories = null) - : ISettingsStore +public sealed partial class AppSettingsStore(string settingsDirectory) : ISettingsStore { private const string CurrentFileName = "appsettings.json"; - private const string LegacyFileName = "chaptertool.json"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - private readonly IReadOnlyList legacyDirectories = legacyDirectories ?? [settingsDirectory]; private readonly Lock syncRoot = new(); private AppSettings? cachedSettings; - private SettingsFileState cachedFileState; + private FileStamp cachedFileStamp; public async ValueTask LoadAsync(CancellationToken cancellationToken) { var currentPath = Path.Combine(settingsDirectory, CurrentFileName); - var fileState = GetFileState(currentPath, legacyDirectories); + var stamp = GetFileStamp(currentPath); lock (syncRoot) { - if (cachedSettings is not null && cachedFileState == fileState) + if (cachedSettings is not null && cachedFileStamp == stamp) { return cachedSettings; } @@ -35,9 +30,9 @@ public async ValueTask LoadAsync(CancellationToken cancellationToke try { await using var stream = File.OpenRead(currentPath); - settings = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + settings = await JsonSerializer.DeserializeAsync(stream, AppJsonSerializerContext.Default.AppSettings, cancellationToken) ?? new AppSettings(); - Cache(settings, fileState); + Cache(settings, stamp); return settings; } catch (JsonException exception) @@ -46,24 +41,8 @@ public async ValueTask LoadAsync(CancellationToken cancellationToke } } - foreach (var legacyDirectory in legacyDirectories) - { - var legacyPath = Path.Combine(legacyDirectory, LegacyFileName); - if (!File.Exists(legacyPath)) - { - continue; - } - - var migrated = await TryLoadLegacyAsync(legacyPath, cancellationToken); - if (migrated is not null) - { - Cache(migrated, fileState); - return migrated; - } - } - settings = new AppSettings(); - Cache(settings, fileState); + Cache(settings, stamp); return settings; } @@ -77,11 +56,11 @@ public async ValueTask SaveAsync(AppSettings settings, CancellationToken cancell { await using (var stream = File.Create(tempPath)) { - await JsonSerializer.SerializeAsync(stream, settings, JsonOptions, cancellationToken); + await JsonSerializer.SerializeAsync(stream, settings, AppJsonSerializerContext.Default.AppSettings, cancellationToken); } File.Move(tempPath, currentPath, overwrite: true); - Cache(settings, GetFileState(currentPath, legacyDirectories)); + Cache(settings, GetFileStamp(currentPath)); } catch { @@ -89,68 +68,20 @@ public async ValueTask SaveAsync(AppSettings settings, CancellationToken cancell { File.Delete(tempPath); } - throw; - } - } - private static async ValueTask TryLoadLegacyAsync(string path, CancellationToken cancellationToken) - { - try - { - await using var stream = File.OpenRead(path); - var values = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: cancellationToken); - if (values is null) - { - return null; - } - - return new AppSettings( - SavingPath: Get(values, @"Software\ChapterTool.SavingPath"), - Language: Get(values, @"Software\ChapterTool.Language") ?? Get(values, "Language") ?? "", - MainWindowLocation: ParseLocation( - Get(values, @"Software\ChapterTool.Location") - ?? Get(values, @"Software\ChapterTool.location")), - MkvToolnixPath: Get(values, @"Software\ChapterTool.mkvToolnixPath") ?? Get(values, "mkvToolnixPath"), - Eac3toPath: Get(values, @"Software\ChapterTool.eac3toPath") ?? Get(values, "eac3toPath")); - } - catch (JsonException) - { - return null; - } - catch (IOException) - { - return null; + throw; } } - private static string? Get(IReadOnlyDictionary values, string key) => - values.GetValueOrDefault(key); - - private void Cache(AppSettings settings, SettingsFileState fileState) + private void Cache(AppSettings settings, FileStamp stamp) { lock (syncRoot) { cachedSettings = settings; - cachedFileState = fileState; + cachedFileStamp = stamp; } } - private static SettingsFileState GetFileState(string currentPath, IReadOnlyList legacyDirectories) - { - var current = GetFileStamp(currentPath); - FileStamp legacy = default; - foreach (var legacyDirectory in legacyDirectories) - { - legacy = GetFileStamp(Path.Combine(legacyDirectory, LegacyFileName)); - if (legacy.Exists) - { - break; - } - } - - return new SettingsFileState(current, legacy); - } - private static FileStamp GetFileStamp(string path) { var file = new FileInfo(path); @@ -159,28 +90,5 @@ private static FileStamp GetFileStamp(string path) : default; } - private readonly record struct SettingsFileState(FileStamp Current, FileStamp Legacy); - private readonly record struct FileStamp(bool Exists, DateTime LastWriteTimeUtc, long Length); - - private static WindowLocation? ParseLocation(string? value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return null; - } - - var match = LocationRegex().Match(value); - if (!match.Success) - { - return null; - } - - return new WindowLocation( - int.Parse(match.Groups["x"].Value), - int.Parse(match.Groups["y"].Value)); - } - - [GeneratedRegex(@"\{X=(?-?\d+),Y=(?-?\d+)\}", RegexOptions.CultureInvariant)] - private static partial Regex LocationRegex(); } diff --git a/src/ChapterTool.Infrastructure/Configuration/JsonOptions.cs b/src/ChapterTool.Infrastructure/Configuration/JsonOptions.cs deleted file mode 100644 index 5395e81d..00000000 --- a/src/ChapterTool.Infrastructure/Configuration/JsonOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json; - -namespace ChapterTool.Infrastructure.Configuration; - -internal static class JsonOptions -{ - public static readonly JsonSerializerOptions Default = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; -} diff --git a/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs b/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs index acf11860..6df82b4b 100644 --- a/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs +++ b/src/ChapterTool.Infrastructure/Configuration/ThemeSettingsStore.cs @@ -1,19 +1,11 @@ using System.Text.Json; -using System.Text.RegularExpressions; using ChapterTool.Core.Services; namespace ChapterTool.Infrastructure.Configuration; -public sealed partial class ThemeSettingsStore( - string settingsDirectory, - IReadOnlyList? legacyDirectories = null) - : ISettingsStore +public sealed partial class ThemeSettingsStore(string settingsDirectory) : ISettingsStore { private const string CurrentFileName = "theme-colors.json"; - private const string LegacyFileName = "color-config.json"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - - private readonly IReadOnlyList legacyDirectories = legacyDirectories ?? [settingsDirectory]; public async ValueTask LoadAsync(CancellationToken cancellationToken) { @@ -23,7 +15,7 @@ public async ValueTask LoadAsync(CancellationToken cancellat try { await using var stream = File.OpenRead(currentPath); - return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + return await JsonSerializer.DeserializeAsync(stream, AppJsonSerializerContext.Default.ThemeColorSettings, cancellationToken) ?? ThemeColorSettings.Default; } catch (JsonException exception) @@ -32,21 +24,6 @@ public async ValueTask LoadAsync(CancellationToken cancellat } } - foreach (var legacyDirectory in legacyDirectories) - { - var legacyPath = Path.Combine(legacyDirectory, LegacyFileName); - if (!File.Exists(legacyPath)) - { - continue; - } - - var migrated = await TryLoadLegacyAsync(legacyPath, cancellationToken); - if (migrated is not null) - { - return migrated; - } - } - return ThemeColorSettings.Default; } @@ -60,7 +37,7 @@ public async ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken { await using (var stream = File.Create(tempPath)) { - await JsonSerializer.SerializeAsync(stream, settings, JsonOptions, cancellationToken); + await JsonSerializer.SerializeAsync(stream, settings, AppJsonSerializerContext.Default.ThemeColorSettings, cancellationToken); } File.Move(tempPath, currentPath, overwrite: true); @@ -71,42 +48,8 @@ public async ValueTask SaveAsync(ThemeColorSettings settings, CancellationToken { File.Delete(tempPath); } - throw; - } - } - private static async ValueTask TryLoadLegacyAsync(string path, CancellationToken cancellationToken) - { - try - { - var json = await File.ReadAllTextAsync(path, cancellationToken); - var matches = HexStringRegex().Matches(json); - if (matches.Count < 6) - { - return null; - } - - var defaults = ThemeColorSettings.Default.OrderedSlots.Select(static slot => slot.Value).ToList(); - var colors = new string[6]; - for (var index = 0; index < colors.Length; index++) - { - var candidate = matches[index].Groups["hex"].Value; - colors[index] = IsHexColor(candidate) ? candidate.ToUpperInvariant() : defaults[index]; - } - - return new ThemeColorSettings(colors[0], colors[1], colors[2], colors[3], colors[4], colors[5]); - } - catch (IOException) - { - return null; + throw; } } - - private static bool IsHexColor(string value) => StrictHexColorRegex().IsMatch(value); - - [GeneratedRegex("\"(?#[0-9A-Fa-f]{6}|[^\"]+)\"", RegexOptions.CultureInvariant)] - private static partial Regex HexStringRegex(); - - [GeneratedRegex("^#[0-9A-Fa-f]{6}$", RegexOptions.CultureInvariant)] - private static partial Regex StrictHexColorRegex(); } diff --git a/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs b/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs index 85d48059..0c504c51 100644 --- a/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs +++ b/src/ChapterTool.Infrastructure/Importing/Media/FfprobeMediaChapterReader.cs @@ -1,6 +1,8 @@ using System.Text.Json; +using System.Text.Json.Serialization; using ChapterTool.Core.Importing.Media; using ChapterTool.Core.Services; +using ChapterTool.Infrastructure.Configuration; namespace ChapterTool.Infrastructure.Importing.Media; @@ -65,31 +67,26 @@ private static MediaChapterReadResult Parse(string json, ProcessRunResult result { try { - using var document = JsonDocument.Parse(json); - if (document.RootElement.ValueKind != JsonValueKind.Object - || !document.RootElement.TryGetProperty("chapters", out var chaptersElement) - || chaptersElement.ValueKind != JsonValueKind.Array) + var output = JsonSerializer.Deserialize(json, AppJsonSerializerContext.Default.FfprobeChapterOutput); + var rawChapters = output?.Chapters; + if (rawChapters is null || rawChapters.Length == 0) { return MediaChapterReadResult.Failed("FfprobeParseFailed", "ffprobe JSON did not contain a chapters array.", ProcessDetails(result)); } var chapters = new List(); var sourceOrder = 0; - foreach (var chapterElement in chaptersElement.EnumerateArray()) + foreach (var chapter in rawChapters) { - if (chapterElement.ValueKind != JsonValueKind.Object) - { - return MediaChapterReadResult.Failed("FfprobeParseFailed", "ffprobe chapter entry was not an object.", ProcessDetails(result)); - } - + var id = chapter.Id is >= int.MinValue and <= int.MaxValue ? (int?)chapter.Id : null; chapters.Add(new MediaChapterEntry( - Int32Property(chapterElement, "id"), - StringProperty(chapterElement, "time_base"), - Int64Property(chapterElement, "start"), - Int64Property(chapterElement, "end"), - StringProperty(chapterElement, "start_time"), - StringProperty(chapterElement, "end_time"), - Tags(chapterElement), + id, + chapter.TimeBase, + chapter.Start, + chapter.End, + chapter.StartTime, + chapter.EndTime, + chapter.Tags ?? new Dictionary(StringComparer.Ordinal), sourceOrder++)); } @@ -101,70 +98,6 @@ private static MediaChapterReadResult Parse(string json, ProcessRunResult result } } - private static int? Int32Property(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - { - return null; - } - - if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var number)) - { - return number; - } - - if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out number)) - { - return number; - } - - return null; - } - - private static long? Int64Property(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var property)) - { - return null; - } - - if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number)) - { - return number; - } - - if (property.ValueKind == JsonValueKind.String && long.TryParse(property.GetString(), out number)) - { - return number; - } - - return null; - } - - private static string? StringProperty(JsonElement element, string propertyName) => - element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String - ? property.GetString() - : null; - - private static Dictionary Tags(JsonElement element) - { - if (!element.TryGetProperty("tags", out var tagsElement) || tagsElement.ValueKind != JsonValueKind.Object) - { - return new Dictionary(); - } - - var tags = new Dictionary(StringComparer.Ordinal); - foreach (var property in tagsElement.EnumerateObject()) - { - if (property.Value.ValueKind == JsonValueKind.String) - { - tags[property.Name] = property.Value.GetString() ?? string.Empty; - } - } - - return tags; - } - private static string ProcessDetails(ProcessRunResult result) { var stderr = string.IsNullOrWhiteSpace(result.StandardError) @@ -173,3 +106,14 @@ private static string ProcessDetails(ProcessRunResult result) return $"Command: {result.FileName} {string.Join(" ", result.Arguments)} ExitCode: {result.ExitCode?.ToString() ?? ""}{stderr}"; } } + +internal sealed record FfprobeChapterOutput(FfprobeChapter[]? Chapters); + +internal sealed record FfprobeChapter( + long? Id, + [property: JsonPropertyName("time_base")] string? TimeBase, + long? Start, + long? End, + [property: JsonPropertyName("start_time")] string? StartTime, + [property: JsonPropertyName("end_time")] string? EndTime, + Dictionary? Tags); diff --git a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeImportTestFactory.cs b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeImportTestFactory.cs index ecef019f..b18e463c 100644 --- a/tests/ChapterTool.Avalonia.Tests/Services/RuntimeImportTestFactory.cs +++ b/tests/ChapterTool.Avalonia.Tests/Services/RuntimeImportTestFactory.cs @@ -17,7 +17,7 @@ internal static class RuntimeImportTestFactory private static readonly ChapterTimeFormatter Formatter = new(); private static readonly IExternalToolLocator ToolLocator = new ExternalToolLocator( - new AppSettingsStore(SettingsDirectory, [SettingsDirectory]), + new AppSettingsStore(SettingsDirectory), PathSearchDirectories().ToList()); private static readonly ProcessRunner ProcessRunner = new(); private static readonly FfprobeMediaChapterReader MediaChapterReader = new(ToolLocator, ProcessRunner); diff --git a/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs b/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs index 69237d50..1cd71d26 100644 --- a/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/ExternalToolLocatorTests.cs @@ -18,7 +18,7 @@ public async Task LocateAsync_uses_configured_mkvtoolnix_path_before_search_path Directory.CreateDirectory(searchDirectory); await CreateToolFileAsync(Path.Combine(searchDirectory, ToolExecutable("mkvextract"))); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(MkvToolnixPath: configuredDirectory), TestContext.Current.CancellationToken); @@ -41,7 +41,7 @@ public async Task LocateAsync_uses_configured_mkvextract_executable_before_searc Directory.CreateDirectory(searchDirectory); await CreateToolFileAsync(Path.Combine(searchDirectory, ToolExecutable("mkvextract"))); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(MkvToolnixPath: configuredExecutable), TestContext.Current.CancellationToken); @@ -66,7 +66,7 @@ public async Task LocateAsync_uses_search_path_before_platform_install_discovery await CreateToolFileAsync(platformToolPath); var locator = CreateLocatorWithoutDefaultCandidates( - new AppSettingsStore(root, [root]), + new AppSettingsStore(root), [searchDirectory], new FakeMkvToolNixInstallProbe(platformToolPath)); @@ -85,7 +85,7 @@ public async Task LocateAsync_uses_platform_mkvtoolnix_discovery_for_mkvextract( await CreateToolFileAsync(platformToolPath); var locator = CreateLocatorWithoutDefaultCandidates( - new AppSettingsStore(root, [root]), + new AppSettingsStore(root), [], new FakeMkvToolNixInstallProbe(platformToolPath)); @@ -104,7 +104,7 @@ public async Task LocateAsync_does_not_use_platform_discovery_for_eac3to() await CreateToolFileAsync(platformToolPath); var locator = CreateLocatorWithoutDefaultCandidates( - new AppSettingsStore(root, [root]), + new AppSettingsStore(root), [], new FakeMkvToolNixInstallProbe(platformToolPath)); @@ -119,7 +119,7 @@ public async Task LocateAsync_does_not_use_platform_discovery_for_eac3to() public async Task LocateAsync_returns_missing_dependency_when_tool_is_absent() { var root = CreateTempDirectory(); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); var locator = CreateLocatorWithoutDefaultCandidates(settingsStore, [root], new FakeMkvToolNixInstallProbe()); var location = await locator.LocateAsync("eac3to", TestContext.Current.CancellationToken); @@ -135,7 +135,7 @@ public async Task LocateAsync_ignores_configured_file_that_is_not_executable() var root = CreateTempDirectory(); var configuredPath = Path.Combine(root, OperatingSystem.IsWindows() ? "ffprobe.txt" : "ffprobe"); await File.WriteAllTextAsync(configuredPath, ""); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync(new AppSettings(FfprobePath: configuredPath), TestContext.Current.CancellationToken); var locator = CreateLocatorWithoutDefaultCandidates(settingsStore, [], new FakeMkvToolNixInstallProbe()); @@ -159,7 +159,7 @@ public async Task LocateAsync_uses_configured_ffprobe_executable_before_ffmpeg_d Directory.CreateDirectory(searchDirectory); await CreateToolFileAsync(Path.Combine(searchDirectory, ToolExecutable("ffprobe"))); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(FfprobePath: configuredExecutable, FfmpegPath: ffmpegDirectory), TestContext.Current.CancellationToken); @@ -182,7 +182,7 @@ public async Task LocateAsync_uses_search_path_after_configured_path_is_cleared( Directory.CreateDirectory(searchDirectory); var expectedToolPath = Path.Combine(searchDirectory, ToolExecutable("ffprobe")); await CreateToolFileAsync(expectedToolPath); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync(new AppSettings(FfprobePath: configuredDirectory), TestContext.Current.CancellationToken); await settingsStore.SaveAsync(new AppSettings(FfprobePath: null), TestContext.Current.CancellationToken); var locator = CreateLocatorWithoutDefaultCandidates(settingsStore, [searchDirectory], new FakeMkvToolNixInstallProbe()); @@ -205,7 +205,7 @@ public async Task LocateAsync_uses_updated_settings_after_configured_path_is_cle Directory.CreateDirectory(searchDirectory); var searchToolPath = Path.Combine(searchDirectory, ToolExecutable("ffprobe")); await CreateToolFileAsync(searchToolPath); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync(new AppSettings(FfprobePath: configuredDirectory), TestContext.Current.CancellationToken); var locator = CreateLocatorWithoutDefaultCandidates(settingsStore, [searchDirectory], new FakeMkvToolNixInstallProbe()); @@ -225,7 +225,7 @@ public async Task LocateAsync_reuses_cached_found_location() await CreateToolFileAsync(toolPath); var provider = new CountingDefaultCandidateProvider(toolPath); var locator = new ExternalToolLocator( - new AppSettingsStore(root, [root]), + new AppSettingsStore(root), [], new FakeMkvToolNixInstallProbe(), provider); @@ -251,7 +251,7 @@ public async Task LocateAsync_rescans_when_cached_found_location_disappears() await CreateToolFileAsync(firstToolPath); await CreateToolFileAsync(secondToolPath); var locator = CreateLocatorWithoutDefaultCandidates( - new AppSettingsStore(root, [root]), + new AppSettingsStore(root), [firstDirectory, secondDirectory], new FakeMkvToolNixInstallProbe()); @@ -272,7 +272,7 @@ public async Task LocateAsync_uses_configured_ffprobe_directory() var expectedToolPath = Path.Combine(configuredDirectory, ToolExecutable("ffprobe")); await CreateToolFileAsync(expectedToolPath); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(FfprobePath: configuredDirectory), TestContext.Current.CancellationToken); @@ -293,7 +293,7 @@ public async Task LocateAsync_uses_configured_ffmpeg_directory_for_ffprobe() var expectedToolPath = Path.Combine(ffmpegDirectory, ToolExecutable("ffprobe")); await CreateToolFileAsync(expectedToolPath); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(FfmpegPath: ffmpegDirectory), TestContext.Current.CancellationToken); @@ -312,7 +312,7 @@ public async Task LocateAsync_does_not_treat_ffmpeg_path_as_ffprobe_executable() var ffprobePath = Path.Combine(root, ToolExecutable("ffprobe")); await CreateToolFileAsync(ffprobePath); - var settingsStore = new AppSettingsStore(root, [root]); + var settingsStore = new AppSettingsStore(root); await settingsStore.SaveAsync( new AppSettings(FfmpegPath: ffprobePath), TestContext.Current.CancellationToken); @@ -334,7 +334,7 @@ public async Task LocateAsync_uses_search_directory_for_ffprobe_when_unconfigure var expectedToolPath = Path.Combine(searchDirectory, ToolExecutable("ffprobe")); await CreateToolFileAsync(expectedToolPath); - var locator = CreateLocatorWithoutDefaultCandidates(new AppSettingsStore(root, [root]), [searchDirectory], new FakeMkvToolNixInstallProbe()); + var locator = CreateLocatorWithoutDefaultCandidates(new AppSettingsStore(root), [searchDirectory], new FakeMkvToolNixInstallProbe()); var location = await locator.LocateAsync("ffprobe", TestContext.Current.CancellationToken); diff --git a/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs index e3d92f45..2973ce1f 100644 --- a/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/Importing/MatroskaIntegrationTests.cs @@ -23,7 +23,7 @@ public async ValueTask InitializeAsync() .Where(static directory => !string.IsNullOrWhiteSpace(directory)) .ToArray(); - var locator = new ExternalToolLocator(new AppSettingsStore(root, [root]), pathDirs); + var locator = new ExternalToolLocator(new AppSettingsStore(root), pathDirs); mkvextractLocation = await locator.LocateAsync("mkvextract", TestContext.Current.CancellationToken); if (!mkvextractLocation.Found) diff --git a/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs b/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs index ba443241..43182af2 100644 --- a/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs +++ b/tests/ChapterTool.Infrastructure.Tests/SettingsMigrationTests.cs @@ -4,62 +4,11 @@ namespace ChapterTool.Infrastructure.Tests; public sealed class SettingsMigrationTests { - [Fact] - public async Task App_settings_loads_legacy_chaptertool_json_keys() - { - var root = CreateTempDirectory(); - var legacyPath = Path.Combine(root, "chaptertool.json"); - await File.WriteAllTextAsync( - legacyPath, - """ - { - "Software\\ChapterTool.SavingPath": "D:\\\\Output", - "Software\\ChapterTool.Language": "en-US", - "Software\\ChapterTool.Location": "{X=12,Y=34}", - "Software\\ChapterTool.mkvToolnixPath": "C:\\\\Tools\\MKVToolNix", - "Software\\ChapterTool.eac3toPath": "C:\\\\Tools\\eac3to\\eac3to.exe" - } - """); - - var store = new AppSettingsStore(root, [root]); - - var settings = await store.LoadAsync(TestContext.Current.CancellationToken); - - Assert.Equal(@"D:\\Output", settings.SavingPath); - Assert.Equal("en-US", settings.Language); - Assert.Equal(new WindowLocation(12, 34), settings.MainWindowLocation); - Assert.Equal(@"C:\\Tools\MKVToolNix", settings.MkvToolnixPath); - Assert.Equal(@"C:\\Tools\eac3to\eac3to.exe", settings.Eac3toPath); - Assert.Equal("Txt", settings.DefaultSaveFormat); - Assert.Equal("und", settings.DefaultXmlLanguage); - } - - [Fact] - public async Task App_settings_prefers_new_typed_file_over_legacy_file() - { - var root = CreateTempDirectory(); - await File.WriteAllTextAsync( - Path.Combine(root, "chaptertool.json"), - """{"Software\\ChapterTool.Language":"en-US"}"""); - - var store = new AppSettingsStore(root, [root]); - await store.SaveAsync( - new AppSettings(Language: "", SavingPath: @"E:\\Saved", DefaultSaveFormat: "Xml", DefaultXmlLanguage: "ja"), - TestContext.Current.CancellationToken); - - var settings = await store.LoadAsync(TestContext.Current.CancellationToken); - - Assert.Equal("", settings.Language); - Assert.Equal(@"E:\\Saved", settings.SavingPath); - Assert.Equal("Xml", settings.DefaultSaveFormat); - Assert.Equal("ja", settings.DefaultXmlLanguage); - } - [Fact] public async Task App_settings_cache_returns_saved_values_without_stale_reads() { var root = CreateTempDirectory(); - var store = new AppSettingsStore(root, [root]); + var store = new AppSettingsStore(root); var initial = await store.LoadAsync(TestContext.Current.CancellationToken); await store.SaveAsync(new AppSettings(Language: "zh-CN", FfprobePath: @"C:\Tools\ffprobe.exe"), TestContext.Current.CancellationToken); @@ -76,7 +25,7 @@ public async Task App_settings_preserves_corrupt_current_file_and_surfaces_error var root = CreateTempDirectory(); var settingsPath = Path.Combine(root, "appsettings.json"); await File.WriteAllTextAsync(settingsPath, "{"); - var store = new AppSettingsStore(root, [root]); + var store = new AppSettingsStore(root); var exception = await Assert.ThrowsAsync( async () => await store.LoadAsync(TestContext.Current.CancellationToken)); @@ -87,52 +36,13 @@ public async Task App_settings_preserves_corrupt_current_file_and_surfaces_error Assert.Equal("{", await File.ReadAllTextAsync(exception.BackupPath)); } - [Fact] - public async Task Theme_settings_loads_legacy_color_config_in_six_slot_order() - { - var root = CreateTempDirectory(); - await File.WriteAllTextAsync( - Path.Combine(root, "color-config.json"), - """["#010203","#111213","#212223","#313233","#414243","#515253"]"""); - - var store = new ThemeSettingsStore(root, [root]); - - var theme = await store.LoadAsync(TestContext.Current.CancellationToken); - - Assert.Equal("#010203", theme.BackChange); - Assert.Equal("#111213", theme.TextBack); - Assert.Equal("#212223", theme.MouseOverColor); - Assert.Equal("#313233", theme.MouseDownColor); - Assert.Equal("#414243", theme.BorderBackColor); - Assert.Equal("#515253", theme.TextFrontColor); - Assert.Equal( - ["BackChange", "TextBack", "MouseOverColor", "MouseDownColor", "BorderBackColor", "TextFrontColor"], - theme.OrderedSlots.Select(static slot => slot.Name).ToArray()); - } - - [Fact] - public async Task Theme_settings_ignores_invalid_legacy_color_values() - { - var root = CreateTempDirectory(); - await File.WriteAllTextAsync( - Path.Combine(root, "color-config.json"), - """["#010203","not-a-color","#212223","#313233","#414243","#515253"]"""); - - var store = new ThemeSettingsStore(root, [root]); - - var theme = await store.LoadAsync(TestContext.Current.CancellationToken); - - Assert.Equal(ThemeColorSettings.Default.TextBack, theme.TextBack); - Assert.Equal("#212223", theme.MouseOverColor); - } - [Fact] public async Task Theme_settings_preserves_corrupt_current_file_and_surfaces_error() { var root = CreateTempDirectory(); var settingsPath = Path.Combine(root, "theme-colors.json"); await File.WriteAllTextAsync(settingsPath, "{"); - var store = new ThemeSettingsStore(root, [root]); + var store = new ThemeSettingsStore(root); var exception = await Assert.ThrowsAsync( async () => await store.LoadAsync(TestContext.Current.CancellationToken));