diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift new file mode 100644 index 0000000..3d8b82d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/ArgyllRunner.swift @@ -0,0 +1,207 @@ +import Foundation + +/// Errors from `ArgyllRunner` executions. +public enum ArgyllRunnerError: LocalizedError, Equatable { + case processFailed(code: Int32, logs: [String]) + case missingArtefact(String) + case malformedManifest(String) + + public var errorDescription: String? { + switch self { + case .processFailed(let code, _): + return "Process exited with code \(code)" + case .missingArtefact(let path): + return "Expected output file was not created: \(path)" + case .malformedManifest(let reason): + return "Failed to parse printtarg manifest: \(reason)" + } + } +} + +/// Result of a successful `printtarg` run: the `.ti2` artefact plus the +/// validated manifest with per-page PNG previews already decoded. +public struct PrinttargResult: Sendable, Equatable { + public let ti2URL: URL + public let manifest: PrinttargManifest + public let pages: [GalleryPage] +} + +/// Service driving Argyll subprocesses off the main actor +/// (docs/03, docs/08, docs/09). +/// +/// - Subscribes to the event bus *before* spawning so no stdout or exit +/// is ever lost (subscription is synchronous in `ProcessManager`). +/// - Accumulates stdout/stderr without touching `@MainActor`; the +/// optional `onLogBatch` callback receives coalesced chunks (20 lines +/// or ~100 ms), never one call per line. +/// - Exit code 0 is necessary but not sufficient: the expected artefact +/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a +/// valid `-u` manifest. +public struct ArgyllRunner: Sendable { + public let processManager: ProcessManager + public let binaryResolver: BinaryResolver + + public init( + processManager: ProcessManager = .shared, + binaryResolver: BinaryResolver = BinaryResolver() + ) { + self.processManager = processManager + self.binaryResolver = binaryResolver + } + + // MARK: - targen (Stage 1) + + /// Runs `targen` streaming, collecting logs and verifying `.ti1` + /// upon completion. Returns the `.ti1` URL. + public func runTargen( + config: TargenConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> URL { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try TargenArgs.build(config: config) + let binaryURL = binaryResolver.resolve("targen") + let processId = ProcessID.targen(cleanBasename) + + 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 + } + + // MARK: - printtarg (Stage 2) + + /// Runs `printtarg` streaming, then parses the `-u` manifest from + /// the complete accumulated stdout and loads each page's PNG + /// preview via `TiffPreview` (host-side, never raw TIFF to the UI). + public func runPrinttarg( + config: PrinttargConfig, + onLogBatch: (@Sendable ([String]) -> Void)? = nil + ) async throws -> PrinttargResult { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory) + let args = try PrinttargArgs.build(config: config) + let binaryURL = binaryResolver.resolve("printtarg") + let processId = ProcessID.printtarg(cleanBasename) + + 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 ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2") + guard FileManager.default.fileExists(atPath: ti2URL.path) else { + throw ArgyllRunnerError.missingArtefact(ti2URL.path) + } + + let manifest: PrinttargManifest + do { + manifest = try PrinttargManifestExtractor.manifest(from: run.stdout) + } catch { + throw ArgyllRunnerError.malformedManifest(error.localizedDescription) + } + + let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in + let fileURL = cwd.appendingPathComponent(page.filename) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: nil, previewError: "File not found" + ) + } + if let png = TiffPreview.previewPNG(tiff: fileURL) { + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: png, previewError: nil + ) + } + return GalleryPage( + index: index, page: page, fileURL: fileURL, + previewPNG: nil, previewError: "Could not decode TIFF" + ) + } + return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages) + } + + // MARK: - Shared collection + + private struct CollectedRun { + var exitCode: Int32? + var stdout: String + var lines: [String] + } + + /// Drains the event stream until this child's `exit` event. + /// stdout is accumulated both per-line (logs) and verbatim (for + /// the manifest parse — the pretty JSON needs its newlines). + private func collect( + id processId: String, + events: AsyncStream, + onLogBatch: (@Sendable ([String]) -> Void)? + ) async -> CollectedRun { + var lines: [String] = [] + var stdout = "" + var pendingBatch: [String] = [] + var exitCode: Int32? + var lastFlush = Date() + + func flush(_ batch: inout [String]) { + guard !batch.isEmpty else { return } + let out = batch + batch.removeAll(keepingCapacity: true) + onLogBatch?(out) + } + + for await event in events { + guard event.id == processId else { continue } + switch event { + case .stdout(_, let line): + lines.append(line) + stdout += line + "\n" + pendingBatch.append(line) + case .stderr(_, let line): + lines.append(line) + pendingBatch.append(line) + case .error(_, let message): + lines.append("Error: \(message)") + pendingBatch.append("Error: \(message)") + case .jsonRow: + // Only chartread emits these; targen/printtarg never do. + break + case .exit(_, let code): + exitCode = code + } + if exitCode == nil, + pendingBatch.count >= 20 + || Date().timeIntervalSince(lastFlush) >= 0.1 { + flush(&pendingBatch) + lastFlush = Date() + } + if exitCode != nil { + flush(&pendingBatch) + break + } + } + return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift new file mode 100644 index 0000000..bd4280c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargArgs.swift @@ -0,0 +1,99 @@ +import Foundation + +/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2). +public enum PrinttargArgError: LocalizedError, Equatable { + case invalidCustomPageDimension(Double) + case invalidDPI(Int) + case invalidSeed(Int) + + public var errorDescription: String? { + switch self { + case .invalidCustomPageDimension(let mm): + return "Custom page dimensions must be at least 50 mm, got: \(mm)" + case .invalidDPI(let dpi): + return "TIFF DPI must be between 72 and 600, got: \(dpi)" + case .invalidSeed(let seed): + return "Custom layout seed must be ≥ 1, got: \(seed)" + } + } +} + +/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2). +/// +/// Contract: +/// ``` +/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename +/// ``` +public enum PrinttargArgs { + + /// Builds the exact command-line arguments for `printtarg`. + /// + /// Invariants: + /// - Always `-v -u` (the fork's `-u` emits the JSON page manifest). + /// - Default layout is deterministic `-R 1` (#163) — a missing seed + /// reshuffles patches on every re-run and desyncs print vs `.ti2`. + /// - `.raster` emits `-r` and supersedes any seed. This is NOT + /// targen's `-r` full-spread algorithm (docs/25). + /// - `-d` is the chart **label** string, not colour space. + /// - `-K`/`-I` are never emitted for `CAL_` basenames — the + /// calibration chart must not embed its own curves. + /// - Basename is the last positional argument. + public static func build(config: PrinttargConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + var args: [String] = [ + "-v", "-u", + "-i", config.instrument.rawValue, + "-p", try pageSizeValue(config), + ] + + switch config.layoutOrder { + case .deterministic: + args.append(contentsOf: ["-R", "1"]) + case .customSeed: + guard config.customSeed >= 1 else { + throw PrinttargArgError.invalidSeed(config.customSeed) + } + args.append(contentsOf: ["-R", "\(config.customSeed)"]) + case .raster: + args.append("-r") + } + + if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines), + !label.isEmpty { + args.append(contentsOf: ["-d", label]) + } + + guard (72...600).contains(config.dpi) else { + throw PrinttargArgError.invalidDPI(config.dpi) + } + args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"]) + + if !cleanBasename.hasPrefix("CAL_"), + let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines), + !cal.isEmpty { + args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal]) + } + + args.append(cleanBasename) + return args + } + + private static func pageSizeValue(_ config: PrinttargConfig) throws -> String { + guard config.pageSize == .custom else { return config.pageSize.rawValue } + for dim in [config.customPageWidth, config.customPageHeight] { + guard dim >= 50 else { + throw PrinttargArgError.invalidCustomPageDimension(dim) + } + } + return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))" + } + + /// Formats millimetres as an integer when exact, else decimal. + private static func formatMM(_ value: Double) -> String { + if value == value.rounded(), abs(value) < 1e15 { + return "\(Int(value))" + } + return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift new file mode 100644 index 0000000..6e93351 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargConfig.swift @@ -0,0 +1,208 @@ +import Foundation + +/// Measurement instrument for `printtarg -i` chart geometry +/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes. +public enum PrintInstrument: String, Codable, Sendable, CaseIterable { + case i1 + case p3 + case cm = "CM" + case ss = "SS" + case dtp20 = "20" + case dtp22 = "22" + case dtp41 = "41" + case dtp51 = "51" + + public var displayName: String { + switch self { + case .i1: return "X-Rite i1Pro / i1Pro 2" + case .p3: return "X-Rite i1Pro 3 / 3 Plus" + case .cm: return "ColorMunki" + case .ss: return "Specbos / Spectraval (XY table)" + case .dtp20: return "Gretag i1Display 2" + case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display" + case .dtp41: return "Datacolor Spyder 4/5" + case .dtp51: return "Spyder X" + } + } +} + +/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm. +public enum PageSize: String, Codable, Sendable, CaseIterable { + case a4 = "A4" + case a4r = "A4R" + case a3 = "A3" + case a2 = "A2" + case letter = "Letter" + case letterR = "LetterR" + case legal = "Legal" + case fourBySix = "4x6" + case elevenBySeventeen = "11x17" + case custom = "custom" + + public var isCustom: Bool { self == .custom } +} + +/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit). +public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable { + case eight = 8 + case sixteen = 16 + + public var flag: String { + switch self { + case .eight: return "-t" + case .sixteen: return "-T" + } + } +} + +/// Patch layout order (docs/09 §Randomisation, #163). +/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and +/// supersedes any seed — never confuse with targen's `-r` algorithm. +public enum LayoutOrder: String, Codable, Sendable, CaseIterable { + case deterministic + case customSeed = "custom_seed" + case raster + + public var displayName: String { + switch self { + case .deterministic: return "Deterministic (seed 1)" + case .customSeed: return "Custom seed" + case .raster: return "Raster order (no shuffle)" + } + } +} + +/// Chart label metadata used to assemble the automatic `printtarg -d` +/// label. Any empty/missing component becomes `Unspecified` until real +/// printer metadata lands in M3. +public struct TargetLabelMetadata: Codable, Equatable, Sendable { + public var printer: String + public var inkSet: String + public var driverPaper: String + public var actualPaper: String + + public init( + printer: String = "", + inkSet: String = "", + driverPaper: String = "", + actualPaper: String = "" + ) { + self.printer = printer + self.inkSet = inkSet + self.driverPaper = driverPaper + self.actualPaper = actualPaper + } +} + +/// Builds the chart legend for `printtarg -d` (fork argyllcms#19, +/// ICCery #119). `-d` here is a **label string** — not targen's colour +/// space, not iccgamut's density (docs/25). +public enum PrinttargLabel { + + public static let unspecified = "Unspecified" + + /// `ICCery - {basename} - {printer} - {ink} - {driverPaper} - + /// {actualPaper} - DD/MM/YYYY HH:MM` + /// + /// `date` is injected for deterministic tests; production passes + /// the current local time. A fixed POSIX locale keeps the format + /// stable regardless of user locale. + public static func automatic( + basename: String, + metadata: TargetLabelMetadata, + date: Date = Date(), + timeZone: TimeZone = .current + ) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "dd/MM/yyyy HH:mm" + + return [ + "ICCery", + basename, + field(metadata.printer), + field(metadata.inkSet), + field(metadata.driverPaper), + field(metadata.actualPaper), + formatter.string(from: date), + ].joined(separator: " - ") + } + + /// Resolves the label to emit: an explicit non-empty manual label + /// wins; otherwise the assembled automatic label. + public static func resolved( + customLabel: String?, + basename: String, + metadata: TargetLabelMetadata, + date: Date = Date(), + timeZone: TimeZone = .current + ) -> String { + if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !label.isEmpty { + return label + } + return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone) + } + + private static func field(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? unspecified : trimmed + } +} + +/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2). +public struct PrinttargConfig: Codable, Equatable, Sendable { + public var instrument: PrintInstrument + public var pageSize: PageSize + /// Custom page dimensions in millimetres; each must be ≥ 50 when + /// `pageSize == .custom`. + public var customPageWidth: Double + public var customPageHeight: Double + public var bitDepth: TiffBitDepth + public var dpi: Int + public var layoutOrder: LayoutOrder + /// Seed for `.customSeed` layout (`-R N`, N ≥ 1). Ignored for + /// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`). + public var customSeed: Int + /// Resolved `-d` label. Callers usually compute this via + /// `PrinttargLabel.resolved` so tests can inject the clock. + public var label: String? + /// `.cal` file applied to printed patches (`-K`), or embedded + /// without applying (`-I` when `calibrationEmbedOnly`). Never + /// emitted for `CAL_` basenames (Stage 0 protection). + public var calibrationFile: String? + public var calibrationEmbedOnly: Bool + public var basename: String + public var workingDirectory: URL? + + public init( + instrument: PrintInstrument = .i1, + pageSize: PageSize = .a4, + customPageWidth: Double = 210, + customPageHeight: Double = 297, + bitDepth: TiffBitDepth = .eight, + dpi: Int = 300, + layoutOrder: LayoutOrder = .deterministic, + customSeed: Int = 1, + label: String? = nil, + calibrationFile: String? = nil, + calibrationEmbedOnly: Bool = false, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.instrument = instrument + self.pageSize = pageSize + self.customPageWidth = customPageWidth + self.customPageHeight = customPageHeight + self.bitDepth = bitDepth + self.dpi = dpi + self.layoutOrder = layoutOrder + self.customSeed = customSeed + self.label = label + self.calibrationFile = calibrationFile + self.calibrationEmbedOnly = calibrationEmbedOnly + self.basename = basename + self.workingDirectory = workingDirectory + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift new file mode 100644 index 0000000..b5c9e70 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/PrinttargManifest.swift @@ -0,0 +1,180 @@ +import Foundation + +/// One page of a `printtarg -u` manifest (docs/05 §2.3). +/// `patches` is a page-assigned count including TID/padding cells, +/// not strictly user patches. +public struct PrinttargPage: Codable, Equatable, Sendable { + public var filename: String + public var patches: Int + public var widthMm: Double + public var heightMm: Double + + public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) { + self.filename = filename + self.patches = patches + self.widthMm = widthMm + self.heightMm = heightMm + } + + enum CodingKeys: String, CodingKey { + case filename, patches + case widthMm = "width_mm" + case heightMm = "height_mm" + } +} + +/// The final-only, pretty-printed, **unprefixed** JSON object emitted +/// by fork `printtarg -u` after all pages are written (docs/05 §2.3). +public struct PrinttargManifest: Codable, Equatable, Sendable { + public var event: String + public var pages: [PrinttargPage] + + public init(event: String = "manifest", pages: [PrinttargPage]) { + self.event = event + self.pages = pages + } +} + +/// A manifest page resolved against the working directory, with its +/// host-side PNG preview (#58 — TIFF is never fed to the UI directly). +public struct GalleryPage: Equatable, Sendable, Identifiable { + public var id: Int { index } + public let index: Int + public let page: PrinttargPage + public let fileURL: URL + public let previewPNG: Data? + public let previewError: String? + + public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) { + self.index = index + self.page = page + self.fileURL = fileURL + self.previewPNG = previewPNG + self.previewError = previewError + } +} + +public enum ManifestError: LocalizedError, Equatable { + case noJSONDocument + case wrongEvent(String) + case decodeFailed(String) + case invalidPage(String) + + public var errorDescription: String? { + switch self { + case .noJSONDocument: + return "No JSON document found in printtarg stdout." + case .wrongEvent(let event): + return "Unexpected JSON event \"\(event)\" — expected \"manifest\"." + case .decodeFailed(let reason): + return "printtarg manifest JSON failed to decode: \(reason)" + case .invalidPage(let reason): + return "printtarg manifest page is invalid: \(reason)" + } + } +} + +/// Extracts and decodes the `printtarg -u` manifest from the complete +/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest). +/// +/// #68 invariant: the JSON is a structured document, not a brace-hunt. +/// Extraction is string/escape-aware — a `{` or `}` inside a quoted +/// filename can never corrupt the scan — and starts only at a `{` that +/// begins a trimmed stdout line. +public enum PrinttargManifestExtractor { + + /// Finds the manifest object in accumulated stdout. + public static func manifest(from stdout: String) throws -> PrinttargManifest { + for block in jsonObjects(in: stdout) { + let data = Data(block.utf8) + guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else { + continue + } + guard manifest.event == "manifest" else { + throw ManifestError.wrongEvent(manifest.event) + } + try validate(manifest) + return manifest + } + if let first = jsonObjects(in: stdout).first, + let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any], + let event = obj["event"] as? String { + throw ManifestError.wrongEvent(event) + } + throw ManifestError.noJSONDocument + } + + private static func validate(_ manifest: PrinttargManifest) throws { + for page in manifest.pages { + guard page.patches >= 0 else { + throw ManifestError.invalidPage("negative patch count \(page.patches)") + } + guard page.widthMm > 0, page.heightMm > 0 else { + throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)") + } + let name = page.filename + guard !name.isEmpty, + !name.hasPrefix("/"), + !name.contains("/"), + !name.contains("\\"), + !name.contains("..") else { + throw ManifestError.invalidPage("unsafe filename \"\(name)\"") + } + let ext = (name as NSString).pathExtension.lowercased() + guard ext == "tif" || ext == "tiff" else { + throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"") + } + } + } + + /// Yields every complete top-level JSON object `{...}` found at a + /// trimmed line boundary, in document order. Depth tracking respects + /// quoted strings and backslash escapes. + static func jsonObjects(in text: String) -> [String] { + var out: [String] = [] + let scalars = Array(text.unicodeScalars) + var i = 0 + + func isLineStart(_ idx: Int) -> Bool { + var j = idx - 1 + while j >= 0 && scalars[j] != "\n" { + if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" { + return false + } + j -= 1 + } + return true + } + + while i < scalars.count { + if scalars[i] == "{", isLineStart(i) { + var depth = 0 + var inString = false + var escaped = false + var j = i + while j < scalars.count { + let c = scalars[j] + if inString { + if escaped { escaped = false } + else if c == "\\" { escaped = true } + else if c == "\"" { inString = false } + } else { + if c == "\"" { inString = true } + else if c == "{" { depth += 1 } + else if c == "}" { + depth -= 1 + if depth == 0 { + out.append(String(String.UnicodeScalarView(scalars[i...j]))) + i = j + break + } + } + } + j += 1 + } + } + i += 1 + } + return out + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift new file mode 100644 index 0000000..b9c14b8 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenArgs.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Errors during `buildTargenArgs` validation (docs/04 §1.2). +public enum TargenArgError: LocalizedError, Equatable { + case invalidBasename(String) + case invalidPatchCount(Int) + case invalidWhitePatches(Int) + case invalidBlackPatches(Int) + case invalidInkLimit(Int) + + public var errorDescription: String? { + switch self { + case .invalidBasename(let name): + return "Invalid target basename: \(name)" + case .invalidPatchCount(let count): + return "Patch count must be positive, got: \(count)" + case .invalidWhitePatches(let count): + return "White patches cannot be negative, got: \(count)" + case .invalidBlackPatches(let count): + return "Black patches cannot be negative, got: \(count)" + case .invalidInkLimit(let limit): + return "Ink limit must be between 1 and 400, got: \(limit)" + } + } +} + +/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2). +public enum TargenArgs { + + /// Builds the exact command-line arguments for `targen`. + /// + /// Invariants: + /// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4). + /// - Never emits `-u` (Argyll fork progress is not enabled for targen). + /// - Always emits `-f N` when patchCount > 0 (#44). + /// - White `-e`, Black `-B`. + /// - `-N` omitted when approximately 0.50. + /// - `-A` is emitted even at 0.10 (no default-skip). + /// - `-l` is CMYK only (1...400). + /// - `-V` omitted when approximately 1.0. + /// - `-p` omitted when non-positive or approximately 1.0. + /// - Basename is the last positional argument. + public static func build(config: TargenConfig) throws -> [String] { + let cleanBasename = try PathSecurity.sanitizeBasename(config.basename) + + guard config.patchCount > 0 else { + throw TargenArgError.invalidPatchCount(config.patchCount) + } + guard config.whitePatches >= 0 else { + throw TargenArgError.invalidWhitePatches(config.whitePatches) + } + guard config.blackPatches >= 0 else { + throw TargenArgError.invalidBlackPatches(config.blackPatches) + } + + var args: [String] = [ + "-v", + "-d", config.colourSpace.dFlagValue, + "-f", "\(config.patchCount)", + "-e", "\(config.whitePatches)", + "-B", "\(config.blackPatches)" + ] + + if let g = config.greySteps, g > 0 { + args.append(contentsOf: ["-g", "\(g)"]) + } + if let s = config.singleChannelSteps, s > 0 { + args.append(contentsOf: ["-s", "\(s)"]) + } + if let n = config.neutralSteps, n > 0 { + args.append(contentsOf: ["-n", "\(n)"]) + } + if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 { + args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)]) + } + if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty { + args.append(contentsOf: ["-c", c]) + } + if config.ofpsHighQuality == true { + args.append("-G") + } + if let a = config.ofpsAdaptation { + args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)]) + } + if let algFlag = config.fullSpreadAlgorithm?.flag { + args.append(algFlag) + } + if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit { + guard (1...400).contains(inkLimit) else { + throw TargenArgError.invalidInkLimit(inkLimit) + } + args.append(contentsOf: ["-l", "\(inkLimit)"]) + } + if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 { + args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)]) + } + if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 { + args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)]) + } + + args.append(cleanBasename) + return args + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift new file mode 100644 index 0000000..fa62fdd --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Argyll/TargenConfig.swift @@ -0,0 +1,150 @@ +import Foundation + +/// Colour space for patch generation (docs/08, docs/04 §1.2). +public enum ColourSpace: String, Codable, Sendable, CaseIterable { + case rgb + case cmyk + + /// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK. + public var dFlagValue: String { + switch self { + case .rgb: return "2" + case .cmyk: return "4" + } + } +} + +/// Patch count preset for Stage 1. +public enum PatchCountPreset: String, Codable, Sendable, CaseIterable { + case draft400 = "400" + case standard800 = "800" + case photo1500 = "1500" + case custom = "custom" + + public var patchCount: Int? { + switch self { + case .draft400: return 400 + case .standard800: return 800 + case .photo1500: return 1500 + case .custom: return nil + } + } + + public var title: String { + switch self { + case .draft400: return "Draft (400)" + case .standard800: return "Standard (800)" + case .photo1500: return "Photo (1500)" + case .custom: return "Custom" + } + } +} + +/// Full spread patch distribution algorithm (docs/08). +/// Default is "ofps" (no flag emitted). +public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable { + case ofps = "ofps" + case target = "-t" + case random = "-r" + case uniformRandom = "-R" + case quasiRandom = "-q" + case uniformQuasiRandom = "-Q" + case invertedQuasiRandom = "-i" + case invertedUniformQuasiRandom = "-I" + + public var displayName: String { + switch self { + case .ofps: return "OFPS (Default)" + case .target: return "Target (-t)" + case .random: return "Random (-r)" + case .uniformRandom: return "Uniform Random (-R)" + case .quasiRandom: return "Quasi-random (-q)" + case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)" + case .invertedQuasiRandom: return "Inverted Quasi-random (-i)" + case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)" + } + } + + public var flag: String? { + switch self { + case .ofps: return nil + default: return rawValue + } + } + + /// Preset JSON value: `"ofps"` or the bare flag letter + /// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) — docs/22. + public var presetValue: String { + switch self { + case .ofps: return "ofps" + default: return String(rawValue.dropFirst()) + } + } + + public init?(presetValue: String) { + if presetValue == "ofps" { + self = .ofps + } else { + self.init(rawValue: "-" + presetValue) + } + } +} + +/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2). +public struct TargenConfig: Codable, Equatable, Sendable { + public var colourSpace: ColourSpace + public var patchCount: Int + public var whitePatches: Int + public var blackPatches: Int + public var greySteps: Int? + public var singleChannelSteps: Int? + public var neutralSteps: Int? + public var neutralConcentration: Double? + public var preconditioningProfile: String? + public var ofpsHighQuality: Bool? + public var ofpsAdaptation: Double? + public var fullSpreadAlgorithm: FullSpreadAlgorithm? + public var totalInkLimit: Int? + public var darkEmphasis: Double? + public var devicePower: Double? + public var basename: String + public var workingDirectory: URL? + + public init( + colourSpace: ColourSpace = .rgb, + patchCount: Int = 800, + whitePatches: Int = 4, + blackPatches: Int = 4, + greySteps: Int? = nil, + singleChannelSteps: Int? = nil, + neutralSteps: Int? = nil, + neutralConcentration: Double? = nil, + preconditioningProfile: String? = nil, + ofpsHighQuality: Bool? = nil, + ofpsAdaptation: Double? = nil, + fullSpreadAlgorithm: FullSpreadAlgorithm? = nil, + totalInkLimit: Int? = nil, + darkEmphasis: Double? = nil, + devicePower: Double? = nil, + basename: String = "", + workingDirectory: URL? = nil + ) { + self.colourSpace = colourSpace + self.patchCount = patchCount + self.whitePatches = whitePatches + self.blackPatches = blackPatches + self.greySteps = greySteps + self.singleChannelSteps = singleChannelSteps + self.neutralSteps = neutralSteps + self.neutralConcentration = neutralConcentration + self.preconditioningProfile = preconditioningProfile + self.ofpsHighQuality = ofpsHighQuality + self.ofpsAdaptation = ofpsAdaptation + self.fullSpreadAlgorithm = fullSpreadAlgorithm + self.totalInkLimit = totalInkLimit + self.darkEmphasis = darkEmphasis + self.devicePower = devicePower + self.basename = basename + self.workingDirectory = workingDirectory + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift index 24a280f..3cda13c 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Paths/AppPaths.swift @@ -14,20 +14,41 @@ 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. public static var appDataDir: URL { - FileManager.default + #if DEBUG + if let root = testRoot { + return root.appendingPathComponent("AppData", isDirectory: true) + } + #endif + return FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent(bundleIdentifier, isDirectory: true) } /// `~/Library/Logs/com.gronod.iccery2` public static var logDir: URL { - FileManager.default + #if DEBUG + if let root = testRoot { + return root.appendingPathComponent("Logs", isDirectory: true) + } + #endif + return FileManager.default .urls(for: .libraryDirectory, in: .userDomainMask)[0] .appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent(bundleIdentifier, isDirectory: true) } + #if DEBUG + 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) + } + #endif + /// `~/Library/Logs/com.gronod.iccery2/iccery.log` public static var logFile: URL { logDir.appendingPathComponent("iccery.log", isDirectory: false) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift index d04acf3..ea5b54b 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessEvent.swift @@ -34,3 +34,18 @@ public enum ProcessError: Error, Equatable, Sendable { /// stdin write failed (pipe closed / process gone). case stdinFailed(String) } + +extension ProcessError: LocalizedError { + public var errorDescription: String? { + switch self { + case .duplicateID(let id): + return "Process already running: \(id)" + case .unknownID(let id): + return "Unknown process: \(id)" + case .spawnFailed(let detail): + return "Could not launch \(detail)" + case .stdinFailed(let detail): + return "stdin failed: \(detail)" + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index 1e130af..6bfe6ab 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -30,37 +30,54 @@ public actor ProcessManager { // MARK: - Event bus (multicast) - private var subscribers: [UUID: AsyncStream.Continuation] = [:] + /// Lock-protected subscriber table. Registration is *synchronous* + /// inside `events()` so a caller can subscribe, then spawn, without + /// racing the child's first output or exit event. + private final class SubscriberBox: @unchecked Sendable { + private let lock = NSLock() + private var map: [UUID: AsyncStream.Continuation] = [:] - /// Subscribe to the event bus. Each call returns an independent - /// stream; every event is delivered to every live subscriber. - public nonisolated func events() -> AsyncStream { - AsyncStream { continuation in - let token = UUID() - Task { await self.addSubscriber(continuation, token: token) } - continuation.onTermination = { _ in - Task { await self.removeSubscriber(token) } + func add(_ continuation: AsyncStream.Continuation, token: UUID) { + lock.lock() + map[token] = continuation + lock.unlock() + } + + func remove(_ token: UUID) { + lock.lock() + map.removeValue(forKey: token) + lock.unlock() + } + + func yield(_ event: ProcessEvent) { + lock.lock() + let continuations = Array(map.values) + lock.unlock() + for continuation in continuations { + continuation.yield(event) } } } - private func addSubscriber( - _ continuation: AsyncStream.Continuation, - token: UUID - ) { - subscribers[token] = continuation - } + private nonisolated let subscriberBox = SubscriberBox() - private func removeSubscriber(_ token: UUID) { - subscribers.removeValue(forKey: token) - } - - private func emit(_ event: ProcessEvent) { - for continuation in subscribers.values { - continuation.yield(event) + /// Subscribe to the event bus. Each call returns an independent + /// stream; every event is delivered to every live subscriber. + /// The subscriber is registered before `events()` returns — callers + /// may spawn immediately after subscribing without losing events. + public nonisolated func events() -> AsyncStream { + let box = subscriberBox + let token = UUID() + return AsyncStream { continuation in + box.add(continuation, token: token) + continuation.onTermination = { _ in box.remove(token) } } } + private nonisolated func emit(_ event: ProcessEvent) { + subscriberBox.yield(event) + } + // MARK: - Child registry private struct RunningChild { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift index 2464ac9..6e81bf9 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/AppSettings.swift @@ -1,7 +1,8 @@ import Foundation -/// A saved wizard preset slot (docs/22 §Presets). The preset *engine* -/// lands in issue #11; for M1 the store only needs a Codable container. +/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained +/// solely to decode and migrate pre-M2 `settings.json`; new code uses +/// `ProfilingPreset` (docs/22 §ProfilingPreset). public struct CustomPreset: Codable, Equatable, Sendable { public var name: String /// Opaque per-stage form values — keyed by field id. @@ -21,6 +22,11 @@ public enum InstallLocation: String, Codable, Sendable, CaseIterable { /// `settings.json` model (docs/22). snake_case keys match the v1 file /// so field names stay identical across rewrites. +/// +/// Decoding is tolerant: missing keys take documented defaults and each +/// `custom_presets` element is tried as a typed `ProfilingPreset` first +/// and as a legacy M1 `CustomPreset` second — a malformed entry never +/// drops the rest of the array (preset migration, issue #11). public struct AppSettings: Codable, Equatable, Sendable { /// User override for Argyll binaries; `nil` → bundled sidecars. @@ -35,7 +41,7 @@ public struct AppSettings: Codable, Equatable, Sendable { public var deltaEGoodMax: Double public var deltaEWarningMax: Double - public var customPresets: [CustomPreset] + public var customPresets: [ProfilingPreset] public var enableI1Pro2Leds: Bool public var calibrationStaleDays: Int public var defaultInstallLocation: InstallLocation @@ -48,7 +54,7 @@ public struct AppSettings: Codable, Equatable, Sendable { logLevel: LogLevel? = nil, deltaEGoodMax: Double = 2.0, deltaEWarningMax: Double = 5.0, - customPresets: [CustomPreset] = [], + customPresets: [ProfilingPreset] = [], enableI1Pro2Leds: Bool = false, calibrationStaleDays: Int = 30, defaultInstallLocation: InstallLocation = .user, @@ -94,6 +100,62 @@ public struct AppSettings: Codable, Equatable, Sendable { case openColorPanelAfterInstall = "open_color_panel_after_install" } + /// One element of `custom_presets`: typed first, legacy M1 second. + private enum AnyPreset: Decodable { + case typed(ProfilingPreset) + case legacy(CustomPreset) + + init(from decoder: Decoder) throws { + if let p = try? ProfilingPreset(from: decoder) { + self = .typed(p) + return + } + self = .legacy(try CustomPreset(from: decoder)) + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = AppSettings.default + argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir + defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument + logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel + deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax + deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax + enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds + calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays + defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation + askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile + openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall + + // Per-element decode: typed presets win; a legacy M1 shape + // ({"name","values"}) migrates; unconvertible entries are + // skipped so one bad record never drops the array. + let elements = (try? c.decodeIfPresent( + [FailableDecodable].self, forKey: .customPresets + )) ?? nil + var migrated: [ProfilingPreset] = [] + for (index, element) in (elements ?? []).enumerated() { + switch element.value { + case .typed(let preset): + migrated.append(preset) + case .legacy(let legacy): + if let converted = ProfilingPreset(migrating: legacy, index: index) { + migrated.append(converted) + } else { + AppLogger(category: "settings").warn( + "Skipped unmigratable legacy preset: \(legacy.name)" + ) + } + case .none: + AppLogger(category: "settings").warn( + "Skipped malformed preset entry at index \(index)" + ) + } + } + customPresets = migrated + } + /// UI-facing validation. Strings are part of the contract (issue #5). public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative." public static let errorThresholdOrder = @@ -113,3 +175,77 @@ public struct AppSettings: Codable, Equatable, Sendable { public var isValid: Bool { validate().isEmpty } } + +extension ProfilingPreset { + + /// Migrates a legacy M1 `CustomPreset` (`name` + string values) to + /// the typed schema. Known keys are coerced; anything else is + /// ignored. Returns `nil` only when the name is unusable — a + /// deterministic `custom-{index}-{slug}` id is always produced. + init?(migrating legacy: CustomPreset, index: Int) { + let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return nil } + + let v = legacy.values + func int(_ key: String) -> Int? { + v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } + func double(_ key: String) -> Double? { + v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) } + } + func bool(_ key: String) -> Bool? { + v[key].flatMap { s in + switch s.trimmingCharacters(in: .whitespaces).lowercased() { + case "true", "1", "yes": return true + case "false", "0", "no": return false + default: return nil + } + } + } + func string(_ key: String) -> String? { + v[key].map { $0.trimmingCharacters(in: .whitespaces) } + .flatMap { $0.isEmpty ? nil : $0 } + } + + let slug = trimmedName.lowercased() + .map { $0.isLetter || $0.isNumber ? $0 : "-" } + .reduce(into: "") { $0.append($1) } + + self.init( + id: "custom-\(index)-\(slug)", + name: trimmedName, + description: string("description") ?? "", + colourSpace: string("colour_space")?.lowercased() ?? "rgb", + patchCount: int("patch_count") ?? 800, + whitePatches: int("white_patches") ?? 4, + blackPatches: int("black_patches") ?? 4, + greySteps: int("grey_steps"), + singleChannelSteps: int("single_channel_steps"), + neutralSteps: int("neutral_steps"), + neutralConcentration: double("neutral_concentration"), + preconditioningProfile: string("preconditioning_profile"), + ofpsHighQuality: bool("ofps_high_quality"), + ofpsAdaptation: double("ofps_adaptation"), + fullSpreadAlgorithm: string("full_spread_algorithm"), + totalInkLimit: int("total_ink_limit"), + darkEmphasis: double("dark_emphasis"), + devicePower: double("device_power"), + instrument: string("instrument") ?? "i1", + pageSize: string("page_size") ?? "A4", + bitDepth: int("bit_depth") ?? 8, + dpi: int("dpi") ?? 300, + randomSeed: int("random_seed"), + noRandomize: bool("no_randomize"), + calibrationFile: string("calibration_file"), + applyCalibration: bool("apply_calibration"), + colprofAlgorithm: string("colprof_algorithm"), + colprofQuality: string("colprof_quality"), + colprofIntent: string("colprof_intent"), + colprofFwa: string("colprof_fwa"), + colprofIlluminant: string("colprof_illuminant"), + colprofObserver: string("colprof_observer"), + colprofInputViewingCond: string("colprof_input_viewing_cond"), + colprofOutputViewingCond: string("colprof_output_viewing_cond") + ) + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift new file mode 100644 index 0000000..f404331 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetCatalog.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Built-in presets shipped with the app (docs/22 §Built-in presets). +/// All four: instrument `i1`, FWA `D50`, random seed `1`, +/// `no_randomize == false`, colprof algorithm `l`. +/// +/// Built-ins cannot be deleted; custom presets overlay by `id`. +public enum PresetCatalog { + + /// `preset-std-rgb` — Standard RGB Photo (800 patches). + public static let standardRGB = ProfilingPreset( + id: "preset-std-rgb", + name: "Standard RGB Photo (800 patches)", + description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.", + colourSpace: "rgb", + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + instrument: "i1", + pageSize: "A4", + bitDepth: 8, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "m", + colprofFwa: "D50" + ) + + /// `preset-hq-cmyk` — High-Gamut CMYK Proofing (1500 patches). + public static let highQualityCMYK = ProfilingPreset( + id: "preset-hq-cmyk", + name: "High-Gamut CMYK Proofing (1500 patches)", + description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.", + colourSpace: "cmyk", + patchCount: 1500, + whitePatches: 4, + blackPatches: 8, + totalInkLimit: 320, + instrument: "i1", + pageSize: "A3", + bitDepth: 16, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "h", + colprofFwa: "D50" + ) + + /// `preset-draft-rgb` — Fast RGB Draft (400 patches, **150 dpi**). + public static let draftRGB = ProfilingPreset( + id: "preset-draft-rgb", + name: "Fast RGB Draft (400 patches)", + description: "Quick sanity check — 400 patches on A4 at 150 dpi.", + colourSpace: "rgb", + patchCount: 400, + whitePatches: 4, + blackPatches: 4, + instrument: "i1", + pageSize: "A4", + bitDepth: 8, + dpi: 150, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "l", + colprofFwa: "D50" + ) + + /// `preset-ultra-rgb` — Ultra Precision RGB (2500 patches, `-G`). + public static let ultraRGB = ProfilingPreset( + id: "preset-ultra-rgb", + name: "Ultra Precision RGB (2500 patches)", + description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.", + colourSpace: "rgb", + patchCount: 2500, + whitePatches: 6, + blackPatches: 6, + ofpsHighQuality: true, + instrument: "i1", + pageSize: "A3", + bitDepth: 16, + dpi: 300, + randomSeed: 1, + noRandomize: false, + colprofAlgorithm: "l", + colprofQuality: "u", + colprofFwa: "D50" + ) + + public static let builtIns: [ProfilingPreset] = [ + standardRGB, highQualityCMYK, draftRGB, ultraRGB, + ] + + public static let builtInIDs: Set = Set(builtIns.map(\.id)) + + public static func isBuiltIn(_ id: String) -> Bool { + builtInIDs.contains(id) + } + + /// Built-ins plus custom presets, with custom entries overlaying by + /// `id` (a custom preset with a built-in id replaces that entry in + /// place — the built-in is still not deletable). + public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] { + var result = builtIns + var seen = builtInIDs + for custom in custom { + if let idx = result.firstIndex(where: { $0.id == custom.id }) { + result[idx] = custom + } else if !seen.contains(custom.id) { + result.append(custom) + seen.insert(custom.id) + } + } + return result + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift new file mode 100644 index 0000000..eec99f3 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/PresetStore.swift @@ -0,0 +1,98 @@ +import Foundation + +/// CRUD + import/export for profiling presets on top of `SettingsStore` +/// (docs/22 §Built-in presets, issue #11). +/// +/// - `all()` = built-ins overlaid by custom presets (by `id`). +/// - Built-ins are never written to `settings.json` and cannot be +/// deleted or overwritten by `saveCustom` (a custom id that collides +/// with a built-in still overlays at read time, per spec). +/// - Import/export is single-preset JSON with schema validation. +/// - Imported names/descriptions are untrusted: callers must render +/// them with `Text`, never HTML (#114). +public final class PresetStore: Sendable { + + public let settingsStore: SettingsStore + + public init(settingsStore: SettingsStore = SettingsStore()) { + self.settingsStore = settingsStore + } + + /// All presets: built-ins overlaid by customs, catalog order. + public func all() -> [ProfilingPreset] { + PresetCatalog.all(custom: settingsStore.load().customPresets) + } + + /// Custom presets only, as persisted. + public func customs() -> [ProfilingPreset] { + settingsStore.load().customPresets + } + + /// Insert or replace a custom preset (matched by `id`). Throws + /// `PresetStoreError.builtIn` when the id belongs to a built-in — + /// built-ins are immutable. Validates before persisting. + public func saveCustom(_ preset: ProfilingPreset) throws { + let validated = try preset.validated() + guard !PresetCatalog.isBuiltIn(validated.id) else { + throw PresetStoreError.builtInImmutable(validated.id) + } + var settings = settingsStore.load() + if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) { + settings.customPresets[idx] = validated + } else { + settings.customPresets.append(validated) + } + try settingsStore.save(settings) + } + + /// Deletes a custom preset by id. Returns false when the id is a + /// built-in (undeletable) or no custom preset with that id exists. + @discardableResult + public func deleteCustom(id: String) throws -> Bool { + guard !PresetCatalog.isBuiltIn(id) else { return false } + var settings = settingsStore.load() + let before = settings.customPresets.count + settings.customPresets.removeAll { $0.id == id } + guard settings.customPresets.count != before else { return false } + try settingsStore.save(settings) + return true + } + + /// Single-preset pretty JSON export. + public func export(_ preset: ProfilingPreset) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(preset) + } + + /// Parses + validates a preset from JSON. The preset is assigned a + /// fresh custom id when its id is empty or collides with a built-in. + /// Does **not** persist — call `saveCustom` to keep it. + public func `import`(_ data: Data) throws -> ProfilingPreset { + let decoded: ProfilingPreset + do { + decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) + } catch { + throw PresetStoreError.invalidJSON(error.localizedDescription) + } + var preset = try decoded.validated() + if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) { + preset.id = "custom-\(UUID().uuidString.lowercased())" + } + return preset + } + + public enum PresetStoreError: LocalizedError, Equatable { + case builtInImmutable(String) + case invalidJSON(String) + + public var errorDescription: String? { + switch self { + case .builtInImmutable(let id): + return "Built-in preset \"\(id)\" cannot be modified or deleted." + case .invalidJSON(let reason): + return "Not a valid preset file: \(reason)" + } + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift new file mode 100644 index 0000000..c0aad66 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Settings/ProfilingPreset.swift @@ -0,0 +1,272 @@ +import Foundation + +/// A profiling preset: a complete snapshot of the Stage 1/2 form plus +/// the Stage 4 fields that are stored now and applied in issue 23 +/// (docs/22 §ProfilingPreset). +/// +/// snake_case keys match the v1 JSON schema so import/export stays +/// compatible. Identity + Stage 1/2 core fields are required; every +/// other field is optional-defaulted. Unknown keys are ignored on +/// decode; missing required fields fail. +public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable { + + // Identity + public var id: String + public var name: String + public var description: String + + // Stage 1 (required core) + public var colourSpace: String // "rgb" | "cmyk" + public var patchCount: Int + public var whitePatches: Int + public var blackPatches: Int + + // Stage 1 advanced (optional) + public var greySteps: Int? + public var singleChannelSteps: Int? + public var neutralSteps: Int? + public var neutralConcentration: Double? + public var preconditioningProfile: String? + public var ofpsHighQuality: Bool? + public var ofpsAdaptation: Double? + /// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I". + public var fullSpreadAlgorithm: String? + public var totalInkLimit: Int? + public var darkEmphasis: Double? + public var devicePower: Double? + + // Stage 2 (required core) + public var instrument: String + public var pageSize: String + public var bitDepth: Int + public var dpi: Int + public var randomSeed: Int? + public var noRandomize: Bool? + + // Stage 0 / 2 calibration + public var calibrationFile: String? + public var applyCalibration: Bool? + + // Stage 4 (stored now, applied by issue 23) + public var colprofAlgorithm: String? + public var colprofQuality: String? + public var colprofIntent: String? + public var colprofFwa: String? + public var colprofIlluminant: String? + public var colprofObserver: String? + public var colprofInputViewingCond: String? + public var colprofOutputViewingCond: String? + + public init( + id: String, + name: String, + description: String = "", + colourSpace: String = "rgb", + patchCount: Int = 800, + whitePatches: Int = 4, + blackPatches: Int = 4, + greySteps: Int? = nil, + singleChannelSteps: Int? = nil, + neutralSteps: Int? = nil, + neutralConcentration: Double? = nil, + preconditioningProfile: String? = nil, + ofpsHighQuality: Bool? = nil, + ofpsAdaptation: Double? = nil, + fullSpreadAlgorithm: String? = nil, + totalInkLimit: Int? = nil, + darkEmphasis: Double? = nil, + devicePower: Double? = nil, + instrument: String = "i1", + pageSize: String = "A4", + bitDepth: Int = 8, + dpi: Int = 300, + randomSeed: Int? = 1, + noRandomize: Bool? = false, + calibrationFile: String? = nil, + applyCalibration: Bool? = nil, + colprofAlgorithm: String? = nil, + colprofQuality: String? = nil, + colprofIntent: String? = nil, + colprofFwa: String? = nil, + colprofIlluminant: String? = nil, + colprofObserver: String? = nil, + colprofInputViewingCond: String? = nil, + colprofOutputViewingCond: String? = nil + ) { + self.id = id + self.name = name + self.description = description + self.colourSpace = colourSpace + self.patchCount = patchCount + self.whitePatches = whitePatches + self.blackPatches = blackPatches + self.greySteps = greySteps + self.singleChannelSteps = singleChannelSteps + self.neutralSteps = neutralSteps + self.neutralConcentration = neutralConcentration + self.preconditioningProfile = preconditioningProfile + self.ofpsHighQuality = ofpsHighQuality + self.ofpsAdaptation = ofpsAdaptation + self.fullSpreadAlgorithm = fullSpreadAlgorithm + self.totalInkLimit = totalInkLimit + self.darkEmphasis = darkEmphasis + self.devicePower = devicePower + self.instrument = instrument + self.pageSize = pageSize + self.bitDepth = bitDepth + self.dpi = dpi + self.randomSeed = randomSeed + self.noRandomize = noRandomize + self.calibrationFile = calibrationFile + self.applyCalibration = applyCalibration + self.colprofAlgorithm = colprofAlgorithm + self.colprofQuality = colprofQuality + self.colprofIntent = colprofIntent + self.colprofFwa = colprofFwa + self.colprofIlluminant = colprofIlluminant + self.colprofObserver = colprofObserver + self.colprofInputViewingCond = colprofInputViewingCond + self.colprofOutputViewingCond = colprofOutputViewingCond + } + + enum CodingKeys: String, CodingKey { + case id, name, description + case colourSpace = "colour_space" + case patchCount = "patch_count" + case whitePatches = "white_patches" + case blackPatches = "black_patches" + case greySteps = "grey_steps" + case singleChannelSteps = "single_channel_steps" + case neutralSteps = "neutral_steps" + case neutralConcentration = "neutral_concentration" + case preconditioningProfile = "preconditioning_profile" + case ofpsHighQuality = "ofps_high_quality" + case ofpsAdaptation = "ofps_adaptation" + case fullSpreadAlgorithm = "full_spread_algorithm" + case totalInkLimit = "total_ink_limit" + case darkEmphasis = "dark_emphasis" + case devicePower = "device_power" + case instrument + case pageSize = "page_size" + case bitDepth = "bit_depth" + case dpi + case randomSeed = "random_seed" + case noRandomize = "no_randomize" + case calibrationFile = "calibration_file" + case applyCalibration = "apply_calibration" + case colprofAlgorithm = "colprof_algorithm" + case colprofQuality = "colprof_quality" + case colprofIntent = "colprof_intent" + case colprofFwa = "colprof_fwa" + case colprofIlluminant = "colprof_illuminant" + case colprofObserver = "colprof_observer" + case colprofInputViewingCond = "colprof_input_viewing_cond" + case colprofOutputViewingCond = "colprof_output_viewing_cond" + } + + /// Strict decode: required identity + Stage 1/2 core fields must be + /// present; optionals default to nil. Unknown keys are ignored. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + description = try c.decodeIfPresent(String.self, forKey: .description) ?? "" + colourSpace = try c.decode(String.self, forKey: .colourSpace) + patchCount = try c.decode(Int.self, forKey: .patchCount) + whitePatches = try c.decode(Int.self, forKey: .whitePatches) + blackPatches = try c.decode(Int.self, forKey: .blackPatches) + greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps) + singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps) + neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps) + neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration) + preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile) + ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality) + ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation) + fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm) + totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit) + darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis) + devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower) + instrument = try c.decode(String.self, forKey: .instrument) + pageSize = try c.decode(String.self, forKey: .pageSize) + bitDepth = try c.decode(Int.self, forKey: .bitDepth) + dpi = try c.decode(Int.self, forKey: .dpi) + randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed) + noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize) + calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile) + applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) + colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm) + colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality) + colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent) + colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa) + colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant) + colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver) + colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond) + colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond) + } + + // MARK: - Validation (import path) + + public enum ValidationError: LocalizedError, Equatable { + case emptyID + case emptyName + case invalidColourSpace(String) + case invalidPatchCount(Int) + case invalidBitDepth(Int) + case invalidDPI(Int) + case emptyPageSize + case emptyInstrument + + public var errorDescription: String? { + switch self { + case .emptyID: return "Preset is missing an id." + case .emptyName: return "Preset is missing a name." + case .invalidColourSpace(let v): + return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"." + case .invalidPatchCount(let v): + return "patch_count must be positive, got \(v)." + case .invalidBitDepth(let v): + return "bit_depth must be 8 or 16, got \(v)." + case .invalidDPI(let v): + return "dpi must be between 72 and 600, got \(v)." + case .emptyPageSize: return "page_size is empty." + case .emptyInstrument: return "instrument is empty." + } + } + } + + /// Validates the required fields for import / catalog use. + /// `colourSpace` is normalized to lowercase before comparison. + @discardableResult + public func validated() throws -> ProfilingPreset { + var p = self + p.id = id.trimmingCharacters(in: .whitespacesAndNewlines) + p.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + p.colourSpace = colourSpace.lowercased() + guard !p.id.isEmpty else { throw ValidationError.emptyID } + guard !p.name.isEmpty else { throw ValidationError.emptyName } + guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else { + throw ValidationError.invalidColourSpace(colourSpace) + } + guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) } + guard p.bitDepth == 8 || p.bitDepth == 16 else { + throw ValidationError.invalidBitDepth(bitDepth) + } + guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) } + guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ValidationError.emptyPageSize + } + guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else { + throw ValidationError.emptyInstrument + } + return p + } +} + +/// Per-element non-throwing decode wrapper — one malformed preset entry +/// must not drop the whole `custom_presets` array during migration. +struct FailableDecodable: Decodable { + let value: T? + init(from decoder: Decoder) throws { + value = try? T(from: decoder) + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift new file mode 100644 index 0000000..00b796a --- /dev/null +++ b/Sources/ICCery/AppEnvironment.swift @@ -0,0 +1,70 @@ +import Foundation +import ICCeryCore + +/// App dependency container (docs/02). Production builds resolve the +/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI +/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and +/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the +/// developer's settings, wizard state, or real Argyll install. +struct AppEnvironment: Sendable { + let stateStore: WizardStateStore + let settingsStore: SettingsStore + let presetStore: PresetStore + let runner: ArgyllRunner + + static func live( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> AppEnvironment { + let settingsStore = SettingsStore() + var overrideDir = settingsStore.load().argyllBinaryDir + .map { URL(fileURLWithPath: $0) } + #if DEBUG + if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty { + overrideDir = URL(fileURLWithPath: dir) + } + #endif + return AppEnvironment( + stateStore: WizardStateStore(), + settingsStore: settingsStore, + presetStore: PresetStore(settingsStore: settingsStore), + runner: ArgyllRunner( + processManager: .shared, + binaryResolver: BinaryResolver(overrideDir: overrideDir) + ) + ) + } +} + +/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow +/// honours these env-provided paths instead of presenting modal panels +/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are +/// compiled out of release builds. +enum UITestHooks { + private static var env: [String: String] { + ProcessInfo.processInfo.environment + } + + static var isEnabled: Bool { + #if DEBUG + return env["ICCERY_UI_TESTING"] == "1" + #else + return false + #endif + } + + /// `select_target_file` result (Stage 1 save picker). + static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") } + /// `select_existing_target` result (`.ti1`/`.ti2` resume). + static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") } + /// `select_directory` result (working-directory browse). + static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") } + /// Preset import file. + static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") } + /// Preset export destination. + static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") } + + private static func url(_ key: String) -> URL? { + guard let raw = env[key], !raw.isEmpty else { return nil } + return URL(fileURLWithPath: raw) + } +} diff --git a/Sources/ICCery/FileDialogService.swift b/Sources/ICCery/FileDialogService.swift index d7b7a33..13e7c0c 100644 --- a/Sources/ICCery/FileDialogService.swift +++ b/Sources/ICCery/FileDialogService.swift @@ -80,6 +80,23 @@ final class FileDialogService { message: "Choose a calibration file (.cal)") } + /// `btnImportPreset` — open a `.json` preset file. + func selectPresetFile(startingAt start: URL? = nil) -> URL? { + open(extensions: ["json"], startingAt: start, + message: "Import a profiling preset (.json)") + } + + /// `btnExportActivePreset` — save a `.json` preset file. + func selectPresetSavePath(name: String, startingAt start: URL? = nil) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(name).json" + panel.allowedContentTypes = utTypes(["json"]) + panel.allowsOtherFileTypes = false + panel.directoryURL = start + panel.message = "Export this preset as JSON" + return run(panel) + } + // MARK: - Internals (private — not a shared public picker API) private func open( diff --git a/Sources/ICCery/ICCeryApp.swift b/Sources/ICCery/ICCeryApp.swift index a61a29d..fb5eeeb 100644 --- a/Sources/ICCery/ICCeryApp.swift +++ b/Sources/ICCery/ICCeryApp.swift @@ -5,19 +5,21 @@ import SwiftUI @main struct ICCeryApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - @State private var model = WizardViewModel() + @State private var workflow: TargetWorkflowViewModel init() { + let environment = AppEnvironment.live() + _workflow = State(initialValue: TargetWorkflowViewModel(environment: environment)) try? AppPaths.ensureDirectories() // Log level is runtime state — apply persisted settings at // startup (#158); the Settings sheet re-applies on save. - LogSink.shared.applySettings(SettingsStore().load()) + LogSink.shared.applySettings(environment.settingsStore.load()) } var body: some Scene { // Single fixed window (docs/21 §Shell: 1280×800, min 1100×700). Window("ICCery", id: "main") { - RootView(model: model) + RootView(workflow: workflow) .frame(minWidth: 1100, minHeight: 700) .preferredColorScheme(.dark) } diff --git a/Sources/ICCery/NoticeBanner.swift b/Sources/ICCery/NoticeBanner.swift index c0bfb67..4490475 100644 --- a/Sources/ICCery/NoticeBanner.swift +++ b/Sources/ICCery/NoticeBanner.swift @@ -42,6 +42,7 @@ struct NoticeBanner: View { .font(.callout) .foregroundStyle(Theme.text) .lineLimit(3) + .accessibilityIdentifier("noticeText") Spacer() Button(action: onClose) { Image(systemName: "xmark") diff --git a/Sources/ICCery/PresetDialogs.swift b/Sources/ICCery/PresetDialogs.swift new file mode 100644 index 0000000..e9781a9 --- /dev/null +++ b/Sources/ICCery/PresetDialogs.swift @@ -0,0 +1,92 @@ +import SwiftUI +import ICCeryCore + +/// `#savePresetDialog` — save the live Stage 1/2 form as a custom +/// preset (issue #11). Names/descriptions render via `Text` only (#114). +struct SavePresetDialog: View { + @Bindable var workflow: TargetWorkflowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Save Preset").font(.title3).foregroundStyle(Theme.text) + TextField("Name", text: $workflow.savePresetName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("savePresetName") + TextField("Description (optional)", text: $workflow.savePresetDesc) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("savePresetDesc") + HStack { + Spacer() + Button("Cancel") { workflow.showingSavePreset = false } + .accessibilityIdentifier("btnCloseSavePresetDialog") + Button("Save") { workflow.saveCurrentAsPreset() } + .accessibilityIdentifier("btnConfirmSavePreset") + .disabled(workflow.savePresetName + .trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 380) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savePresetDialog") + } +} + +/// `#managePresetsDialog` — list, delete (custom only), import, export. +struct ManagePresetsDialog: View { + @Bindable var workflow: TargetWorkflowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Manage Presets").font(.title3).foregroundStyle(Theme.text) + List { + ForEach(workflow.presets) { preset in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(preset.name).foregroundStyle(Theme.text) + if !preset.description.isEmpty { + Text(preset.description) + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if PresetCatalog.isBuiltIn(preset.id) { + Text("Built-in") + .font(.caption).foregroundStyle(.secondary) + } else { + Button("Export") { workflow.exportPreset(preset) } + .accessibilityIdentifier( + "btnExportPreset-\(preset.id)") + Button("Delete", role: .destructive) { + workflow.deletePreset(preset) + } + .accessibilityIdentifier("btnDeletePreset-\(preset.id)") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("presetRow-\(preset.id)") + } + } + .accessibilityIdentifier("managePresetsList") + .frame(minHeight: 240) + HStack { + Button("Import…") { workflow.importPreset() } + .accessibilityIdentifier("btnImportPreset") + if let selected = workflow.selectedPreset, + !PresetCatalog.isBuiltIn(selected.id) { + Button("Export Active") { workflow.exportPreset(selected) } + .accessibilityIdentifier("btnExportActivePreset") + } + Spacer() + Button("Close") { workflow.showingManagePresets = false } + .accessibilityIdentifier("btnCloseManagePresetsDialog") + } + } + .padding(20) + .frame(width: 480) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("managePresetsDialog") + } +} diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index 3a38f5c..b7b2141 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -1,17 +1,20 @@ import AppKit import SwiftUI +import ICCeryCore /// Root layout: 270 pt sidebar + main stage area with the notification /// banner pinned to the top (docs/21 §Shell). struct RootView: View { - @Bindable var model: WizardViewModel + @Bindable var workflow: TargetWorkflowViewModel @State private var showingSettings = false @State private var showingAbout = false + private var model: WizardViewModel { workflow.wizard } + var body: some View { HStack(spacing: 0) { SidebarView( - model: model, + workflow: workflow, onOpenSettings: { showingSettings = true }, onOpenAbout: { showingAbout = true } ) @@ -24,7 +27,7 @@ struct RootView: View { if let notice = model.notice { NoticeBanner(notice: notice, onClose: model.dismissNotice) } - StagePlaceholderView(stage: model.stage) + stageContent } } .frame(minWidth: 1100, minHeight: 700) @@ -39,10 +42,44 @@ struct RootView: View { .sheet(isPresented: $showingSettings) { SettingsView() } + .sheet(isPresented: $workflow.showingSavePreset) { + SavePresetDialog(workflow: workflow) + } + .sheet(isPresented: $workflow.showingManagePresets) { + ManagePresetsDialog(workflow: workflow) + } .alert("ICCery 2.0.0", isPresented: $showingAbout) { Button("OK") {} } message: { Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.") } } + + @ViewBuilder + private var stageContent: some View { + switch model.stage { + case .generate: + Stage1View(workflow: workflow) + case .layOutPrint: + Stage2View(workflow: workflow) + case .measure: + // Stage 3 stays a shell until M4, but a .ti2 resume still + // lands here — show the persisted state (#8, issue #140). + VStack(spacing: 16) { + if workflow.resumedFromTi2 { + Label("Resumed from .ti2", systemImage: "arrow.uturn.right") + .font(.callout) + .foregroundStyle(Theme.accent) + .accessibilityIdentifier("stage3LoadedTargetBanner") + } + Text(model.basename) + .font(.title3) + .foregroundStyle(Theme.text) + .accessibilityIdentifier("stage3TargetBasename") + StagePlaceholderView(stage: model.stage) + } + default: + StagePlaceholderView(stage: model.stage) + } + } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 7774bed..f2ed065 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -4,10 +4,12 @@ import ICCeryCore /// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset /// select, Calibrate Printer + status chip, and the 1–5 stepper. struct SidebarView: View { - @Bindable var model: WizardViewModel + @Bindable var workflow: TargetWorkflowViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void + private var model: WizardViewModel { workflow.wizard } + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { @@ -31,16 +33,38 @@ struct SidebarView: View { Divider().overlay(Theme.border) - // Preset select (`#presetSelect`). Disabled until the preset - // engine lands in issue #11. - Picker("Preset", selection: .constant("none")) { + // Preset select (`#presetSelect`) — issue #11. Selection + // applies the preset immediately; names render via Text only. + Picker("Preset", selection: Binding( + get: { workflow.selectedPresetID }, + set: { id in + if id == "none" { + workflow.selectedPresetID = "none" + } else if let preset = workflow.presets.first(where: { $0.id == id }) { + workflow.applyPreset(preset) + } + } + )) { Text("No preset").tag("none") + ForEach(workflow.presets) { preset in + Text(preset.name).tag(preset.id) + } } .pickerStyle(.menu) - .disabled(true) + .accessibilityIdentifier("presetSelect") .padding(.horizontal, 12) .padding(.vertical, 8) + HStack(spacing: 8) { + Button("Save") { workflow.showingSavePreset = true } + .accessibilityIdentifier("btnSavePresetModal") + Button("Manage") { workflow.showingManagePresets = true } + .accessibilityIdentifier("btnOpenPresetsDialog") + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + // Calibrate Printer (`#btnCalibratePrinter`). Disabled until // Stage 0 lands in issue #29; `#calStatusChip` likewise. Button(action: { model.enterCalibration() }) { diff --git a/Sources/ICCery/Stage1View.swift b/Sources/ICCery/Stage1View.swift new file mode 100644 index 0000000..1db8cb3 --- /dev/null +++ b/Sources/ICCery/Stage1View.swift @@ -0,0 +1,256 @@ +import SwiftUI +import ICCeryCore + +/// Stage 1 — `#stage-1` Generate Target (`targen` → `.ti1`, issue #7, +/// docs/08). All documented element ids are wired as accessibility +/// identifiers so the UI-test contract stays stable. +struct Stage1View: View { + @Bindable var workflow: TargetWorkflowViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + colourSpaceSection + patchSection + targetSection + advancedSection + actionRow + logSection + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.background) + .accessibilityIdentifier("stage-1") + } + + // MARK: - Colour space (name="colourSpace") + + private var colourSpaceSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Colour space").font(.headline).foregroundStyle(Theme.text) + Picker("Colour space", selection: $workflow.colourSpace) { + Text("RGB (print drivers)").tag(ColourSpace.rgb) + Text("CMYK (RIP output)").tag(ColourSpace.cmyk) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("colourSpace") + } + } + + // MARK: - Patch count + white/black + + private var patchSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Patches").font(.headline).foregroundStyle(Theme.text) + HStack(spacing: 16) { + Picker("Patch count", selection: $workflow.patchPreset) { + ForEach(PatchCountPreset.allCases, id: \.self) { + Text($0.title).tag($0) + } + } + .accessibilityIdentifier("patchCountPreset") + .frame(maxWidth: 220) + + if workflow.patchPreset == .custom { + TextField("Patches", value: $workflow.customPatchCount, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 90) + .accessibilityIdentifier("patchCountCustom") + } + } + HStack(spacing: 16) { + Stepper(value: $workflow.whitePatches, in: 0...50) { + Text("White patches: \(workflow.whitePatches)") + } + .accessibilityIdentifier("whitePatches") + Stepper(value: $workflow.blackPatches, in: 0...50) { + Text("Black patches: \(workflow.blackPatches)") + } + .accessibilityIdentifier("blackPatches") + } + .foregroundStyle(Theme.text) + } + } + + // MARK: - Target file / working directory + + private var targetSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Target file").font(.headline).foregroundStyle(Theme.text) + HStack(spacing: 8) { + TextField("Basename (no extension)", text: $workflow.targetBasename) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetBasename") + Button("Browse…") { workflow.browseForTargetFile() } + .accessibilityIdentifier("btnBrowse") + Button("Working Dir…") { workflow.browseForWorkingDirectory() } + Button("Open Existing…") { workflow.openExistingTarget() } + .accessibilityIdentifier("btnOpenExisting") + Button("Import Dataset…") { /* CGATS import — #94, later */ } + .accessibilityIdentifier("btn-import-dataset") + .disabled(true) + } + Text(workflow.targetDirectory?.path ?? "No working directory selected") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .accessibilityIdentifier("selectedPathDisplay") + } + } + + // MARK: - Advanced (#targenAdvancedDetails) + + /// UI tests pre-expand the group — XCUI cannot reliably toggle a + /// macOS `DisclosureTriangle` (its click lands on the label). + @State private var advancedExpanded = UITestHooks.isEnabled + + private var advancedSection: some View { + DisclosureGroup("Advanced", isExpanded: $advancedExpanded) { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + optionalInt("Grey steps (-g)", + enabled: $workflow.greyStepsEnabled, + value: $workflow.greySteps) + .accessibilityIdentifier("targenGreySteps") + optionalInt("Single-channel steps (-s)", + enabled: $workflow.singleChannelEnabled, + value: $workflow.singleChannelSteps) + .accessibilityIdentifier("targenSingleChannelSteps") + optionalInt("Neutral steps (-n)", + enabled: $workflow.neutralStepsEnabled, + value: $workflow.neutralSteps) + .accessibilityIdentifier("targenNeutralSteps") + optionalDouble("Neutral concentration (-N)", + enabled: $workflow.neutralConcEnabled, + value: $workflow.neutralConcentration, + range: 0.0...1.0) + .accessibilityIdentifier("targenNeutralConcentration") + optionalDouble("OFPS adaptation (-A)", + enabled: $workflow.adaptationEnabled, + value: $workflow.adaptation, + range: 0.0...1.0) + .accessibilityIdentifier("targenAdaptation") + } + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("Preconditioning profile", + text: Binding( + get: { workflow.preconditioningProfile ?? "" }, + set: { + workflow.preconditioningProfile = + $0.isEmpty ? nil : $0 + })) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targenPrecondProfile") + Button("…") { workflow.browseForPreconditioningProfile() } + .accessibilityIdentifier("btnBrowsePrecondProfile") + } + Toggle("OFPS high quality (-G)", isOn: $workflow.highQuality) + .accessibilityIdentifier("targenHighQuality") + Picker("Full-spread algorithm", selection: $workflow.algorithm) { + ForEach(FullSpreadAlgorithm.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("targenAlgorithm") + if workflow.colourSpace == .cmyk { + optionalInt("Total ink limit (-l)", + enabled: $workflow.inkLimitEnabled, + value: $workflow.totalInkLimit) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("targenInkLimitGroup") + } + optionalDouble("Dark emphasis (-V)", + enabled: $workflow.darkEmphasisEnabled, + value: $workflow.darkEmphasis, + range: 0.0...3.0) + .accessibilityIdentifier("targenDarkEmphasis") + optionalDouble("Device power (-p)", + enabled: $workflow.devicePowerEnabled, + value: $workflow.devicePower, + range: 0.0...3.0) + .accessibilityIdentifier("targenDevicePower") + } + } + } + .foregroundStyle(Theme.text) + .padding(.top, 8) + } + .foregroundStyle(Theme.text) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("targenAdvancedDetails") + } + + private func optionalInt( + _ title: String, + enabled: Binding, + value: Binding + ) -> some View { + HStack { + Toggle(title, isOn: enabled) + .toggleStyle(.checkbox) + if enabled.wrappedValue { + TextField("", value: value, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 70) + } + } + } + + private func optionalDouble( + _ title: String, + enabled: Binding, + value: Binding, + range: ClosedRange + ) -> some View { + VStack(alignment: .leading) { + Toggle(title, isOn: enabled) + .toggleStyle(.checkbox) + if enabled.wrappedValue { + HStack { + Slider(value: value, in: range) + Text(value.wrappedValue, format: .number.precision(.fractionLength(2))) + .frame(width: 44) + .monospacedDigit() + } + } + } + } + + // MARK: - Actions + log + + private var actionRow: some View { + HStack { + Button(action: workflow.generateTarget) { + Label(workflow.targenRunning ? "Generating…" : "Generate Target", + systemImage: "square.grid.3x3") + } + .controlSize(.large) + .disabled(!workflow.canGenerate || workflow.targenRunning) + .accessibilityIdentifier("btnGenerate") + if workflow.targenRunning { + ProgressView().controlSize(.small) + } + Spacer() + } + } + + private var logSection: some View { + DisclosureGroup("Process log") { + ScrollView { + Text(workflow.targenLog.joined(separator: "\n")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.text) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 120, maxHeight: 200) + .accessibilityIdentifier("targenLog") + } + .foregroundStyle(Theme.text) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("targenLogContainer") + } +} diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift new file mode 100644 index 0000000..206fbf9 --- /dev/null +++ b/Sources/ICCery/Stage2View.swift @@ -0,0 +1,282 @@ +import SwiftUI +import ICCeryCore + +/// Stage 2 — `#stage-2` Lay Out & Print (`printtarg` → `.ti2` + TIFFs, +/// issues #9/#10, docs/09). Print controls are visible but inert — +/// real spooling lands in M3. +struct Stage2View: View { + @Bindable var workflow: TargetWorkflowViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + cmWarning + formSection + labelSection + actionRow + logSection + gallerySection + printPanel + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Theme.background) + .accessibilityIdentifier("stage-2") + } + + // MARK: - Colour-management warning (#cmWarningBanner) + + private var cmWarning: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Set your printer driver to “No Colour Adjustment” " + + "(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " + + "Any driver colour management corrupts the target.") + .font(.callout) + .foregroundStyle(Theme.text) + Spacer() + } + .padding(10) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium) + .stroke(Color.orange.opacity(0.4)) + ) + .accessibilityIdentifier("cmWarningBanner") + } + + // MARK: - Layout form + + private var formSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 16) { + Picker("Instrument", selection: $workflow.instrument) { + ForEach(PrintInstrument.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("instrumentSelect") + Picker("Page size", selection: $workflow.pageSize) { + ForEach(PageSize.allCases, id: \.self) { + Text($0.rawValue).tag($0) + } + } + .accessibilityIdentifier("pageSizeSelect") + } + if workflow.pageSize == .custom { + HStack(spacing: 8) { + Text("Custom size (mm):") + TextField("W", value: $workflow.customPageW, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 70) + .accessibilityIdentifier("customPageW") + Text("×") + TextField("H", value: $workflow.customPageH, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 70) + .accessibilityIdentifier("customPageH") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("customPageSizeRow") + } + HStack(spacing: 16) { + Picker("Bit depth", selection: $workflow.bitDepth) { + Text("8-bit TIFF").tag(TiffBitDepth.eight) + Text("16-bit TIFF").tag(TiffBitDepth.sixteen) + } + Stepper("DPI: \(workflow.tiffDpi)", + value: $workflow.tiffDpi, in: 72...600, step: 1) + .accessibilityIdentifier("tiffDpi") + } + HStack(spacing: 16) { + Picker("Layout order", selection: $workflow.layoutOrder) { + ForEach(LayoutOrder.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + .accessibilityIdentifier("printtargLayoutOrder") + if workflow.layoutOrder == .customSeed { + HStack { + Text("Seed:") + TextField("", value: $workflow.customSeed, format: .number) + .textFieldStyle(.roundedBorder).frame(width: 80) + .accessibilityIdentifier("printtargCustomSeed") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("printtargCustomSeedGroup") + } + } + } + .foregroundStyle(Theme.text) + } + + // MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview) + + private var labelSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Chart label").font(.headline).foregroundStyle(Theme.text) + Spacer() + Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") { + workflow.labelIsCustom.toggle() + } + .accessibilityIdentifier("btnToggleLabelEdit") + } + HStack(spacing: 12) { + TextField("Printer", text: $workflow.metaPrinter) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataPrinter") + TextField("Ink set", text: $workflow.metaInkSet) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataInkSet") + } + HStack(spacing: 12) { + TextField("Driver paper", text: $workflow.metaDriverPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataDriverPaper") + TextField("Actual paper", text: $workflow.metaActualPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetMetadataActualPaper") + } + if workflow.labelIsCustom { + TextField("Custom label", text: $workflow.customLabel) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("targetLabelPreview") + } else { + Text(workflow.automaticLabel) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("targetLabelPreview") + } + } + } + + // MARK: - Actions + log + + private var actionRow: some View { + HStack { + Button(action: workflow.createLayout) { + Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout", + systemImage: "rectangle.grid.2x2") + } + .controlSize(.large) + .disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty) + .accessibilityIdentifier("btnCreateLayout") + if workflow.printtargRunning { + ProgressView().controlSize(.small) + } + Spacer() + } + } + + private var logSection: some View { + DisclosureGroup("Process log") { + ScrollView { + Text(workflow.printtargLog.joined(separator: "\n")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.text) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(minHeight: 100, maxHeight: 180) + .accessibilityIdentifier("printtargLog") + } + .foregroundStyle(Theme.text) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("printtargLogContainer") + } + + // MARK: - TIFF gallery (#tiffGallery) — host-side PNG only (#58) + + @ViewBuilder + private var gallerySection: some View { + if let result = workflow.printtargResult { + VStack(alignment: .leading, spacing: 8) { + Text("Target pages — \(result.manifest.pages.count) page(s), " + + "\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches") + .font(.headline).foregroundStyle(Theme.text) + .accessibilityIdentifier("galleryInfo") + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 220))], + spacing: 12 + ) { + ForEach(result.pages) { page in + GalleryPageView(page: page) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("galleryGrid") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tiffGallery") + } + } + + // MARK: - Raw print panel (#rawPrintPanel) — stubbed until M3 + + private var printPanel: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Print").font(.headline).foregroundStyle(Theme.text) + Text("Unmanaged printing (lp) lands in Milestone 3.") + .font(.caption).foregroundStyle(.secondary) + .accessibilityIdentifier("printNotification") + HStack(spacing: 8) { + Button("Print All") {} + .accessibilityIdentifier("btnPrintAll") + .disabled(true) + Button("Refresh Printers") {} + .accessibilityIdentifier("btnRefreshPrinters") + .disabled(true) + Button("Printer Properties") {} + .accessibilityIdentifier("btnPrinterProperties") + .disabled(true) + Spacer() + Button("Advance to Stage 3") { workflow.advanceToStage3() } + .accessibilityIdentifier("btnAdvanceToStage3") + .disabled(workflow.printtargResult == nil + || !workflow.wizard.isUnlocked(.measure)) + } + } + .padding(12) + .background(Theme.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("rawPrintPanel") + } +} + +/// One gallery cell: PNG preview + per-page stubbed Print button. +private struct GalleryPageView: View { + let page: GalleryPage + + var body: some View { + VStack(spacing: 6) { + if let png = page.previewPNG, let image = NSImage(data: png) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 240) + } else { + ZStack { + Rectangle().fill(Theme.panel).frame(height: 160) + Text(page.previewError ?? "No preview") + .font(.caption).foregroundStyle(.secondary) + } + } + Text(page.page.filename) + .font(.caption).foregroundStyle(Theme.text) + Text("\(page.page.patches) patches · " + + "\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm") + .font(.caption2).foregroundStyle(.secondary) + Button("Print") {} + .disabled(true) + .accessibilityIdentifier("btnPrintPage-\(page.index)") + } + .padding(8) + .background(Theme.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("galleryPage-\(page.index)") + } +} diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift new file mode 100644 index 0000000..80b5707 --- /dev/null +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -0,0 +1,465 @@ +import Foundation +import Observation +import ICCeryCore + +/// Stage 1/2 form state, runner orchestration, resume flow, and preset +/// application (issues #7–#11). +/// +/// `wizard` stays authoritative for persisted identity + disk gating; +/// this model owns the editable form, logs, gallery, and preset state. +/// All process work runs through `ArgyllRunner` off `@MainActor`; only +/// coalesced log batches and completion hop back. +@MainActor +@Observable +final class TargetWorkflowViewModel { + + let wizard: WizardViewModel + let environment: AppEnvironment + private let fileDialogs = FileDialogService.shared + + // MARK: - Stage 1 form (targen) + + var colourSpace: ColourSpace = .rgb { + didSet { + guard colourSpace != oldValue else { return } + // CMYK black patches default to 0, RGB to 4 (docs/08). + blackPatches = colourSpace == .cmyk ? 0 : 4 + } + } + var patchPreset: PatchCountPreset = .standard800 + /// `#patchCountCustom` — used when `patchPreset == .custom`. + var customPatchCount = 2500 + var whitePatches = 4 + var blackPatches = 4 + + // Advanced — each optional flag is enabled + value, so an untouched + // control emits nothing (#advanced fields are opt-in). + var greyStepsEnabled = false + var greySteps = 5 + var singleChannelEnabled = false + var singleChannelSteps = 5 + var neutralStepsEnabled = false + var neutralSteps = 3 + var neutralConcEnabled = false + var neutralConcentration = 0.50 + var preconditioningProfile: String? + var highQuality = false + var adaptationEnabled = false + var adaptation = 0.10 + var algorithm: FullSpreadAlgorithm = .ofps + var inkLimitEnabled = false + var totalInkLimit = 320 + var darkEmphasisEnabled = false + var darkEmphasis = 1.0 + var devicePowerEnabled = false + var devicePower = 1.0 + + /// `#targetBasename` — no placeholder is ever invented (#60). + var targetBasename = "" + /// `#selectedPathDisplay` / resolved cwd. + var targetDirectory: URL? + + // MARK: - Stage 2 form (printtarg) + + var instrument: PrintInstrument = .i1 + var pageSize: PageSize = .a4 + var customPageW = 210.0 + var customPageH = 297.0 + var bitDepth: TiffBitDepth = .eight + /// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft + /// regression must be visible here). + var tiffDpi = 300 + var layoutOrder: LayoutOrder = .deterministic + var customSeed = 1 + var labelIsCustom = false + var customLabel = "" + var metaPrinter = "" + var metaInkSet = "" + var metaDriverPaper = "" + var metaActualPaper = "" + + // MARK: - Run state + + var targenRunning = false + var targenLog: [String] = [] + var printtargRunning = false + var printtargLog: [String] = [] + var printtargResult: PrinttargResult? + /// Sticky until the target changes: `.ti2` resume landed us on + /// Stage 3 (`#stage3LoadedTargetBanner` data). + var resumedFromTi2 = false + + // MARK: - Presets + + var presets: [ProfilingPreset] = [] + var selectedPresetID = "none" + var showingSavePreset = false + var showingManagePresets = false + var savePresetName = "" + var savePresetDesc = "" + + init(environment: AppEnvironment = .live()) { + self.environment = environment + self.wizard = WizardViewModel(stateStore: environment.stateStore) + reloadPresets() + } + + // MARK: - Derived + + var effectivePatchCount: Int { + patchPreset.patchCount ?? customPatchCount + } + + var canGenerate: Bool { + PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil + } + + var labelMetadata: TargetLabelMetadata { + TargetLabelMetadata( + printer: metaPrinter, inkSet: metaInkSet, + driverPaper: metaDriverPaper, actualPaper: metaActualPaper) + } + + /// `#targetLabelPreview` — live preview of the automatic label. + var automaticLabel: String { + PrinttargLabel.automatic( + basename: wizard.basename.isEmpty ? "target" : wizard.basename, + metadata: labelMetadata) + } + + var selectedPreset: ProfilingPreset? { + presets.first { $0.id == selectedPresetID } + } + + // MARK: - Stage 1: generate + + func buildTargenConfig() -> TargenConfig { + TargenConfig( + colourSpace: colourSpace, + patchCount: effectivePatchCount, + whitePatches: whitePatches, + blackPatches: blackPatches, + greySteps: greyStepsEnabled ? greySteps : nil, + singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil, + neutralSteps: neutralStepsEnabled ? neutralSteps : nil, + neutralConcentration: neutralConcEnabled ? neutralConcentration : nil, + preconditioningProfile: preconditioningProfile, + ofpsHighQuality: highQuality ? true : nil, + ofpsAdaptation: adaptationEnabled ? adaptation : nil, + fullSpreadAlgorithm: algorithm == .ofps ? nil : algorithm, + totalInkLimit: inkLimitEnabled ? totalInkLimit : nil, + darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil, + devicePower: devicePowerEnabled ? devicePower : nil, + basename: targetBasename, + workingDirectory: targetDirectory + ) + } + + func browseForTargetFile() { + let url = UITestHooks.isEnabled + ? UITestHooks.saveTargetURL + : fileDialogs.selectTargetFile() + guard let url else { return } + targetBasename = url.deletingPathExtension().lastPathComponent + targetDirectory = url.deletingLastPathComponent() + } + + func browseForWorkingDirectory() { + let url = UITestHooks.isEnabled + ? UITestHooks.workDirURL + : fileDialogs.selectDirectory() + if let url { targetDirectory = url } + } + + func browseForPreconditioningProfile() { + if let url = fileDialogs.selectProfileFile() { + preconditioningProfile = url.path + } + } + + func generateTarget() { + guard canGenerate, !targenRunning else { return } + let config = buildTargenConfig() + targenRunning = true + targenLog = [] + resumedFromTi2 = false + let runner = environment.runner + Task { + do { + let url = try await runner.runTargen(config: config) { [weak self] batch in + Task { @MainActor [weak self] in + self?.targenLog.append(contentsOf: batch) + } + } + wizard.setTarget( + basename: config.basename, + workingDirectory: config.workingDirectory) + wizard.refreshGating() + wizard.showNotice("Target generated: \(url.lastPathComponent)") + wizard.go(to: .layOutPrint) + } catch { + wizard.showNotice( + "targen failed: \(error.localizedDescription)", kind: .error) + } + targenRunning = false + } + } + + // MARK: - Issue 8: resume an existing target + + /// `#btnOpenExisting` — open `.ti1`/`.ti2` (open dialog, #103). + /// `.ti1` → Stage 2; `.ti2` → Stage 3 with the resume notice, but + /// only when the sibling `.ti1` exists so the artefact gate holds. + func openExistingTarget() { + let url = UITestHooks.isEnabled + ? UITestHooks.existingTargetURL + : fileDialogs.selectExistingTarget() + guard let url else { return } + + let stem = url.deletingPathExtension().lastPathComponent + let dir = url.deletingLastPathComponent() + guard PathSecurity.isValidBasename(stem) else { + wizard.showNotice("Invalid target name.", kind: .error) + return + } + + switch url.pathExtension.lowercased() { + case "ti1": + wizard.setTarget(basename: stem, workingDirectory: dir) + wizard.refreshGating() + resumedFromTi2 = false + wizard.go(to: .layOutPrint) + case "ti2": + let header = Ti2Header.parse(url) + guard header.hasSiblingTi1 else { + wizard.showNotice( + "Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.", + kind: .error) + return + } + wizard.setTarget(basename: stem, workingDirectory: dir) + wizard.refreshGating() + resumedFromTi2 = true + wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil) + wizard.go(to: .measure) + default: + wizard.showNotice( + "Not a target file — choose a .ti1 or .ti2.", kind: .error) + } + } + + // MARK: - Stage 2: create layout + + func buildPrinttargConfig() -> PrinttargConfig { + PrinttargConfig( + instrument: instrument, + pageSize: pageSize, + customPageWidth: customPageW, + customPageHeight: customPageH, + bitDepth: bitDepth, + dpi: tiffDpi, + layoutOrder: layoutOrder, + customSeed: customSeed, + label: PrinttargLabel.resolved( + customLabel: labelIsCustom ? customLabel : nil, + basename: wizard.basename, + metadata: labelMetadata), + basename: wizard.basename, + workingDirectory: wizard.effectiveWorkingDirectory + ) + } + + func createLayout() { + guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return } + let config = buildPrinttargConfig() + printtargRunning = true + printtargLog = [] + printtargResult = nil + let runner = environment.runner + Task { + do { + let result = try await runner.runPrinttarg(config: config) { [weak self] batch in + Task { @MainActor [weak self] in + self?.printtargLog.append(contentsOf: batch) + } + } + printtargResult = result + wizard.refreshGating() + wizard.showNotice( + "Layout created — \(result.manifest.pages.count) page(s) ready.") + } catch { + // Stay on Stage 2: non-zero exit, malformed manifest, or + // missing .ti2 must never advance the wizard (#156). + wizard.showNotice( + "printtarg failed: \(error.localizedDescription)", kind: .error) + } + printtargRunning = false + } + } + + /// `#btnAdvanceToStage3` — manual advance once `.ti2` exists. + func advanceToStage3() { + wizard.refreshGating() + wizard.go(to: .measure) + } + + // MARK: - Presets + + func reloadPresets() { + presets = environment.presetStore.all() + } + + /// Applies every Stage 1/2 field of the preset to the live form + /// (bidirectional — the draft preset's dpi=150 must be visible). + func applyPreset(_ preset: ProfilingPreset) { + colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb + patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom + customPatchCount = preset.patchCount + whitePatches = preset.whitePatches + blackPatches = preset.blackPatches + greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil + singleChannelSteps = preset.singleChannelSteps ?? 5 + singleChannelEnabled = preset.singleChannelSteps != nil + neutralSteps = preset.neutralSteps ?? 3 + neutralStepsEnabled = preset.neutralSteps != nil + neutralConcentration = preset.neutralConcentration ?? 0.50 + neutralConcEnabled = preset.neutralConcentration != nil + preconditioningProfile = preset.preconditioningProfile + highQuality = preset.ofpsHighQuality == true + adaptation = preset.ofpsAdaptation ?? 0.10 + adaptationEnabled = preset.ofpsAdaptation != nil + algorithm = preset.fullSpreadAlgorithm + .flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps + totalInkLimit = preset.totalInkLimit ?? 320 + inkLimitEnabled = preset.totalInkLimit != nil + darkEmphasis = preset.darkEmphasis ?? 1.0 + darkEmphasisEnabled = preset.darkEmphasis != nil + devicePower = preset.devicePower ?? 1.0 + devicePowerEnabled = preset.devicePower != nil + + instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1 + if let size = PageSize(rawValue: preset.pageSize) { + pageSize = size + } else if let (w, h) = Self.parseCustomPage(preset.pageSize) { + pageSize = .custom; customPageW = w; customPageH = h + } else { + pageSize = .a4 + } + bitDepth = preset.bitDepth == 16 ? .sixteen : .eight + tiffDpi = preset.dpi + if preset.noRandomize == true { + layoutOrder = .raster + } else if (preset.randomSeed ?? 1) == 1 { + layoutOrder = .deterministic + } else { + layoutOrder = .customSeed + } + customSeed = preset.randomSeed ?? 1 + + selectedPresetID = preset.id + } + + /// Snapshot of the live Stage 1/2 form as a custom preset. + func saveCurrentAsPreset() { + let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + wizard.showNotice("Preset needs a name.", kind: .warning) + return + } + let preset = ProfilingPreset( + id: "custom-\(UUID().uuidString.lowercased())", + name: name, + description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines), + colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb", + patchCount: effectivePatchCount, + whitePatches: whitePatches, + blackPatches: blackPatches, + greySteps: greyStepsEnabled ? greySteps : nil, + singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil, + neutralSteps: neutralStepsEnabled ? neutralSteps : nil, + neutralConcentration: neutralConcEnabled ? neutralConcentration : nil, + preconditioningProfile: preconditioningProfile, + ofpsHighQuality: highQuality ? true : nil, + ofpsAdaptation: adaptationEnabled ? adaptation : nil, + fullSpreadAlgorithm: algorithm.presetValue, + totalInkLimit: inkLimitEnabled ? totalInkLimit : nil, + darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil, + devicePower: devicePowerEnabled ? devicePower : nil, + instrument: instrument.rawValue, + pageSize: pageSize == .custom + ? "\(Int(customPageW))x\(Int(customPageH))" + : pageSize.rawValue, + bitDepth: bitDepth.rawValue, + dpi: tiffDpi, + randomSeed: layoutOrder == .deterministic ? 1 : customSeed, + noRandomize: layoutOrder == .raster + ) + do { + try environment.presetStore.saveCustom(preset) + reloadPresets() + selectedPresetID = preset.id + showingSavePreset = false + savePresetName = "" + savePresetDesc = "" + wizard.showNotice("Preset saved: \(preset.name)") + } catch { + wizard.showNotice( + "Could not save preset: \(error.localizedDescription)", kind: .error) + } + } + + func deletePreset(_ preset: ProfilingPreset) { + do { + if try environment.presetStore.deleteCustom(id: preset.id) { + if selectedPresetID == preset.id { selectedPresetID = "none" } + reloadPresets() + } else { + wizard.showNotice("Built-in presets cannot be deleted.", kind: .warning) + } + } catch { + wizard.showNotice( + "Could not delete preset: \(error.localizedDescription)", kind: .error) + } + } + + func importPreset() { + let url = UITestHooks.isEnabled + ? UITestHooks.presetImportURL + : fileDialogs.selectPresetFile() + guard let url else { return } + do { + let data = try Data(contentsOf: url) + let preset = try environment.presetStore.import(data) + try environment.presetStore.saveCustom(preset) + reloadPresets() + selectedPresetID = preset.id + wizard.showNotice("Preset imported: \(preset.name)") + } catch { + wizard.showNotice( + "Import failed: \(error.localizedDescription)", kind: .error) + } + } + + func exportPreset(_ preset: ProfilingPreset) { + let url = UITestHooks.isEnabled + ? UITestHooks.presetExportURL + : fileDialogs.selectPresetSavePath(name: preset.id) + guard let url else { return } + do { + try environment.presetStore.export(preset) + .write(to: url, options: .atomic) + wizard.showNotice("Preset exported: \(url.lastPathComponent)") + } catch { + wizard.showNotice( + "Export failed: \(error.localizedDescription)", kind: .error) + } + } + + static func parseCustomPage(_ raw: String) -> (Double, Double)? { + let parts = raw.lowercased().split(separator: "x") + guard parts.count == 2, + let w = Double(parts[0]), let h = Double(parts[1]), + w >= 50, h >= 50 else { return nil } + return (w, h) + } +} diff --git a/Tests/ICCeryCoreTests/PresetTests.swift b/Tests/ICCeryCoreTests/PresetTests.swift new file mode 100644 index 0000000..0e8c519 --- /dev/null +++ b/Tests/ICCeryCoreTests/PresetTests.swift @@ -0,0 +1,232 @@ +import Testing +import Foundation +@testable import ICCeryCore + +@Suite("ProfilingPreset") +struct ProfilingPresetTests { + + @Test("snake_case keys round-trip through Codable") + func roundTrip() throws { + var p = PresetCatalog.highQualityCMYK + p.colprofInputViewingCond = "D50_2" + let data = try JSONEncoder().encode(p) + let decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data) + #expect(decoded == p) + // Spot-check the wire format. + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(obj["colour_space"] as? String == "cmyk") + #expect(obj["patch_count"] as? Int == 1500) + #expect(obj["total_ink_limit"] as? Int == 320) + #expect(obj["bit_depth"] as? Int == 16) + #expect(obj["colprof_input_viewing_cond"] as? String == "D50_2") + } + + @Test("Unknown keys ignored; missing required field fails") + func schemaTolerance() throws { + let json = """ + {"id":"x","name":"N","colour_space":"rgb","patch_count":10, + "white_patches":1,"black_patches":1,"instrument":"i1", + "page_size":"A4","bit_depth":8,"dpi":300,"future_key":42} + """.data(using: .utf8)! + let ok = try JSONDecoder().decode(ProfilingPreset.self, from: json) + #expect(ok.id == "x") + + let missing = """ + {"id":"x","name":"N","colour_space":"rgb"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(ProfilingPreset.self, from: missing) + } + } + + @Test("Validation rejects bad colour space / dpi / bit depth") + 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") +struct PresetCatalogTests { + + @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 std = byID["preset-std-rgb"]! + #expect(std.colourSpace == "rgb" && std.patchCount == 800 + && std.pageSize == "A4" && std.bitDepth == 8 + && std.dpi == 300 && std.colprofQuality == "m" + && std.whitePatches == 4 && std.blackPatches == 4) + + let hq = byID["preset-hq-cmyk"]! + #expect(hq.colourSpace == "cmyk" && hq.patchCount == 1500 + && hq.pageSize == "A3" && hq.bitDepth == 16 + && hq.dpi == 300 && hq.colprofQuality == "h" + && hq.totalInkLimit == 320 && hq.blackPatches == 8) + + let draft = byID["preset-draft-rgb"]! + #expect(draft.colourSpace == "rgb" && draft.patchCount == 400 + && draft.pageSize == "A4" && draft.bitDepth == 8 + && draft.dpi == 150 && draft.colprofQuality == "l") + + let ultra = byID["preset-ultra-rgb"]! + #expect(ultra.colourSpace == "rgb" && ultra.patchCount == 2500 + && ultra.pageSize == "A3" && ultra.bitDepth == 16 + && ultra.dpi == 300 && ultra.colprofQuality == "u" + && ultra.ofpsHighQuality == true + && ultra.whitePatches == 6 && ultra.blackPatches == 6) + + for p in PresetCatalog.builtIns { + #expect(p.instrument == "i1") + #expect(p.colprofFwa == "D50") + #expect(p.randomSeed == 1) + #expect(p.noRandomize == false) + #expect(p.colprofAlgorithm == "l") + } + } + + @Test("Custom presets overlay by id; built-ins are not deletable") + func overlay() { + let custom = ProfilingPreset( + id: "preset-std-rgb", name: "Shadowed", patchCount: 42) + let all = PresetCatalog.all(custom: [custom]) + #expect(all.count == 4) + #expect(all.first { $0.id == "preset-std-rgb" }?.patchCount == 42) + #expect(PresetCatalog.isBuiltIn("preset-std-rgb")) + #expect(!PresetCatalog.isBuiltIn("custom-1")) + } +} + +@Suite("PresetStore") +struct PresetStoreTests { + + private func tempSettingsURL() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("settings.json") + } + + @Test("CRUD + export/import round-trip") + func crud() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + + var p = ProfilingPreset(id: "custom-x", name: "Mine", patchCount: 999, dpi: 150) + try store.saveCustom(p) + #expect(store.customs().count == 1) + #expect(store.all().count == 5) + + p.name = "Renamed" + try store.saveCustom(p) + #expect(store.customs().count == 1) + #expect(store.customs()[0].name == "Renamed") + + let data = try store.export(p) + let imported = try store.import(data) + #expect(imported.name == "Renamed") + #expect(imported.dpi == 150) + + #expect(try store.deleteCustom(id: "custom-x")) + #expect(store.customs().isEmpty) + #expect(try !store.deleteCustom(id: "preset-std-rgb")) + } + + @Test("Import rewrites a built-in id to a fresh custom id") + func importBuiltinCollision() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + let data = try store.export(PresetCatalog.standardRGB) + let imported = try store.import(data) + #expect(imported.id.hasPrefix("custom-")) + #expect(!PresetCatalog.isBuiltIn(imported.id)) + } + + @Test("Built-ins are immutable through saveCustom") + func builtInImmutable() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + var shadowed = PresetCatalog.standardRGB + shadowed.name = "Hacked" + #expect(throws: PresetStore.PresetStoreError.self) { + try store.saveCustom(shadowed) + } + } +} + +@Suite("AppSettings preset migration") +struct PresetMigrationTests { + + private func tempSettingsURL() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("settings.json") + } + + @Test("Legacy M1 custom_presets migrate to typed schema") + func legacyMigration() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let legacy = """ + {"custom_presets":[ + {"name":"Old One","values":{"colour_space":"cmyk","patch_count":"900", + "dpi":"150","bit_depth":"16","instrument":"p3","page_size":"A3"}}, + {"name":"","values":{}}, + 42 + ]} + """.data(using: .utf8)! + try legacy.write(to: url) + + let settings = SettingsStore(fileURL: url).load() + #expect(settings.customPresets.count == 1) + let p = settings.customPresets[0] + #expect(p.name == "Old One") + #expect(p.id.hasPrefix("custom-0-")) + #expect(p.colourSpace == "cmyk") + #expect(p.patchCount == 900) + #expect(p.dpi == 150) + #expect(p.bitDepth == 16) + #expect(p.instrument == "p3") + #expect(p.pageSize == "A3") + } + + @Test("Typed presets load and re-save as the typed schema") + func typedRoundTrip() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = SettingsStore(fileURL: url) + var s = AppSettings() + s.customPresets = [ProfilingPreset(id: "c1", name: "C1", patchCount: 700)] + try store.save(s) + let loaded = store.load() + #expect(loaded.customPresets.first?.patchCount == 700) + } + + @Test("Draft preset dpi=150 survives Codable + settings round-trip") + func draftDPI() throws { + let url = try tempSettingsURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let store = PresetStore(settingsStore: SettingsStore(fileURL: url)) + let data = try store.export(PresetCatalog.draftRGB) + let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(obj["dpi"] as? Int == 150) + let back = try store.import(data) + #expect(back.dpi == 150) + } +} diff --git a/Tests/ICCeryCoreTests/PrinttargTests.swift b/Tests/ICCeryCoreTests/PrinttargTests.swift new file mode 100644 index 0000000..440b919 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrinttargTests.swift @@ -0,0 +1,429 @@ +import Testing +import Foundation +@testable import ICCeryCore + +@Suite("PrinttargArgs") +struct PrinttargArgsTests { + + private func config( + instrument: PrintInstrument = .i1, + pageSize: PageSize = .a4, + customW: Double = 210, customH: Double = 297, + bitDepth: TiffBitDepth = .eight, + dpi: Int = 300, + layout: LayoutOrder = .deterministic, + seed: Int = 1, + label: String? = nil, + calFile: String? = nil, + calEmbed: Bool = false, + basename: String = "target" + ) -> PrinttargConfig { + PrinttargConfig( + instrument: instrument, pageSize: pageSize, + customPageWidth: customW, customPageHeight: customH, + bitDepth: bitDepth, dpi: dpi, + layoutOrder: layout, customSeed: seed, label: label, + calibrationFile: calFile, calibrationEmbedOnly: calEmbed, + basename: basename + ) + } + + @Test("Baseline: -v -u -i i1 -p A4 -R 1 -t 300") + func baseline() throws { + let args = try PrinttargArgs.build(config: config()) + #expect(args == ["-v", "-u", "-i", "i1", "-p", "A4", + "-R", "1", "-t", "300", "target"]) + } + + @Test("Default layout is deterministic -R 1, never bare") + func deterministicDefault() throws { + let args = try PrinttargArgs.build(config: config()) + #expect(args.contains("-R")) + #expect(!args.contains("-r")) + #expect(args[args.firstIndex(of: "-R")! + 1] == "1") + } + + @Test("Custom seed -R N; seed < 1 throws") + func customSeed() throws { + let args = try PrinttargArgs.build(config: config(layout: .customSeed, seed: 42)) + #expect(args[args.firstIndex(of: "-R")! + 1] == "42") + #expect(throws: PrinttargArgError.self) { + try PrinttargArgs.build(config: config(layout: .customSeed, seed: 0)) + } + } + + @Test("Raster emits -r and supersedes seed (printtarg -r, not targen -r)") + func raster() throws { + let args = try PrinttargArgs.build(config: config(layout: .raster, seed: 9)) + #expect(args.contains("-r")) + #expect(!args.contains("-R")) + } + + @Test("Label: -d emits the resolved string, not a colour space") + func label() throws { + let args = try PrinttargArgs.build( + config: config(label: "ICCery - t - P - I - D - A - 01/02/2026 03:04")) + let i = args.firstIndex(of: "-d")! + #expect(args[i + 1].hasPrefix("ICCery - t")) + } + + @Test("Bit depth: -t 8-bit, -T 16-bit; DPI range 72-600") + func bitDepthAndDPI() throws { + #expect(try PrinttargArgs.build(config: config(bitDepth: .sixteen, dpi: 600)) + .contains("-T")) + #expect(try PrinttargArgs.build(config: config(bitDepth: .eight, dpi: 72)) + .contains("-t")) + #expect(throws: PrinttargArgError.self) { + try PrinttargArgs.build(config: config(dpi: 71)) + } + #expect(throws: PrinttargArgError.self) { + try PrinttargArgs.build(config: config(dpi: 601)) + } + } + + @Test("All instruments emit their Argyll code") + func instruments() throws { + let expected: [(PrintInstrument, String)] = [ + (.i1, "i1"), (.p3, "p3"), (.cm, "CM"), (.ss, "SS"), + (.dtp20, "20"), (.dtp22, "22"), (.dtp41, "41"), (.dtp51, "51"), + ] + for (inst, code) in expected { + let args = try PrinttargArgs.build(config: config(instrument: inst)) + #expect(args[args.firstIndex(of: "-i")! + 1] == code) + } + } + + @Test("All fixed page sizes; custom emits WxH in mm") + func pageSizes() throws { + for size in PageSize.allCases where size != .custom { + let args = try PrinttargArgs.build(config: config(pageSize: size)) + #expect(args[args.firstIndex(of: "-p")! + 1] == size.rawValue) + } + let custom = try PrinttargArgs.build(config: config( + pageSize: .custom, customW: 150, customH: 220)) + #expect(custom[custom.firstIndex(of: "-p")! + 1] == "150x220") + } + + @Test("Custom page below 50 mm throws") + func customPageTooSmall() { + #expect(throws: PrinttargArgError.self) { + try PrinttargArgs.build(config: config(pageSize: .custom, customW: 49.9)) + } + #expect(throws: PrinttargArgError.self) { + try PrinttargArgs.build(config: config(pageSize: .custom, customH: 10)) + } + } + + @Test("Calibration: -K applies, -I embeds") + func calibrationFlags() throws { + let k = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal")) + #expect(k[k.firstIndex(of: "-K")! + 1] == "/tmp/a.cal") + let i = try PrinttargArgs.build(config: config(calFile: "/tmp/a.cal", calEmbed: true)) + #expect(i[i.firstIndex(of: "-I")! + 1] == "/tmp/a.cal") + #expect(!i.contains("-K")) + } + + @Test("CAL_ basename never gets -K or -I") + func calProtection() throws { + let args = try PrinttargArgs.build( + config: config(calFile: "/tmp/a.cal", basename: "CAL_test")) + #expect(!args.contains("-K")) + #expect(!args.contains("-I")) + } + + @Test("Unsafe basename throws") + func unsafeBasename() { + #expect(throws: PathSecurity.Error.self) { + try PrinttargArgs.build(config: config(basename: "../x")) + } + } +} + +@Suite("PrinttargLabel") +struct PrinttargLabelTests { + + private var fixedDate: Date { + var comps = DateComponents() + comps.year = 2026; comps.month = 2; comps.day = 3 + comps.hour = 14; comps.minute = 5 + return Calendar(identifier: .gregorian).date(from: comps)! + } + + @Test("Automatic label: ICCery - basename - P - I - DP - AP - DD/MM/YYYY HH:MM") + func automatic() { + let label = PrinttargLabel.automatic( + basename: "tgt", + metadata: TargetLabelMetadata( + printer: "Epson", inkSet: "CMYK", + driverPaper: "Photo", actualPaper: "Matte"), + date: fixedDate, timeZone: .current) + #expect(label.hasPrefix("ICCery - tgt - Epson - CMYK - Photo - Matte - ")) + #expect(label.hasSuffix("03/02/2026") || label.contains("/02/2026")) + } + + @Test("Missing metadata becomes Unspecified") + func unspecified() { + let label = PrinttargLabel.automatic( + basename: "tgt", metadata: TargetLabelMetadata(), + date: fixedDate, timeZone: .current) + #expect(label.contains(" - Unspecified - Unspecified - Unspecified - Unspecified - ")) + } + + @Test("Manual label wins over automatic") + func manualWins() { + let resolved = PrinttargLabel.resolved( + customLabel: " My Label ", basename: "tgt", + metadata: TargetLabelMetadata(), date: fixedDate) + #expect(resolved == "My Label") + } +} + +@Suite("PrinttargManifest") +struct PrinttargManifestTests { + + private let prettySingle = """ + Some log line + Doing work... + { + "event": "manifest", + "pages": [ + { + "filename": "target.tif", + "patches": 800, + "width_mm": 210.0, + "height_mm": 297.0 + } + ] + } + trailing text + """ + + private let prettyMulti = """ + { + "event": "manifest", + "pages": [ + {"filename": "p1.tif", "patches": 400, "width_mm": 210, "height_mm": 148}, + {"filename": "p2.tif", "patches": 400, "width_mm": 210, "height_mm": 148} + ] + } + """ + + @Test("Decodes a single-page pretty manifest amid log noise") + func singlePage() throws { + let m = try PrinttargManifestExtractor.manifest(from: prettySingle) + #expect(m.event == "manifest") + #expect(m.pages.count == 1) + #expect(m.pages[0].filename == "target.tif") + #expect(m.pages[0].patches == 800) + } + + @Test("Multi-page manifest preserves order") + func multiPage() throws { + let m = try PrinttargManifestExtractor.manifest(from: prettyMulti) + #expect(m.pages.map(\.filename) == ["p1.tif", "p2.tif"]) + } + + @Test("No JSON document → noJSONDocument") + func noJSON() { + #expect(throws: ManifestError.self) { + try PrinttargManifestExtractor.manifest(from: "plain text\nno json") + } + } + + @Test("Wrong event → wrongEvent") + func wrongEvent() { + let stdout = "{\n \"event\": \"row\",\n \"row\": 1\n}\n" + #expect(throws: ManifestError.self) { + try PrinttargManifestExtractor.manifest(from: stdout) + } + } + + @Test("ROW_COLORS_JSON line is never treated as the manifest") + func rowColorsNotManifest() { + let stdout = "ROW_COLORS_JSON: {\"a\":1}\n{\"event\":\"manifest\",\"pages\":[]}" + // Extraction only starts at a '{' that begins a trimmed line, + // so the ROW_COLORS_JSON line is skipped entirely. + let m = try? PrinttargManifestExtractor.manifest(from: stdout) + #expect(m != nil) + #expect(m?.event == "manifest") + } + + @Test("Braces inside a quoted filename do not corrupt the scan") + func bracesInFilename() throws { + let stdout = "log\n{\n\"event\": \"manifest\",\n\"pages\": [{\"filename\": \"a}b.tif\", \"patches\": 1, \"width_mm\": 50, \"height_mm\": 50}]\n}\n" + let m = try PrinttargManifestExtractor.manifest(from: stdout) + #expect(m.pages[0].filename == "a}b.tif") + } + + @Test("Unsafe / non-TIFF filenames rejected") + func unsafeFilenames() { + for bad in ["../x.tif", "/abs/x.tif", "dir/x.tif", "x.txt", ""] { + let stdout = "{\n\"event\":\"manifest\",\"pages\":[{\"filename\":\"\(bad)\",\"patches\":1,\"width_mm\":50,\"height_mm\":50}]\n}" + #expect(throws: ManifestError.self) { + try PrinttargManifestExtractor.manifest(from: stdout) + } + } + } +} + +@Suite("ArgyllRunner Printtarg") +struct ArgyllRunnerPrinttargTests { + + private func makeFixture(_ body: String, name: String = "printtarg") throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + return dir + } + + /// A minimal valid TIFF (8-bit, tiny) for gallery preview tests. + private func writeTinyTIFF(at url: URL) throws { + // 1x1 8-bit grayscale TIFF, little-endian. + var bytes: [UInt8] = [ + 0x49, 0x49, 0x2A, 0x00, // II + magic + 0x08, 0x00, 0x00, 0x00, // IFD offset + ] + let ifdCount: UInt16 = 10 + bytes += withUnsafeBytes(of: ifdCount.littleEndian) { Array($0) } + func tag(_ t: UInt16, _ type: UInt16, _ count: UInt32, _ value: UInt32) { + bytes += withUnsafeBytes(of: t.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: type.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: count.littleEndian) { Array($0) } + bytes += withUnsafeBytes(of: value.littleEndian) { Array($0) } + } + tag(256, 3, 1, 1) // ImageWidth = 1 + tag(257, 3, 1, 1) // ImageLength = 1 + tag(258, 3, 1, 8) // BitsPerSample = 8 + tag(259, 3, 1, 1) // Compression = none + tag(262, 3, 1, 1) // Photometric = BlackIsZero + tag(273, 4, 1, 0) // StripOffsets — patched below + tag(277, 3, 1, 1) // SamplesPerPixel = 1 + tag(278, 3, 1, 1) // RowsPerStrip = 1 + tag(279, 4, 1, 1) // StripByteCounts = 1 + tag(284, 3, 1, 1) // PlanarConfig + bytes += [0, 0, 0, 0] // next IFD = none + let pixelOffset = bytes.count + bytes += [0x80] // the pixel + // Patch StripOffsets (located right after the tag header at + // offset 8 + 2 + 5*12 + 8 = position of value field). + let valuePos = 8 + 2 + 5 * 12 + 8 + let off = UInt32(pixelOffset).littleEndian + withUnsafeBytes(of: off) { b in + bytes[valuePos] = b[0]; bytes[valuePos+1] = b[1] + bytes[valuePos+2] = b[2]; bytes[valuePos+3] = b[3] + } + try Data(bytes).write(to: url) + } + + @Test("Successful printtarg emits .ti2 + manifest + PNG previews") + func success() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'log line\\n' + printf '{\\n "event": "manifest",\\n "pages": [\\n {"filename": "%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\\n ]\\n}\\n' "$last" + touch "$last.ti2" + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + // Basename "pt" → manifest references pt.tif; write a real TIFF. + try writeTinyTIFF(at: dir.appendingPathComponent("pt.tif")) + + let resolver = BinaryResolver(bundledRoot: dir, overrideDir: dir) + let runner = ArgyllRunner( + processManager: ProcessManager(), binaryResolver: resolver) + let config = PrinttargConfig(basename: "pt", workingDirectory: dir) + let result = try await runner.runPrinttarg(config: config) + #expect(result.ti2URL.lastPathComponent == "pt.ti2") + #expect(result.manifest.pages.count == 1) + #expect(result.pages.count == 1) + let png = result.pages[0].previewPNG + #expect(png != nil) + if let png { + #expect(png.prefix(8) == Data([0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A])) + } + } + + @Test("Non-zero exit throws processFailed and stays on stage") + func failure() async throws { + let dir = try makeFixture(""" + #!/bin/sh + echo "oops" >&2 + exit 3 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await #expect(throws: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } + } + + @Test("Exit 0 without manifest → malformedManifest") + func noManifest() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + touch "$last.ti2" + echo "no json here" + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await #expect(throws: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } + } + + @Test("Exit 0 without .ti2 → missingArtefact") + func noTi2() async throws { + let dir = try makeFixture(""" + #!/bin/sh + printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + await #expect(throws: ArgyllRunnerError.self) { + try await runner.runPrinttarg( + config: PrinttargConfig(basename: "x", workingDirectory: dir)) + } + } + + @Test("Deterministic config produces byte-identical .ti2") + func determinism() async throws { + let dir = try makeFixture(""" + #!/bin/sh + last="" + for arg in "$@"; do last="$arg"; done + printf 'TI2\\nDETERMINISTIC\\n' > "$last.ti2" + printf '{\\n"event":"manifest",\\n"pages":[]\\n}\\n' + exit 0 + """) + defer { try? FileManager.default.removeItem(at: dir) } + let runner = ArgyllRunner( + processManager: ProcessManager(), + binaryResolver: BinaryResolver(bundledRoot: dir, overrideDir: dir)) + // Two runs, two basenames — same argv except basename. + _ = try await runner.runPrinttarg( + config: PrinttargConfig(basename: "a", workingDirectory: dir)) + _ = try await runner.runPrinttarg( + config: PrinttargConfig(basename: "b", workingDirectory: dir)) + let d1 = try Data(contentsOf: dir.appendingPathComponent("a.ti2")) + let d2 = try Data(contentsOf: dir.appendingPathComponent("b.ti2")) + #expect(d1 == d2) + } +} diff --git a/Tests/ICCeryCoreTests/TargenTests.swift b/Tests/ICCeryCoreTests/TargenTests.swift new file mode 100644 index 0000000..3329bcc --- /dev/null +++ b/Tests/ICCeryCoreTests/TargenTests.swift @@ -0,0 +1,330 @@ +import Testing +import Foundation +@testable import ICCeryCore + +@Suite("TargenArgs") +struct TargenArgsTests { + + @Test("RGB baseline: -v -d 2 -f 800 -e 4 -B 4") + func rgbBaseline() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "test_rgb" + ) + let args = try TargenArgs.build(config: config) + #expect(args == ["-v", "-d", "2", "-f", "800", "-e", "4", "-B", "4", "test_rgb"]) + #expect(!args.contains("-u")) + } + + @Test("CMYK baseline: -v -d 4 -f 1500 -e 4 -B 0") + func cmykBaseline() throws { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 1500, + whitePatches: 4, + blackPatches: 0, + basename: "test_cmyk" + ) + let args = try TargenArgs.build(config: config) + #expect(args == ["-v", "-d", "4", "-f", "1500", "-e", "4", "-B", "0", "test_cmyk"]) + } + + @Test("Custom patch count honours -f (#44)") + func customPatchCount() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 2500, + whitePatches: 4, + blackPatches: 4, + basename: "custom_patches" + ) + let args = try TargenArgs.build(config: config) + #expect(args.contains("-f")) + #expect(args[args.firstIndex(of: "-f")! + 1] == "2500") + } + + @Test("All advanced flags in stable order") + func allAdvancedFlags() throws { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 1200, + whitePatches: 6, + blackPatches: 2, + greySteps: 12, + singleChannelSteps: 8, + neutralSteps: 6, + neutralConcentration: 0.75, + preconditioningProfile: "/path/to/profile.icc", + ofpsHighQuality: true, + ofpsAdaptation: 0.10, + fullSpreadAlgorithm: .target, + totalInkLimit: 320, + darkEmphasis: 1.50, + devicePower: 2.0, + basename: "advanced_cmyk" + ) + let args = try TargenArgs.build(config: config) + let expected = [ + "-v", "-d", "4", + "-f", "1200", + "-e", "6", + "-B", "2", + "-g", "12", + "-s", "8", + "-n", "6", + "-N", "0.75", + "-c", "/path/to/profile.icc", + "-G", + "-A", "0.10", + "-t", + "-l", "320", + "-V", "1.50", + "-p", "2.00", + "advanced_cmyk" + ] + #expect(args == expected) + } + + @Test("RGB ignores total ink limit") + func rgbIgnoresInkLimit() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + totalInkLimit: 300, + basename: "rgb_no_ink" + ) + let args = try TargenArgs.build(config: config) + #expect(!args.contains("-l")) + } + + @Test("Neutral concentration omitted when approximately 0.50") + func neutralConcentrationOmittedWhenDefault() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + neutralConcentration: 0.5005, + basename: "n_default" + ) + let args = try TargenArgs.build(config: config) + #expect(!args.contains("-N")) + } + + @Test("Adaptation emitted even at 0.10 (no default-skip)") + func adaptationEmittedAtPointOne() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + ofpsAdaptation: 0.10, + basename: "a_flag" + ) + let args = try TargenArgs.build(config: config) + #expect(args.contains("-A")) + #expect(args[args.firstIndex(of: "-A")! + 1] == "0.10") + } + + @Test("OFPS full spread algorithm emits no flag") + func ofpsEmitsNoFlag() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + fullSpreadAlgorithm: .ofps, + basename: "ofps_test" + ) + let args = try TargenArgs.build(config: config) + #expect(!args.contains("ofps")) + #expect(!args.contains("-t")) + } + + @Test("Dark emphasis and device power omitted when 1.0") + func darkEmphasisAndPowerOmittedWhenOne() throws { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + darkEmphasis: 1.0, + devicePower: 1.0, + basename: "defaults_omitted" + ) + let args = try TargenArgs.build(config: config) + #expect(!args.contains("-V")) + #expect(!args.contains("-p")) + } + + @Test("Invalid basename throws") + func invalidBasenameThrows() { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "../bad_name" + ) + #expect(throws: PathSecurity.Error.self) { + try TargenArgs.build(config: config) + } + } + + @Test("Invalid patch count throws") + func invalidPatchCountThrows() { + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 0, + whitePatches: 4, + blackPatches: 4, + basename: "bad_count" + ) + #expect(throws: TargenArgError.self) { + try TargenArgs.build(config: config) + } + } + + @Test("Invalid ink limit throws for CMYK") + func invalidInkLimitThrows() { + let config = TargenConfig( + colourSpace: .cmyk, + patchCount: 800, + whitePatches: 4, + blackPatches: 0, + totalInkLimit: 450, + basename: "bad_ink" + ) + #expect(throws: TargenArgError.self) { + try TargenArgs.build(config: config) + } + } +} + +@Suite("ArgyllRunner Targen") +struct ArgyllRunnerTargenTests { + + @Test("Successful targen execution creates .ti1 and returns URL") + func successfulTargenExecution() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + // Create a mock targen script + let mockScript = """ + #!/bin/sh + # Find the last argument which is the basename + for arg do shift; set -- "$@" "$arg"; done + last="$arg" + echo "Generating patches..." + touch "$last.ti1" + echo "Done!" + exit 0 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "mock_test", + workingDirectory: tempDir + ) + + var logLines: [String] = [] + final class LogBox: @unchecked Sendable { + var lines: [String] = [] + let lock = NSLock() + func append(_ batch: [String]) { + lock.lock(); lines.append(contentsOf: batch); lock.unlock() + } + } + let box = LogBox() + let ti1URL = try await runner.runTargen(config: config) { batch in + box.append(batch) + } + logLines = box.lines + #expect(logLines.contains("Generating patches...")) + + #expect(FileManager.default.fileExists(atPath: ti1URL.path)) + #expect(ti1URL.lastPathComponent == "mock_test.ti1") + } + + @Test("Failed targen execution throws processFailed") + func failedTargenExecution() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mockScript = """ + #!/bin/sh + echo "Error: something went wrong" >&2 + exit 1 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "fail_test", + workingDirectory: tempDir + ) + + await #expect(throws: ArgyllRunnerError.self) { + try await runner.runTargen(config: config) + } + } + + @Test("Targen exit 0 without .ti1 throws missingArtefact") + func missingArtefactThrows() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let mockScript = """ + #!/bin/sh + echo "Exited 0 but did not create file" + exit 0 + """ + let mockURL = tempDir.appendingPathComponent("targen") + try mockScript.write(to: mockURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mockURL.path) + + let resolver = BinaryResolver(bundledRoot: tempDir, overrideDir: tempDir) + let pm = ProcessManager() + let runner = ArgyllRunner(processManager: pm, binaryResolver: resolver) + + let config = TargenConfig( + colourSpace: .rgb, + patchCount: 800, + whitePatches: 4, + blackPatches: 4, + basename: "no_file", + workingDirectory: tempDir + ) + + await #expect(throws: ArgyllRunnerError.self) { + try await runner.runTargen(config: config) + } + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/printtarg b/Tests/ICCeryUITests/Fixtures/bin/printtarg new file mode 100755 index 0000000..16f482d --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/printtarg @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty +# manifest on stdout, and a fake .ti2 next to the basename (last argv). +# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT. +last="" +for arg in "$@"; do last="$arg"; done +if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then + echo "mock printtarg failure" >&2 + exit "$ICCERY_MOCK_PRINTTARG_EXIT" +fi +echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif" +printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n' +touch "$last.ti2" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/targen b/Tests/ICCeryUITests/Fixtures/bin/targen new file mode 100755 index 0000000..d5f46c5 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/targen @@ -0,0 +1,13 @@ +#!/bin/sh +# Mock targen for Milestone2UITests. Emits a fake .ti1 next to the +# basename (last argv) in the process working directory. Exit code is +# overridable via ICCERY_MOCK_TARGEN_EXIT. +last="" +for arg in "$@"; do last="$arg"; done +echo "targen mock: generating $last" +if [ "${ICCERY_MOCK_TARGEN_EXIT:-0}" -ne 0 ]; then + echo "mock targen failure" >&2 + exit "$ICCERY_MOCK_TARGEN_EXIT" +fi +touch "$last.ti1" +exit 0 diff --git a/Tests/ICCeryUITests/Milestone2UITests.swift b/Tests/ICCeryUITests/Milestone2UITests.swift new file mode 100644 index 0000000..cc6919e --- /dev/null +++ b/Tests/ICCeryUITests/Milestone2UITests.swift @@ -0,0 +1,355 @@ +import XCTest + +/// Milestone 2 UI tests — issues #7–#11 (docs/21 element contract). +/// Every test launches the app with an isolated `ICCERY_TEST_ROOT`, +/// fixture sidecars via `ICCERY_ARGYLL_BINARY_DIR`, and +/// `ICCERY_UI_TESTING=1` so file dialogs resolve to env-provided +/// paths instead of modal panels. No hardware, no network, no real +/// Argyll install, and nothing is written to the developer's app data. +@MainActor +final class Milestone2UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + // The xctrunner sandbox only permits writes inside its own + // container — the work dir lives there (the app can read/write + // it). Executable fixtures, however, must live outside the + // container or the app-under-test cannot posix_spawn them, so + // `bin` points at the committed Fixtures/bin scripts in the + // repo checkout (resolved via #filePath). + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/ICCeryUITests + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + /// Force the fixture printtarg to exit with `code`. + private func failPrinttarg(exitCode: Int) { + app.launchEnvironment["ICCERY_MOCK_PRINTTARG_EXIT"] = "\(exitCode)" + } + + /// Launch and bring the app to the front — other app windows + /// (the IDE, notification banners) covering the test window count + /// as "interrupting elements" and stall synthesized clicks. + private func launchApp() { + app.launch() + app.activate() + } + + /// Sheet content on macOS lives under `app.sheets`, outside the + /// main window's descendant tree — probe both scopes. + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + // Poll both scopes so sheet-hosted elements resolve too. + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + private func staticText(_ exact: String) -> XCUIElement { + let inApp = app.staticTexts[exact] + if inApp.exists { return inApp } + return app.sheets.firstMatch.staticTexts[exact] + } + + private func buttonsMatching(_ predicateFormat: String) -> XCUIElementQuery { + let pred = NSPredicate(format: predicateFormat) + let inApp = app.buttons.matching(pred) + if inApp.count > 0 { return inApp } + return app.sheets.firstMatch.buttons.matching(pred) + } + + // MARK: - Tests + + /// Stage 1 opens with the Standard 800-patch default; Generate stays + /// disabled until basename + cwd are valid (issue #7). + func testStage1DefaultsAndGenerateGate() throws { + launchApp() + XCTAssertTrue(waitFor("btnGenerate").exists) + XCTAssertTrue(element("patchCountPreset").exists) + XCTAssertTrue(element("targetBasename").exists) + XCTAssertTrue(element("btnOpenExisting").exists) + XCTAssertFalse(app.buttons["btnGenerate"].isEnabled) + + // Browse fills basename + working dir via the test hook. + app.buttons["btnBrowse"].click() + XCTAssertTrue(app.buttons["btnGenerate"].isEnabled) + } + + /// RGB/CMYK + advanced controls expose the documented identifiers + /// and the ink-limit group is hidden for RGB (issue #7). + func testStage1AdvancedVisibility() throws { + launchApp() + XCTAssertTrue(waitFor("targenAdvancedDetails").exists) + // RGB default: ink-limit group must not exist. + XCTAssertFalse(element("targenInkLimitGroup").exists) + // The ink-limit group lives inside the Advanced disclosure — + // pre-expanded under UI testing (XCUI can't toggle a macOS + // DisclosureTriangle reliably). Switch the picker to CMYK. + XCTAssertTrue(element("targenAdvancedDetails").exists) + let cmyk = app.radioGroups["colourSpace"] + .radioButtons["CMYK (RIP output)"] + XCTAssertTrue(cmyk.waitForExistence(timeout: 5)) + cmyk.click() + XCTAssertTrue(element("targenInkLimitGroup").waitForExistence(timeout: 5)) + } + + /// Fixture-backed targen run creates .ti1 and unlocks Stage 2. + func testTargenFixtureUnlocksStage2() throws { + launchApp() + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti1").path)) + } + + /// Fixture printtarg → .ti2, gallery page renders, print controls + /// stay disabled, Stage 3 advance becomes available (issues #9/#10). + func testPrinttargFixtureGalleryAndStubbedPrint() throws { + launchApp() + + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + + // Colour-management warning is always present on Stage 2. + XCTAssertTrue(element("cmWarningBanner").exists) + XCTAssertTrue(element("instrumentSelect").exists) + XCTAssertTrue(element("pageSizeSelect").exists) + XCTAssertTrue(element("tiffDpi").exists) + XCTAssertTrue(element("targetLabelPreview").exists) + + app.buttons["btnCreateLayout"].click() + XCTAssertTrue(waitFor("galleryPage-0", timeout: 20).exists) + XCTAssertTrue(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti2").path)) + + // M3 stubs: visible but inert. + XCTAssertTrue(element("rawPrintPanel").exists) + XCTAssertFalse(app.buttons["btnPrintAll"].isEnabled) + XCTAssertFalse(app.buttons["btnPrintPage-0"].isEnabled) + XCTAssertTrue(app.buttons["btnAdvanceToStage3"].isEnabled) + } + + /// A failed printtarg run stays on Stage 2 (non-zero exit, #156). + func testPrinttargFailureStaysOnStage2() throws { + failPrinttarg(exitCode: 3) + launchApp() + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 20).exists) + + app.buttons["btnCreateLayout"].click() + // The notice banner reports the failure and we never advance: + // btnCreateLayout is still the stage's action, and no gallery + // appears. + let failureText = element("noticeText") + XCTAssertTrue(failureText.waitForExistence(timeout: 20)) + XCTAssertTrue((failureText.value as? String ?? "") + .contains("printtarg failed")) + XCTAssertTrue(element("btnCreateLayout").exists) + XCTAssertFalse(element("galleryPage-0").exists) + XCTAssertFalse(FileManager.default.fileExists( + atPath: workDir.appendingPathComponent("mytarget.ti2").path)) + } + + /// Resume: .ti1 jumps to Stage 2 (issue #8). + func testResumeTi1() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti1").path, + contents: Data("CGATS".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("old.ti1").path + launchApp() + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists) + } + + /// Resume: .ti2 with sibling .ti1 reaches the Stage 3 shell and + /// shows the persisted "Resumed from .ti2" state (issue #8). + func testResumeTi2ShowsStage3AndNotice() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti1").path, + contents: Data("CGATS".utf8)) + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("old.ti2").path, + contents: Data(""" + CTI2 + TARGET_INSTRUMENT "i1" + NUMBER_OF_SETS 4 + NUMBER_OF_PAGES 1 + BEGIN_DATA_FORMAT + """.utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("old.ti2").path + launchApp() + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("stage3TargetBasename", timeout: 10).exists) + XCTAssertTrue(element("stage3LoadedTargetBanner").exists) + let notice = element("noticeText") + XCTAssertTrue(notice.exists) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Resumed from .ti2")) + } + + /// A .ti2 without its sibling .ti1 must not advance (issue #8). + func testResumeTi2WithoutSiblingFails() throws { + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("orphan.ti2").path, + contents: Data("CTI2".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("orphan.ti2").path + launchApp() + app.buttons["btnOpenExisting"].click() + let err = element("noticeText") + XCTAssertTrue(err.waitForExistence(timeout: 10)) + XCTAssertTrue((err.value as? String ?? "").contains("Cannot resume")) + XCTAssertTrue(element("btnGenerate").exists) // still Stage 1 + } + + /// Preset apply is bidirectional: the draft preset's 150 dpi must + /// be visible on Stage 2; built-ins cannot be deleted (issue #11). + func testPresetApplyAndBuiltInProtection() throws { + // Land on Stage 2 via a .ti1 resume so tiffDpi is visible. + FileManager.default.createFile( + atPath: workDir.appendingPathComponent("p.ti1").path, + contents: Data("CGATS".utf8)) + app.launchEnvironment["ICCERY_TEST_EXISTING_TARGET"] = + workDir.appendingPathComponent("p.ti1").path + launchApp() + + // Sidebar preset picker is enabled; apply the draft preset. + let picker = app.popUpButtons["presetSelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + XCTAssertTrue(picker.isEnabled) + picker.click() + let draftItem = app.menuItems["Fast RGB Draft (400 patches)"] + XCTAssertTrue(draftItem.waitForExistence(timeout: 5)) + draftItem.click() + + app.buttons["btnOpenExisting"].click() + XCTAssertTrue(waitFor("btnCreateLayout", timeout: 10).exists) + // StaticText content is exposed via `value` on macOS, not `label`. + XCTAssertTrue(app.staticTexts + .matching(NSPredicate(format: "value CONTAINS 'DPI: 150'")) + .firstMatch.waitForExistence(timeout: 5)) + + // Manage dialog: built-ins show "Built-in" and have no delete. + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + XCTAssertFalse(element("btnDeletePreset-preset-std-rgb").exists) + XCTAssertTrue(element("presetRow-preset-std-rgb").exists) + element("btnCloseManagePresetsDialog").click() + } + + /// Save a custom preset through the dialog; it appears in the list + /// and can be deleted (issue #11). + func testSaveAndDeleteCustomPreset() throws { + launchApp() + app.buttons["btnSavePresetModal"].click() + XCTAssertTrue(waitFor("savePresetDialog", timeout: 10).exists) + let nameField = element("savePresetName") + XCTAssertTrue(nameField.waitForExistence(timeout: 5)) + nameField.click() + nameField.typeText("UI Test Preset") + element("btnConfirmSavePreset").click() + + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + XCTAssertTrue(staticText("UI Test Preset") + .waitForExistence(timeout: 5)) + // The custom row is deletable (id prefix custom-). + let deleteButtons = buttonsMatching( + "identifier BEGINSWITH 'btnDeletePreset-'") + XCTAssertTrue(deleteButtons.firstMatch.waitForExistence(timeout: 5)) + deleteButtons.firstMatch.click() + XCTAssertFalse(staticText("UI Test Preset").waitForExistence(timeout: 3)) + } + + /// Export a preset to JSON and re-import it (issue #11). + func testPresetExportImport() throws { + let exportURL = testRoot.appendingPathComponent("export.json") + let importURL = testRoot.appendingPathComponent("import.json") + app.launchEnvironment["ICCERY_TEST_PRESET_EXPORT"] = exportURL.path + app.launchEnvironment["ICCERY_TEST_PRESET_IMPORT"] = importURL.path + launchApp() + + // Save a custom preset first, then export it. + app.buttons["btnSavePresetModal"].click() + let nameField = element("savePresetName") + XCTAssertTrue(nameField.waitForExistence(timeout: 10)) + nameField.click() + nameField.typeText("RoundTrip") + element("btnConfirmSavePreset").click() + + // Export via the manage dialog. + app.buttons["btnOpenPresetsDialog"].click() + XCTAssertTrue(waitFor("managePresetsList", timeout: 10).exists) + let exportButtons = buttonsMatching( + "identifier BEGINSWITH 'btnExportPreset-'") + XCTAssertTrue(exportButtons.firstMatch.waitForExistence(timeout: 5)) + exportButtons.firstMatch.click() + XCTAssertTrue(waitForFile(exportURL), "preset export file missing") + + // Import must land back in the store (delete → re-import). + let deleteButtons = buttonsMatching( + "identifier BEGINSWITH 'btnDeletePreset-'") + deleteButtons.firstMatch.click() + XCTAssertFalse(staticText("RoundTrip").waitForExistence(timeout: 3)) + + // Copy the export to the import path so the hook picks it up. + try FileManager.default.copyItem(at: exportURL, to: importURL) + element("btnImportPreset").click() + XCTAssertTrue(staticText("RoundTrip") + .waitForExistence(timeout: 5)) + } + + private func waitForFile(_ url: URL, timeout: TimeInterval = 5) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: url.path) { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return false + } +} diff --git a/project.yml b/project.yml index 1afb884..d5014f6 100644 --- a/project.yml +++ b/project.yml @@ -81,12 +81,29 @@ targets: SWIFT_VERSION: "6.0" MACOSX_DEPLOYMENT_TARGET: "14.0" + ICCeryUITests: + type: bundle.ui-testing + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Tests/ICCeryUITests + dependencies: + - target: ICCery + settings: + base: + TEST_TARGET_NAME: ICCery + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_IDENTITY: "-" + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" + schemes: ICCery: build: targets: ICCery: all ICCeryCoreTests: [test] + ICCeryUITests: [test] run: config: Debug test: @@ -94,3 +111,4 @@ schemes: gatherCoverageData: false targets: - ICCeryCoreTests + - ICCeryUITests