- 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>
165 lines
5.8 KiB
Swift
165 lines
5.8 KiB
Swift
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)
|
|
}
|
|
}
|