Compare commits

...
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ee16fb3fae Settings store, logging & settings dialog (#5)
- 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>
2026-09-08 19:14:14 +01:00
9 changed files with 709 additions and 13 deletions
@@ -15,6 +15,7 @@ public enum LogLevel: String, Codable, Sendable, CaseIterable {
} }
} }
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
var rank: Int { var rank: Int {
switch self { switch self {
case .error: return 0 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; /// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
/// issue #5 adds the rolling file sink and runtime `setLevel`. /// gated at write time so a settings save takes effect immediately
/// (#158).
public struct AppLogger: Sendable { public struct AppLogger: Sendable {
public static let shared = AppLogger(category: "app") public static let shared = AppLogger(category: "app")
private let osLog: Logger private let osLog: Logger
private let sink: LogSink
public let category: String public let category: String
public init(category: String) { public init(category: String, sink: LogSink = .shared) {
self.category = category self.category = category
self.sink = sink
self.osLog = Logger( self.osLog = Logger(
subsystem: AppPaths.bundleIdentifier, subsystem: AppPaths.bundleIdentifier,
category: category category: category
@@ -44,8 +48,11 @@ public struct AppLogger: Sendable {
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) { public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
let text = LogSanitizer.sanitize(message()) let text = LogSanitizer.sanitize(message())
if level.rank <= sink.level.rank {
osLog.log(level: level.osType, "\(text, privacy: .public)") 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()) } public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) } public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
@@ -0,0 +1,127 @@
import Foundation
import OSLog
/// Rolling file sink for `AppLogger` `~/Library/Logs/<bundle>/
/// 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)
}
}
@@ -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 }
}
@@ -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])
}
}
+7
View File
@@ -7,6 +7,13 @@ struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var model = WizardViewModel() @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 { var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
Window("ICCery", id: "main") { Window("ICCery", id: "main") {
+1 -9
View File
@@ -29,15 +29,7 @@ struct RootView: View {
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.background(Theme.background) .background(Theme.background)
.sheet(isPresented: $showingSettings) { .sheet(isPresented: $showingSettings) {
// Full settings dialog lands in issue #5. SettingsView()
VStack(spacing: 12) {
Text("Settings").font(.headline)
Text("Implemented in issue #5.")
.foregroundStyle(.secondary)
Button("Close") { showingSettings = false }
}
.padding(24)
.frame(width: 420)
} }
.alert("ICCery 2.0.0", isPresented: $showingAbout) { .alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {} Button("OK") {}
+169
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
}