Compare commits

...
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9857ddb4d0 test(m9): retry dropped calibration generate tap in UI test
macOS CI / build-and-test (push) Failing after 7s
macOS CI / package (push) Skipped
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 22:38:30 +01:00
gronod 7a6b82816b Merge pull request 'feat(m9): state management and view demotions for macOS 12 (Slice 4)' (#108) from feat/m9-slice4-state-and-views into milestone/m9-monterey 2026-09-11 21:57:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00
gronod a76120d9f7 Merge pull request 'test(m9): complete migration of stateful and async suites to XCTest (Slice 3)' (#107) from feat/m9-slice3-xctest-async-stateful into milestone/m9-monterey 2026-09-11 21:34:15 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a30a8fc551 test(m9): complete migration of all remaining test suites to XCTest
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:29:16 +01:00
gronod 83f3a4f0e2 Merge pull request 'test(m9): convert pure core suites to XCTest (Slice 2)' (#106) from feat/m9-slice2-xctest-core-pure into milestone/m9-monterey 2026-09-11 21:06:33 +01:00
40 changed files with 1012 additions and 962 deletions
+3 -4
View File
@@ -3,8 +3,8 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07). /// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View { struct CalibrationView: View {
@Bindable var model: CalibrationViewModel @ObservedObject var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel @ObservedObject var wizard: WizardViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -73,7 +73,7 @@ struct CalibrationView: View {
if let url = model.computedCalURL { if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() } .onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle") .accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)") Text("Loaded: \(url.lastPathComponent)")
@@ -96,7 +96,6 @@ struct CalibrationView: View {
} }
} }
} }
.formStyle(.grouped)
HStack { HStack {
Spacer() Spacer()
+12 -13
View File
@@ -1,13 +1,12 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and /// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves. /// compute `.cal` curves.
@MainActor @MainActor
@Observable final class CalibrationViewModel: ObservableObject {
final class CalibrationViewModel {
let workflow: TargetWorkflowViewModel let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel let profile: ProfileWorkflowViewModel
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
// MARK: - Form state // MARK: - Form state
var colourSpace: ColourSpace = .cmyk @Published var colourSpace: ColourSpace = .cmyk
var steps: Int = 21 @Published var steps: Int = 21
var whitePatches: Int = 4 @Published var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false @Published var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320" @Published var inkLimit: String = "320"
var applyToProfile: Bool = false @Published var applyToProfile: Bool = false
var computedCalURL: URL? @Published var computedCalURL: URL?
var calibrationLog: [String] = [] @Published var calibrationLog: [String] = []
var isGenerating = false @Published var isGenerating = false
var isComputing = false @Published var isComputing = false
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow self.workflow = workflow
+30 -7
View File
@@ -69,12 +69,12 @@ internal struct GamutSceneGeometryBuilder {
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b* /// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth. /// (blue-yellow) is depth.
struct GamutView: View { struct GamutView: View {
@State private var viewModel: GamutViewModel @StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {} @State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool @FocusState private var isFocused: Bool
init(profileGamURL: URL? = nil) { init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL)) _viewModel = StateObject(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
} }
var body: some View { var body: some View {
@@ -87,11 +87,6 @@ struct GamutView: View {
) )
.focusable() .focusable()
.focused($isFocused) .focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true } .onAppear { isFocused = true }
VStack { VStack {
@@ -148,6 +143,7 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView context.coordinator.scnView = scnView
context.coordinator.scene = scene context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh) context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.installKeyMonitor()
return scnView return scnView
} }
@@ -168,6 +164,7 @@ private struct GamutSceneView: NSViewRepresentable {
} }
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
nsView.isPlaying = false nsView.isPlaying = false
} }
@@ -175,6 +172,7 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject { final class Coordinator: NSObject {
weak var scnView: SCNView? weak var scnView: SCNView?
weak var scene: SCNScene? weak var scene: SCNScene?
private var keyMonitor: Any?
private let profileNode = SCNNode() private let profileNode = SCNNode()
private let referenceGroup = SCNNode() private let referenceGroup = SCNNode()
@@ -431,6 +429,31 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false scnView?.isPlaying = false
} }
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() { func resetCamera() {
guard let scnView else { return } guard let scnView else { return }
+6 -7
View File
@@ -1,26 +1,25 @@
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
import Observation
/// View model for the native SceneKit gamut viewer. /// View model for the native SceneKit gamut viewer.
/// ///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a /// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory. /// printer/profile `.gam` from the current working directory.
@MainActor @MainActor
@Observable final class GamutViewModel: ObservableObject {
final class GamutViewModel {
/// Parsed reference sRGB gamut mesh. /// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh? @Published var sRGBMesh: GamutMesh?
/// Parsed printer/profile gamut mesh. /// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh? @Published var profileMesh: GamutMesh?
/// User-facing status line. /// User-facing status line.
var status = "Loading gamut…" @Published var status = "Loading gamut…"
/// Closure injected into the SceneKit view to request a camera reset. /// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {} @Published var resetCamera: () -> Void = {}
private let profileGamURL: URL? private let profileGamURL: URL?
+26 -10
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main @main
struct ICCeryApp: App { struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var workflow: TargetWorkflowViewModel @StateObject private var workflow: TargetWorkflowViewModel
init() { init() {
let environment = AppEnvironment.live() let environment = AppEnvironment.live()
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) _workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories() try? AppPaths.ensureDirectories()
// Log level is runtime state apply persisted settings at // Log level is runtime state apply persisted settings at
// startup (#158); the Settings sheet re-applies on save. // startup (#158); the Settings sheet re-applies on save.
@@ -17,15 +17,17 @@ struct ICCeryApp: App {
} }
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") { // metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
RootView(workflow: workflow) RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700) .frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
} }
.defaultSize(width: 1280, height: 800) .commands {
.windowResizability(.contentMinSize) // Single-window app: no File > New window.
.defaultPosition(.center) CommandGroup(replacing: .newItem) {}
}
} }
} }
@@ -37,16 +39,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI `Window` scenes launched by XCTest stay // SwiftUI scenes launched by XCTest stay `.runningBackground`
// `.runningBackground` unless the app takes regular activation // unless the app takes regular activation and orders the window
// and orders the window front (CI run 29804). // front (CI run 29804).
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
for window in NSApp.windows { for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
} }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
private func configureMainWindow(_ window: NSWindow) {
window.setContentSize(NSSize(width: 1280, height: 800))
window.contentMinSize = NSSize(width: 1100, height: 700)
window.center()
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true true
} }
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18#22). /// Stage 3 workflow state and interaction (issues #18#22).
@MainActor @MainActor
@Observable final class MeasurementWorkflowViewModel: ObservableObject {
final class MeasurementWorkflowViewModel {
// MARK: - Authorities // MARK: - Authorities
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
// MARK: - Settings-driven thresholds // MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0 @Published private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0 @Published private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false @Published private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection // MARK: - Instrument detection
var instruments: [InstrumentDevice] = [] @Published var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto @Published var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false @Published var isDetecting = false
var detectionError: String? @Published var detectionError: String?
// MARK: - Chartread session // MARK: - Chartread session
var isChartreadRunning = false @Published var isChartreadRunning = false
var chartreadState: ChartreadState = .idle @Published var chartreadState: ChartreadState = .idle
var currentPrompt: String? @Published var currentPrompt: String?
var requestedWarningKey: String? @Published var requestedWarningKey: String?
var chartreadLog: [String] = [] @Published var chartreadLog: [String] = []
var rows: [ChartreadRow] = [] @Published var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = [] @Published var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false @Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80). /// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice? @Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>? private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging // MARK: - Averaging
var passSnapshots: [URL] = [] @Published var passSnapshots: [URL] = []
var isFinishing = false @Published var isFinishing = false
var finishNotice: Notice? @Published var finishNotice: Notice?
var resumedFromTi2 = false @Published var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
+2 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// `#savePresetDialog` save the live Stage 1/2 form as a custom /// `#savePresetDialog` save the live Stage 1/2 form as a custom
/// preset (issue #11). Names/descriptions render via `Text` only (#114). /// preset (issue #11). Names/descriptions render via `Text` only (#114).
struct SavePresetDialog: View { struct SavePresetDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` list, delete (custom only), import, export. /// `#managePresetsDialog` list, delete (custom only), import, export.
struct ManagePresetsDialog: View { struct ManagePresetsDialog: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -1,23 +1,22 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85). /// CUPS queue selection, bound print panel, and `lp` spool (issues 1215, 17 / #85).
@MainActor @MainActor
@Observable final class PrintSessionViewModel: ObservableObject {
final class PrintSessionViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
var printers: [Printer] = [] @Published var printers: [Printer] = []
var selectedPrinter = "" @Published var selectedPrinter = ""
var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
var selectedTray: Int? @Published var selectedTray: Int?
var selectedMediaType: String? @Published var selectedMediaType: String?
var printOrientation = "portrait" @Published var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:] @Published var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice? @Published var printNotice: Notice?
var isPrinting = false @Published var isPrinting = false
private var printTask: Task<Void, Never>? private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
+30 -31
View File
@@ -1,12 +1,11 @@
import Combine
import Foundation import Foundation
import Observation
import SwiftUI import SwiftUI
import ICCeryCore import ICCeryCore
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install. /// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor @MainActor
@Observable final class ProfileWorkflowViewModel: ObservableObject {
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
// MARK: - Stage 4 form // MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m @Published var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u @Published var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4 @Published var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none @Published var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = "" @Published var fwaCustomPath: String = ""
var illuminant: String = "" @Published var illuminant: String = ""
var observer: String = "" @Published var observer: String = ""
var inputViewingCond: String = "" @Published var inputViewingCond: String = ""
var outputViewingCond: String = "" @Published var outputViewingCond: String = ""
var profileDescription: String = "" @Published var profileDescription: String = ""
var copyright: String = "" @Published var copyright: String = ""
// MARK: - Run state // MARK: - Run state
var isColprofRunning = false @Published var isColprofRunning = false
var colprofLog: [String] = [] @Published var colprofLog: [String] = []
var colprofProgress: String? @Published var colprofProgress: String?
var createdProfileURL: URL? @Published var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28). /// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL? @Published var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24) // MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false @Published var applyCalibration = false
var calibrationFile: String = "" @Published var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25) // MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport? @Published var profcheckReport: ProfcheckReport?
var profcheckWarning: String? @Published var profcheckWarning: String?
var isProfcheckRunning = false @Published var isProfcheckRunning = false
// MARK: - History / drift (issue #26) // MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = [] @Published var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil @Published var driftPrinterFilter: String? = nil
var driftAlert: String? @Published var driftAlert: String?
var isHistoryStoreError: String? @Published var isHistoryStoreError: String?
// MARK: - Install (issue #27) // MARK: - Install (issue #27)
var installResult: InstallProfileResult? @Published var installResult: InstallProfileResult?
var showingInstallCollision = false @Published var showingInstallCollision = false
var installCollisionMessage: String = "" @Published var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions? var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
+11 -5
View File
@@ -5,12 +5,18 @@ import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification /// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell). /// banner pinned to the top (docs/21 §Shell).
struct RootView: View { struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@State private var showingSettings = false @State private var showingSettings = false
@State private var showingAbout = false @State private var showingAbout = false
@State private var showingAllHelp = false @State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard } init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -64,11 +70,11 @@ struct RootView: View {
} }
/// Content for the active wizard stage. Isolated into its own view so that /// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's /// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations. /// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View { private struct WizardStageContent: View {
@Bindable var model: WizardViewModel @ObservedObject var model: WizardViewModel
var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
switch model.stage { switch model.stage {
+1 -2
View File
@@ -4,7 +4,7 @@ import ICCeryCore
/// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with /// Settings sheet (issue #5, docs/21 §Settings). Dark-theme Form with
/// the full v1 field set; ΔE validation shows inline under the fields. /// the full v1 field set; ΔE validation shows inline under the fields.
struct SettingsView: View { struct SettingsView: View {
@State var model = SettingsViewModel() @StateObject var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [ private static let instruments: [(code: String, label: String)] = [
@@ -143,7 +143,6 @@ struct SettingsView: View {
} }
} }
} }
.formStyle(.grouped)
Divider() Divider()
+5 -5
View File
@@ -1,4 +1,5 @@
import AppKit import AppKit
import Combine
import Foundation import Foundation
import ICCeryCore import ICCeryCore
@@ -6,12 +7,11 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a /// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20. /// `settingsDidChange` notification fans out to #20.
@MainActor @MainActor
@Observable final class SettingsViewModel: ObservableObject {
final class SettingsViewModel {
var settings: AppSettings @Published var settings: AppSettings
var validationErrors: [String] = [] @Published var validationErrors: [String] = []
var savedFlash = false @Published var savedFlash = false
private let store: SettingsStore private let store: SettingsStore
private let sink: LogSink private let sink: LogSink
+18 -2
View File
@@ -4,12 +4,28 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 15 stepper. /// select, Calibrate Printer + status chip, and the 15 stepper.
struct SidebarView: View { struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
var onOpenSettings: () -> Void var onOpenSettings: () -> Void
var onOpenAbout: () -> Void var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool @Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard } init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility /// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable. /// identifiers so the UI-test contract stays stable.
struct Stage1View: View { struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
var body: some View { var body: some View {
ScrollView { ScrollView {
+36 -6
View File
@@ -5,7 +5,19 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert /// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3. /// real spooling lands in M3.
struct Stage2View: View { struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -240,7 +252,7 @@ struct Stage2View: View {
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect") .accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _, _ in .onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil workflow.print.selectedMediaType = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() } Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
@@ -328,9 +340,18 @@ struct Stage2View: View {
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel") .accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) { .onAppear { schedulePrinterRefresh() }
// Auto-enumerate once a manifest exists and whenever it .onChange(of: workflow.printtargResult?.pages.count) { _ in
// changes (e.g. resume from .ti2). schedulePrinterRefresh()
}
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
if workflow.print.printers.isEmpty, workflow.printtargResult != nil { if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters() workflow.print.refreshPrinters()
} }
@@ -341,7 +362,16 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button. /// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View { private struct GalleryPageView: View {
let page: GalleryPage let page: GalleryPage
let workflow: TargetWorkflowViewModel @ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
+9 -1
View File
@@ -4,7 +4,15 @@ import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging. /// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View { struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel @ObservedObject var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 4 build an ICC/ICM profile from the canonical `.ti3`. /// Stage 4 build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View { struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel @ObservedObject var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 5 verify the generated profile, track drift, and install. /// Stage 5 verify the generated profile, track drift, and install.
struct Stage5View: View { struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel @ObservedObject var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
+58 -59
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset /// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,8 +10,7 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only /// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back. /// coalesced log batches and completion hop back.
@MainActor @MainActor
@Observable final class TargetWorkflowViewModel: ObservableObject {
final class TargetWorkflowViewModel {
let wizard: WizardViewModel let wizard: WizardViewModel
let environment: AppEnvironment let environment: AppEnvironment
@@ -19,95 +18,95 @@ final class TargetWorkflowViewModel {
// MARK: - Stage 1 form (targen) // MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb { @Published var colourSpace: ColourSpace = .rgb {
didSet { didSet {
guard colourSpace != oldValue else { return } guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08). // CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4 blackPatches = colourSpace == .cmyk ? 0 : 4
} }
} }
var patchPreset: PatchCountPreset = .standard800 @Published var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`. /// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500 @Published var customPatchCount = 2500
var whitePatches = 4 @Published var whitePatches = 4
var blackPatches = 4 @Published var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched // Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in). // control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false @Published var greyStepsEnabled = false
var greySteps = 5 @Published var greySteps = 5
var singleChannelEnabled = false @Published var singleChannelEnabled = false
var singleChannelSteps = 5 @Published var singleChannelSteps = 5
var neutralStepsEnabled = false @Published var neutralStepsEnabled = false
var neutralSteps = 3 @Published var neutralSteps = 3
var neutralConcEnabled = false @Published var neutralConcEnabled = false
var neutralConcentration = 0.50 @Published var neutralConcentration = 0.50
var preconditioningProfile: String? @Published var preconditioningProfile: String?
var highQuality = false @Published var highQuality = false
var adaptationEnabled = false @Published var adaptationEnabled = false
var adaptation = 0.10 @Published var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps @Published var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false @Published var inkLimitEnabled = false
var totalInkLimit = 320 @Published var totalInkLimit = 320
var darkEmphasisEnabled = false @Published var darkEmphasisEnabled = false
var darkEmphasis = 1.0 @Published var darkEmphasis = 1.0
var devicePowerEnabled = false @Published var devicePowerEnabled = false
var devicePower = 1.0 @Published var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60). /// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = "" @Published var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd. /// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL? @Published var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg) // MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1 @Published var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4 @Published var pageSize: PageSize = .a4
var customPageW = 210.0 @Published var customPageW = 210.0
var customPageH = 297.0 @Published var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight @Published var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft /// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here). /// regression must be visible here).
var tiffDpi = 300 @Published var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic @Published var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1 @Published var customSeed = 1
var labelIsCustom = false @Published var labelIsCustom = false
var customLabel = "" @Published var customLabel = ""
var metaPrinter = "" @Published var metaPrinter = ""
var metaInkSet = "" @Published var metaInkSet = ""
var metaDriverPaper = "" @Published var metaDriverPaper = ""
var metaActualPaper = "" @Published var metaActualPaper = ""
// MARK: - Run state // MARK: - Run state
var targenRunning = false @Published var targenRunning = false
var targenLog: [String] = [] @Published var targenLog: [String] = []
var printtargRunning = false @Published var printtargRunning = false
var printtargLog: [String] = [] @Published var printtargLog: [String] = []
var printtargResult: PrinttargResult? @Published var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on /// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data). /// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false @Published var resumedFromTi2 = false
// MARK: - Presets // MARK: - Presets
var presets: [ProfilingPreset] = [] @Published var presets: [ProfilingPreset] = []
var selectedPresetID = "none" @Published var selectedPresetID = "none"
var showingSavePreset = false @Published var showingSavePreset = false
var showingManagePresets = false @Published var showingManagePresets = false
var savePresetName = "" @Published var savePresetName = ""
var savePresetDesc = "" @Published var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists /// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes. /// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel @Published var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists /// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values. /// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel @Published var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow. /// Stage 0 calibration workflow.
var calibration: CalibrationViewModel! @Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session. /// Stage 2 unmanaged print session.
var print: PrintSessionViewModel! @Published var print: PrintSessionViewModel!
init(environment: AppEnvironment = .live()) { init(environment: AppEnvironment = .live()) {
self.environment = environment self.environment = environment
+13 -14
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation import Foundation
import Observation
import ICCeryCore import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06). /// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,47 +10,46 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` /// `wizard_state.json`; unlocks come from `ArtefactProbe.verify`
/// navigation is disk, not buttons. /// navigation is disk, not buttons.
@MainActor @MainActor
@Observable final class WizardViewModel: ObservableObject {
final class WizardViewModel {
// MARK: - wizardState fields (persisted) // MARK: - wizardState fields (persisted)
var stage: WizardStage { @Published var stage: WizardStage {
didSet { if stage != oldValue { persist() } } didSet { if stage != oldValue { persist() } }
} }
/// `wizardState.basename` empty until a real artefact names it (#60). /// `wizardState.basename` empty until a real artefact names it (#60).
var basename: String { @Published var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } } didSet { if basename != oldValue { refreshGating(); persist() } }
} }
/// `wizardState.cwd` resolved via `resolveSafeCwd` (#59). /// `wizardState.cwd` resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? { @Published var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } } didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
} }
var printerName: String? { @Published var printerName: String? {
didSet { if printerName != oldValue { persist() } } didSet { if printerName != oldValue { persist() } }
} }
var sessionMode: SessionMode { @Published var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } } didSet { if sessionMode != oldValue { persist() } }
} }
/// `profileBasename` may differ after a `.ti3` import (#94). /// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? { @Published var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } } didSet { if profileBasename != oldValue { persist() } }
} }
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29). /// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String { @Published var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } } didSet { if calibrationOriginalBasename != oldValue { persist() } }
} }
// MARK: - Ephemeral // MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`). /// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice? @Published var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`. /// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts() @Published private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28). /// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false @Published var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference. /// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL? @Published var gamutProfileURL: URL?
private let stateStore: WizardStateStore private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>? private var noticeDismissTask: Task<Void, Never>?
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ArgyllRunner Calibration") final class ArgyllRunnerCalibrationTests: XCTestCase {
struct ArgyllRunnerCalibrationTests {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner { private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root return root
} }
@Test("Calibration targen produces CAL_*.ti1") func testCalibrationTargenProducesTi1() async throws {
func calibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let config = CalibrationTargenConfig( let config = CalibrationTargenConfig(
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1") XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Calibration targen from foo runs as process id targen_CAL_foo") func testCalibrationTargenProcessId() async throws {
func calibrationTargenProcessId() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let pm = ProcessManager() let pm = ProcessManager()
let runner = makeRunner(processManager: pm) let runner = makeRunner(processManager: pm)
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config) let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_foo.ti1") XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
#expect(await sawExit.value) let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal captured run creates .cal") func testPrintcalProducesCal() async throws {
func printcalProducesCal() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
let runner = makeRunner() let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal") let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config) let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal") XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("printcal failure throws toolFailed") func testPrintcalFailureThrows() async throws {
func printcalFailureThrows() async throws {
let testRoot = try makeTestDir() let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) } defer { try? FileManager.default.removeItem(at: testRoot) }
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output outputURL: output
) )
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
_ = try await runner.runPrintcal(config: config) _ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
} }
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
final class LogHolder: @unchecked Sendable { final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
} }
} }
@Suite("ArgyllRunner colprof") final class ArgyllRunnerColprofTests: XCTestCase {
struct ArgyllRunnerColprofTests {
@Test("Mock colprof produces .icc") func testColprofProducesIcc() async throws {
func colprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath) let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() .deletingLastPathComponent()
.deletingLastPathComponent() .deletingLastPathComponent()
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "testrun.icc") XCTAssertEqual(url.lastPathComponent, "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") }) XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot) try? FileManager.default.removeItem(at: testRoot)
} }
@Test("Failing colprof throws toolFailed with code and logs") func testColprofFailureThrowsToolFailed() async throws {
func colprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)") .appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
) )
let config = ColprofConfig(basename: "failrun", workingDirectory: dir) let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "colprof", code: 4, logs: ["colprof broke"])) {
try await runner.runColprof(config: config) try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
} }
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79). /// Focused contracts for the shared `runStreamingTool` loop (#79).
@@ -7,8 +7,7 @@ import Testing
/// Every test uses a per-test temporary directory, unique basenames, /// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` no shared UI fixture scripts and no /// and a fresh `ProcessManager` no shared UI fixture scripts and no
/// process-environment mutation. /// process-environment mutation.
@Suite("ArgyllRunner streaming loop contracts") final class ArgyllRunnerStreamingLoopTests: XCTestCase {
struct ArgyllRunnerStreamingLoopTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir)) binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
} }
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines") func testNonZeroExitThrowsToolFailed() async throws {
func nonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
do { do {
_ = try await runner.runTargen(config: config) _ = try await runner.runTargen(config: config)
Issue.record("Expected toolFailed") XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError { } catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else { guard case .toolFailed(let tool, let code, let logs) = error else {
Issue.record("Expected toolFailed, got \(error)") XCTFail("Expected toolFailed, got \(error)")
return return
} }
#expect(tool == "targen") XCTAssertEqual(tool, "targen")
#expect(code == 3) XCTAssertEqual(code, 3)
#expect(logs.contains("Generating patches...")) XCTAssertTrue(logs.contains("Generating patches..."))
#expect(logs.contains("targen: too few patches")) XCTAssertTrue(logs.contains("targen: too few patches"))
} }
} }
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path") func testZeroExitMissingArtefact() async throws {
func zeroExitMissingArtefact() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
colourSpace: .rgb, patchCount: 800, whitePatches: 4, colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir) blackPatches: 4, basename: "gone", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
} }
} }
@Test("Immediate exit after one stdout line still delivers the line and succeeds") func testImmediateExitDeliversLine() async throws {
func immediateExitDeliversLine() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """ try writeMock("targen", """
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runTargen(config: config) { batch in let url = try await runner.runTargen(config: config) { batch in
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "quick.ti1") XCTAssertEqual(url.lastPathComponent, "quick.ti1")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("only line")) XCTAssertTrue(holder.lines.contains("only line"))
} }
@Test("colprof unterminated progress fragment reaches onLogBatch before exit") func testColprofPartialLineFlush() async throws {
func colprofPartialLineFlush() async throws {
let dir = try makeTempDir() let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) } defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps // The fragment is printed without a newline, then the mock sleeps
@@ -129,13 +126,13 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runColprof(config: config) { batch in let url = try await runner.runColprof(config: config) { batch in
holder.append(batch) holder.append(batch)
} }
#expect(url.lastPathComponent == "frag.icc") XCTAssertEqual(url.lastPathComponent, "frag.icc")
#expect(FileManager.default.fileExists(atPath: url.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("Doing gamut mapping")) XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
} }
@Test("toolFailed maps each tool to its user-facing description", func testToolDescriptions() {
arguments: [ let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"), (tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"), (tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"), (tool: "colprof", expected: "Profile creation failed: boom"),
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
(tool: "applycal", expected: "Apply calibration failed: boom"), (tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"), (tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"), (tool: "profcheck", expected: "Profile verification failed: boom"),
]) ]
func toolDescriptions(tool: String, expected: String) { for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"]) let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
#expect(error.errorDescription == expected) XCTAssertEqual(error.errorDescription, expected)
}
} }
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs") func testGenericFallbacks() {
func genericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"]) let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
#expect(unknown.errorDescription == "Process exited with code 7") XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: []) let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
#expect(emptyLogs.errorDescription XCTAssertEqual(emptyLogs.errorDescription,
== "Profile creation failed: exited with code 2") "Profile creation failed: exited with code 2")
} }
} }
@@ -1,9 +1,8 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("CalibrationStore") final class CalibrationStoreTests: XCTestCase {
struct CalibrationStoreTests {
private static let sampleCal = """ private static let sampleCal = """
CTI3 CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA END_DATA
""" """
@Test("Loads metadata and curves from .cal") func testParseCal() async throws {
func parseCal() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal") .appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
let data = await store.data let data = await store.data
#expect(data?.colorRep == "RGB") XCTAssertEqual(data?.colorRep, "RGB")
#expect(data?.descriptor == "Test printer") XCTAssertEqual(data?.descriptor, "Test printer")
#expect(data?.maxTac == 300) XCTAssertEqual(data?.maxTac, 300)
#expect(data?.curves.count == 3) XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" } let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255]) XCTAssertEqual(r?.output, [0, 64, 255])
} }
@Test("Staleness is true for a very old calibration") func testStaleCalibration() async throws {
func staleCalibration() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal") .appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0) let store = CalibrationStore(staleDays: 0)
try await store.load(url: url) try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other") let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
@Test("Printer mismatch is flagged as stale") func testPrinterMismatch() async throws {
func printerMismatch() async throws {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal") .appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url) try await store.load(url: url)
await store.setPrinterName("Printer A") await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B") let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true) XCTAssertEqual(stale, true)
} }
} }
@@ -71,10 +71,14 @@ final class ColorSyncSuppressorTests: XCTestCase {
s.modeResolver = { name in s.modeResolver = { name in
if Self.missing.contains(name) { return nil } if Self.missing.contains(name) { return nil }
Self.currentSymbol = name Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture spell the (final) class name instead.
return { _, modeArg in return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String)) ColorSyncSuppressorTests.recorded.append(
if let ok = Self.succeeding, (ColorSyncSuppressorTests.currentSymbol, modeArg as String))
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0 return 0
} }
return 1 return 1
+14 -21
View File
@@ -1,26 +1,23 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("JSONFileStore") final class JSONFileStoreTests: XCTestCase {
struct JSONFileStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json") .appendingPathComponent("json-store-\(UUID().uuidString).json")
} }
@Test("Missing file returns default") func testMissingFileDefaults() throws {
func missingFileDefaults() throws {
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: tempURL(), fileURL: tempURL(),
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { .default } defaultValue: { .default }
) )
#expect(try store.load() == .default) XCTAssertEqual(try store.load(), .default)
} }
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes") func testCorruptDefaults() 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)
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
corrupt: .replaceWithDefault, corrupt: .replaceWithDefault,
defaultValue: { .default } defaultValue: { .default }
) )
#expect(try store.load() == .default) XCTAssertEqual(try store.load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "{ not json") XCTAssertEqual(kept, "{ not json")
} }
@Test("Corrupt file with throwCorrupt throws and leaves bytes") func testCorruptThrows() throws {
func corruptThrows() 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)
let store = JSONFileStore<[Int]>( let store = JSONFileStore<[Int]>(
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
corrupt: .throwCorrupt, corrupt: .throwCorrupt,
defaultValue: { [] } defaultValue: { [] }
) )
#expect(throws: DecodingError.self) { XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
_ = try store.load()
}
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json") XCTAssertEqual(kept, "not json")
} }
@Test("Pretty sorted keys") func testPrettySorted() throws {
func prettySorted() throws {
let url = tempURL() let url = tempURL()
let store = JSONFileStore<AppSettings>( let store = JSONFileStore<AppSettings>(
fileURL: url, fileURL: url,
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
) )
try store.save(.default) try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n")) XCTAssertTrue(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\"")) XCTAssertTrue(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys. // Lexical key sorting: ascending order of top-level keys.
let keys = [ let keys = [
"ask_before_overwrite_profile", "ask_before_overwrite_profile",
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
var lastIndex = text.startIndex var lastIndex = text.startIndex
for key in keys { for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else { guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
Issue.record("missing or out-of-order key \(key)") XCTFail("missing or out-of-order key \(key)")
return return
} }
lastIndex = range.upperBound lastIndex = range.upperBound
+165 -188
View File
@@ -1,116 +1,98 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("ProfilingPreset") final class ProfilingPresetTests: XCTestCase {
struct ProfilingPresetTests {
@Test("snake_case keys round-trip through Codable") func testRoundTrip() throws {
func roundTrip() throws {
var p = PresetCatalog.highQualityCMYK var p = PresetCatalog.highQualityCMYK
p.colprofInputViewingCond = "D50_2" p.colprofInputViewingCond = "D50_2"
let data = try JSONEncoder().encode(p) let data = try JSONEncoder().encode(p)
let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
#expect(decoded == p) XCTAssertEqual(decoded, p)
// Spot-check the wire format. // Spot-check the wire format.
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["colour_space"] as? String == "cmyk") XCTAssertEqual(obj["colour_space"] as? String, "cmyk")
#expect(obj["patch_count"] as? Int == 1500) XCTAssertEqual(obj["patch_count"] as? Int, 1500)
#expect(obj["total_ink_limit"] as? Int == 320) XCTAssertEqual(obj["total_ink_limit"] as? Int, 320)
#expect(obj["bit_depth"] as? Int == 16) XCTAssertEqual(obj["bit_depth"] as? Int, 16)
#expect(obj["colprof_input_viewing_cond"] as? String == "D50_2") XCTAssertEqual(obj["colprof_input_viewing_cond"] as? String, "D50_2")
} }
@Test("Unknown keys ignored; missing required field fails") func testSchemaTolerance() throws {
func schemaTolerance() throws {
let json = """ let json = """
{"id":"x","name":"N","colour_space":"rgb","patch_count":10, {"id":"x","name":"N","colour_space":"rgb","patch_count":10,
"white_patches":1,"black_patches":1,"instrument":"i1", "white_patches":1,"black_patches":1,"instrument":"i1",
"page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42}
""".data(using: .utf8)! """.data(using: .utf8)!
let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json)
#expect(ok.id == "x") XCTAssertEqual(ok.id, "x")
let missing = """ let missing = """
{"id":"x","name":"N","colour_space":"rgb"} {"id":"x","name":"N","colour_space":"rgb"}
""".data(using: .utf8)! """.data(using: .utf8)!
#expect(throws: DecodingError.self) { XCTAssertThrowsError(try JSONDecoder().decode(ProfilingPreset.self, from: missing)) { error in XCTAssertTrue(error is DecodingError) }
try JSONDecoder().decode(ProfilingPreset.self, from: missing) }
func testValidation() {
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
XCTAssertThrowsError(try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()) { error in XCTAssertTrue(error is ProfilingPreset.ValidationError) }
} }
} }
@Test("Validation rejects bad colour space / dpi / bit depth") final class PresetCatalogTests: XCTestCase {
func validation() {
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", colourSpace: "lab").validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", dpi: 10).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", bitDepth: 12).validated()
}
#expect(throws: ProfilingPreset.ValidationError.self) {
try ProfilingPreset(id: "a", name: "n", patchCount: 0).validated()
}
}
}
@Suite("PresetCatalog") func testBuiltIns() {
struct PresetCatalogTests { XCTAssertEqual(PresetCatalog.builtIns.count, 4)
@Test("Four built-ins with the documented values")
func builtIns() {
#expect(PresetCatalog.builtIns.count == 4)
let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) }) let byID = Dictionary(uniqueKeysWithValues: PresetCatalog.builtIns.map { ($0.id, $0) })
let std = byID["preset-std-rgb"]! let std = byID["preset-std-rgb"]!
#expect(std.colourSpace == "rgb" && std.patchCount == 800 XCTAssertTrue(std.colourSpace == "rgb" && std.patchCount == 800
&& std.pageSize == "A4" && std.bitDepth == 8 && std.pageSize == "A4" && std.bitDepth == 8
&& std.dpi == 300 && std.colprofQuality == "m" && std.dpi == 300 && std.colprofQuality == "m"
&& std.whitePatches == 4 && std.blackPatches == 4) && std.whitePatches == 4 && std.blackPatches == 4)
let hq = byID["preset-hq-cmyk"]! let hq = byID["preset-hq-cmyk"]!
#expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500 XCTAssertTrue(hq.colourSpace == "cmyk" && hq.patchCount == 1500
&& hq.pageSize == "A3" && hq.bitDepth == 16 && hq.pageSize == "A3" && hq.bitDepth == 16
&& hq.dpi == 300 && hq.colprofQuality == "h" && hq.dpi == 300 && hq.colprofQuality == "h"
&& hq.totalInkLimit == 320 && hq.blackPatches == 8) && hq.totalInkLimit == 320 && hq.blackPatches == 8)
let draft = byID["preset-draft-rgb"]! let draft = byID["preset-draft-rgb"]!
#expect(draft.colourSpace == "rgb" && draft.patchCount == 400 XCTAssertTrue(draft.colourSpace == "rgb" && draft.patchCount == 400
&& draft.pageSize == "A4" && draft.bitDepth == 8 && draft.pageSize == "A4" && draft.bitDepth == 8
&& draft.dpi == 150 && draft.colprofQuality == "l") && draft.dpi == 150 && draft.colprofQuality == "l")
let ultra = byID["preset-ultra-rgb"]! let ultra = byID["preset-ultra-rgb"]!
#expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 XCTAssertTrue(ultra.colourSpace == "rgb" && ultra.patchCount == 2500
&& ultra.pageSize == "A3" && ultra.bitDepth == 16 && ultra.pageSize == "A3" && ultra.bitDepth == 16
&& ultra.dpi == 300 && ultra.colprofQuality == "u" && ultra.dpi == 300 && ultra.colprofQuality == "u"
&& ultra.ofpsHighQuality == true && ultra.ofpsHighQuality == true
&& ultra.whitePatches == 6 && ultra.blackPatches == 6) && ultra.whitePatches == 6 && ultra.blackPatches == 6)
for p in PresetCatalog.builtIns { for p in PresetCatalog.builtIns {
#expect(p.instrument == "i1") XCTAssertEqual(p.instrument, "i1")
#expect(p.colprofFwa == "D50") XCTAssertEqual(p.colprofFwa, "D50")
#expect(p.randomSeed == 1) XCTAssertEqual(p.randomSeed, 1)
#expect(p.noRandomize == false) XCTAssertEqual(p.noRandomize, false)
#expect(p.colprofAlgorithm == "l") XCTAssertEqual(p.colprofAlgorithm, "l")
} }
} }
@Test("Custom presets overlay by id; built-ins are not deletable") func testOverlay() {
func overlay() {
let custom = ProfilingPreset( let custom = ProfilingPreset(
id: "preset-std-rgb", name: "Shadowed", patchCount: 42) id: "preset-std-rgb", name: "Shadowed", patchCount: 42)
let all = PresetCatalog.all(custom: [custom]) let all = PresetCatalog.all(custom: [custom])
#expect(all.count == 4) XCTAssertEqual(all.count, 4)
#expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42) XCTAssertEqual(all.first { $0.id == "preset-std-rgb" }?.patchCount, 42)
#expect(PresetCatalog.isBuiltIn("preset-std-rgb")) XCTAssertTrue(PresetCatalog.isBuiltIn("preset-std-rgb"))
#expect(!PresetCatalog.isBuiltIn("custom-1")) XCTAssertFalse(PresetCatalog.isBuiltIn("custom-1"))
} }
} }
@Suite("PresetStore") final class PresetStoreTests: XCTestCase {
struct PresetStoreTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -119,58 +101,52 @@ struct PresetStoreTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("CRUD + export/import round-trip") func testCrud() throws {
func crud() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150)
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.all().count == 5) XCTAssertEqual(store.all().count, 5)
p.name = "Renamed" p.name = "Renamed"
try store.saveCustom(p) try store.saveCustom(p)
#expect(store.customs().count == 1) XCTAssertEqual(store.customs().count, 1)
#expect(store.customs()[0].name == "Renamed") XCTAssertEqual(store.customs()[0].name, "Renamed")
let data = try store.export(p) let data = try store.export(p)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.name == "Renamed") XCTAssertEqual(imported.name, "Renamed")
#expect(imported.dpi == 150) XCTAssertEqual(imported.dpi, 150)
#expect(try store.deleteCustom(id: "custom-x")) XCTAssertTrue(try store.deleteCustom(id: "custom-x"))
#expect(store.customs().isEmpty) XCTAssertTrue(store.customs().isEmpty)
#expect(try !store.deleteCustom(id: "preset-std-rgb")) XCTAssertFalse(try store.deleteCustom(id: "preset-std-rgb"))
} }
@Test("Import rewrites a built-in id to a fresh custom id") func testImportBuiltinCollision() throws {
func importBuiltinCollision() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.standardRGB) let data = try store.export(PresetCatalog.standardRGB)
let imported = try store.import(data) let imported = try store.import(data)
#expect(imported.id.hasPrefix("custom-")) XCTAssertTrue(imported.id.hasPrefix("custom-"))
#expect(!PresetCatalog.isBuiltIn(imported.id)) XCTAssertFalse(PresetCatalog.isBuiltIn(imported.id))
} }
@Test("Built-ins are immutable through saveCustom") func testBuiltInImmutable() throws {
func builtInImmutable() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
var shadowed = PresetCatalog.standardRGB var shadowed = PresetCatalog.standardRGB
shadowed.name = "Hacked" shadowed.name = "Hacked"
#expect(throws: PresetStore.PresetStoreError.self) { XCTAssertThrowsError(try store.saveCustom(shadowed)) { error in XCTAssertTrue(error is PresetStore.PresetStoreError) }
try store.saveCustom(shadowed)
}
} }
} }
@Suite("AppSettings preset migration") final class PresetMigrationTests: XCTestCase {
struct PresetMigrationTests {
private func tempSettingsURL() throws -> URL { private func tempSettingsURL() throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -179,8 +155,7 @@ struct PresetMigrationTests {
return dir.appendingPathComponent("settings.json") return dir.appendingPathComponent("settings.json")
} }
@Test("Legacy M1 custom_presets migrate to typed schema") func testLegacyMigration() throws {
func legacyMigration() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let legacy = """ let legacy = """
@@ -194,20 +169,19 @@ struct PresetMigrationTests {
try legacy.write(to: url) try legacy.write(to: url)
let settings = SettingsStore(fileURL: url).load() let settings = SettingsStore(fileURL: url).load()
#expect(settings.customPresets.count == 1) XCTAssertEqual(settings.customPresets.count, 1)
let p = settings.customPresets[0] let p = settings.customPresets[0]
#expect(p.name == "Old One") XCTAssertEqual(p.name, "Old One")
#expect(p.id.hasPrefix("custom-0-")) XCTAssertTrue(p.id.hasPrefix("custom-0-"))
#expect(p.colourSpace == "cmyk") XCTAssertEqual(p.colourSpace, "cmyk")
#expect(p.patchCount == 900) XCTAssertEqual(p.patchCount, 900)
#expect(p.dpi == 150) XCTAssertEqual(p.dpi, 150)
#expect(p.bitDepth == 16) XCTAssertEqual(p.bitDepth, 16)
#expect(p.instrument == "p3") XCTAssertEqual(p.instrument, "p3")
#expect(p.pageSize == "A3") XCTAssertEqual(p.pageSize, "A3")
} }
@Test("Typed presets load and re-save as the typed schema") func testTypedRoundTrip() throws {
func typedRoundTrip() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
@@ -215,50 +189,45 @@ struct PresetMigrationTests {
s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)]
try store.save(s) try store.save(s)
let loaded = store.load() let loaded = store.load()
#expect(loaded.customPresets.first?.patchCount == 700) XCTAssertEqual(loaded.customPresets.first?.patchCount, 700)
} }
@Test("Draft preset dpi=150 survives Codable + settings round-trip") func testDraftDPI() throws {
func draftDPI() throws {
let url = try tempSettingsURL() let url = try tempSettingsURL()
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) let store = PresetStore(settingsStore: SettingsStore(fileURL: url))
let data = try store.export(PresetCatalog.draftRGB) let data = try store.export(PresetCatalog.draftRGB)
let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]
#expect(obj["dpi"] as? Int == 150) XCTAssertEqual(obj["dpi"] as? Int, 150)
let back = try store.import(data) let back = try store.import(data)
#expect(back.dpi == 150) XCTAssertEqual(back.dpi, 150)
} }
} }
@Suite("Preset mapping") final class PresetMappingTests: XCTestCase {
struct PresetMappingTests { func testDraftDpi() {
@Test("Draft 150 DPI maps into PrinttargConfig")
func draftDpi() {
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: PresetCatalog.draftRGB, preset: PresetCatalog.draftRGB,
basename: "t", basename: "t",
workingDirectory: nil, workingDirectory: nil,
calibrationFile: nil calibrationFile: nil
) )
#expect(cfg.dpi == 150) XCTAssertEqual(cfg.dpi, 150)
#expect(cfg.layoutOrder == .deterministic) XCTAssertEqual(cfg.layoutOrder, .deterministic)
} }
@Test("Nil optional targen fields stay nil") func testOptionalNil() {
func optionalNil() {
let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800) let preset = ProfilingPreset(id: "x", name: "n", patchCount: 800)
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.greySteps == nil) XCTAssertNil(cfg.greySteps)
#expect(cfg.singleChannelSteps == nil) XCTAssertNil(cfg.singleChannelSteps)
#expect(cfg.neutralSteps == nil) XCTAssertNil(cfg.neutralSteps)
#expect(cfg.totalInkLimit == nil) XCTAssertNil(cfg.totalInkLimit)
#expect(cfg.darkEmphasis == nil) XCTAssertNil(cfg.darkEmphasis)
#expect(cfg.devicePower == nil) XCTAssertNil(cfg.devicePower)
} }
@Test("Custom page and FWA survive a config round-trip") func testRoundTripConfigs() {
func roundTripConfigs() {
var preset = PresetCatalog.highQualityCMYK var preset = PresetCatalog.highQualityCMYK
preset.pageSize = "210x297" preset.pageSize = "210x297"
preset.colprofFwa = "D50" preset.colprofFwa = "D50"
@@ -268,9 +237,9 @@ struct PresetMappingTests {
preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil preset: preset, basename: "job", workingDirectory: nil, calibrationFile: nil
) )
let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "job", workingDirectory: nil)
#expect(printtarg.pageSize == .custom) XCTAssertEqual(printtarg.pageSize, .custom)
#expect(printtarg.customPageWidth == 210) XCTAssertEqual(printtarg.customPageWidth, 210)
#expect(colprof.fwa == "D50") XCTAssertEqual(colprof.fwa, "D50")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
name: preset.name, name: preset.name,
@@ -281,15 +250,14 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
#expect(back.dpi == preset.dpi) XCTAssertEqual(back.dpi, preset.dpi)
#expect(back.colourSpace == "cmyk") XCTAssertEqual(back.colourSpace, "cmyk")
#expect(back.pageSize == "210x297") XCTAssertEqual(back.pageSize, "210x297")
#expect(back.colprofFwa == "D50") XCTAssertEqual(back.colprofFwa, "D50")
#expect(back.greySteps == nil) XCTAssertNil(back.greySteps)
} }
@Test("Full preset round-trips through all three configs with every field asserted") func testFullRoundTrip() {
func fullRoundTrip() {
let preset = ProfilingPreset( let preset = ProfilingPreset(
id: "custom-full", id: "custom-full",
name: "Full", name: "Full",
@@ -328,21 +296,21 @@ struct PresetMappingTests {
) )
let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil) let targen = TargenConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(targen.colourSpace == .cmyk) XCTAssertEqual(targen.colourSpace, .cmyk)
#expect(targen.patchCount == 1500) XCTAssertEqual(targen.patchCount, 1500)
#expect(targen.whitePatches == 6) XCTAssertEqual(targen.whitePatches, 6)
#expect(targen.blackPatches == 8) XCTAssertEqual(targen.blackPatches, 8)
#expect(targen.greySteps == 9) XCTAssertEqual(targen.greySteps, 9)
#expect(targen.singleChannelSteps == 7) XCTAssertEqual(targen.singleChannelSteps, 7)
#expect(targen.neutralSteps == 4) XCTAssertEqual(targen.neutralSteps, 4)
#expect(targen.neutralConcentration == 0.7) XCTAssertEqual(targen.neutralConcentration, 0.7)
#expect(targen.preconditioningProfile == "/tmp/pre.icm") XCTAssertEqual(targen.preconditioningProfile, "/tmp/pre.icm")
#expect(targen.ofpsHighQuality == true) XCTAssertEqual(targen.ofpsHighQuality, true)
#expect(targen.ofpsAdaptation == 0.2) XCTAssertEqual(targen.ofpsAdaptation, 0.2)
#expect(targen.fullSpreadAlgorithm == .uniformRandom) XCTAssertEqual(targen.fullSpreadAlgorithm, .uniformRandom)
#expect(targen.totalInkLimit == 280) XCTAssertEqual(targen.totalInkLimit, 280)
#expect(targen.darkEmphasis == 1.3) XCTAssertEqual(targen.darkEmphasis, 1.3)
#expect(targen.devicePower == 1.2) XCTAssertEqual(targen.devicePower, 1.2)
let printtarg = PrinttargConfig( let printtarg = PrinttargConfig(
preset: preset, preset: preset,
@@ -350,25 +318,25 @@ struct PresetMappingTests {
workingDirectory: nil, workingDirectory: nil,
calibrationFile: preset.calibrationFile calibrationFile: preset.calibrationFile
) )
#expect(printtarg.instrument == .p3) XCTAssertEqual(printtarg.instrument, .p3)
#expect(printtarg.pageSize == .custom) XCTAssertEqual(printtarg.pageSize, .custom)
#expect(printtarg.customPageWidth == 250) XCTAssertEqual(printtarg.customPageWidth, 250)
#expect(printtarg.customPageHeight == 300) XCTAssertEqual(printtarg.customPageHeight, 300)
#expect(printtarg.bitDepth == .sixteen) XCTAssertEqual(printtarg.bitDepth, .sixteen)
#expect(printtarg.dpi == 360) XCTAssertEqual(printtarg.dpi, 360)
#expect(printtarg.layoutOrder == .customSeed) XCTAssertEqual(printtarg.layoutOrder, .customSeed)
#expect(printtarg.customSeed == 42) XCTAssertEqual(printtarg.customSeed, 42)
#expect(printtarg.calibrationFile == "/tmp/a.cal") XCTAssertEqual(printtarg.calibrationFile, "/tmp/a.cal")
let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil) let colprof = ColprofConfig(preset: preset, basename: "j", workingDirectory: nil)
#expect(colprof.algorithm == "x") XCTAssertEqual(colprof.algorithm, "x")
#expect(colprof.quality == "u") XCTAssertEqual(colprof.quality, "u")
#expect(colprof.intent == "p") XCTAssertEqual(colprof.intent, "p")
#expect(colprof.fwa == "D65") XCTAssertEqual(colprof.fwa, "D65")
#expect(colprof.illuminant == "D65") XCTAssertEqual(colprof.illuminant, "D65")
#expect(colprof.observer == "1931_2") XCTAssertEqual(colprof.observer, "1931_2")
#expect(colprof.inputViewingCond == "D50_2") XCTAssertEqual(colprof.inputViewingCond, "D50_2")
#expect(colprof.outputViewingCond == "D65_2") XCTAssertEqual(colprof.outputViewingCond, "D65_2")
let back = ProfilingPreset( let back = ProfilingPreset(
id: preset.id, id: preset.id,
@@ -380,11 +348,12 @@ struct PresetMappingTests {
calibrationFile: preset.calibrationFile, calibrationFile: preset.calibrationFile,
applyCalibration: preset.applyCalibration applyCalibration: preset.applyCalibration
) )
#expect(back == preset) XCTAssertEqual(back, preset)
} }
@Test("Every full-spread algorithm round-trips", arguments: [ func testFullSpreadAlgorithms() {
("ofps", FullSpreadAlgorithm.ofps), let cases: [(String, FullSpreadAlgorithm)] = [
("ofps", .ofps),
("t", .target), ("t", .target),
("r", .random), ("r", .random),
("R", .uniformRandom), ("R", .uniformRandom),
@@ -392,16 +361,16 @@ struct PresetMappingTests {
("Q", .uniformQuasiRandom), ("Q", .uniformQuasiRandom),
("i", .invertedQuasiRandom), ("i", .invertedQuasiRandom),
("I", .invertedUniformQuasiRandom) ("I", .invertedUniformQuasiRandom)
]) ]
func fullSpreadAlgorithms(value: String, expected: FullSpreadAlgorithm) { for (value, expected) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.fullSpreadAlgorithm = value preset.fullSpreadAlgorithm = value
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
if expected == .ofps { if expected == .ofps {
// ofps is the default no flag emitted, stored value is nil. // ofps is the default no flag emitted, stored value is nil.
#expect(cfg.fullSpreadAlgorithm == nil) XCTAssertNil(cfg.fullSpreadAlgorithm)
} else { } else {
#expect(cfg.fullSpreadAlgorithm == expected) XCTAssertEqual(cfg.fullSpreadAlgorithm, expected)
} }
let back = ProfilingPreset( let back = ProfilingPreset(
id: "x", name: "n", description: "", id: "x", name: "n", description: "",
@@ -414,30 +383,31 @@ struct PresetMappingTests {
calibrationFile: nil, calibrationFile: nil,
applyCalibration: nil applyCalibration: nil
) )
#expect(back.fullSpreadAlgorithm == value) XCTAssertEqual(back.fullSpreadAlgorithm, value)
}
} }
@Test("Explicit ofpsHighQuality=false is preserved, distinct from nil") func testOfpsHighQualityFalse() {
func ofpsHighQualityFalse() {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.ofpsHighQuality = false preset.ofpsHighQuality = false
let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let cfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(cfg.ofpsHighQuality == false) XCTAssertEqual(cfg.ofpsHighQuality, false)
preset.ofpsHighQuality = nil preset.ofpsHighQuality = nil
let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil) let nilCfg = TargenConfig(preset: preset, basename: "t", workingDirectory: nil)
#expect(nilCfg.ofpsHighQuality == nil) XCTAssertNil(nilCfg.ofpsHighQuality)
} }
@Test("noRandomize/seed layout mapping rules", arguments: [ func testLayoutMapping() {
(true, nil, LayoutOrder.raster, 1), let cases: [(Bool?, Int?, LayoutOrder, Int)] = [
(true, nil, .raster, 1),
(true, 7, .raster, 7), (true, 7, .raster, 7),
(false, nil, .deterministic, 1), (false, nil, .deterministic, 1),
(false, 1, .deterministic, 1), (false, 1, .deterministic, 1),
(nil, 1, .deterministic, 1), (nil, 1, .deterministic, 1),
(false, 5, .customSeed, 5) (false, 5, .customSeed, 5)
] as [(Bool?, Int?, LayoutOrder, Int)]) ]
func layoutMapping(noRandomize: Bool?, seed: Int?, layout: LayoutOrder, expectedSeed: Int) { for (noRandomize, seed, layout, expectedSeed) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.noRandomize = noRandomize preset.noRandomize = noRandomize
preset.randomSeed = seed preset.randomSeed = seed
@@ -445,31 +415,35 @@ struct PresetMappingTests {
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
#expect(cfg.layoutOrder == layout) XCTAssertEqual(cfg.layoutOrder, layout)
#expect(cfg.customSeed == expectedSeed) XCTAssertEqual(cfg.customSeed, expectedSeed)
}
} }
@Test("Custom page fallback matrix", arguments: [ func testCustomPageFallback() {
("250x300", PageSize.custom, 250.0, 300.0), let cases: [(String, PageSize, Double, Double)] = [
("250x300", .custom, 250.0, 300.0),
("50x50", .custom, 50.0, 50.0), ("50x50", .custom, 50.0, 50.0),
("foo", .a4, 210.0, 297.0), ("foo", .a4, 210.0, 297.0),
("30x40", .a4, 210.0, 297.0), ("30x40", .a4, 210.0, 297.0),
("210x", .a4, 210.0, 297.0) ("210x", .a4, 210.0, 297.0)
] as [(String, PageSize, Double, Double)]) ]
func customPageFallback(raw: String, page: PageSize, w: Double, h: Double) { for (raw, page, w, h) in cases {
var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100) var preset = ProfilingPreset(id: "x", name: "n", patchCount: 100)
preset.pageSize = raw preset.pageSize = raw
let cfg = PrinttargConfig( let cfg = PrinttargConfig(
preset: preset, basename: "t", preset: preset, basename: "t",
workingDirectory: nil, calibrationFile: nil workingDirectory: nil, calibrationFile: nil
) )
#expect(cfg.pageSize == page) XCTAssertEqual(cfg.pageSize, page)
#expect(cfg.customPageWidth == w) XCTAssertEqual(cfg.customPageWidth, w)
#expect(cfg.customPageHeight == h) XCTAssertEqual(cfg.customPageHeight, h)
}
} }
@Test("FWA preset value → selection matrix", arguments: [ func testFwaToSelection() {
(nil, ColprofFwaSelection.none), let cases: [(String?, ColprofFwaSelection)] = [
(nil, .none),
("none", .none), ("none", .none),
("NONE", .none), ("NONE", .none),
("", .empty), ("", .empty),
@@ -478,19 +452,22 @@ struct PresetMappingTests {
("D65", .D65), ("D65", .D65),
("d65", .D65), ("d65", .D65),
("/tmp/fwa.sp", .custom) ("/tmp/fwa.sp", .custom)
] as [(String?, ColprofFwaSelection)]) ]
func fwaToSelection(raw: String?, expected: ColprofFwaSelection) { for (raw, expected) in cases {
#expect(ColprofFwaSelection(presetValue: raw) == expected) XCTAssertEqual(ColprofFwaSelection(presetValue: raw), expected)
}
} }
@Test("FWA selection → preset value matrix", arguments: [ func testFwaToPresetValue() {
(ColprofFwaSelection.none, nil), let cases: [(ColprofFwaSelection, String?)] = [
(.none, nil),
(.empty, ""), (.empty, ""),
(.D50, "D50"), (.D50, "D50"),
(.D65, "D65"), (.D65, "D65"),
(.custom, "/tmp/fwa.sp") (.custom, "/tmp/fwa.sp")
] as [(ColprofFwaSelection, String?)]) ]
func fwaToPresetValue(selection: ColprofFwaSelection, expected: String?) { for (selection, expected) in cases {
#expect(selection.presetValue(customPath: "/tmp/fwa.sp") == expected) XCTAssertEqual(selection.presetValue(customPath: "/tmp/fwa.sp"), expected)
}
} }
} }
@@ -1,21 +1,19 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #82 preset application through the live view models, under an /// Issue #82 preset application through the live view models, under an
/// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager). /// isolated `TestAppEnvironment` (temp stores, fresh ProcessManager).
@Suite("PresetViewModelMapping")
@MainActor @MainActor
struct PresetViewModelMappingTests { final class PresetViewModelMappingTests: XCTestCase {
private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) { private func makeWorkflow() throws -> (TestAppEnvironment, TargetWorkflowViewModel) {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
return (env, TargetWorkflowViewModel(environment: env.environment)) return (env, TargetWorkflowViewModel(environment: env.environment))
} }
@Test("Applying a nil-FWA preset after a custom FWA clears the stale path") func testNilFwaClearsCustomPath() throws {
func nilFwaClearsCustomPath() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -24,18 +22,17 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/fwa.sp" colprofFwa: "/tmp/fwa.sp"
) )
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .custom) XCTAssertEqual(vm.profile.fwaSelection, .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/fwa.sp") XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/fwa.sp")
customPreset.colprofFwa = nil customPreset.colprofFwa = nil
vm.applyPreset(customPreset) vm.applyPreset(customPreset)
#expect(vm.profile.fwaSelection == .none) XCTAssertEqual(vm.profile.fwaSelection, .none)
#expect(vm.profile.fwaCustomPath == "") XCTAssertEqual(vm.profile.fwaCustomPath, "")
#expect(vm.profile.fwaValue == nil) XCTAssertNil(vm.profile.fwaValue)
} }
@Test("Custom FWA preset path survives the round-trip to colprof_fwa") func testCustomFwaRoundTrip() throws {
func customFwaRoundTrip() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -44,13 +41,12 @@ struct PresetViewModelMappingTests {
colprofFwa: "/tmp/other.sp" colprofFwa: "/tmp/other.sp"
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.profile.fwaSelection == .custom) XCTAssertEqual(vm.profile.fwaSelection, .custom)
#expect(vm.profile.fwaCustomPath == "/tmp/other.sp") XCTAssertEqual(vm.profile.fwaCustomPath, "/tmp/other.sp")
#expect(vm.profile.fwaValue == "/tmp/other.sp") XCTAssertEqual(vm.profile.fwaValue, "/tmp/other.sp")
} }
@Test("Preset calibration reaches Stage 2 instead of stale live state") func testPresetCalibrationReachesStage2() throws {
func presetCalibrationReachesStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -65,13 +61,12 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.profile.applyCalibration) XCTAssertTrue(vm.profile.applyCalibration)
#expect(vm.profile.calibrationFile == "/tmp/preset.cal") XCTAssertEqual(vm.profile.calibrationFile, "/tmp/preset.cal")
#expect(vm.buildPrinttargConfig().calibrationFile == "/tmp/preset.cal") XCTAssertEqual(vm.buildPrinttargConfig().calibrationFile, "/tmp/preset.cal")
} }
@Test("Preset with calibration disabled clears Stage 2 calibration") func testDisabledCalibrationClearsStage2() throws {
func disabledCalibrationClearsStage2() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -85,12 +80,11 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(!vm.profile.applyCalibration) XCTAssertFalse(vm.profile.applyCalibration)
#expect(vm.buildPrinttargConfig().calibrationFile == nil) XCTAssertNil(vm.buildPrinttargConfig().calibrationFile)
} }
@Test("Preset Stage 1/2 form fields apply to the live form") func testFormFieldsApply() throws {
func formFieldsApply() throws {
let (env, vm) = try makeWorkflow() let (env, vm) = try makeWorkflow()
defer { env.cleanup() } defer { env.cleanup() }
@@ -106,22 +100,22 @@ struct PresetViewModelMappingTests {
) )
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(vm.colourSpace == .cmyk) XCTAssertEqual(vm.colourSpace, .cmyk)
#expect(vm.effectivePatchCount == 1500) XCTAssertEqual(vm.effectivePatchCount, 1500)
#expect(vm.whitePatches == 6) XCTAssertEqual(vm.whitePatches, 6)
#expect(vm.blackPatches == 8) XCTAssertEqual(vm.blackPatches, 8)
#expect(vm.greyStepsEnabled && vm.greySteps == 9) XCTAssertTrue(vm.greyStepsEnabled && vm.greySteps == 9)
#expect(vm.algorithm == .random) XCTAssertEqual(vm.algorithm, .random)
#expect(vm.tiffDpi == 150) XCTAssertEqual(vm.tiffDpi, 150)
#expect(vm.pageSize == .custom) XCTAssertEqual(vm.pageSize, .custom)
#expect(vm.customPageW == 250 && vm.customPageH == 300) XCTAssertTrue(vm.customPageW == 250 && vm.customPageH == 300)
#expect(vm.selectedPresetID == "c-form") XCTAssertEqual(vm.selectedPresetID, "c-form")
// Disabled advanced controls stay nil in the snapshot, not // Disabled advanced controls stay nil in the snapshot, not
// numeric sentinels. // numeric sentinels.
preset.greySteps = nil preset.greySteps = nil
vm.applyPreset(preset) vm.applyPreset(preset)
#expect(!vm.greyStepsEnabled) XCTAssertFalse(vm.greyStepsEnabled)
#expect(vm.buildTargenConfig().greySteps == nil) XCTAssertNil(vm.buildTargenConfig().greySteps)
} }
} }
+13 -17
View File
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result). /// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the /// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on. /// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub") final class PrintPanelStubTests: XCTestCase {
struct PrintPanelStubTests {
private func withEnv( private func withEnv(
_ vars: [String: String?], _ vars: [String: String?],
@@ -28,19 +27,17 @@ struct PrintPanelStubTests {
try body() try body()
} }
@Test("Cancel returns nil — not an error") func testCancelIsNil() throws {
func cancelIsNil() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel", "ICCERY_TEST_PRINT_PANEL": "cancel",
]) { ]) {
#expect(UITestHooks.printPanelStubbed) XCTAssertTrue(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil) XCTAssertNil(UITestHooks.printPanelResult(forQueue: "q"))
} }
} }
@Test("OK returns captured options + selected printer") func testOkResult() throws {
func okResult() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -48,15 +45,14 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "q") let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue") XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@Test("OK defaults selected printer to the opened queue") func testOkDefaultsPrinter() throws {
func okDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok", "ICCERY_TEST_PRINT_PANEL": "ok",
@@ -64,8 +60,8 @@ struct PrintPanelStubTests {
"ICCERY_TEST_PANEL_PRINTER": nil, "ICCERY_TEST_PANEL_PRINTER": nil,
]) { ]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue") let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue") XCTAssertEqual(result?.selectedPrinter, "My_Queue")
#expect(result?.options.cupsOptions == nil) XCTAssertNil(result?.options.cupsOptions)
} }
} }
} }
+18 -23
View File
@@ -1,4 +1,3 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -257,8 +256,7 @@ final class PrinttargManifestTests: XCTestCase {
} }
} }
@Suite("ArgyllRunner Printtarg") final class ArgyllRunnerPrinttargTests: XCTestCase {
struct ArgyllRunnerPrinttargTests {
private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL {
let dir = FileManager.default.temporaryDirectory let dir = FileManager.default.temporaryDirectory
@@ -310,8 +308,7 @@ struct ArgyllRunnerPrinttargTests {
try Data(bytes).write(to: url) try Data(bytes).write(to: url)
} }
@Test("Successful printtarg emits .ti2 + manifest + PNG previews") func testSuccess() async throws {
func success() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -330,18 +327,17 @@ struct ArgyllRunnerPrinttargTests {
processManager: ProcessManager(), binaryResolver: resolver) processManager: ProcessManager(), binaryResolver: resolver)
let config = PrinttargConfig(basename: "pt", workingDirectory: dir) let config = PrinttargConfig(basename: "pt", workingDirectory: dir)
let result = try await runner.runPrinttarg(config: config) let result = try await runner.runPrinttarg(config: config)
#expect(result.ti2URL.lastPathComponent == "pt.ti2") XCTAssertEqual(result.ti2URL.lastPathComponent, "pt.ti2")
#expect(result.manifest.pages.count == 1) XCTAssertEqual(result.manifest.pages.count, 1)
#expect(result.pages.count == 1) XCTAssertEqual(result.pages.count, 1)
let png = result.pages[0].previewPNG let png = result.pages[0].previewPNG
#expect(png != nil) XCTAssertNotNil(png)
if let png { if let png {
#expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) XCTAssertEqual(png.prefix(8), Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A]))
} }
} }
@Test("Non-zero exit throws toolFailed and stays on stage") func testFailure() async throws {
func failure() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
echo "oops" >&2 echo "oops" >&2
@@ -351,15 +347,16 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "printtarg", code: 3, logs: ["oops"])) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printtarg", code: 3, logs: ["oops"]))
} }
} }
@Test("Exit 0 without manifest → malformedManifest") func testNoManifest() async throws {
func noManifest() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -372,14 +369,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Exit 0 without .ti2 → missingArtefact") func testNoTi2() async throws {
func noTi2() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n'
@@ -389,14 +385,13 @@ struct ArgyllRunnerPrinttargTests {
let runner = ArgyllRunner( let runner = ArgyllRunner(
processManager: ProcessManager(), processManager: ProcessManager(),
binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir))
await #expect(throws: ArgyllRunnerError.self) { await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runPrinttarg( try await runner.runPrinttarg(
config: PrinttargConfig(basename: "x", workingDirectory: dir)) config: PrinttargConfig(basename: "x", workingDirectory: dir))
} }
} }
@Test("Deterministic config produces byte-identical .ti2") func testDeterminism() async throws {
func determinism() async throws {
let dir = try makeFixture(""" let dir = try makeFixture("""
#!/bin/sh #!/bin/sh
last="" last=""
@@ -416,6 +411,6 @@ struct ArgyllRunnerPrinttargTests {
config: PrinttargConfig(basename: "b", workingDirectory: dir)) config: PrinttargConfig(basename: "b", workingDirectory: dir))
let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2"))
let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2"))
#expect(d1 == d2) XCTAssertEqual(d1, d2)
} }
} }
+104 -85
View File
@@ -1,12 +1,11 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
/// Helpers shared across ProcessManager tests. Fixture binaries are shell /// Helpers shared across ProcessManager tests. Fixture binaries are shell
/// scripts written to a temp dir no resource bundling required. /// scripts written to a temp dir no resource bundling required.
@Suite("ProcessManager", .serialized) /// XCTest executes test methods serially by default.
struct ProcessManagerTests { final class ProcessManagerTests: XCTestCase {
// MARK: - Fixture plumbing // MARK: - Fixture plumbing
@@ -44,7 +43,7 @@ struct ProcessManagerTests {
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
Task { Task {
try? await Task.sleep(for: .seconds(timeout)) try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if box.finish() { cont.resume(returning: box.events) } if box.finish() { cont.resume(returning: box.events) }
} }
} }
@@ -85,7 +84,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if exitCount(in: box) > 0 { return true } if exitCount(in: box) > 0 { return true }
try? await Task.sleep(for: .milliseconds(10)) try? await Task.sleep(nanoseconds: 10_000_000)
} }
return false return false
} }
@@ -94,7 +93,7 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if FileManager.default.fileExists(atPath: url.path) { return true } if FileManager.default.fileExists(atPath: url.path) { return true }
try? await Task.sleep(for: .milliseconds(10)) try? await Task.sleep(nanoseconds: 10_000_000)
} }
return false return false
} }
@@ -107,14 +106,14 @@ struct ProcessManagerTests {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if await manager.isRunning(id) { return true } if await manager.isRunning(id) { return true }
try? await Task.sleep(for: .milliseconds(10)) try? await Task.sleep(nanoseconds: 10_000_000)
} }
return false return false
} }
// MARK: - Tests // MARK: - Tests
@Test func streamsStdoutAndEmitsExit() async throws { func testStreamsStdoutAndEmitsExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n") let bin = try script("lines.sh", "#!/bin/sh\necho hello\necho world\n")
async let events = collect(pm, id: "t1") async let events = collect(pm, id: "t1")
@@ -123,21 +122,21 @@ struct ProcessManagerTests {
let lines = evs.compactMap { e -> String? in let lines = evs.compactMap { e -> String? in
if case .stdout(_, let l) = e { return l }; return nil if case .stdout(_, let l) = e { return l }; return nil
} }
#expect(lines == ["hello", "world"]) XCTAssertEqual(lines, ["hello", "world"])
#expect(evs.contains(.exit(id: "t1", code: 0))) XCTAssertTrue(evs.contains(.exit(id: "t1", code: 0)))
} }
@Test func routesStderrSeparately() async throws { func testRoutesStderrSeparately() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n") let bin = try script("err.sh", "#!/bin/sh\necho out\necho oops 1>&2\n")
async let evs = collect(pm, id: "t2") async let evs = collect(pm, id: "t2")
try await pm.runStreaming(id: "t2", binary: bin, arguments: []) try await pm.runStreaming(id: "t2", binary: bin, arguments: [])
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t2", line: "out"))) XCTAssertTrue(events.contains(.stdout(id: "t2", line: "out")))
#expect(events.contains(.stderr(id: "t2", line: "oops"))) XCTAssertTrue(events.contains(.stderr(id: "t2", line: "oops")))
} }
@Test func stripsRowColorsJSONPrefix() async throws { func testStripsRowColorsJSONPrefix() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"rows.sh", "rows.sh",
@@ -150,21 +149,22 @@ struct ProcessManagerTests {
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":1}"]) XCTAssertEqual(rows, ["{\"row\":1}"])
#expect(events.contains(.stdout(id: "t3", line: "plain"))) XCTAssertTrue(events.contains(.stdout(id: "t3", line: "plain")))
// Prefixed lines must not leak into stdout. // Prefixed lines must not leak into stdout.
#expect(!events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}"))) XCTAssertFalse(events.contains(.stdout(id: "t3", line: "ROW_COLORS_JSON: {\"row\":1}")))
} }
@Test func unterminatedTailFlushesOnExit() async throws { func testUnterminatedTailFlushesOnExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n") let bin = try script("tail.sh", "#!/bin/sh\nprintf 'no-newline'\n")
async let evs = collect(pm, id: "t4") async let evs = collect(pm, id: "t4")
try await pm.runStreaming(id: "t4", binary: bin, arguments: []) try await pm.runStreaming(id: "t4", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t4", line: "no-newline"))) let t4SawTail = await evs.contains(.stdout(id: "t4", line: "no-newline"))
XCTAssertTrue(t4SawTail)
} }
@Test func stdinRoundTrip() async throws { func testStdinRoundTrip() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// Read two lines then exit naturally a killed sh would lose its // Read two lines then exit naturally a killed sh would lose its
// buffered stdio output, which is exactly the chartread pattern. // buffered stdio output, which is exactly the chartread pattern.
@@ -177,21 +177,23 @@ struct ProcessManagerTests {
try await pm.sendStdin(id: "t5", text: " \n") try await pm.sendStdin(id: "t5", text: " \n")
try await pm.sendStdin(id: "t5", text: "d\n") try await pm.sendStdin(id: "t5", text: "d\n")
let events = await evs let events = await evs
#expect(events.contains(.stdout(id: "t5", line: "got: "))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got: ")))
#expect(events.contains(.stdout(id: "t5", line: "got:d"))) XCTAssertTrue(events.contains(.stdout(id: "t5", line: "got:d")))
} }
@Test func duplicateIDRejected() async throws { func testDuplicateIDRejected() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
await #expect(throws: ProcessError.duplicateID("t6")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.runStreaming(id: "t6", binary: bin, arguments: []) try await pm.runStreaming(id: "t6", binary: bin, arguments: [])
} errorHandler: { error in
XCTAssertEqual(error, .duplicateID("t6"))
} }
await pm.kill(id: "t6") await pm.kill(id: "t6")
} }
@Test func killEmitsExitAndClosesStdin() async throws { func testKillEmitsExitAndClosesStdin() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow2.sh", "#!/bin/sh\ncat\n") let bin = try script("slow2.sh", "#!/bin/sh\ncat\n")
async let evs = collect(pm, id: "t7") async let evs = collect(pm, id: "t7")
@@ -200,49 +202,51 @@ struct ProcessManagerTests {
let events = await evs let events = await evs
// exit emitted exactly once // exit emitted exactly once
let exits = events.filter { if case .exit = $0 { return true }; return false } let exits = events.filter { if case .exit = $0 { return true }; return false }
#expect(exits.count == 1) XCTAssertEqual(exits.count, 1)
await #expect(throws: ProcessError.unknownID("t7")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "t7", text: "d\n") try await pm.sendStdin(id: "t7", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("t7"))
} }
} }
@Test func killAllCountsSignaled() async throws { func testKillAllCountsSignaled() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n") let bin = try script("slow3.sh", "#!/bin/sh\nsleep 30\n")
try await pm.runStreaming(id: "a", binary: bin, arguments: []) try await pm.runStreaming(id: "a", binary: bin, arguments: [])
try await pm.runStreaming(id: "b", binary: bin, arguments: []) try await pm.runStreaming(id: "b", binary: bin, arguments: [])
let count = await pm.killAll() let count = await pm.killAll()
#expect(count == 2) XCTAssertEqual(count, 2)
} }
@Test func capturedRunReturnsBothStreams() async throws { func testCapturedRunReturnsBothStreams() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n") let bin = try script("cap.sh", "#!/bin/sh\necho out-data\necho err-data 1>&2\nexit 3\n")
let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "cap", binary: bin, arguments: [])
#expect(result.stdout.contains("out-data")) XCTAssertTrue(result.stdout.contains("out-data"))
#expect(result.stderr.contains("err-data")) XCTAssertTrue(result.stderr.contains("err-data"))
#expect(result.exitCode == 3) XCTAssertEqual(result.exitCode, 3)
} }
@Test func capturedRunFastExit() async throws { func testCapturedRunFastExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast.sh", "#!/bin/sh\nexit 7\n")
let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "fast", binary: bin, arguments: [])
#expect(result.exitCode == 7) XCTAssertEqual(result.exitCode, 7)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr == "") XCTAssertEqual(result.stderr, "")
} }
@Test func capturedRunStderrOnly() async throws { func testCapturedRunStderrOnly() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n") let bin = try script("stderr-only.sh", "#!/bin/sh\necho 'mock lp failure' 1>&2\nexit 1\n")
let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "stderr-only", binary: bin, arguments: [])
#expect(result.exitCode == 1) XCTAssertEqual(result.exitCode, 1)
#expect(result.stdout == "") XCTAssertEqual(result.stdout, "")
#expect(result.stderr.contains("mock lp failure")) XCTAssertTrue(result.stderr.contains("mock lp failure"))
} }
@Test func capturedRunDoesNotDeadlockOnLargeOutput() async throws { func testCapturedRunDoesNotDeadlockOnLargeOutput() async throws {
let pm = ProcessManager() let pm = ProcessManager()
// 5000 lines each stream exceeds the 64 KiB pipe buffer. // 5000 lines each stream exceeds the 64 KiB pipe buffer.
let bin = try script( let bin = try script(
@@ -250,26 +254,29 @@ struct ProcessManagerTests {
"#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n" "#!/bin/sh\ni=0; while [ $i -lt 5000 ]; do echo \"out-$i\"; echo \"err-$i\" 1>&2; i=$((i+1)); done\n"
) )
let result = try await pm.runCaptured(id: "big", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "big", binary: bin, arguments: [])
#expect(result.stdout.contains("out-4999")) XCTAssertTrue(result.stdout.contains("out-4999"))
#expect(result.stderr.contains("err-4999")) XCTAssertTrue(result.stderr.contains("err-4999"))
} }
@Test func argyllEnvVarIsSet() async throws { func testArgyllEnvVarIsSet() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n") let bin = try script("env.sh", "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n")
async let evs = collect(pm, id: "t10") async let evs = collect(pm, id: "t10")
try await pm.runStreaming(id: "t10", binary: bin, arguments: []) try await pm.runStreaming(id: "t10", binary: bin, arguments: [])
#expect(await evs.contains(.stdout(id: "t10", line: "ANI=1"))) let t10SawEnv = await evs.contains(.stdout(id: "t10", line: "ANI=1"))
XCTAssertTrue(t10SawEnv)
} }
@Test func unknownIDStdinThrows() async throws { func testUnknownIDStdinThrows() async throws {
let pm = ProcessManager() let pm = ProcessManager()
await #expect(throws: ProcessError.unknownID("nope")) { await assertAsyncThrows(expectedType: ProcessError.self) {
try await pm.sendStdin(id: "nope", text: "d\n") try await pm.sendStdin(id: "nope", text: "d\n")
} errorHandler: { error in
XCTAssertEqual(error, .unknownID("nope"))
} }
} }
@Test func explicitPartialFlushEmitsRowColorsJSON() async throws { func testExplicitPartialFlushEmitsRowColorsJSON() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("partial-row-ready-\(UUID().uuidString)") .appendingPathComponent("partial-row-ready-\(UUID().uuidString)")
@@ -280,7 +287,8 @@ struct ProcessManagerTests {
let box = Box() let box = Box()
let observer = observe(pm, id: "t11", into: box) let observer = observe(pm, id: "t11", into: box)
try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path]) try await pm.runStreaming(id: "t11", binary: bin, arguments: [marker.path])
#expect(await waitForFile(marker)) let markerReady = await waitForFile(marker)
XCTAssertTrue(markerReady)
// Retry the flush so the pipe-ingest task can win the actor race // Retry the flush so the pipe-ingest task can win the actor race
// on a loaded host; the first successful flush emits the row. // on a loaded host; the first successful flush emits the row.
var flushed = false var flushed = false
@@ -290,24 +298,25 @@ struct ProcessManagerTests {
flushed = true flushed = true
break break
} }
try await Task.sleep(for: .milliseconds(20)) try await Task.sleep(nanoseconds: 20_000_000)
} }
#expect(flushed) XCTAssertTrue(flushed)
await pm.kill(id: "t11") await pm.kill(id: "t11")
#expect(await waitForExit(in: box)) let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":9}"]) XCTAssertEqual(rows, ["{\"row\":9}"])
// Prefixed tails must not leak into stdout, even via finalize. // Prefixed tails must not leak into stdout, even via finalize.
#expect(!events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}"))) XCTAssertFalse(events.contains(.stdout(id: "t11", line: "ROW_COLORS_JSON: {\"row\":9}")))
#expect(exitCount(in: box) == 1) XCTAssertEqual(exitCount(in: box), 1)
} }
@Test func unterminatedRowTailFinalizesAsJSONRow() async throws { func testUnterminatedRowTailFinalizesAsJSONRow() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"row-tail.sh", "row-tail.sh",
@@ -316,68 +325,70 @@ struct ProcessManagerTests {
let box = Box() let box = Box()
let observer = observe(pm, id: "t12", into: box) let observer = observe(pm, id: "t12", into: box)
try await pm.runStreaming(id: "t12", binary: bin, arguments: []) try await pm.runStreaming(id: "t12", binary: bin, arguments: [])
#expect(await waitForExit(in: box)) let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
observer.cancel() observer.cancel()
let events = box.events let events = box.events
let rows = events.compactMap { e -> String? in let rows = events.compactMap { e -> String? in
if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) } if case .jsonRow(_, let d) = e { return String(decoding: d, as: UTF8.self) }
return nil return nil
} }
#expect(rows == ["{\"row\":42}"]) XCTAssertEqual(rows, ["{\"row\":42}"])
#expect(!events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}"))) XCTAssertFalse(events.contains(.stdout(id: "t12", line: "ROW_COLORS_JSON: {\"row\":42}")))
let rowIndex = events.firstIndex { let rowIndex = events.firstIndex {
if case .jsonRow = $0 { return true }; return false if case .jsonRow = $0 { return true }; return false
} }
let exitIndexes = events.indices.filter { let exitIndexes = events.indices.filter {
if case .exit = events[$0] { return true }; return false if case .exit = events[$0] { return true }; return false
} }
#expect(exitIndexes.count == 1) XCTAssertEqual(exitIndexes.count, 1)
if let rowIndex, let exitIndex = exitIndexes.first { if let rowIndex, let exitIndex = exitIndexes.first {
#expect(rowIndex < exitIndex) XCTAssertTrue(rowIndex < exitIndex)
} else { } else {
Issue.record("expected a jsonRow before the exit event") XCTFail("expected a jsonRow before the exit event")
} }
} }
@Test func fastStreamingExitEmitsExactlyOneExit() async throws { func testFastStreamingExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n") let bin = try script("fast-stream.sh", "#!/bin/sh\nexit 0\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t13", into: box) let observer = observe(pm, id: "t13", into: box)
try await pm.runStreaming(id: "t13", binary: bin, arguments: []) try await pm.runStreaming(id: "t13", binary: bin, arguments: [])
#expect(await waitForExit(in: box)) let sawExit = await waitForExit(in: box)
XCTAssertTrue(sawExit)
// The grace window must outlast the 2 s finalize watchdog so a // The grace window must outlast the 2 s finalize watchdog so a
// duplicate emission from it would be observed. // duplicate emission from it would be observed.
try await Task.sleep(for: .milliseconds(2500)) try await Task.sleep(nanoseconds: 2_500_000_000)
observer.cancel() observer.cancel()
#expect(box.events == [.exit(id: "t13", code: 0)]) XCTAssertEqual(box.events, [.exit(id: "t13", code: 0)])
} }
@Test func fastCapturedExitEmitsExactlyOneExit() async throws { func testFastCapturedExitEmitsExactlyOneExit() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n") let bin = try script("fast-cap.sh", "#!/bin/sh\nexit 7\n")
let box = Box() let box = Box()
let observer = observe(pm, id: "t14", into: box) let observer = observe(pm, id: "t14", into: box)
let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "t14", binary: bin, arguments: [])
#expect(result.exitCode == 7) XCTAssertEqual(result.exitCode, 7)
// Both the termination handler and the waitUntilExit watchdog // Both the termination handler and the waitUntilExit watchdog
// resume the same box; give the slower path time to fire. // resume the same box; give the slower path time to fire.
try await Task.sleep(for: .milliseconds(500)) try await Task.sleep(nanoseconds: 500_000_000)
observer.cancel() observer.cancel()
#expect(box.events == [.exit(id: "t14", code: 7)]) XCTAssertEqual(box.events, [.exit(id: "t14", code: 7)])
} }
@Test func capturedRunSetsArgyllNotInteractive() async throws { func testCapturedRunSetsArgyllNotInteractive() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let bin = try script( let bin = try script(
"cap-env.sh", "cap-env.sh",
"#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n" "#!/bin/sh\necho \"ANI=$ARGYLL_NOT_INTERACTIVE\"\n"
) )
let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: []) let result = try await pm.runCaptured(id: "t15", binary: bin, arguments: [])
#expect(result.stdout == "ANI=1\n") XCTAssertEqual(result.stdout, "ANI=1\n")
} }
@Test func killAllTerminatesStreamingAndCapturedChildren() async throws { func testKillAllTerminatesStreamingAndCapturedChildren() async throws {
let pm = ProcessManager() let pm = ProcessManager()
let marker = Self.fixtureDir let marker = Self.fixtureDir
.appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)") .appendingPathComponent("mixed-cap-ready-\(UUID().uuidString)")
@@ -391,21 +402,29 @@ struct ProcessManagerTests {
let capTask = Task { let capTask = Task {
try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path]) try await pm.runCaptured(id: "t17", binary: capBin, arguments: [marker.path])
} }
#expect(await waitForFile(marker)) let markerReady = await waitForFile(marker)
#expect(await waitForRunning(pm, id: "t16")) XCTAssertTrue(markerReady)
#expect(await waitForRunning(pm, id: "t17")) let t16Running = await waitForRunning(pm, id: "t16")
#expect(await pm.killAll() == 2) XCTAssertTrue(t16Running)
let t17Running = await waitForRunning(pm, id: "t17")
XCTAssertTrue(t17Running)
let killed = await pm.killAll()
XCTAssertEqual(killed, 2)
_ = try await capTask.value _ = try await capTask.value
#expect(await waitForExit(in: streamBox)) let streamExit = await waitForExit(in: streamBox)
#expect(await waitForExit(in: capBox)) XCTAssertTrue(streamExit)
let capExit = await waitForExit(in: capBox)
XCTAssertTrue(capExit)
// Grace window outlasts the streaming finalize watchdog. // Grace window outlasts the streaming finalize watchdog.
try await Task.sleep(for: .milliseconds(2500)) try await Task.sleep(nanoseconds: 2_500_000_000)
streamObserver.cancel() streamObserver.cancel()
capObserver.cancel() capObserver.cancel()
#expect(!(await pm.isRunning("t16"))) let t16RunningAfter = await pm.isRunning("t16")
#expect(!(await pm.isRunning("t17"))) XCTAssertFalse(t16RunningAfter)
#expect(exitCount(in: streamBox) == 1) let t17RunningAfter = await pm.isRunning("t17")
#expect(exitCount(in: capBox) == 1) XCTAssertFalse(t17RunningAfter)
XCTAssertEqual(exitCount(in: streamBox), 1)
XCTAssertEqual(exitCount(in: capBox), 1)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCery @testable import ICCery
/// Direct contracts for the shared logged-run helper (issue #80). /// Direct contracts for the shared logged-run helper (issue #80).
@@ -7,14 +7,12 @@ import Testing
/// `runLogged` owns the running-flag transition (`false true false`) /// `runLogged` owns the running-flag transition (`false true false`)
/// and the log-reset decision; these tests pin both sides of the /// and the log-reset decision; these tests pin both sides of the
/// contract plus the coalesced `@MainActor` log hop. /// contract plus the coalesced `@MainActor` log hop.
@Suite("ProcessRunSupport runLogged")
@MainActor @MainActor
struct ProcessRunSupportTests { final class ProcessRunSupportTests: XCTestCase {
private struct SentinelError: Error {} private struct SentinelError: Error {}
@Test("Success: running transitions [true, false], log resets once, batches reach the main actor, value preserved") func testSuccessTransitions() async throws {
func successTransitions() async throws {
var running: [Bool] = [] var running: [Bool] = []
var resets = 0 var resets = 0
var received: [String] = [] var received: [String] = []
@@ -31,20 +29,19 @@ struct ProcessRunSupportTests {
return 42 return 42
} }
#expect(result == 42) XCTAssertEqual(result, 42)
#expect(running == [true, false]) XCTAssertEqual(running, [true, false])
#expect(resets == 1) XCTAssertEqual(resets, 1)
// The sink hops back through a main-actor Task; yield until the // The sink hops back through a main-actor Task; yield until the
// coalesced batch lands. // coalesced batch lands.
for _ in 0..<200 where received.isEmpty { for _ in 0..<200 where received.isEmpty {
try await Task.sleep(for: .milliseconds(10)) try await Task.sleep(nanoseconds: 10_000_000)
} }
#expect(received == ["alpha", "beta"]) XCTAssertEqual(received, ["alpha", "beta"])
} }
@Test("Failure: running still transitions [true, false], log resets once, error is rethrown") func testFailureTransitions() async throws {
func failureTransitions() async throws {
var running: [Bool] = [] var running: [Bool] = []
var resets = 0 var resets = 0
@@ -56,12 +53,12 @@ struct ProcessRunSupportTests {
) { _ -> Int in ) { _ -> Int in
throw SentinelError() throw SentinelError()
} }
Issue.record("Expected runLogged to rethrow") XCTFail("Expected runLogged to rethrow")
} catch is SentinelError { } catch is SentinelError {
// Expected path. // Expected path.
} }
#expect(running == [true, false]) XCTAssertEqual(running, [true, false])
#expect(resets == 1) XCTAssertEqual(resets, 1)
} }
} }
@@ -1,5 +1,5 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
/// A `FileManager` subclass that reports a temporary directory as the /// A `FileManager` subclass that reports a temporary directory as the
@@ -18,8 +18,7 @@ private final class TestFileManager: FileManager {
} }
} }
@Suite("ProfileInstaller") final class ProfileInstallerTests: XCTestCase {
struct ProfileInstallerTests {
private func makeTempDir() throws -> URL { private func makeTempDir() throws -> URL {
let fm = FileManager.default let fm = FileManager.default
@@ -39,8 +38,7 @@ struct ProfileInstallerTests {
return url return url
} }
@Test("Installs .icc to user ColorSync folder") func testUserInstall() throws {
func userInstall() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -51,15 +49,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.registered) XCTAssertTrue(result.registered)
#expect(!result.overwritten) XCTAssertFalse(result.overwritten)
#expect(!result.renamed) XCTAssertFalse(result.renamed)
#expect(result.destPath.hasSuffix("test.icc")) XCTAssertTrue(result.destPath.hasSuffix("test.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Overwrite succeeds and replaces the existing file") func testOverwriteSucceeds() throws {
func overwriteSucceeds() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -70,7 +67,7 @@ struct ProfileInstallerTests {
config: InstallProfileConfig(sourceURL: source), config: InstallProfileConfig(sourceURL: source),
fileManager: testFM fileManager: testFM
) )
#expect(!first.overwritten) XCTAssertFalse(first.overwritten)
// Change the source contents. // Change the source contents.
let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) } let newBytes: [UInt8] = (0..<256).map { UInt8(($0 + 100) % 256) }
@@ -87,15 +84,14 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(second.overwritten) XCTAssertTrue(second.overwritten)
#expect(!second.renamed) XCTAssertFalse(second.renamed)
#expect(fm.fileExists(atPath: second.destPath)) XCTAssertTrue(fm.fileExists(atPath: second.destPath))
let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath)) let installed = try Data(contentsOf: URL(fileURLWithPath: second.destPath))
#expect(Array(installed) == newBytes) XCTAssertEqual(Array(installed), newBytes)
} }
@Test("Preserves .icm source extension") func testPreservesIcmExtension() throws {
func preservesIcmExtension() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
let source = try makeSource(at: tmp, name: "m5_profile.icm") let source = try makeSource(at: tmp, name: "m5_profile.icm")
@@ -105,12 +101,11 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(URL(fileURLWithPath: result.destPath).pathExtension == "icm") XCTAssertEqual(URL(fileURLWithPath: result.destPath).pathExtension, "icm")
#expect(result.destPath.hasSuffix("m5_profile.icm")) XCTAssertTrue(result.destPath.hasSuffix("m5_profile.icm"))
} }
@Test("Rejects parent traversal in source path") func testRejectsParentTraversal() throws {
func rejectsParentTraversal() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
@@ -125,20 +120,19 @@ struct ProfileInstallerTests {
let sourceURL = tmp let sourceURL = tmp
.appendingPathComponent("..") .appendingPathComponent("..")
.appendingPathComponent(naughtyName) .appendingPathComponent(naughtyName)
#expect(fm.fileExists(atPath: sourceURL.path)) XCTAssertTrue(fm.fileExists(atPath: sourceURL.path))
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: sourceURL))
Issue.record("Expected unsafeStem error") XCTFail("Expected unsafeStem error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .unsafeStem = error { } else { Issue.record("Expected unsafeStem, got \(error)") } if case .unsafeStem = error { } else { XCTFail("Expected unsafeStem, got \(error)") }
} catch { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
@Test("Allows stems with consecutive dots like foo..bar") func testAllowsDoubleDotStem() throws {
func allowsDoubleDotStem() throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = try makeTempDir() let tmp = try makeTempDir()
let testFM = TestFileManager(home: tmp) let testFM = TestFileManager(home: tmp)
@@ -149,23 +143,22 @@ struct ProfileInstallerTests {
fileManager: testFM fileManager: testFM
) )
#expect(result.destPath.hasSuffix("foo..bar.icc")) XCTAssertTrue(result.destPath.hasSuffix("foo..bar.icc"))
#expect(fm.fileExists(atPath: result.destPath)) XCTAssertTrue(fm.fileExists(atPath: result.destPath))
} }
@Test("Rejects source files that are too small") func testRejectsSmallSource() throws {
func rejectsSmallSource() throws {
let tmp = try makeTempDir() let tmp = try makeTempDir()
let source = tmp.appendingPathComponent("tiny.icc") let source = tmp.appendingPathComponent("tiny.icc")
try Data(repeating: 0, count: 64).write(to: source) try Data(repeating: 0, count: 64).write(to: source)
do { do {
_ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source)) _ = try ProfileInstaller.install(config: InstallProfileConfig(sourceURL: source))
Issue.record("Expected sourceTooSmall error") XCTFail("Expected sourceTooSmall error")
} catch let error as ProfileInstallError { } catch let error as ProfileInstallError {
if case .sourceTooSmall = error { } else { Issue.record("Expected sourceTooSmall, got \(error)") } if case .sourceTooSmall = error { } else { XCTFail("Expected sourceTooSmall, got \(error)") }
} catch { } catch {
Issue.record("Unexpected error type: \(error)") XCTFail("Unexpected error type: \(error)")
} }
} }
} }
+54 -57
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func tempStoreURL() -> URL { private func tempStoreURL() -> URL {
@@ -8,92 +8,90 @@ private func tempStoreURL() -> URL {
.appendingPathComponent("settings.json") .appendingPathComponent("settings.json")
} }
@Suite("AppSettings") final class AppSettingsTests: XCTestCase {
struct AppSettingsTests { func testDefaults() {
@Test func defaults() {
let s = AppSettings.default let s = AppSettings.default
#expect(s.argyllBinaryDir == nil) XCTAssertNil(s.argyllBinaryDir)
#expect(s.defaultInstrument == nil) XCTAssertNil(s.defaultInstrument)
#expect(s.logLevel == nil) XCTAssertNil(s.logLevel)
#expect(s.deltaEGoodMax == 2.0) XCTAssertEqual(s.deltaEGoodMax, 2.0)
#expect(s.deltaEWarningMax == 5.0) XCTAssertEqual(s.deltaEWarningMax, 5.0)
#expect(s.customPresets.isEmpty) XCTAssertTrue(s.customPresets.isEmpty)
#expect(!s.enableI1Pro2Leds) XCTAssertFalse(s.enableI1Pro2Leds)
#expect(s.calibrationStaleDays == 30) XCTAssertEqual(s.calibrationStaleDays, 30)
#expect(s.defaultInstallLocation == .user) XCTAssertEqual(s.defaultInstallLocation, .user)
#expect(s.askBeforeOverwriteProfile) XCTAssertTrue(s.askBeforeOverwriteProfile)
#expect(!s.openColorPanelAfterInstall) XCTAssertFalse(s.openColorPanelAfterInstall)
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func negativeThresholds() { func testNegativeThresholds() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = -1 s.deltaEGoodMax = -1
#expect(s.validate() == [AppSettings.errorNegativeDeltaE]) XCTAssertEqual(s.validate(), [AppSettings.errorNegativeDeltaE])
s.deltaEGoodMax = 2.0 s.deltaEGoodMax = 2.0
s.deltaEWarningMax = -0.5 s.deltaEWarningMax = -0.5
// -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too // -0.5 < 0 negative error; good(2.0) >= warn(-0.5) order error too
#expect(s.validate() == [ XCTAssertTrue(s.validate() == [
AppSettings.errorNegativeDeltaE, AppSettings.errorNegativeDeltaE,
AppSettings.errorThresholdOrder, AppSettings.errorThresholdOrder,
]) ])
} }
@Test func goodMustBeStrictlyLessThanWarning() { func testGoodMustBeStrictlyLessThanWarning() {
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 5.0 s.deltaEGoodMax = 5.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 6.0 s.deltaEGoodMax = 6.0
#expect(s.validate() == [AppSettings.errorThresholdOrder]) XCTAssertEqual(s.validate(), [AppSettings.errorThresholdOrder])
s.deltaEGoodMax = 4.9 s.deltaEGoodMax = 4.9
#expect(s.isValid) XCTAssertTrue(s.isValid)
} }
@Test func snakeCaseKeys() throws { func testSnakeCaseKeys() throws {
let s = AppSettings.default let s = AppSettings.default
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let json = String(data: data, encoding: .utf8)! let json = String(data: data, encoding: .utf8)!
#expect(json.contains("\"delta_e_good_max\"")) XCTAssertTrue(json.contains("\"delta_e_good_max\""))
#expect(json.contains("\"default_install_location\"")) XCTAssertTrue(json.contains("\"default_install_location\""))
#expect(json.contains("\"enable_i1pro2_leds\"")) XCTAssertTrue(json.contains("\"enable_i1pro2_leds\""))
} }
} }
@Suite("SettingsStore") final class SettingsStoreTests: XCTestCase {
struct SettingsStoreTests { func testRoundTrip() throws {
@Test func roundTrip() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 1.5 s.deltaEGoodMax = 1.5
s.defaultInstrument = "p3" s.defaultInstrument = "p3"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func corruptJsonFallsBackToDefaults() throws { func testCorruptJsonFallsBackToDefaults() throws {
let url = tempStoreURL() let url = tempStoreURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "{ not json".write(to: url, atomically: true, encoding: .utf8) try "{ not json".write(to: url, atomically: true, encoding: .utf8)
#expect(SettingsStore(fileURL: url).load() == .default) XCTAssertEqual(SettingsStore(fileURL: url).load(), .default)
} }
@Test func missingFileReturnsDefaults() { func testMissingFileReturnsDefaults() {
#expect(SettingsStore(fileURL: tempStoreURL()).load() == .default) XCTAssertEqual(SettingsStore(fileURL: tempStoreURL()).load(), .default)
} }
@Test func invalidSettingsNotPersisted() throws { func testInvalidSettingsNotPersisted() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var s = AppSettings.default var s = AppSettings.default
s.deltaEGoodMax = 9.0 // >= warning 5.0 s.deltaEGoodMax = 9.0 // >= warning 5.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(s) } XCTAssertThrowsError(try store.save(s)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
#expect(!FileManager.default.fileExists(atPath: url.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
} }
@Test func invalidSaveOverValidFilePreservesBytesAndPostsNothing() throws { func testInvalidSaveOverValidFilePreservesBytesAndPostsNothing() throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var valid = AppSettings.default var valid = AppSettings.default
@@ -109,13 +107,13 @@ struct SettingsStoreTests {
var invalid = AppSettings.default var invalid = AppSettings.default
invalid.deltaEGoodMax = 9.0 invalid.deltaEGoodMax = 9.0
#expect(throws: SettingsStore.SettingsError.self) { try store.save(invalid) } XCTAssertThrowsError(try store.save(invalid)) { error in XCTAssertTrue(error is SettingsStore.SettingsError) }
#expect(try Data(contentsOf: url) == originalBytes) XCTAssertEqual(try Data(contentsOf: url), originalBytes)
#expect(!fired) XCTAssertFalse(fired)
#expect(store.load() == valid) XCTAssertEqual(store.load(), valid)
} }
@Test func savePostsNotification() async throws { func testSavePostsNotification() async throws {
let url = tempStoreURL() let url = tempStoreURL()
let store = SettingsStore(fileURL: url) let store = SettingsStore(fileURL: url)
var fired = false var fired = false
@@ -124,12 +122,11 @@ struct SettingsStoreTests {
) { _ in fired = true } ) { _ in fired = true }
defer { NotificationCenter.default.removeObserver(token) } defer { NotificationCenter.default.removeObserver(token) }
try store.save(.default) try store.save(.default)
#expect(fired) XCTAssertTrue(fired)
} }
} }
@Suite("LogSink") final class LogSinkTests: XCTestCase {
struct LogSinkTests {
private func tempLog() -> (URL, LogSink) { private func tempLog() -> (URL, LogSink) {
let url = FileManager.default.temporaryDirectory let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-log-\(UUID().uuidString)") .appendingPathComponent("iccery-log-\(UUID().uuidString)")
@@ -137,26 +134,26 @@ struct LogSinkTests {
return (url, LogSink(fileURL: url)) return (url, LogSink(fileURL: url))
} }
@Test func writesFormattedLines() { func testWritesFormattedLines() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "test", message: "hello") sink.write(level: .info, category: "test", message: "hello")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(content.contains("[INFO] test: hello")) XCTAssertTrue(content.contains("[INFO] test: hello"))
} }
@Test func levelFilteringIsLive() { func testLevelFilteringIsLive() {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.error) sink.setLevel(.error)
sink.write(level: .info, category: "t", message: "hidden") sink.write(level: .info, category: "t", message: "hidden")
sink.setLevel(.info) // runtime change, no restart (#158) sink.setLevel(.info) // runtime change, no restart (#158)
sink.write(level: .info, category: "t", message: "shown") sink.write(level: .info, category: "t", message: "shown")
let content = (try? String(contentsOf: url, encoding: .utf8)) ?? "" let content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
#expect(!content.contains("hidden")) XCTAssertFalse(content.contains("hidden"))
#expect(content.contains("shown")) XCTAssertTrue(content.contains("shown"))
} }
@Test func rotatesAt5MiBKeeping5Segments() throws { func testRotatesAt5MiBKeeping5Segments() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.trace) sink.setLevel(.trace)
// Pre-fill the active log just under the cap, then cross it. // Pre-fill the active log just under the cap, then cross it.
@@ -167,20 +164,20 @@ struct LogSinkTests {
try big.write(to: url, atomically: true, encoding: .utf8) try big.write(to: url, atomically: true, encoding: .utf8)
sink.write(level: .info, category: "t", message: "trigger rotation") sink.write(level: .info, category: "t", message: "trigger rotation")
#expect(FileManager.default.fileExists( XCTAssertTrue(FileManager.default.fileExists(
atPath: url.appendingPathExtension("1").path atPath: url.appendingPathExtension("1").path
)) ))
// Active log is small again. // Active log is small again.
let size = try FileManager.default.attributesOfItem( let size = try FileManager.default.attributesOfItem(
atPath: url.path atPath: url.path
)[.size] as? UInt64 )[.size] as? UInt64
#expect((size ?? 0) < 1024) XCTAssertTrue((size ?? 0) < 1024)
} }
@Test func tailExcerptCaps() throws { func testTailExcerptCaps() throws {
let (url, sink) = tempLog() let (url, sink) = tempLog()
sink.setLevel(.debug) sink.setLevel(.debug)
sink.write(level: .info, category: "t", message: "line") sink.write(level: .info, category: "t", message: "line")
#expect(sink.tailExcerpt(maxBytes: 8).count <= 8) XCTAssertTrue(sink.tailExcerpt(maxBytes: 8).count <= 8)
} }
} }
+15 -16
View File
@@ -1,4 +1,3 @@
import Testing
import XCTest import XCTest
import Foundation import Foundation
@testable import ICCeryCore @testable import ICCeryCore
@@ -220,11 +219,9 @@ final class TargenArgsTests: XCTestCase {
} }
} }
@Suite("ArgyllRunner Targen") final class ArgyllRunnerTargenTests: XCTestCase {
struct ArgyllRunnerTargenTests {
@Test("Successful targen execution creates .ti1 and returns URL") func testSuccessfulTargenExecution() async throws {
func successfulTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -270,14 +267,13 @@ struct ArgyllRunnerTargenTests {
box.append(batch) box.append(batch)
} }
logLines = box.lines logLines = box.lines
#expect(logLines.contains("Generating patches...")) XCTAssertTrue(logLines.contains("Generating patches..."))
#expect(FileManager.default.fileExists(atPath: ti1URL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: ti1URL.path))
#expect(ti1URL.lastPathComponent == "mock_test.ti1") XCTAssertEqual(ti1URL.lastPathComponent, "mock_test.ti1")
} }
@Test("Failed targen execution throws toolFailed") func testFailedTargenExecution() async throws {
func failedTargenExecution() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -304,14 +300,15 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.toolFailed( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tool: "targen", code: 1, logs: ["Error: something went wrong"])) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "targen", code: 1, logs: ["Error: something went wrong"]))
} }
} }
@Test("Targen exit 0 without .ti1 throws missingArtefact") func testMissingArtefactThrows() async throws {
func missingArtefactThrows() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) } defer { try? FileManager.default.removeItem(at: tempDir) }
@@ -338,9 +335,11 @@ struct ArgyllRunnerTargenTests {
workingDirectory: tempDir workingDirectory: tempDir
) )
await #expect(throws: ArgyllRunnerError.missingArtefact( await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
tempDir.appendingPathComponent("no_file.ti1").path)) {
try await runner.runTargen(config: config) try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(
tempDir.appendingPathComponent("no_file.ti1").path))
} }
} }
} }
@@ -1,17 +1,15 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Dataset-import error contracts through the /// Dataset-import error contracts through the
/// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O /// `importMeasurementDataset(from:)` seam (issue #80): parser and I/O
/// failures must surface identically as a single `.error` Notice. /// failures must surface identically as a single `.error` Notice.
@Suite("TargetWorkflowViewModel dataset import")
@MainActor @MainActor
struct TargetWorkflowViewModelTests { final class TargetWorkflowViewModelTests: XCTestCase {
@Test("Malformed content (CGATSParseError) produces one .error notice prefixed 'Import failed:'") func testMalformedDatasetNotice() throws {
func malformedDatasetNotice() throws {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
defer { env.cleanup() } defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment) let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -21,13 +19,12 @@ struct TargetWorkflowViewModelTests {
vm.importMeasurementDataset(from: bad) vm.importMeasurementDataset(from: bad)
let notice = try #require(vm.wizard.notice) let notice = try XCTUnwrap(vm.wizard.notice)
#expect(notice.kind == .error) XCTAssertEqual(notice.kind, .error)
#expect(notice.text.hasPrefix("Import failed:")) XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
} }
@Test("Missing file (CocoaError) produces one .error notice prefixed 'Import failed:'") func testMissingDatasetNotice() throws {
func missingDatasetNotice() throws {
let env = try TestAppEnvironment.make() let env = try TestAppEnvironment.make()
defer { env.cleanup() } defer { env.cleanup() }
let vm = TargetWorkflowViewModel(environment: env.environment) let vm = TargetWorkflowViewModel(environment: env.environment)
@@ -35,8 +32,8 @@ struct TargetWorkflowViewModelTests {
let missing = env.root.appendingPathComponent("does-not-exist.ti3") let missing = env.root.appendingPathComponent("does-not-exist.ti3")
vm.importMeasurementDataset(from: missing) vm.importMeasurementDataset(from: missing)
let notice = try #require(vm.wizard.notice) let notice = try XCTUnwrap(vm.wizard.notice)
#expect(notice.kind == .error) XCTAssertEqual(notice.kind, .error)
#expect(notice.text.hasPrefix("Import failed:")) XCTAssertTrue(notice.text.hasPrefix("Import failed:"))
} }
} }
@@ -1,12 +1,10 @@
import Foundation import Foundation
import Testing import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@Suite("VerificationHistoryStore") final class VerificationHistoryStoreTests: XCTestCase {
struct VerificationHistoryStoreTests {
@Test("Append and cap") func testAppendAndCap() async throws {
func appendAndCap() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -29,12 +27,11 @@ struct VerificationHistoryStoreTests {
} }
let all = await store.all() let all = await store.all()
#expect(all.count == 3) XCTAssertEqual(all.count, 3)
#expect(all.first?.avgDE == 2.0) XCTAssertEqual(all.first?.avgDE, 2.0)
} }
@Test("Parse failure preserves file") func testParseFailurePreservesFile() async {
func parseFailurePreservesFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -45,14 +42,13 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
_ = try await store.load() _ = try await store.load()
Issue.record("load() should throw on invalid JSON") XCTFail("load() should throw on invalid JSON")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
} }
} }
@Test("Append loads existing records first") func testAppendLoadsExisting() async throws {
func appendLoadsExisting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -89,13 +85,12 @@ struct VerificationHistoryStoreTests {
_ = try await store2.append(new) _ = try await store2.append(new)
let all = await store2.all() let all = await store2.all()
#expect(all.count == 2) XCTAssertEqual(all.count, 2)
#expect(all.contains { $0.id == "vr-existing" }) XCTAssertTrue(all.contains { $0.id == "vr-existing" })
#expect(all.contains { $0.id == "vr-new" }) XCTAssertTrue(all.contains { $0.id == "vr-new" })
} }
@Test("Append does not overwrite an unparseable file") func testAppendPreservesUnparseableFile() async {
func appendPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -119,20 +114,19 @@ struct VerificationHistoryStoreTests {
do { do {
_ = try await store.append(record) _ = try await store.append(record)
Issue.record("append() should propagate the load error") XCTFail("append() should propagate the load error")
} catch { } catch {
#expect(fm.fileExists(atPath: url.path)) XCTAssertTrue(fm.fileExists(atPath: url.path))
if let data = try? Data(contentsOf: url), if let data = try? Data(contentsOf: url),
let contents = String(data: data, encoding: .utf8) { let contents = String(data: data, encoding: .utf8) {
#expect(contents == badJSON) XCTAssertEqual(contents, badJSON)
} else { } else {
Issue.record("Could not read preserved file") XCTFail("Could not read preserved file")
} }
} }
} }
@Test("Clear does not overwrite an unparseable file") func testClearPreservesUnparseableFile() async {
func clearPreservesUnparseableFile() async {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? fm.createDirectory(at: tmp, withIntermediateDirectories: true) try? fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -144,15 +138,14 @@ struct VerificationHistoryStoreTests {
let store = VerificationHistoryStore(url: url) let store = VerificationHistoryStore(url: url)
do { do {
try await store.clear() try await store.clear()
Issue.record("clear() should propagate the load error") XCTFail("clear() should propagate the load error")
} catch { } catch {
let contents = try? String(contentsOf: url, encoding: .utf8) let contents = try? String(contentsOf: url, encoding: .utf8)
#expect(contents == badJSON) XCTAssertEqual(contents, badJSON)
} }
} }
@Test("ISO-8601 timestamps round-trip through a fresh store") func testIso8601RoundTrip() async throws {
func iso8601RoundTrip() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -174,16 +167,15 @@ struct VerificationHistoryStoreTests {
_ = try await store1.append(record) _ = try await store1.append(record)
let text = try String(contentsOf: url, encoding: .utf8) let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains(ISO8601DateFormatter().string(from: timestamp))) XCTAssertTrue(text.contains(ISO8601DateFormatter().string(from: timestamp)))
let store2 = VerificationHistoryStore(url: url) let store2 = VerificationHistoryStore(url: url)
let loaded = try await store2.load() let loaded = try await store2.load()
#expect(loaded.count == 1) XCTAssertEqual(loaded.count, 1)
#expect(loaded.first?.timestamp == timestamp) XCTAssertEqual(loaded.first?.timestamp, timestamp)
} }
@Test("CSV export quoting") func testCsvQuoting() async throws {
func csvQuoting() async throws {
let fm = FileManager.default let fm = FileManager.default
let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmp = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try fm.createDirectory(at: tmp, withIntermediateDirectories: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true)
@@ -204,7 +196,7 @@ struct VerificationHistoryStoreTests {
_ = try await store.append(record) _ = try await store.append(record)
let csv = await store.exportCSV() let csv = await store.exportCSV()
#expect(csv.contains("\"a,b\"")) XCTAssertTrue(csv.contains("\"a,b\""))
#expect(csv.contains("\"\"quoted\"\"")) XCTAssertTrue(csv.contains("\"\"quoted\"\""))
} }
} }
@@ -1,13 +1,12 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #29 `CAL_` basename must be restored on relaunch and on any /// Issue #29 `CAL_` basename must be restored on relaunch and on any
/// attempt to navigate to a non-calibration stage that would use it. /// attempt to navigate to a non-calibration stage that would use it.
@Suite("WizardCalibrationSession")
@MainActor @MainActor
struct WizardCalibrationSessionTests { final class WizardCalibrationSessionTests: XCTestCase {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
@@ -24,8 +23,7 @@ struct WizardCalibrationSessionTests {
return url return url
} }
@Test("Persist and restore calibrationOriginalBasename across a relaunch") func testRelaunchRestoresOriginal() throws {
func relaunchRestoresOriginal() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var saved = WizardState( var saved = WizardState(
@@ -39,14 +37,13 @@ struct WizardCalibrationSessionTests {
let model = WizardViewModel(stateStore: store) let model = WizardViewModel(stateStore: store)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .generate) XCTAssertEqual(model.stage, .generate)
} }
@Test("go(to: .buildProfile) while basename is CAL_ refuses and restores the original") func testGoToBuildProfileRefusesAndRestores() throws {
func goToBuildProfileRefusesAndRestores() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -60,14 +57,13 @@ struct WizardCalibrationSessionTests {
model.go(to: .buildProfile) model.go(to: .buildProfile)
#expect(model.basename == "DemoTarget") XCTAssertEqual(model.basename, "DemoTarget")
#expect(model.calibrationOriginalBasename == "") XCTAssertEqual(model.calibrationOriginalBasename, "")
#expect(model.sessionMode == .profile) XCTAssertEqual(model.sessionMode, .profile)
#expect(model.stage == .calibrate) XCTAssertEqual(model.stage, .calibrate)
} }
@Test("go(to: .layOutPrint) while basename is CAL_ stays in calibration") func testGoToLayoutStaysCal() throws {
func goToLayoutStaysCal() throws {
let dir = try tempDir() let dir = try tempDir()
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
@@ -81,9 +77,9 @@ struct WizardCalibrationSessionTests {
model.go(to: .layOutPrint) model.go(to: .layOutPrint)
#expect(model.basename == "CAL_DemoTarget") XCTAssertEqual(model.basename, "CAL_DemoTarget")
#expect(model.calibrationOriginalBasename == "DemoTarget") XCTAssertEqual(model.calibrationOriginalBasename, "DemoTarget")
#expect(model.sessionMode == .calibration) XCTAssertEqual(model.sessionMode, .calibration)
#expect(model.stage == .layOutPrint) XCTAssertEqual(model.stage, .layOutPrint)
} }
} }
+47 -49
View File
@@ -1,5 +1,5 @@
import Testing
import Foundation import Foundation
import XCTest
@testable import ICCeryCore @testable import ICCeryCore
private func artefacts( private func artefacts(
@@ -16,77 +16,75 @@ private func artefacts(
return a return a
} }
@Suite("WizardGating matrix") final class WizardGatingTests: XCTestCase {
struct WizardGatingTests {
@Test func emptyProjectOnlyStage1() { func testEmptyProjectOnlyStage1() {
let a = artefacts() let a = artefacts()
#expect(WizardGating.isUnlocked(.generate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.generate, artefacts: a))
#expect(WizardGating.isUnlocked(.calibrate, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.calibrate, artefacts: a))
for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] { for s in [WizardStage.layOutPrint, .measure, .buildProfile, .verifyInstall] {
#expect(!WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked") XCTAssertFalse(WizardGating.isUnlocked(s, artefacts: a), "\(s) should be locked")
} }
} }
@Test func ti1UnlocksStage2Only() { func testTi1UnlocksStage2Only() {
let a = artefacts(ti1: true) let a = artefacts(ti1: true)
#expect(WizardGating.isUnlocked(.layOutPrint, artefacts: a)) XCTAssertTrue(WizardGating.isUnlocked(.layOutPrint, artefacts: a))
#expect(!WizardGating.isUnlocked(.measure, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: a))
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: a))
} }
@Test func stage3NeedsTi1AndTi2() { func testStage3NeedsTi1AndTi2() {
#expect(!WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true))) XCTAssertFalse(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti2: true)))
#expect(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true))) XCTAssertTrue(WizardGating.isUnlocked(.measure, artefacts: artefacts(ti1: true, ti2: true)))
} }
@Test func stage4NeedsTi3NotTi2() { func testStage4NeedsTi3NotTi2() {
// #109/#110: .ti2 alone must never unlock Stage 4. // #109/#110: .ti2 alone must never unlock Stage 4.
let a = artefacts(ti1: true, ti2: true) let a = artefacts(ti1: true, ti2: true)
#expect(!WizardGating.isUnlocked(.buildProfile, artefacts: a)) XCTAssertFalse(WizardGating.isUnlocked(.buildProfile, artefacts: a))
#expect(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true))) XCTAssertTrue(WizardGating.isUnlocked(.buildProfile, artefacts: artefacts(ti3: true)))
} }
@Test func stage5NeedsTi3AndProfile() { func testStage5NeedsTi3AndProfile() {
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(ti3: true)))
#expect(!WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true))) XCTAssertFalse(WizardGating.isUnlocked(.verifyInstall, artefacts: artefacts(profile: true)))
#expect(WizardGating.isUnlocked( XCTAssertTrue(WizardGating.isUnlocked(
.verifyInstall, artefacts: artefacts(ti3: true, profile: true) .verifyInstall, artefacts: artefacts(ti3: true, profile: true)
)) ))
} }
@Test func forwardGatedBackwardFree() { func testForwardGatedBackwardFree() {
let a = artefacts() let a = artefacts()
#expect(!WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a)) XCTAssertFalse(WizardGating.canNavigate(to: .layOutPrint, from: .generate, artefacts: a))
// Backward always allowed even when artefacts vanished. // Backward always allowed even when artefacts vanished.
#expect(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a)) XCTAssertTrue(WizardGating.canNavigate(to: .generate, from: .measure, artefacts: a))
// Same stage is a no-op. // Same stage is a no-op.
#expect(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a)) XCTAssertTrue(WizardGating.canNavigate(to: .measure, from: .measure, artefacts: a))
// Stage 0 is a side-trip, never gated. // Stage 0 is a side-trip, never gated.
#expect(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a)) XCTAssertTrue(WizardGating.canNavigate(to: .calibrate, from: .generate, artefacts: a))
} }
@Test func deepestUnlocked() { func testDeepestUnlocked() {
#expect(WizardGating.deepestUnlocked(artefacts: artefacts()) == .generate) XCTAssertEqual(WizardGating.deepestUnlocked(artefacts: artefacts()), .generate)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti1: true, ti2: true) artefacts: artefacts(ti1: true, ti2: true)
) == .measure) ), .measure)
#expect(WizardGating.deepestUnlocked( XCTAssertEqual(WizardGating.deepestUnlocked(
artefacts: artefacts(ti3: true, profile: true) artefacts: artefacts(ti3: true, profile: true)
) == .verifyInstall) ), .verifyInstall)
} }
} }
@Suite("WizardStateStore") final class WizardStateStoreTests: XCTestCase {
struct WizardStateStoreTests {
private func tempURL() -> URL { private func tempURL() -> URL {
FileManager.default.temporaryDirectory FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-wiz-\(UUID().uuidString)") .appendingPathComponent("iccery-wiz-\(UUID().uuidString)")
.appendingPathComponent("wizard_state.json") .appendingPathComponent("wizard_state.json")
} }
@Test func roundTrip() throws { func testRoundTrip() throws {
let url = tempURL() let url = tempURL()
let store = WizardStateStore(fileURL: url) let store = WizardStateStore(fileURL: url)
var s = WizardState() var s = WizardState()
@@ -97,43 +95,43 @@ struct WizardStateStoreTests {
s.profileBasename = "imported" s.profileBasename = "imported"
s.calibrationOriginalBasename = "pre-cal" s.calibrationOriginalBasename = "pre-cal"
try store.save(s) try store.save(s)
#expect(store.load() == s) XCTAssertEqual(store.load(), s)
} }
@Test func missingFileDefaults() { func testMissingFileDefaults() {
let s = WizardStateStore(fileURL: tempURL()).load() let s = WizardStateStore(fileURL: tempURL()).load()
#expect(s == .default) XCTAssertEqual(s, .default)
#expect(s.stage == .generate) XCTAssertEqual(s.stage, .generate)
#expect(s.sessionMode == .profile) XCTAssertEqual(s.sessionMode, .profile)
} }
@Test func corruptStageFallsBackToGenerate() throws { func testCorruptStageFallsBackToGenerate() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"# try #"{"current_stage": 99, "basename": "", "cwd": "", "session_mode": "profile"}"#
.write(to: url, atomically: true, encoding: .utf8) .write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load().stage == .generate) XCTAssertEqual(WizardStateStore(fileURL: url).load().stage, .generate)
} }
@Test func corruptJsonReturnsDefaultAndKeepsBytes() throws { func testCorruptJsonReturnsDefaultAndKeepsBytes() throws {
let url = tempURL() let url = tempURL()
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true at: url.deletingLastPathComponent(), withIntermediateDirectories: true
) )
try "not json".write(to: url, atomically: true, encoding: .utf8) try "not json".write(to: url, atomically: true, encoding: .utf8)
#expect(WizardStateStore(fileURL: url).load() == .default) XCTAssertEqual(WizardStateStore(fileURL: url).load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8) let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json") XCTAssertEqual(kept, "not json")
} }
@Test func sessionModeCalibrationRoundTrips() throws { func testSessionModeCalibrationRoundTrips() throws {
var s = WizardState(sessionMode: .calibration) var s = WizardState(sessionMode: .calibration)
let data = try JSONEncoder().encode(s) let data = try JSONEncoder().encode(s)
let decoded = try JSONDecoder().decode(WizardState.self, from: data) let decoded = try JSONDecoder().decode(WizardState.self, from: data)
#expect(decoded.sessionMode == .calibration) XCTAssertEqual(decoded.sessionMode, .calibration)
s.sessionMode = .profile s.sessionMode = .profile
#expect(s.sessionMode == .profile) XCTAssertEqual(s.sessionMode, .profile)
} }
} }
@@ -76,8 +76,15 @@ final class Milestone6CalibrationUITests: XCTestCase {
// After generation the wizard should advance to Stage 2 (layout) because // After generation the wizard should advance to Stage 2 (layout) because
// a CAL_ .ti1 now exists and the session is in calibration mode. // a CAL_ .ti1 now exists and the session is in calibration mode.
let layout = app.buttons["btnCreateLayout"] let layout = app.buttons["btnCreateLayout"]
if !layout.waitForExistence(timeout: 25) {
// The generate tap can be dropped while the dashboard is still
// settling after the stage transition; retry once before failing.
if calGenerate.waitForExistence(timeout: 2) {
calGenerate.tap()
}
XCTAssertTrue(layout.waitForExistence(timeout: 25)) XCTAssertTrue(layout.waitForExistence(timeout: 25))
} }
}
/// A failing calibration targen surfaces the error through the /// A failing calibration targen surfaces the error through the
/// wizard notice and restores the original basename (issue #80). /// wizard notice and restores the original basename (issue #80).