Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12584d156a | ||
|
|
0332a2bb4f | ||
|
|
0d98440a15 | ||
|
|
073e3aa308 |
+2
-1
@@ -27,4 +27,5 @@ ICCery.xcodeproj/
|
|||||||
Release/
|
Release/
|
||||||
notarization/
|
notarization/
|
||||||
build/
|
build/
|
||||||
docs/megaplans/
|
docs/megaplans/*
|
||||||
|
docs/megaplans
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
self.fileURL = fileURL
|
self.fileURL = fileURL
|
||||||
self.corrupt = corrupt
|
self.corrupt = corrupt
|
||||||
self.defaultValue = defaultValue
|
self.defaultValue = defaultValue
|
||||||
let encoder = JSONEncoder()
|
self.encoder = JSONEncoder.icceryPretty(dateEncoding: dateEncoding)
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
encoder.dateEncodingStrategy = dateEncoding
|
|
||||||
self.encoder = encoder
|
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = dateDecoding
|
decoder.dateDecodingStrategy = dateDecoding
|
||||||
self.decoder = decoder
|
self.decoder = decoder
|
||||||
@@ -75,10 +72,14 @@ public struct JSONFileStore<T: Codable & Sendable>: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension JSONEncoder {
|
extension JSONEncoder {
|
||||||
/// Pretty-printed, sorted-keys encoder used by preset export.
|
/// Shared pretty-printed, sorted-keys encoder used by `JSONFileStore`
|
||||||
public static func icceryPretty() -> JSONEncoder {
|
/// and preset export.
|
||||||
|
static func icceryPretty(
|
||||||
|
dateEncoding: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||||
|
) -> JSONEncoder {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
encoder.dateEncodingStrategy = dateEncoding
|
||||||
return encoder
|
return encoder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public actor VerificationHistoryStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes all history and updates disk.
|
/// 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 {
|
public func clear() throws {
|
||||||
|
try load()
|
||||||
try write([])
|
try write([])
|
||||||
records = []
|
records = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ All measurement, chart generation, and profile mathematics live in the [Gronod A
|
|||||||
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
|
||||||
| Default branch | `develop` |
|
| Default branch | `develop` |
|
||||||
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
|
||||||
| M7 | UAT-ready hardening of the v2.0 wizard paths |
|
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
|
||||||
|
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
|
||||||
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -170,11 +171,11 @@ Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md)
|
|||||||
|
|
||||||
```
|
```
|
||||||
develop
|
develop
|
||||||
└── milestone/mN-<slug> # integration only
|
└── milestone/m8-consolidation # integration branch
|
||||||
└── feat/<issue>-<slug> # one issue per branch
|
└── feat/<issue>-<slug> # one issue per branch
|
||||||
```
|
```
|
||||||
|
|
||||||
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. M7 is small; its PRs target `develop` directly. Do not open umbrella "bugfix" branches that mix tickets.
|
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ struct JSONFileStoreTests {
|
|||||||
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
.appendingPathComponent("json-store-\(UUID().uuidString).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Corrupt file with replaceWithDefault returns default")
|
@Test("Missing file returns default")
|
||||||
|
func missingFileDefaults() throws {
|
||||||
|
let store = JSONFileStore<AppSettings>(
|
||||||
|
fileURL: tempURL(),
|
||||||
|
corrupt: .throwCorrupt,
|
||||||
|
defaultValue: { .default }
|
||||||
|
)
|
||||||
|
#expect(try store.load() == .default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
|
||||||
func corruptDefaults() throws {
|
func corruptDefaults() throws {
|
||||||
let url = tempURL()
|
let url = tempURL()
|
||||||
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
@@ -19,7 +29,8 @@ struct JSONFileStoreTests {
|
|||||||
defaultValue: { .default }
|
defaultValue: { .default }
|
||||||
)
|
)
|
||||||
#expect(try store.load() == .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")
|
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
|
||||||
@@ -50,6 +61,25 @@ struct JSONFileStoreTests {
|
|||||||
let text = try String(contentsOf: url, encoding: .utf8)
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
#expect(text.contains("\n"))
|
#expect(text.contains("\n"))
|
||||||
#expect(text.contains("\"delta_e_good_max\""))
|
#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..<text.endIndex) else {
|
||||||
|
Issue.record("missing or out-of-order key \(key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastIndex = range.upperBound
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,28 @@ struct SettingsStoreTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
|
||||||
|
let url = tempStoreURL()
|
||||||
|
let store = SettingsStore(fileURL: url)
|
||||||
|
var valid = AppSettings.default
|
||||||
|
valid.deltaEGoodMax = 1.5
|
||||||
|
try store.save(valid)
|
||||||
|
let originalBytes = try Data(contentsOf: url)
|
||||||
|
|
||||||
|
var fired = false
|
||||||
|
let token = NotificationCenter.default.addObserver(
|
||||||
|
forName: SettingsStore.settingsDidChange, object: nil, queue: nil
|
||||||
|
) { _ in fired = true }
|
||||||
|
defer { NotificationCenter.default.removeObserver(token) }
|
||||||
|
|
||||||
|
var invalid = AppSettings.default
|
||||||
|
invalid.deltaEGoodMax = 9.0
|
||||||
|
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) }
|
||||||
|
#expect(try Data(contentsOf: url) == originalBytes)
|
||||||
|
#expect(!fired)
|
||||||
|
#expect(store.load() == valid)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func savePostsNotification() async throws {
|
@Test func savePostsNotification() async throws {
|
||||||
let url = tempStoreURL()
|
let url = tempStoreURL()
|
||||||
let store = SettingsStore(fileURL: url)
|
let store = SettingsStore(fileURL: url)
|
||||||
|
|||||||
@@ -131,6 +131,57 @@ struct VerificationHistoryStoreTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Clear does not overwrite an unparseable file")
|
||||||
|
func clearPreservesUnparseableFile() async {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let badJSON = "not json"
|
||||||
|
try? badJSON.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let store = VerificationHistoryStore(url: url)
|
||||||
|
do {
|
||||||
|
try await store.clear()
|
||||||
|
Issue.record("clear() should propagate the load error")
|
||||||
|
} catch {
|
||||||
|
let contents = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(contents == badJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ISO-8601 timestamps round-trip through a fresh store")
|
||||||
|
func iso8601RoundTrip() async throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
|
let url = tmp.appendingPathComponent("verification_history.json")
|
||||||
|
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
let record = VerificationRecord(
|
||||||
|
id: "vr-iso",
|
||||||
|
profileName: "p",
|
||||||
|
printerName: "",
|
||||||
|
avgDE: 1.0,
|
||||||
|
maxDE: 2.0,
|
||||||
|
rmsDE: 1.5,
|
||||||
|
patchCount: 1,
|
||||||
|
status: .good,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
let store1 = VerificationHistoryStore(url: url)
|
||||||
|
_ = try await store1.append(record)
|
||||||
|
|
||||||
|
let text = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp)))
|
||||||
|
|
||||||
|
let store2 = VerificationHistoryStore(url: url)
|
||||||
|
let loaded = try await store2.load()
|
||||||
|
#expect(loaded.count == 1)
|
||||||
|
#expect(loaded.first?.timestamp == timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("CSV export quoting")
|
@Test("CSV export quoting")
|
||||||
func csvQuoting() async throws {
|
func csvQuoting() async throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ struct WizardStateStoreTests {
|
|||||||
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
#expect(WizardStateStore(fileURL: url).load().stage == .generate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws {
|
||||||
|
let url = tempURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try "not json".write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
#expect(WizardStateStore(fileURL: url).load() == .default)
|
||||||
|
let kept = try String(contentsOf: url, encoding: .utf8)
|
||||||
|
#expect(kept == "not json")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func sessionModeCalibrationRoundTrips() throws {
|
@Test func sessionModeCalibrationRoundTrips() throws {
|
||||||
var s = WizardState(sessionMode: .calibration)
|
var s = WizardState(sessionMode: .calibration)
|
||||||
let data = try JSONEncoder().encode(s)
|
let data = try JSONEncoder().encode(s)
|
||||||
|
|||||||
Reference in New Issue
Block a user