diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift index 7e4d499..d3a7c29 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -9,6 +9,7 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { case chartreadFailed(String) case averageFailed(String) case colprofFailed(String) + case printcalFailed(String) case applycalFailed(String) case iccgamutFailed(String) case profcheckFailed(String) @@ -30,6 +31,8 @@ public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable { return "Averaging failed: \(reason)" case .colprofFailed(let reason): return "Profile creation failed: \(reason)" + case .printcalFailed(let reason): + return "Calibration curve computation failed: \(reason)" case .applycalFailed(let reason): return "Apply calibration failed: \(reason)" case .iccgamutFailed(let reason): @@ -755,6 +758,82 @@ public struct ArgyllRunner: Sendable { await processManager.kill(id: processId) } } + + // MARK: - Stage 0 calibration + + /// Generates a calibration wedge `.ti1`. + public func runCalibrationTargen( + config: CalibrationTargenConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try CalibrationTargenArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let calBasename = config.basename.hasPrefix("CAL_") ? config.basename : "CAL_\(config.basename)" + let cleanBasename = try PathSecurity.sanitizeBasename(calBasename) + let binaryURL = binaryResolver.resolve("targen") + let processId = ProcessID.targen(cleanBasename) + + await ensureNotRunning(id: processId) + let events = processManager.events() + try await processManager.runStreaming( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + let run = await collect(id: processId, events: events, onLogBatch: onLogBatch) + + guard run.exitCode == 0 else { + throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines) + } + + let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1") + guard FileManager.default.fileExists(atPath: ti1URL.path) else { + throw ArgyllRunnerError.missingArtefact(ti1URL.path) + } + return ti1URL + } + + /// Computes a `.cal` curve from a measured `CAL_*.ti3`. + /// + /// `printcal` is captured (not streamed) and is exempt from the `-u` + /// JSON policy. + public func runPrintcal( + config: PrintcalConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let args = try PrintcalArgs.build(config: config) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let binaryURL = binaryResolver.resolve("printcal") + let calBasename = config.ti3Basename.hasPrefix("CAL_") ? config.ti3Basename : "CAL_\(config.ti3Basename)" + let processId = ProcessID.printcal(calBasename) + + await ensureNotRunning(id: processId) + let result = try await processManager.runCaptured( + id: processId, + binary: binaryURL, + arguments: args, + workingDirectory: cwd + ) + + if let onLogBatch = onLogBatch, !result.stdout.isEmpty { + onLogBatch(result.stdout.components(separatedBy: .newlines)) + } + + guard result.exitCode == 0 else { + throw ArgyllRunnerError.printcalFailed( + result.stderr.isEmpty + ? "printcal exited with code \(result.exitCode)" + : result.stderr + ) + } + + let calURL = config.outputURL + guard FileManager.default.fileExists(atPath: calURL.path) else { + throw ArgyllRunnerError.missingArtefact(calURL.path) + } + return calURL + } } /// Events emitted by a running `chartread` session. diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift index 3cda13c..c1ee0d2 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift @@ -15,8 +15,9 @@ public enum AppPaths { /// `~/Library/Application Support/com.gronod.iccery2` /// - /// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run - /// against an isolated root and never touch the developer's state. + /// DEBUG only: `ICCERY_TEST_ROOT` or `ICCERY_TEST_WORKDIR` redirect app + /// data so UI tests run against an isolated root and never touch the + /// developer's state. public static var appDataDir: URL { #if DEBUG if let root = testRoot { @@ -42,10 +43,31 @@ public enum AppPaths { } #if DEBUG + /// DEBUG-only root override. Order: + /// 1. `ICCERY_TEST_ROOT` for an explicit test root. + /// 2. `ICCERY_TEST_WORKDIR` so the app data and log files live next to + /// the current UI test's working directory. + /// 3. `ICCERY_UI_TESTING=1` creates a per-process temp root so a UI test + /// that sets neither of the above still runs in isolation. + /// + /// Computed from `ProcessInfo` each call — no mutable static state. private static var testRoot: URL? { - guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], - !raw.isEmpty else { return nil } - return URL(fileURLWithPath: raw, isDirectory: true) + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_WORKDIR"], + !raw.isEmpty { + return URL(fileURLWithPath: raw, isDirectory: true) + } + if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" { + return FileManager.default.temporaryDirectory + .appendingPathComponent( + "iccery-ui-\(ProcessInfo.processInfo.processIdentifier)", + isDirectory: true + ) + } + return nil } #endif diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift new file mode 100644 index 0000000..3b3904f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationStore.swift @@ -0,0 +1,203 @@ +import Foundation + +/// A single channel's calibration curve. +public struct CalibrationCurve: Sendable, Equatable { + public let channel: Character + public let input: [Double] + public let output: [Double] + + public init(channel: Character, input: [Double], output: [Double]) { + self.channel = channel + self.input = input + self.output = output + } +} + +/// Parsed Argyll `.cal` curve data. +public struct CalibrationData: Sendable, Equatable { + public var colorRep: String + public var descriptor: String? + public var created: Date? + public var maxTac: Double? + public var inkLimits: [Character: Double] + public var curves: [CalibrationCurve] + + public init( + colorRep: String = "", + descriptor: String? = nil, + created: Date? = nil, + maxTac: Double? = nil, + inkLimits: [Character: Double] = [:], + curves: [CalibrationCurve] = [] + ) { + self.colorRep = colorRep + self.descriptor = descriptor + self.created = created + self.maxTac = maxTac + self.inkLimits = inkLimits + self.curves = curves + } +} + +/// Errors from loading and parsing a `.cal` file. +public enum CalibrationStoreError: Error, Equatable { + case unreadableFile + case missingColorRep + case missingCurveData + case unsupportedFormat + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .unreadableFile: + return "Could not read the calibration file." + case .missingColorRep: + return "The .cal file is missing its COLOR_REP header." + case .missingCurveData: + return "The .cal file contains no calibration curve data." + case .unsupportedFormat: + return "The .cal file format is not supported." + case .parseFailed(let reason): + return "Calibration parse failed: \(reason)" + } + } +} + +/// Store for a calibration curve, its metadata, and staleness checks. +public actor CalibrationStore { + + public private(set) var data: CalibrationData? + public private(set) var sourceURL: URL? + public private(set) var storedPrinterName: String? + + /// Number of days after which a calibration is considered stale. + public var staleDays: Int + + public init(staleDays: Int = 30) { + self.staleDays = staleDays + } + + /// Load and parse a `.cal` file. + public func load(url: URL) async throws { + let dataset = try CGATSParser.parse(url: url) + + guard let colorRep = dataset.colorRep, !colorRep.isEmpty else { + throw CalibrationStoreError.missingColorRep + } + + var data = CalibrationData() + data.colorRep = colorRep + data.descriptor = dataset.keywords["DESCRIPTOR"] + + if let createdString = dataset.keywords["CREATED"] { + let formatter = ISO8601DateFormatter() + data.created = formatter.date(from: createdString) + ?? Date(timeIntervalSince1970: 0) + } else { + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + data.created = attrs?[.modificationDate] as? Date + } + + let limitKeys = ["MAX_TAC", "TOTAL_INK_LIMIT", "INK_LIMIT"] + for key in limitKeys { + if let raw = dataset.keywords[key], let value = Double(raw) { + data.maxTac = value + break + } + } + + for (key, raw) in dataset.keywords where key.hasPrefix("INK_LIMIT_") { + let suffix = key.dropFirst("INK_LIMIT_".count) + guard let channel = suffix.first, let value = Double(raw) else { continue } + data.inkLimits[channel] = value + } + + data.curves = try Self.extractCurves(from: dataset) + guard !data.curves.isEmpty else { + throw CalibrationStoreError.missingCurveData + } + + self.data = data + self.sourceURL = url + + // Printer name may live in a sidecar JSON. For now, fall back to the + // descriptor so callers have something to compare. + self.storedPrinterName = data.descriptor + } + + /// Store an explicit printer name (e.g. from a sidecar). + public func setPrinterName(_ name: String?) { + self.storedPrinterName = name + } + + /// True if the loaded calibration is older than `staleDays` or the + /// printer name does not match. + public func isStale(comparedTo currentPrinter: String? = nil) -> Bool { + guard let data else { return true } + + if let created = data.created, + let threshold = Calendar.current.date(byAdding: .day, value: staleDays, to: created), + Date() > threshold { + return true + } + + if let stored = storedPrinterName, !stored.isEmpty, + let current = currentPrinter, !current.isEmpty, + stored != current { + return true + } + + return false + } + + // MARK: - Internals + + private static func extractCurves(from dataset: CGATSDataset) throws -> [CalibrationCurve] { + // Argyll .cal files contain an INPUT_VALUE column and one or more + // per-channel output columns. Field names vary by COLOR_REP. + let outputFields = dataset.fieldNames.filter { $0 != "SAMPLE_ID" && $0 != "SAMPLE_LOC" && $0 != "INPUT_VALUE" } + guard !outputFields.isEmpty else { + // Older .cal files may only have one output column named OUTPUT_VALUE. + if dataset.fieldNames.contains("OUTPUT_VALUE") { + return [try buildCurve(channel: "K", field: "OUTPUT_VALUE", dataset: dataset)] + } + throw CalibrationStoreError.missingCurveData + } + + var curves = [CalibrationCurve]() + for field in outputFields { + let channel = field.first ?? "?" + let curve = try buildCurve(channel: channel, field: field, dataset: dataset) + curves.append(curve) + } + return curves + } + + private static func buildCurve( + channel: Character, + field: String, + dataset: CGATSDataset + ) throws -> CalibrationCurve { + var input = [Double]() + var output = [Double]() + + for sample in dataset.samples { + guard let inRaw = sample.values["INPUT_VALUE"] ?? sample.values[field], + let inVal = parseNumber(inRaw), + let outRaw = sample.values[field], + let outVal = parseNumber(outRaw) else { + throw CalibrationStoreError.parseFailed("Non-numeric curve value in \(field)") + } + input.append(inVal) + output.append(outVal) + } + + return CalibrationCurve(channel: channel, input: input, output: output) + } + + private static func parseNumber(_ raw: String) -> Double? { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.number(from: raw)?.doubleValue + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift new file mode 100644 index 0000000..bccaa02 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/CalibrationTargenArgs.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Errors during calibration `targen` argv construction. +public enum CalibrationTargenArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidSteps(Int) + case invalidInkLimit(Int) + case invalidWhitePatches(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidSteps(let steps): + return "Calibration steps must be 11–51, got: \(steps)" + case .invalidInkLimit(let limit): + return "Calibration ink limit must be 200–400, got: \(limit)" + case .invalidWhitePatches(let count): + return "Calibration white patches cannot be negative, got: \(count)" + } + } +} + +/// Configuration for a calibration wedge `targen` run. +public struct CalibrationTargenConfig: Sendable, Equatable { + public var colourSpace: ColourSpace + public var steps: Int + public var whitePatches: Int + public var includeNeutralEmphasis: Bool + public var inkLimit: Int? + public var basename: String + public var workingDirectory: URL? + + public init( + colourSpace: ColourSpace = .rgb, + steps: Int = 21, + whitePatches: Int = 4, + includeNeutralEmphasis: Bool = false, + inkLimit: Int? = nil, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.colourSpace = colourSpace + self.steps = steps + self.whitePatches = whitePatches + self.includeNeutralEmphasis = includeNeutralEmphasis + self.inkLimit = inkLimit + self.basename = basename + self.workingDirectory = workingDirectory + } +} + +/// Pure argv builder for the Stage 0 calibration `targen` chart. +/// +/// Produces a per-channel wedge with `-f 0` (no full-spread patches). +public enum CalibrationTargenArgs { + + /// Builds `targen -v -d {2|4} -s N -g N [-n N] -e W [-l TAC] -f 0 CAL_basename`. + public static func build(config: CalibrationTargenConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard (11...51).contains(config.steps) else { + throw CalibrationTargenArgError.invalidSteps(config.steps) + } + guard config.whitePatches >= 0 else { + throw CalibrationTargenArgError.invalidWhitePatches(config.whitePatches) + } + + var args: [String] = [ + "-v", + "-d", config.colourSpace.dFlagValue, + "-s", "\(config.steps)", + "-g", "\(config.steps)", + "-e", "\(config.whitePatches)", + "-f", "0" + ] + + if config.includeNeutralEmphasis { + args.append(contentsOf: ["-n", "\(config.steps)"]) + } + + if config.colourSpace == .cmyk, let inkLimit = config.inkLimit { + guard (200...400).contains(inkLimit) else { + throw CalibrationTargenArgError.invalidInkLimit(inkLimit) + } + args.append(contentsOf: ["-l", "\(inkLimit)"]) + } + + let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" + args.append(calBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift new file mode 100644 index 0000000..bdee5e2 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Profile/PrintcalArgs.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Errors during `printcal` argv construction. +public enum PrintcalArgError: LocalizedError, Equatable, Sendable { + case invalidBasename(String) + case invalidTotalInkLimit(Double) + case invalidPerChannelLimit(Character, Double) + case invalidOutputPath + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid calibration basename: \(name)" + case .invalidTotalInkLimit(let limit): + return "Total ink limit must be positive, got: \(limit)" + case .invalidPerChannelLimit(let channel, let limit): + return "\(channel) channel limit must be 0–100, got: \(limit)" + case .invalidOutputPath: + return "Invalid .cal output path" + } + } +} + +/// Per-channel ink limit for `printcal -x{C|M|Y|K} pct`. +public struct PrintcalChannelLimit: Sendable, Equatable { + public let channel: Character + public let percent: Double + + public init(channel: Character, percent: Double) { + self.channel = channel + self.percent = percent + } +} + +/// Configuration for an Argyll `printcal` run. +public struct PrintcalConfig: Sendable, Equatable { + public var ti3Basename: String + public var workingDirectory: URL? + public var outputURL: URL + public var noInkLimit: Bool + public var verify: Bool + public var previousCalPath: String? + public var totalInkLimit: Double? + public var channelLimits: [PrintcalChannelLimit] + + public init( + ti3Basename: String, + workingDirectory: URL? = nil, + outputURL: URL, + noInkLimit: Bool = false, + verify: Bool = false, + previousCalPath: String? = nil, + totalInkLimit: Double? = nil, + channelLimits: [PrintcalChannelLimit] = [] + ) { + self.ti3Basename = ti3Basename + self.workingDirectory = workingDirectory + self.outputURL = outputURL + self.noInkLimit = noInkLimit + self.verify = verify + self.previousCalPath = previousCalPath + self.totalInkLimit = totalInkLimit + self.channelLimits = channelLimits + } +} + +/// Pure argv builder for Argyll's `printcal` tool. +/// +/// `printcal` is captured, not streamed. JS never sends `-u` (unapply). +public enum PrintcalArgs { + + /// Builds `printcal -v -e [-I] [-z] [-a previous.cal] [-m TAC] + /// [-xC pct]... -o out.cal CAL_basename`. + public static func build(config: PrintcalConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.ti3Basename) + guard !cleanBasename.isEmpty else { + throw PrintcalArgError.invalidBasename(config.ti3Basename) + } + + var args: [String] = ["-v", "-e"] + + if config.noInkLimit { + args.append("-I") + } + if config.verify { + args.append("-z") + } + if let previous = config.previousCalPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !previous.isEmpty { + args.append(contentsOf: ["-a", previous]) + } + if let tac = config.totalInkLimit, tac > 0 { + args.append(contentsOf: ["-m", String(format: "%.1f", tac)]) + } else if let tac = config.totalInkLimit { + throw PrintcalArgError.invalidTotalInkLimit(tac) + } + + for limit in config.channelLimits { + guard (0...100).contains(limit.percent) else { + throw PrintcalArgError.invalidPerChannelLimit(limit.channel, limit.percent) + } + args.append(contentsOf: ["-x\(limit.channel)", String(format: "%.1f", limit.percent)]) + } + + guard !config.outputURL.path.isEmpty else { + throw PrintcalArgError.invalidOutputPath + } + args.append(contentsOf: ["-o", config.outputURL.path]) + + let calBasename = cleanBasename.hasPrefix("CAL_") ? cleanBasename : "CAL_\(cleanBasename)" + args.append(calBasename) + return args + } +} diff --git a/Sources/ICCery/CalibrationView.swift b/Sources/ICCery/CalibrationView.swift new file mode 100644 index 0000000..07e197a --- /dev/null +++ b/Sources/ICCery/CalibrationView.swift @@ -0,0 +1,111 @@ +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration dashboard (issue #29, docs/07). +struct CalibrationView: View { + @Bindable var model: CalibrationViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Calibrate Printer") + .font(.title2.bold()) + .padding(.horizontal, 16) + .padding(.top, 16) + + Form { + Section("Wedge Settings") { + Picker("Colour Space", selection: $model.colourSpace) { + Text("RGB").tag(ColourSpace.rgb) + Text("CMYK").tag(ColourSpace.cmyk) + } + + HStack { + Text("Steps per channel") + Spacer() + TextField("", value: $model.steps, format: .number) + .frame(width: 60) + .accessibilityIdentifier("calSteps") + } + + HStack { + Text("White patches") + Spacer() + TextField("", value: $model.whitePatches, format: .number) + .frame(width: 60) + } + + if model.colourSpace == .cmyk { + HStack { + Text("Ink-limit exploration") + Spacer() + TextField("", text: $model.inkLimit) + .frame(width: 60) + .accessibilityIdentifier("calInkExplore") + } + } + + Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis) + } + + Section("Workflow") { + HStack(spacing: 12) { + Button("Generate Target") { model.generateTarget() } + .accessibilityIdentifier("btnCalGenerate") + .disabled(!model.canGenerate) + + Button("Create Layout & Print") { model.createLayout() } + .accessibilityIdentifier("btnCalLayout") + .disabled(!model.canGenerate) + + Button("Measure") { model.measureChart() } + .accessibilityIdentifier("btnCalMeasure") + .disabled(model.calibrationTi3URL == nil) + + Button("Compute Curves") { model.computeCurves() } + .accessibilityIdentifier("btnCalCompute") + .disabled(!model.canCompute) + } + + if let url = model.computedCalURL { + Toggle("Apply calibration to next profile", isOn: $model.applyToProfile) + .onChange(of: model.applyToProfile) { model.updateApplyToProfile() } + .accessibilityIdentifier("calApplyToggle") + + Text("Loaded: \(url.lastPathComponent)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !model.calibrationLog.isEmpty { + Section("Log") { + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(model.calibrationLog, id: \.self) { line in + Text(line) + .font(.system(.caption, design: .monospaced)) + } + } + } + .frame(minHeight: 80, maxHeight: 120) + } + } + + if let error = model.lastError { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("Return to Profiling") { model.returnToProfiling() } + .accessibilityIdentifier("btnCalReturn") + } + .padding(16) + } + } +} diff --git a/Sources/ICCery/CalibrationViewModel.swift b/Sources/ICCery/CalibrationViewModel.swift new file mode 100644 index 0000000..64b8a79 --- /dev/null +++ b/Sources/ICCery/CalibrationViewModel.swift @@ -0,0 +1,209 @@ +import Foundation +import Observation +import SwiftUI +import ICCeryCore + +/// Stage 0 calibration workflow: generate wedge, print, measure, and +/// compute `.cal` curves. +@MainActor +@Observable +final class CalibrationViewModel { + + let workflow: TargetWorkflowViewModel + let profile: ProfileWorkflowViewModel + let environment: AppEnvironment + + // MARK: - Form state + + var colourSpace: ColourSpace = .cmyk + var steps: Int = 21 + var whitePatches: Int = 4 + var includeNeutralEmphasis: Bool = false + var inkLimit: String = "320" + var applyToProfile: Bool = false + var computedCalURL: URL? + var calibrationLog: [String] = [] + var isGenerating = false + var isComputing = false + var lastError: String? + + private var originalBasename: String = "" + + init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.profile = profile + self.environment = environment + } + + private var wizard: WizardViewModel { workflow.wizard } + + // MARK: - Derived + + var canGenerate: Bool { + !wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isGenerating + } + + var canCompute: Bool { + calibrationTi3URL != nil && !isComputing + } + + var calibrationTi3URL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).ti3") + } + + private var calBasename: String { + originalBasename.isEmpty ? "CAL_\(wizard.basename)" : "CAL_\(originalBasename)" + } + + private var calOutputURL: URL? { + guard let cwd = wizard.effectiveWorkingDirectory else { return nil } + return cwd.appendingPathComponent("\(calBasename).cal") + } + + // MARK: - Generate calibration target + + func generateTarget() { + guard canGenerate, let cwd = wizard.effectiveWorkingDirectory else { return } + originalBasename = wizard.basename + wizard.basename = calBasename + wizard.sessionMode = .calibration + + isGenerating = true + calibrationLog = [] + lastError = nil + + let config = CalibrationTargenConfig( + colourSpace: colourSpace, + steps: steps, + whitePatches: whitePatches, + includeNeutralEmphasis: includeNeutralEmphasis, + inkLimit: inkLimitValue, + basename: originalBasename, + workingDirectory: cwd + ) + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isGenerating = false } + + do { + _ = try await self.environment.runner.runCalibrationTargen(config: config) { batch in + Task { @MainActor [weak self] in + self?.calibrationLog.append(contentsOf: batch) + } + } + self.wizard.refreshGating() + self.wizard.showNotice("Calibration target generated.") + self.wizard.go(to: .layOutPrint) + } catch { + self.lastError = error.localizedDescription + self.wizard.showNotice( + "Calibration target failed: \(error.localizedDescription)", + kind: .error + ) + self.restoreProfileBasename() + } + } + } + + // MARK: - Layout, print, measure + + /// Hand off to the normal Stage 2/3 machinery using the `CAL_` basename. + /// After measurement, the user returns and presses Compute Curves. + func createLayout() { + wizard.sessionMode = .calibration + wizard.go(to: .layOutPrint) + } + + func measureChart() { + wizard.sessionMode = .calibration + wizard.go(to: .measure) + } + + // MARK: - Compute curves + + func computeCurves() { + guard canCompute, + let cwd = wizard.effectiveWorkingDirectory, + let outputURL = calOutputURL else { return } + + // Collision check: the Argyll `printcal` exit error contains + // "already exists" when the user declines overwrite. We do not + // silently clobber. + if FileManager.default.fileExists(atPath: outputURL.path) { + lastError = "\(outputURL.lastPathComponent) already exists. Rename or overwrite it first." + wizard.showNotice(lastError!, kind: .error) + return + } + + isComputing = true + calibrationLog = [] + lastError = nil + + let config = PrintcalConfig( + ti3Basename: calBasename, + workingDirectory: cwd, + outputURL: outputURL, + noInkLimit: false, + verify: false, + previousCalPath: nil, + totalInkLimit: inkLimitValue.map { Double($0) }, + channelLimits: [] + ) + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isComputing = false } + + do { + let url = try await self.environment.runner.runPrintcal(config: config) { batch in + Task { @MainActor [weak self] in + self?.calibrationLog.append(contentsOf: batch) + } + } + self.computedCalURL = url + self.profile.calibrationFile = url.path + self.profile.applyCalibration = self.applyToProfile + self.wizard.showNotice("Calibration curves computed.") + } catch { + self.lastError = error.localizedDescription + self.wizard.showNotice( + "Calibration curve computation failed: \(error.localizedDescription)", + kind: .error + ) + } + } + } + + // MARK: - Apply toggle + + func updateApplyToProfile() { + profile.applyCalibration = applyToProfile + if applyToProfile, let url = computedCalURL { + profile.calibrationFile = url.path + } else if applyToProfile { + // User toggled on before computing; keep the path if already set. + } else { + profile.applyCalibration = false + } + } + + func returnToProfiling() { + restoreProfileBasename() + wizard.sessionMode = .profile + wizard.go(to: .generate) + } + + private func restoreProfileBasename() { + if !originalBasename.isEmpty { + wizard.basename = originalBasename + originalBasename = "" + } + } + + private var inkLimitValue: Int? { + guard colourSpace == .cmyk else { return nil } + return Int(inkLimit) + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 13c99b1..002a729 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -53,7 +53,10 @@ struct RootView: View { .sheet(isPresented: $showingAbout) { AboutView { showingAbout = false } } - .sheet(isPresented: $workflow.wizard.showingGamutViewer) { + .sheet(isPresented: Binding( + get: { workflow.wizard.showingGamutViewer }, + set: { workflow.wizard.showingGamutViewer = $0 } + )) { GamutView(profileGamURL: workflow.wizard.gamutProfileURL) } } @@ -71,7 +74,9 @@ struct RootView: View { Stage4View(model: workflow.profile) case .verifyInstall: Stage5View(model: workflow.profile) - default: + case .calibrate: + CalibrationView(model: workflow.calibration) + @unknown default: StagePlaceholderView(stage: model.stage) } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0573885..bb7301d 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -74,14 +74,13 @@ struct SidebarView: View { .padding(.horizontal, 12) .padding(.bottom, 8) - // Calibrate Printer (`#btnCalibratePrinter`). Disabled until - // Stage 0 lands in issue #29; `#calStatusChip` likewise. + // Calibrate Printer (`#btnCalibratePrinter`). Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") .frame(maxWidth: .infinity) } .controlSize(.large) - .disabled(true) + .accessibilityIdentifier("btnCalibratePrinter") .padding(.horizontal, 12) Button(action: { model.openGamut(profileGamURL: workflow.profile.createdGamutURL) }) { diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift index 18b6358..503f08e 100644 --- a/Sources/ICCery/Stage1View.swift +++ b/Sources/ICCery/Stage1View.swift @@ -85,6 +85,7 @@ struct Stage1View: View { Button("Browse…") { workflow.browseForTargetFile() } .accessibilityIdentifier("btnBrowse") Button("Working Dir…") { workflow.browseForWorkingDirectory() } + .accessibilityIdentifier("btnSelectWorkDir") Button("Open Existing…") { workflow.openExistingTarget() } .accessibilityIdentifier("btnOpenExisting") Button("Import Dataset…") { workflow.importMeasurementDataset() } diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 57873a2..960bf40 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -123,8 +123,10 @@ final class TargetWorkflowViewModel { /// across stage switches and can observe settings changes. var measurement: MeasurementWorkflowViewModel /// Stage 4/5 profile workflow, owned at the app level so it persists - /// across stage switches and can apply preset values. + /// across stage switches and can observe preset values. var profile: ProfileWorkflowViewModel + /// Stage 0 calibration workflow. + var calibration: CalibrationViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment @@ -137,6 +139,12 @@ final class TargetWorkflowViewModel { wizard: wizard, environment: environment ) + self.calibration = nil + self.calibration = CalibrationViewModel( + workflow: self, + profile: self.profile, + environment: environment + ) reloadPresets() } @@ -339,6 +347,8 @@ final class TargetWorkflowViewModel { customLabel: labelIsCustom ? customLabel : nil, basename: wizard.basename, metadata: labelMetadata), + calibrationFile: profile.applyCalibration ? profile.calibrationFile : nil, + calibrationEmbedOnly: false, basename: wizard.basename, workingDirectory: wizard.effectiveWorkingDirectory ) diff --git a/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift new file mode 100644 index 0000000..abe5696 --- /dev/null +++ b/Tests/ICCeryCoreTests/ArgyllRunnerCalibrationTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("ArgyllRunner Calibration") +struct ArgyllRunnerCalibrationTests { + + private func makeRunner() -> ArgyllRunner { + let binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("ICCeryUITests/Fixtures/bin") + return ArgyllRunner( + processManager: .shared, + binaryResolver: BinaryResolver(overrideDir: binDir) + ) + } + + private func makeTestDir() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("calibration-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + @Test("Calibration targen produces CAL_*.ti1") + func calibrationTargenProducesTi1() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + basename: "demo", + workingDirectory: testRoot + ) + + let url = try await runner.runCalibrationTargen(config: config) + + #expect(url.lastPathComponent == "CAL_demo.ti1") + #expect(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + @Test("printcal captured run creates .cal") + func printcalProducesCal() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + let url = try await runner.runPrintcal(config: config) + + #expect(url.lastPathComponent == "CAL_demo.cal") + #expect(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: testRoot) + } + + @Test("printcal failure throws printcalFailed") + func printcalFailureThrows() async throws { + let testRoot = try makeTestDir() + let runner = makeRunner() + let output = testRoot.appendingPathComponent("CAL_demo.cal") + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + workingDirectory: testRoot, + outputURL: output + ) + + setenv("ICCERY_MOCK_PRINTCAL_EXIT", "1", 1) + defer { unsetenv("ICCERY_MOCK_PRINTCAL_EXIT") } + + await #expect(throws: (any Error).self) { + _ = try await runner.runPrintcal(config: config) + } + try? FileManager.default.removeItem(at: testRoot) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationStoreTests.swift b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift new file mode 100644 index 0000000..8b7ab56 --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationStoreTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CalibrationStore") +struct CalibrationStoreTests { + + private static let sampleCal = """ + CTI3 + DESCRIPTOR "Test printer" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + MAX_TAC "300" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + @Test("Loads metadata and curves from .cal") + func parseCal() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("test_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 30) + try await store.load(url: url) + + let data = await store.data + #expect(data?.colorRep == "RGB") + #expect(data?.descriptor == "Test printer") + #expect(data?.maxTac == 300) + #expect(data?.curves.count == 3) + + let r = data?.curves.first { $0.channel == "R" } + #expect(r?.output == [0, 64, 255]) + } + + @Test("Staleness is true for a very old calibration") + func staleCalibration() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("stale_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 0) + try await store.load(url: url) + let stale = await store.isStale(comparedTo: "Other") + #expect(stale == true) + } + + @Test("Printer mismatch is flagged as stale") + func printerMismatch() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("mismatch_\(UUID().uuidString).cal") + try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8) + + let store = CalibrationStore(staleDays: 9999) + try await store.load(url: url) + await store.setPrinterName("Printer A") + let stale = await store.isStale(comparedTo: "Printer B") + #expect(stale == true) + } +} diff --git a/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift new file mode 100644 index 0000000..c43313a --- /dev/null +++ b/Tests/ICCeryCoreTests/CalibrationTargenArgsTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("CalibrationTargenArgs") +struct CalibrationTargenArgsTests { + + @Test("RGB baseline") + func rgbBaseline() throws { + let config = CalibrationTargenConfig( + colourSpace: .rgb, + steps: 21, + whitePatches: 4, + basename: "demo", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + #expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"]) + } + + @Test("CMYK baseline with ink limit and neutral emphasis") + func cmykWithOptions() throws { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + steps: 25, + whitePatches: 4, + includeNeutralEmphasis: true, + inkLimit: 320, + basename: "printer", + workingDirectory: URL(fileURLWithPath: "/tmp") + ) + let args = try CalibrationTargenArgs.build(config: config) + #expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"]) + } + + @Test("Rejects out-of-range steps") + func rejectsBadSteps() { + let config = CalibrationTargenConfig(steps: 5, basename: "demo") + #expect(throws: (any Error).self) { + _ = try CalibrationTargenArgs.build(config: config) + } + } + + @Test("Rejects bad CMYK ink limit") + func rejectsBadInkLimit() { + let config = CalibrationTargenConfig( + colourSpace: .cmyk, + inkLimit: 500, + basename: "demo" + ) + #expect(throws: (any Error).self) { + _ = try CalibrationTargenArgs.build(config: config) + } + } + + @Test("Does not double-prefix an existing CAL_ basename") + func noDoublePrefix() throws { + let config = CalibrationTargenConfig(basename: "CAL_test") + let args = try CalibrationTargenArgs.build(config: config) + #expect(args.last == "CAL_test") + } +} diff --git a/Tests/ICCeryCoreTests/PrintcalArgsTests.swift b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift new file mode 100644 index 0000000..e13af6f --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintcalArgsTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import ICCeryCore + +@Suite("PrintcalArgs") +struct PrintcalArgsTests { + + private let tmp = URL(fileURLWithPath: "/tmp/out.cal") + + @Test("Default printcal argv") + func defaults() throws { + let config = PrintcalConfig( + ti3Basename: "CAL_demo", + outputURL: tmp + ) + let args = try PrintcalArgs.build(config: config) + #expect(args == ["-v", "-e", "-o", "/tmp/out.cal", "CAL_demo"]) + } + + @Test("All options and channel limits") + func allOptions() throws { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + noInkLimit: true, + verify: true, + previousCalPath: "/tmp/old.cal", + totalInkLimit: 280, + channelLimits: [ + PrintcalChannelLimit(channel: "C", percent: 95), + PrintcalChannelLimit(channel: "M", percent: 90) + ] + ) + let args = try PrintcalArgs.build(config: config) + #expect(args == [ + "-v", "-e", + "-I", "-z", + "-a", "/tmp/old.cal", + "-m", "280.0", + "-xC", "95.0", + "-xM", "90.0", + "-o", "/tmp/out.cal", + "CAL_demo" + ]) + } + + @Test("Rejects invalid per-channel limit") + func rejectsBadChannelLimit() { + let config = PrintcalConfig( + ti3Basename: "demo", + outputURL: tmp, + channelLimits: [PrintcalChannelLimit(channel: "K", percent: 150)] + ) + #expect(throws: (any Error).self) { + _ = try PrintcalArgs.build(config: config) + } + } + +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/printcal b/Tests/ICCeryUITests/Fixtures/bin/printcal new file mode 100755 index 0000000..a86c770 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/printcal @@ -0,0 +1,25 @@ +#!/bin/sh +# Mock printcal for Stage 0 calibration tests. Creates the .cal named by +# the -o argument in the process working directory. Exit code overridable +# via ICCERY_MOCK_PRINTCAL_EXIT. +output="" +basename="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then + output="$arg" + fi + prev="$arg" +done +# If no -o, derive from the last positional argument. +if [ -z "$output" ]; then + for arg in "$@"; do basename="$arg"; done + output="$basename.cal" +fi +if [ "${ICCERY_MOCK_PRINTCAL_EXIT:-0}" -ne 0 ]; then + echo "mock printcal failure" >&2 + exit "$ICCERY_MOCK_PRINTCAL_EXIT" +fi +echo "ideal power 1.0, device power 0.8" +touch "$output" +exit 0 diff --git a/Tests/ICCeryUITests/Milestone6CGATSUITests.swift b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift index ceb820a..1abe962 100644 --- a/Tests/ICCeryUITests/Milestone6CGATSUITests.swift +++ b/Tests/ICCeryUITests/Milestone6CGATSUITests.swift @@ -44,7 +44,10 @@ final class Milestone6CGATSUITests: XCTestCase { /// Must fail if import presents a save panel or a `.ti1` filter. func testImportUsesOpenPanelNotSaveTi1() throws { app.launch() - app.activate() + + // CGATS import needs a working directory; the env provides one. + XCTAssertTrue(app.buttons["btnSelectWorkDir"].waitForExistence(timeout: 5)) + app.buttons["btnSelectWorkDir"].tap() XCTAssertTrue(app.buttons["btn-import-dataset"].waitForExistence(timeout: 10)) app.buttons["btn-import-dataset"].click() @@ -54,8 +57,7 @@ final class Milestone6CGATSUITests: XCTestCase { XCTAssertFalse(savePanel.exists, "Import must use an open panel, never a save panel.") // The dataset should be accepted and the user should advance to Stage 4. - _ = app.otherElements["stage-4"].waitForExistence(timeout: 10) - XCTAssertTrue(app.otherElements["stage-4"].exists) + XCTAssertTrue(app.staticTexts["stage4TargetBasename"].waitForExistence(timeout: 10)) // The canonical .ti3 should be written next to the source file. let ti3URL = testRoot.appendingPathComponent("imported.ti3") diff --git a/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift new file mode 100644 index 0000000..befa40a --- /dev/null +++ b/Tests/ICCeryUITests/Milestone6CalibrationUITests.swift @@ -0,0 +1,80 @@ +import Foundation +import XCTest + +/// Milestone 6 — Stage 0 printer calibration UI acceptance. +/// +/// Uses the mock Argyll fixtures and UI-test environment flags so no real +/// instrument, printer, or modal file panel is required. +@MainActor +final class Milestone6CalibrationUITests: XCTestCase { + + private var app: XCUIApplication! + private var testWorkDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + + testWorkDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cal-ui-test-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: testWorkDir, + withIntermediateDirectories: true + ) + + let binaryDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_ARGYLL_BINARY_DIR": binaryDir.path, + "ICCERY_TEST_WORKDIR": testWorkDir.path + ] + app.launch() + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testWorkDir { + try? FileManager.default.removeItem(at: testWorkDir) + } + } + + func testCalibrationDashboardOpensAndCanGenerate() throws { + // Set up a target and working directory on Stage 1. + let basename = app.textFields["targetBasename"] + XCTAssertTrue(basename.waitForExistence(timeout: 5)) + basename.tap() + basename.typeText("DemoTarget") + + let workDir = app.buttons["btnSelectWorkDir"] + XCTAssertTrue(workDir.waitForExistence(timeout: 5)) + workDir.tap() + + let generate = app.buttons["btnGenerate"] + XCTAssertTrue(generate.waitForExistence(timeout: 5)) + generate.tap() + + // Open the calibration dashboard once Stage 2 is reached. + let advance = app.buttons["btnAdvanceToStage3"] + XCTAssertTrue(advance.waitForExistence(timeout: 10)) + + let calButton = app.buttons["btnCalibratePrinter"] + XCTAssertTrue(calButton.waitForExistence(timeout: 5)) + calButton.tap() + + XCTAssertTrue(app.staticTexts["Calibrate Printer"].waitForExistence(timeout: 5)) + + // Start the calibration wedge. The mock targen will create CAL_DemoTarget.ti1. + let calGenerate = app.buttons["btnCalGenerate"] + XCTAssertTrue(calGenerate.waitForExistence(timeout: 5)) + calGenerate.tap() + + // After generation the wizard should advance to Stage 2 (layout) because + // a CAL_ .ti1 now exists and the session is in calibration mode. + let layout = app.buttons["btnCreateLayout"] + XCTAssertTrue(layout.waitForExistence(timeout: 10)) + } +}