From ee16fb3fae09d4947149e379a3224e6349dffe3b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 8 Sep 2026 19:14:14 +0100 Subject: [PATCH] Settings store, logging & settings dialog (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSettings: snake_case Codable model — argyll_binary_dir, default_instrument (stored, never applied to argv), log_level (nil -> Debug debug / Info release), delta_e thresholds (2.0/5.0), custom_presets, enable_i1pro2_leds, calibration_stale_days 30, default_install_location user, ask_before_overwrite_profile, open_color_panel_after_install - Validation with the exact contract strings; save() refuses invalid settings; corrupt/missing JSON -> defaults; settingsDidChange notification posted on save (for #20) - LogSink: rolling file at ~/Library/Logs/com.gronod.iccery2/ iccery.log, 5 MiB x 5 segments, runtime setLevel applied at startup and on save (#158); AppLogger gates os_log+file through it - SettingsView sheet: Argyll dir picker, instrument (display-only caveat), i1Pro2 LEDs, ΔE fields + inline errors, stale days, install location, overwrite + ColorSync toggles, log level, open-log-folder / copy-path / copy-excerpt - v1 settings path never read; writes atomic via AtomicFileWriter - 13 new tests; 60/60 green Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Logging/AppLogger.swift | 15 +- .../Logging/RollingFileLogger.swift | 127 +++++++++++++ .../ICCeryCore/Settings/AppSettings.swift | 115 ++++++++++++ .../ICCeryCore/Settings/SettingsStore.swift | 47 +++++ Sources/ICCery/ICCeryApp.swift | 7 + Sources/ICCery/RootView.swift | 10 +- Sources/ICCery/SettingsView.swift | 169 ++++++++++++++++++ Sources/ICCery/SettingsViewModel.swift | 68 +++++++ Tests/ICCeryCoreTests/SettingsTests.swift | 164 +++++++++++++++++ 9 files changed, 709 insertions(+), 13 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift create mode 100644 Sources/ICCery/SettingsView.swift create mode 100644 Sources/ICCery/SettingsViewModel.swift create mode 100644 Tests/ICCeryCoreTests/SettingsTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift index 0012e14..c3cfdc7 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/AppLogger.swift @@ -15,6 +15,7 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable { } } + /// Lower rank = more severe. `shouldLog` keeps `rank <= min`. var rank: Int { switch self { case .error: return 0 @@ -26,16 +27,19 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable { } } -/// Central logger. For M1 PR2 this writes to `os.Logger` only; -/// issue #5 adds the rolling file sink and runtime `setLevel`. +/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level +/// gated at write time so a settings save takes effect immediately +/// (#158). public struct AppLogger: Sendable { public static let shared = AppLogger(category: "app") private let osLog: Logger + private let sink: LogSink public let category: String - public init(category: String) { + public init(category: String, sink: LogSink = .shared) { self.category = category + self.sink = sink self.osLog = Logger( subsystem: AppPaths.bundleIdentifier, category: category @@ -44,7 +48,10 @@ public struct AppLogger: Sendable { public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { let text = LogSanitizer.sanitize(message()) - osLog.log(level: level.osType, "\(text, privacy: .public)") + if level.rank <= sink.level.rank { + osLog.log(level: level.osType, "\(text, privacy: .public)") + } + sink.write(level: level, category: category, message: text) } public func error(_ message: @autoclosure () -> String) { log(.error, message()) } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift new file mode 100644 index 0000000..231c30a --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Logging/RollingFileLogger.swift @@ -0,0 +1,127 @@ +import Foundation +import OSLog + +/// Rolling file sink for `AppLogger` — `~/Library/Logs// +/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments +/// (`iccery.log.1` … `iccery.log.5`). +/// +/// The minimum level is **runtime state** (#158): `setLevel` takes +/// effect immediately — at startup and on every settings save. +public final class LogSink: @unchecked Sendable { + + public static let shared = LogSink(fileURL: AppPaths.logFile) + + private let lock = NSLock() + private let fileURL: URL + private var minimumLevel: LogLevel + private var handle: FileHandle? + + /// 5 MiB per segment, 5 historical segments kept. + public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024 + public static let keptSegments = 5 + + public init( + fileURL: URL = AppPaths.logFile, + minimumLevel: LogLevel? = nil + ) { + self.fileURL = fileURL + #if DEBUG + self.minimumLevel = minimumLevel ?? .debug + #else + self.minimumLevel = minimumLevel ?? .info + #endif + } + + public var level: LogLevel { + lock.lock() + defer { lock.unlock() } + return minimumLevel + } + + /// Applied at startup AND on every settings save (issue #5, #158). + public func setLevel(_ level: LogLevel) { + lock.lock() + minimumLevel = level + lock.unlock() + } + + /// `nil` → DEBUG-build default (.debug) / release (.info). + public func applySettings(_ settings: AppSettings) { + setLevel(settings.effectiveLogLevel) + } + + public func shouldLog(_ level: LogLevel) -> Bool { + level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank + } + + // MARK: - Writing + + /// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line, + /// rotating first when the active segment exceeds 5 MiB. + public func write(level: LogLevel, category: String, message: String) { + guard shouldLog(level) else { return } + lock.lock() + defer { lock.unlock() } + rotateIfNeeded() + openIfNeeded() + let stamp = Self.timestamp() + let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n" + if let data = line.data(using: .utf8) { + handle?.write(data) + } + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static func timestamp() -> String { + formatter.string(from: Date()) + } + + private func openIfNeeded() { + guard handle == nil else { return } + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: fileURL.path) { + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + } + handle = try? FileHandle(forWritingTo: fileURL) + try? handle?.seekToEnd() + } + + /// Shifts `iccery.log.4→.5`, `.3→.4`, …, `.log→.1` and resets the + /// writer. Oldest segment is deleted. + private func rotateIfNeeded() { + guard FileManager.default.fileExists(atPath: fileURL.path), + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path), + let size = attrs[.size] as? UInt64, + size >= Self.maxSegmentBytes + else { return } + + try? handle?.close() + handle = nil + let fm = FileManager.default + let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)") + try? fm.removeItem(at: oldest) + for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) { + let src = fileURL.appendingPathExtension("\(i)") + let dst = fileURL.appendingPathExtension("\(i + 1)") + if fm.fileExists(atPath: src.path) { + try? fm.moveItem(at: src, to: dst) + } + } + try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1")) + } + + /// Tail of the active log for the settings dialog's "copy excerpt". + public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String { + guard let data = try? Data(contentsOf: fileURL) else { return "" } + let slice = data.suffix(maxBytes) + return String(decoding: slice, as: UTF8.self) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift new file mode 100644 index 0000000..2464ac9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift @@ -0,0 +1,115 @@ +import Foundation + +/// A saved wizard preset slot (docs/22 §Presets). The preset *engine* +/// lands in issue #11; for M1 the store only needs a Codable container. +public struct CustomPreset: Codable, Equatable, Sendable { + public var name: String + /// Opaque per-stage form values — keyed by field id. + public var values: [String: String] + + public init(name: String, values: [String: String] = [:]) { + self.name = name + self.values = values + } +} + +/// Where `install_profile` drops finished profiles (docs/22). +public enum InstallLocation: String, Codable, Sendable, CaseIterable { + case user + case system +} + +/// `settings.json` model (docs/22). snake_case keys match the v1 file +/// so field names stay identical across rewrites. +public struct AppSettings: Codable, Equatable, Sendable { + + /// User override for Argyll binaries; `nil` → bundled sidecars. + public var argyllBinaryDir: String? + + /// Stored but **never applied to argv** — Stage 2's own instrument + /// select is the live source (docs/04 §0.1). + public var defaultInstrument: String? + + /// `nil` → `.debug` in debug builds, `.info` in release (#158). + public var logLevel: LogLevel? + + public var deltaEGoodMax: Double + public var deltaEWarningMax: Double + public var customPresets: [CustomPreset] + public var enableI1Pro2Leds: Bool + public var calibrationStaleDays: Int + public var defaultInstallLocation: InstallLocation + public var askBeforeOverwriteProfile: Bool + public var openColorPanelAfterInstall: Bool + + public init( + argyllBinaryDir: String? = nil, + defaultInstrument: String? = nil, + logLevel: LogLevel? = nil, + deltaEGoodMax: Double = 2.0, + deltaEWarningMax: Double = 5.0, + customPresets: [CustomPreset] = [], + enableI1Pro2Leds: Bool = false, + calibrationStaleDays: Int = 30, + defaultInstallLocation: InstallLocation = .user, + askBeforeOverwriteProfile: Bool = true, + openColorPanelAfterInstall: Bool = false + ) { + self.argyllBinaryDir = argyllBinaryDir + self.defaultInstrument = defaultInstrument + self.logLevel = logLevel + self.deltaEGoodMax = deltaEGoodMax + self.deltaEWarningMax = deltaEWarningMax + self.customPresets = customPresets + self.enableI1Pro2Leds = enableI1Pro2Leds + self.calibrationStaleDays = calibrationStaleDays + self.defaultInstallLocation = defaultInstallLocation + self.askBeforeOverwriteProfile = askBeforeOverwriteProfile + self.openColorPanelAfterInstall = openColorPanelAfterInstall + } + + public static let `default` = AppSettings() + + /// Effective log level — runtime state, not just persistence (#158). + public var effectiveLogLevel: LogLevel { + if let logLevel { return logLevel } + #if DEBUG + return .debug + #else + return .info + #endif + } + + enum CodingKeys: String, CodingKey { + case argyllBinaryDir = "argyll_binary_dir" + case defaultInstrument = "default_instrument" + case logLevel = "log_level" + case deltaEGoodMax = "delta_e_good_max" + case deltaEWarningMax = "delta_e_warning_max" + case customPresets = "custom_presets" + case enableI1Pro2Leds = "enable_i1pro2_leds" + case calibrationStaleDays = "calibration_stale_days" + case defaultInstallLocation = "default_install_location" + case askBeforeOverwriteProfile = "ask_before_overwrite_profile" + case openColorPanelAfterInstall = "open_color_panel_after_install" + } + + /// UI-facing validation. Strings are part of the contract (issue #5). + public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative." + public static let errorThresholdOrder = + "Good ΔE threshold must be strictly less than the warning threshold." + + /// All validation errors, in declaration order. Empty = valid. + public func validate() -> [String] { + var errors: [String] = [] + if deltaEGoodMax < 0 || deltaEWarningMax < 0 { + errors.append(Self.errorNegativeDeltaE) + } + if deltaEGoodMax >= deltaEWarningMax { + errors.append(Self.errorThresholdOrder) + } + return errors + } + + public var isValid: Bool { validate().isEmpty } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift new file mode 100644 index 0000000..f4a679c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/SettingsStore.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Persists `AppSettings` to +/// `~/Library/Application Support/com.gronod.iccery2/settings.json` +/// (issue #5 — the v1 path is never read). +/// +/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls +/// back to defaults. Saving posts `settingsDidChange` so #20 can +/// reclassify swatches. +public final class SettingsStore: Sendable { + + /// Posted on `NotificationCenter.default` after every successful save. + public static let settingsDidChange = + Notification.Name("com.gronod.iccery2.settingsDidChange") + + public let fileURL: URL + + public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) { + self.fileURL = fileURL + } + + public func load() -> AppSettings { + guard let data = try? Data(contentsOf: fileURL), + let settings = try? JSONDecoder().decode(AppSettings.self, from: data) + else { + return .default + } + return settings + } + + /// Validates before persisting — throws `SettingsError` listing + /// every violation; nothing is written on failure. + public func save(_ settings: AppSettings) throws { + let errors = settings.validate() + guard errors.isEmpty else { + throw SettingsError.validationFailed(errors) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try AtomicFileWriter.write(encoder.encode(settings), to: fileURL) + NotificationCenter.default.post(name: Self.settingsDidChange, object: nil) + } + + public enum SettingsError: Error, Equatable { + case validationFailed([String]) + } +} diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index e43c41b..a61a29d 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -7,6 +7,13 @@ struct ICCeryApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var model = WizardViewModel() + init() { + try? AppPaths.ensureDirectories() + // Log level is runtime state — apply persisted settings at + // startup (#158); the Settings sheet re-applies on save. + LogSink.shared.applySettings(SettingsStore().load()) + } + var body: some Scene { // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). Window("ICCery", id: "main") { diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 6d5f619..59f5d9f 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -29,15 +29,7 @@ struct RootView: View { .frame(minWidth: 1100, minHeight: 700) .background(Theme.background) .sheet(isPresented: $showingSettings) { - // Full settings dialog lands in issue #5. - VStack(spacing: 12) { - Text("Settings").font(.headline) - Text("Implemented in issue #5.") - .foregroundStyle(.secondary) - Button("Close") { showingSettings = false } - } - .padding(24) - .frame(width: 420) + SettingsView() } .alert("ICCery 2.0.0", isPresented: $showingAbout) { Button("OK") {} diff --git a/Sources/ICCery/SettingsView.swift b/Sources/ICCery/SettingsView.swift new file mode 100644 index 0000000..085e7ff --- /dev/null +++ b/Sources/ICCery/SettingsView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import ICCeryCore + +/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with +/// the full v1 field set; ΔE validation shows inline under the fields. +struct SettingsView: View { + @State var model = SettingsViewModel() + @Environment(\.dismiss) private var dismiss + + private static let instruments: [(code: String, label: String)] = [ + ("i1", "X-Rite i1Pro / i1Pro 2"), + ("p3", "X-Rite i1Pro 3 / 3 Plus"), + ("CM", "ColorMunki"), + ("SS", "Specbos / Spectraval"), + ("20", "Gretag i1Display 2"), + ("22", "X-Rite i1Display Pro / ColorMunki Display"), + ("41", "Datacolor Spyder 4/5"), + ("51", "Spyder X"), + ] + + var body: some View { + VStack(spacing: 0) { + Form { + Section("Argyll") { + HStack { + TextField( + "Bundled sidecars", + text: Binding( + get: { model.settings.argyllBinaryDir ?? "" }, + set: { + model.settings.argyllBinaryDir = + $0.isEmpty ? nil : $0 + } + ) + ) + Button("Browse…") { + if let dir = FileDialogService.shared.selectDirectory() { + model.settings.argyllBinaryDir = dir.path + } + } + } + Text("Leave empty to use the bundled Argyll tools.") + .font(.caption) + .foregroundStyle(.secondary) + + Picker( + "Default instrument", + selection: Binding( + get: { model.settings.defaultInstrument ?? "" }, + set: { + model.settings.defaultInstrument = + $0.isEmpty ? nil : $0 + } + ) + ) { + Text("None").tag("") + ForEach(Self.instruments, id: \.code) { + Text($0.label).tag($0.code) + } + } + Text("Display-only — Stage 2's instrument select is used for actual runs.") + .font(.caption) + .foregroundStyle(.secondary) + + Toggle( + "Enable i1Pro 2 LEDs", + isOn: $model.settings.enableI1Pro2Leds + ) + } + + Section("Verification") { + HStack { + Text("Good ΔE ≤") + TextField( + "2.0", + value: $model.settings.deltaEGoodMax, + format: .number + ) + .frame(width: 60) + Text("Warning ΔE ≤") + TextField( + "5.0", + value: $model.settings.deltaEWarningMax, + format: .number + ) + .frame(width: 60) + } + ForEach(model.validationErrors, id: \.self) { error in + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + + Section("Calibration") { + HStack { + Text("Stale after") + TextField( + "30", + value: $model.settings.calibrationStaleDays, + format: .number + ) + .frame(width: 60) + Text("days") + } + } + + Section("Profile install") { + Picker( + "Install location", + selection: $model.settings.defaultInstallLocation + ) { + Text("User library").tag(InstallLocation.user) + Text("System library").tag(InstallLocation.system) + } + Toggle( + "Ask before overwriting a profile", + isOn: $model.settings.askBeforeOverwriteProfile + ) + Toggle( + "Open ColorSync after install", + isOn: $model.settings.openColorPanelAfterInstall + ) + } + + Section("Logging") { + Picker( + "Log level", + selection: Binding( + get: { model.settings.logLevel }, + set: { model.settings.logLevel = $0 } + ) + ) { + Text("Default").tag(LogLevel?.none) + ForEach(LogLevel.allCases, id: \.self) { + Text($0.rawValue.capitalized).tag(LogLevel?.some($0)) + } + } + HStack { + Button("Open log folder") { model.openLogFolder() } + Button("Copy path") { model.copyLogPath() } + Button("Copy excerpt") { model.copyLogExcerpt() } + } + } + } + .formStyle(.grouped) + + Divider() + + HStack { + if model.savedFlash { + Text("Saved") + .foregroundStyle(.green) + .font(.callout) + } + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Save") { + if model.save() { dismiss() } + } + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(width: 560, height: 620) + .background(Theme.background) + } +} diff --git a/Sources/ICCery/SettingsViewModel.swift b/Sources/ICCery/SettingsViewModel.swift new file mode 100644 index 0000000..2f81970 --- /dev/null +++ b/Sources/ICCery/SettingsViewModel.swift @@ -0,0 +1,68 @@ +import AppKit +import Foundation +import ICCeryCore + +/// Backs the Settings sheet (issue #5). Load → edit → save with +/// validation; the log level is applied live via `LogSink` (#158) and a +/// `settingsDidChange` notification fans out to #20. +@MainActor +@Observable +final class SettingsViewModel { + + var settings: AppSettings + var validationErrors: [String] = [] + var savedFlash = false + + private let store: SettingsStore + private let sink: LogSink + + init(store: SettingsStore = SettingsStore(), sink: LogSink = .shared) { + self.store = store + self.sink = sink + self.settings = store.load() + } + + /// Persists after validation. Returns false (and shows inline + /// errors) when the form is invalid. + @discardableResult + func save() -> Bool { + validationErrors = settings.validate() + guard validationErrors.isEmpty else { return false } + do { + try store.save(settings) + sink.applySettings(settings) + savedFlash = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + savedFlash = false + } + return true + } catch { + validationErrors = ["Could not save settings: \(error.localizedDescription)"] + return false + } + } + + // MARK: - Log helpers + + var logFileURL: URL { AppPaths.logFile } + + func openLogFolder() { + try? FileManager.default.createDirectory( + at: AppPaths.logDir, withIntermediateDirectories: true + ) + NSWorkspace.shared.selectFile( + AppPaths.logFile.path, inFileViewerRootedAtPath: AppPaths.logDir.path + ) + } + + func copyLogPath() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(AppPaths.logFile.path, forType: .string) + } + + func copyLogExcerpt() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(sink.tailExcerpt(), forType: .string) + } +} diff --git a/Tests/ICCeryCoreTests/SettingsTests.swift b/Tests/ICCeryCoreTests/SettingsTests.swift new file mode 100644 index 0000000..5fed795 --- /dev/null +++ b/Tests/ICCeryCoreTests/SettingsTests.swift @@ -0,0 +1,164 @@ +import Testing +import Foundation +@testable import ICCeryCore + +private func tempStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-settings-\(UUID().uuidString)") + .appendingPathComponent("settings.json") +} + +@Suite("AppSettings") +struct AppSettingsTests { + @Test func defaults() { + let s = AppSettings.default + #expect(s.argyllBinaryDir == nil) + #expect(s.defaultInstrument == nil) + #expect(s.logLevel == nil) + #expect(s.deltaEGoodMax == 2.0) + #expect(s.deltaEWarningMax == 5.0) + #expect(s.customPresets.isEmpty) + #expect(!s.enableI1Pro2Leds) + #expect(s.calibrationStaleDays == 30) + #expect(s.defaultInstallLocation == .user) + #expect(s.askBeforeOverwriteProfile) + #expect(!s.openColorPanelAfterInstall) + #expect(s.isValid) + } + + @Test func negativeThresholds() { + var s = AppSettings.default + s.deltaEGoodMax = -1 + #expect(s.validate() == [AppSettings.errorNegativeDeltaE]) + s.deltaEGoodMax = 2.0 + s.deltaEWarningMax = -0.5 + // -0.5 < 0 → negative error; good(2.0) >= warn(-0.5) → order error too + #expect(s.validate() == [ + AppSettings.errorNegativeDeltaE, + AppSettings.errorThresholdOrder, + ]) + } + + @Test func goodMustBeStrictlyLessThanWarning() { + var s = AppSettings.default + s.deltaEGoodMax = 5.0 + #expect(s.validate() == [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 6.0 + #expect(s.validate() == [AppSettings.errorThresholdOrder]) + s.deltaEGoodMax = 4.9 + #expect(s.isValid) + } + + @Test func snakeCaseKeys() throws { + let s = AppSettings.default + let data = try JSONEncoder().encode(s) + let json = String(data: data, encoding: .utf8)! + #expect(json.contains("\"delta_e_good_max\"")) + #expect(json.contains("\"default_install_location\"")) + #expect(json.contains("\"enable_i1pro2_leds\"")) + } +} + +@Suite("SettingsStore") +struct SettingsStoreTests { + @Test func roundTrip() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 1.5 + s.defaultInstrument = "p3" + try store.save(s) + #expect(store.load() == s) + } + + @Test func corruptJsonFallsBackToDefaults() throws { + let url = tempStoreURL() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try "{ not json".write(to: url, atomically: true, encoding: .utf8) + #expect(SettingsStore(fileURL: url).load() == .default) + } + + @Test func missingFileReturnsDefaults() { + #expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) + } + + @Test func invalidSettingsNotPersisted() throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var s = AppSettings.default + s.deltaEGoodMax = 9.0 // >= warning 5.0 + #expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + @Test func savePostsNotification() async throws { + let url = tempStoreURL() + let store = SettingsStore(fileURL: url) + var fired = false + let token = NotificationCenter.default.addObserver( + forName: SettingsStore.settingsDidChange, object: nil, queue: nil + ) { _ in fired = true } + defer { NotificationCenter.default.removeObserver(token) } + try store.save(.default) + #expect(fired) + } +} + +@Suite("LogSink") +struct LogSinkTests { + private func tempLog() -> (URL, LogSink) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-log-\(UUID().uuidString)") + .appendingPathComponent("iccery.log") + return (url, LogSink(fileURL: url)) + } + + @Test func writesFormattedLines() { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "test", message: "hello") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + #expect(content.contains("[INFO] test: hello")) + } + + @Test func levelFilteringIsLive() { + let (url, sink) = tempLog() + sink.setLevel(.error) + sink.write(level: .info, category: "t", message: "hidden") + sink.setLevel(.info) // runtime change, no restart (#158) + sink.write(level: .info, category: "t", message: "shown") + let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + #expect(!content.contains("hidden")) + #expect(content.contains("shown")) + } + + @Test func rotatesAt5MiBKeeping5Segments() throws { + let (url, sink) = tempLog() + sink.setLevel(.trace) + // Pre-fill the active log just under the cap, then cross it. + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let big = String(repeating: "x", count: Int(LogSink.maxSegmentBytes)) + try big.write(to: url, atomically: true, encoding: .utf8) + + sink.write(level: .info, category: "t", message: "trigger rotation") + #expect(FileManager.default.fileExists( + atPath: url.appendingPathExtension("1").path + )) + // Active log is small again. + let size = try FileManager.default.attributesOfItem( + atPath: url.path + )[.size] as? UInt64 + #expect((size ?? 0) < 1024) + } + + @Test func tailExcerptCaps() throws { + let (url, sink) = tempLog() + sink.setLevel(.debug) + sink.write(level: .info, category: "t", message: "line") + #expect(sink.tailExcerpt(maxBytes: 8).count <= 8) + } +} -- 2.39.5