From 12584d156a1ef2bed8129b5da85e716a17ca2069 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 11 Sep 2026 10:34:36 +0100 Subject: [PATCH] fix(persistence): complete M8 JSON store contracts (#81) --- .../ICCeryCore/Files/JSONFileStore.swift | 13 ++--- .../Profile/VerificationHistoryStore.swift | 4 ++ .../ICCeryCoreTests/JSONFileStoreTests.swift | 34 ++++++++++++- Tests/ICCeryCoreTests/SettingsTests.swift | 22 ++++++++ .../VerificationHistoryStoreTests.swift | 51 +++++++++++++++++++ Tests/ICCeryCoreTests/WizardGatingTests.swift | 11 ++++ 6 files changed, 127 insertions(+), 8 deletions(-) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift index 3da31f3..d9a5afe 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Files/JSONFileStore.swift @@ -27,10 +27,7 @@ public struct JSONFileStore: Sendable { self.fileURL = fileURL self.corrupt = corrupt self.defaultValue = defaultValue - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = dateEncoding - self.encoder = encoder + self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding) let decoder = JSONDecoder() decoder.dateDecodingStrategy = dateDecoding self.decoder = decoder @@ -75,10 +72,14 @@ public struct JSONFileStore: Sendable { } extension JSONEncoder { - /// Pretty-printed, sorted-keys encoder used by preset export. - public static func icceryPretty() -> JSONEncoder { + /// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore` + /// and preset export. + static func icceryPretty( + dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate + ) -> JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = dateEncoding return encoder } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift index fea54c9..707ee7d 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/VerificationHistoryStore.swift @@ -73,7 +73,11 @@ public actor VerificationHistoryStore { } /// Removes all history and updates disk. + /// + /// Loads the existing history first and propagates any load error so an + /// unparseable file is never overwritten. public func clear() throws { + try load() try write([]) records = [] } diff --git a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift index b9f20c2..550a0fb 100644 --- a/Tests/ICCeryCoreTests/JSONFileStoreTests.swift +++ b/Tests/ICCeryCoreTests/JSONFileStoreTests.swift @@ -9,7 +9,17 @@ struct JSONFileStoreTests { .appendingPathComponent("json-store-\(UUID().uuidString).json") } - @Test("Corrupt file with replaceWithDefault returns default") + @Test("Missing file returns default") + func missingFileDefaults() throws { + let store = JSONFileStore( + fileURL: tempURL(), + corrupt: .throwCorrupt, + defaultValue: { .default } + ) + #expect(try store.load() == .default) + } + + @Test("Corrupt file with replaceWithDefault returns default and leaves bytes") func corruptDefaults() throws { let url = tempURL() try "{ not json".write(to: url, atomically: true, encoding: .utf8) @@ -19,7 +29,8 @@ struct JSONFileStoreTests { defaultValue: { .default } ) #expect(try store.load() == .default) - #expect(FileManager.default.fileExists(atPath: url.path)) + let kept = try String(contentsOf: url, encoding: .utf8) + #expect(kept == "{ not json") } @Test("Corrupt file with throwCorrupt throws and leaves bytes") @@ -50,6 +61,25 @@ struct JSONFileStoreTests { let text = try String(contentsOf: url, encoding: .utf8) #expect(text.contains("\n")) #expect(text.contains("\"delta_e_good_max\"")) + // Lexical key sorting: ascending order of top-level keys. + let keys = [ + "ask_before_overwrite_profile", + "calibration_stale_days", + "custom_presets", + "default_install_location", + "delta_e_good_max", + "delta_e_warning_max", + "enable_i1pro2_leds", + "open_color_panel_after_install", + ] + var lastIndex = text.startIndex + for key in keys { + guard let range = text.range(of: "\"\(key)\"", range: lastIndex..