From 4f3caf335ddd20de884505d7ece29b40b3b4176f Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 16:30:27 +0100 Subject: [PATCH] feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4) Replace the LpArgs argv builder with the deterministic TicketWriteResolver (locked 1-10 write order, Stage 2 overrides win unconditionally, no raw, nil overrides emit no write) and spool via a headless NSPrintOperation over TargetPageCanvasView instead of the CUPS lp binary. RecordingTargetSpooler (DEBUG) logs deterministic request lines to ICCERY_TEST_SPOOL_LOG for tests. Single spool job toggle switches printAllPages between per-page requests and one multi-page job. CupsService keeps lpstat/lpoptions enumeration but loses printTarget; ProcessID.lp* renamed cupsLp*; LpArgs, its tests and the lp fixture deleted. --- .../Print/ColorMatchingAttempts.swift | 19 ++ .../ICCeryCore/Print/CupsService.swift | 38 +-- .../Sources/ICCeryCore/Print/LpArgs.swift | 136 -------- .../ICCeryCore/Print/PrinterModels.swift | 18 +- .../ICCeryCore/Process/ProcessID.swift | 8 +- Sources/ICCery/AppEnvironment.swift | 8 +- .../ICCery/Print/ColorSyncSuppressor.swift | 55 ++- .../ICCery/Print/NativeTargetSpooler.swift | 317 ++++++++++++++++++ Sources/ICCery/Print/PrintPanelService.swift | 1 + .../ICCery/Print/PrintSessionViewModel.swift | 133 ++++++-- .../ICCery/Print/TicketWriteResolver.swift | 127 +++++++ Sources/ICCery/Stage2View.swift | 59 ++-- .../CupsOptionsFilterTests.swift | 3 +- Tests/ICCeryCoreTests/LpArgsTests.swift | 173 ---------- .../PrintSessionViewModelTests.swift | 81 +++-- .../TicketWriteResolverTests.swift | 196 +++++++++++ Tests/ICCeryUITests/Fixtures/bin/lp | 14 - Tests/ICCeryUITests/Fixtures/bin/printtarg | 17 +- .../Milestone11PrintSettingsUITests.swift | 82 ++++- Tests/ICCeryUITests/Milestone3UITests.swift | 73 ++-- 20 files changed, 1064 insertions(+), 494 deletions(-) delete mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift create mode 100644 Sources/ICCery/Print/NativeTargetSpooler.swift create mode 100644 Sources/ICCery/Print/TicketWriteResolver.swift delete mode 100644 Tests/ICCeryCoreTests/LpArgsTests.swift create mode 100644 Tests/ICCeryCoreTests/TicketWriteResolverTests.swift delete mode 100755 Tests/ICCeryUITests/Fixtures/bin/lp diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift index 5e535a9..267620a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift @@ -46,4 +46,23 @@ public enum ColorMatchingAttempts { "AP_ColorMatchingMode", "AP.ColorMatchingMode", ] + + /// The Quartz/`NSPrintOperation` colour-matching vocabulary + /// (docs/14 §7). With the `lp` path removed there is a single + /// spool path and it carries **both** dictionaries — these keys + /// are written alongside the AP_* pair (#201 D2). + /// + /// `PMColorMatchingMode=APCustomColorMatching` plus an empty + /// `PMCustomColorMatchingProfile` tell Quartz the application + /// supplies device colour; the `com.apple.print.PrintSettings.*` + /// legacy spelling covers drivers that read the flattened + /// dictionary. + public static let quartzModeKey = "PMColorMatchingMode" + public static let quartzCustomMatching = "APCustomColorMatching" + public static let quartzProfileKey = "PMCustomColorMatchingProfile" + public static let quartzLegacyModeKey = + "com.apple.print.PrintSettings.PMColorMatchingMode" + /// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the + /// colour keys are mirrored into. + public static let quartzNestedDictKey = "com.apple.print.printSettings" } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index b70b52f..3a82483 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -3,7 +3,6 @@ import Foundation /// Errors from CUPS tool invocations. public enum CupsError: LocalizedError, Equatable { case toolFailed(tool: String, code: Int32, stderr: String) - case tiffMissing(String) case noPrinterSelected public var errorDescription: String? { @@ -13,8 +12,6 @@ public enum CupsError: LocalizedError, Equatable { return detail.isEmpty ? "\(tool) failed with exit code \(code)" : "\(tool) failed (\(code)): \(detail)" - case .tiffMissing(let path): - return "Target TIFF does not exist: \(path)" case .noPrinterSelected: return "No printer selected." } @@ -27,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable { /// `ProcessManager.runCaptured` so spawns are logged, get killAll /// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are /// injectable so tests use fixture scripts and never touch real CUPS. +/// Target spooling is **not** here — it is the app-target +/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4). public struct CupsService: Sendable { public let processManager: ProcessManager - /// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in + /// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in /// production, a fixture dir under test. public let binaryDir: URL /// `/etc/cups/ppd` in production. @@ -53,11 +52,11 @@ public struct CupsService: Sendable { // lpstat exits non-zero when no destinations exist — an empty // queue list is a valid result, not a failure (issue 12). let destinationsOut = try await run( - "lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true) + "lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true) let statusOut = try await run( - "lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true) + "lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true) let defaultOut = try await run( - "lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true) + "lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true) let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) @@ -80,7 +79,7 @@ public struct CupsService: Sendable { /// display name, docs/11 §binding). public func displayName(for queue: String) async throws -> String? { let result = try await run( - "lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) + "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue)) return CupsParsers.lpoptionsDisplayName(result.stdout) } @@ -90,7 +89,7 @@ public struct CupsService: Sendable { /// to media-key and colour-bypass detection (docs/11 layer ④). public func optionListings(for queue: String) async throws -> [CupsOptionListing] { let result = try await run( - "lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l")) + "lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(queue)-l")) return CupsParsers.lpoptionsList(result.stdout) } @@ -162,27 +161,6 @@ public struct CupsService: Sendable { Set(try await optionListings(for: queue).map(\.key)) } - // MARK: - Spool (issue 15) - - /// `lp -d … ` — spool one target page unmanaged. - /// Never uses `-o raw` (#92). `page` disambiguates the process id - /// when several pages are spooled in sequence. - public func printTarget( - queue: String, - tiffPath: String, - options: PrintOptions, - page: Int = 0 - ) async throws { - guard FileManager.default.fileExists(atPath: tiffPath) else { - throw CupsError.tiffMissing(tiffPath) - } - let optionKeys = (try? await self.optionKeys(for: queue)) ?? [] - let argv = try LpArgs.build( - queue: queue, tiffPath: tiffPath, - options: options, optionKeys: optionKeys) - try await run("lp", argv, id: ProcessID.lp(queue, page: page)) - } - // MARK: - PPD private func loadPPD(for queue: String) -> String? { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift deleted file mode 100644 index 0cc354c..0000000 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift +++ /dev/null @@ -1,136 +0,0 @@ -import Foundation - -/// Errors from `buildLpArgs`. -public enum LpArgsError: LocalizedError, Equatable { - case unsanitisedOption(String) - - public var errorDescription: String? { - switch self { - case .unsanitisedOption(let option): - return "Captured CUPS option contains unsafe characters: \(option)" - } - } -} - -/// `lp` argv builder — issue 15, docs/11 `build_lp_args`. -/// -/// ``` -/// lp -d -t "ICCery Target - " -/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching -/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching -/// -/// -/// (#183) -/// -/// -/// -/// -/// ``` -/// -/// - **Never `-o raw`** — `raw` skips the raster filter that honours -/// `AP_ApplicationColorMatching` (#92). -/// - Captured options **win** over explicit fields: any key already -/// present (case-insensitive) suppresses the derived `-o`. -/// - Captured keys/values are sanitised — `;`, newlines, or shell -/// metacharacters throw `unsanitisedOption`; args are passed as a -/// `Process` argv array, never through a shell. -/// - The TIFF path is always the **last** argument. -public enum LpArgs { - - /// `options` = the captured `PrintOptions`; `optionKeys` = the - /// queue's `lpoptions -l` key set (for media-key/bypass detection). - public static func build( - queue: String, - tiffPath: String, - options: PrintOptions, - optionKeys: Set - ) throws -> [String] { - var argv: [String] = [ - "-d", queue, - "-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)", - "-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching", - "-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching", - ] - var addedKeys: Set = [ - "ap_colormatchingmode", "ap.colormatchingmode", - ] - - // Captured CUPS options — sanitised, lowercased-key dedup. - if let captured = options.cupsOptions, !captured.isEmpty { - // Newlines can't survive the tokeniser — check the raw - // string so embedded line breaks are still rejected. - if captured.contains("\n") || captured.contains("\r") { - throw LpArgsError.unsanitisedOption(captured) - } - for pair in CupsParsers.lpoptions(captured) { - try sanitize(pair.key, pair.value) - let lowered = pair.key.lowercased() - // Defence in depth: never let a captured `raw` reach - // argv — `-o raw` skips the raster filter that honours - // AP_ApplicationColorMatching (#92). - if lowered == "raw" { continue } - guard !addedKeys.contains(lowered) else { continue } - addedKeys.insert(lowered) - argv += ["-o", "\(pair.key)=\(pair.value)"] - } - } - - // Media type — only when the captured options didn't carry one. - if let mediaType = options.mediaType, - let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys), - !addedKeys.contains(mediaKey.lowercased()) { - addedKeys.insert(mediaKey.lowercased()) - argv += ["-o", "\(mediaKey)=\(mediaType)"] - } - - // Print quality — after media, before the driver bypass; the - // detected queue key is skipped when already captured (#183). - if let quality = options.quality, - let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys), - !addedKeys.contains(qualityKey.lowercased()) { - addedKeys.insert(qualityKey.lowercased()) - argv += ["-o", "\(qualityKey)=\(quality)"] - } - - // Driver colour bypass — when no bypass key was captured. NOT - // gated on ppdUncorrectedPassthrough (macOS always bypasses). - let capturedKeys = Set( - CupsParsers.lpoptions(options.cupsOptions ?? "") - .map { $0.key }) - if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys), - let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys), - !addedKeys.contains(bypass.key.lowercased()) { - addedKeys.insert(bypass.key.lowercased()) - argv += ["-o", "\(bypass.key)=\(bypass.value)"] - } - - // Orientation — portrait=3, landscape=4. - if let orientation = options.orientation, - !addedKeys.contains("orientation-requested") { - let value = orientation == "landscape" ? "4" : "3" - addedKeys.insert("orientation-requested") - argv += ["-o", "orientation-requested=\(value)"] - } - - // PageSize — the printtarg layout page size. - if let paperSize = options.paperSize, !paperSize.isEmpty, - !addedKeys.contains("pagesize") { - argv += ["-o", "PageSize=\(paperSize)"] - } - - argv.append(tiffPath) - return argv - } - - /// Reject shell/metachar injection — args go to `Process` as an - /// argv array, but a hostile captured string must not smuggle a - /// second option or command. - static func sanitize(_ key: String, _ value: String) throws { - let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'") - if key.rangeOfCharacter(from: forbidden) != nil - || value.rangeOfCharacter(from: forbidden) != nil - || key.isEmpty { - throw LpArgsError.unsanitisedOption("\(key)=\(value)") - } - } -} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index fd5b614..3549610 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -111,24 +111,24 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable { } } -/// Options carried into `lp` (docs/10 §PrintOptions). On macOS -/// `paperSource` is ignored unless already present inside captured -/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets -/// it on OK) but never gates the argv — macOS always bypasses driver -/// colour management. +/// The Stage 2 mirror — panel selections and the captured `k=v` +/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path +/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6) +/// and the mirror apply-back; the opaque vendor state now travels +/// inside the `PrintTicket`, not a flattened option string. public struct PrintOptions: Codable, Equatable, Sendable { public var paperSource: Int? /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. public var orientation: String? - /// Stage 2 paper token → `PageSize=` (skipped if captured, #183). + /// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`. public var paperSize: String? public var mediaType: String? - /// Print-quality token → `-o =` (skipped if - /// captured, #183). + /// Print-quality token → `=` ticket write. public var quality: String? public var ppdUncorrectedPassthrough: Bool? /// Space-separated `key=value` captured from - /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥). + /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) — + /// the Stage 2 mirror only, never a spool payload. public var cupsOptions: String? public init( diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 8c2d562..7dd7e6c 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -18,8 +18,8 @@ public enum ProcessID { public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not - /// streaming Argyll children. - public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } - public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } - public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } + /// streaming Argyll children. Named `cups*` so no `lp*`-prefixed + /// identifier survives the target-print removal (#201). + public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" } + public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } } diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index a3ccbcd..c9b794c 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -106,12 +106,14 @@ enum UITestHooks { // MARK: - Print panel / CUPS stubs (issue 13/17) - /// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts — + /// Directory of mock `lpstat`/`lpoptions` fixture scripts — /// `CupsService.binaryDir` under UI tests. static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") } - /// Path the mock `lp` script appends its argv to, for assertions. - static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") } + /// Path the `RecordingTargetSpooler` appends resolved ticket + /// lines to, for assertions (#201 D8 — replaces the `lp` argv + /// log). + static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") } /// Whether the `NSPrintPanel` should be stubbed under UI testing — /// separate from the stub's *result* so "cancel" (`nil`) does not diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift index 28862b6..2764b43 100644 --- a/Sources/ICCery/Print/ColorSyncSuppressor.swift +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -84,6 +84,35 @@ struct ColorSyncSuppressor { return applied } + // MARK: - Layer ⑤′ Quartz vocabulary (#201 D2) + + /// With `lp` gone there is one print path and it carries **both** + /// dictionaries: write keys 3–5 of the resolver order into + /// `PMPrintSettings` so the PDE and the spool job see them + /// (docs/14 §7). Warn-only — a rejected key never aborts. + @discardableResult + func applyQuartzMode(to settings: PMPrintSettings) -> Int { + var applied = 0 + let pairs: [(key: String, value: String, locked: Bool)] = [ + (ColorMatchingAttempts.quartzModeKey, + ColorMatchingAttempts.quartzCustomMatching, true), + (ColorMatchingAttempts.quartzProfileKey, "", false), + (ColorMatchingAttempts.quartzLegacyModeKey, + ColorMatchingAttempts.quartzCustomMatching, false), + ] + for pair in pairs { + let status = PMPrintSettingsSetValue( + settings, pair.key as CFString, pair.value as CFString, + pair.locked) + if status == 0 { applied += 1 } + } + if applied == 0 { + log("ColorSync: PMPrintSettingsSetValue rejected the " + + "Quartz colour-matching keys") + } + return applied + } + // MARK: - Layer ④ driver bypass /// Pre-select the driver "no colour adjustment" option, unlocked — @@ -112,7 +141,11 @@ struct ColorSyncSuppressor { // MARK: - Layer ⑤ NSPrintInfo mirror /// Mirror the applied keys into `printSettings` so the PDE pick - /// sees them. + /// sees them. Also populates the nested + /// `com.apple.print.printSettings` sub-dictionary of + /// `printInfo.dictionary()` with the AP_* **and** Quartz keys — + /// the single remaining path carries both vocabularies (#201 D2, + /// docs/14 §7). func mirror( into printInfo: NSPrintInfo, driverBypass: (key: String, value: String)? @@ -121,9 +154,29 @@ struct ColorSyncSuppressor { for key in ColorMatchingAttempts.printSettingsKeys { settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString } + settings[ColorMatchingAttempts.quartzModeKey as NSString] = + ColorMatchingAttempts.quartzCustomMatching as NSString + settings[ColorMatchingAttempts.quartzProfileKey as NSString] = + "" as NSString + settings[ColorMatchingAttempts.quartzLegacyModeKey as NSString] = + ColorMatchingAttempts.quartzCustomMatching as NSString if let driverBypass { settings[driverBypass.key as NSString] = driverBypass.value as NSString } + + // Nested mirror — drivers that read the flattened dictionary. + let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString + let nested = (settings[nestedKey] as? NSMutableDictionary) + ?? NSMutableDictionary() + for key in ColorMatchingAttempts.printSettingsKeys { + nested[key] = ColorMatchingAttempts.applicationMatchingValue + } + nested[ColorMatchingAttempts.quartzModeKey] = + ColorMatchingAttempts.quartzCustomMatching + nested[ColorMatchingAttempts.quartzProfileKey] = "" + nested[ColorMatchingAttempts.quartzLegacyModeKey] = + ColorMatchingAttempts.quartzCustomMatching + settings[nestedKey] = nested } // MARK: - Layer ⑥ capture diff --git a/Sources/ICCery/Print/NativeTargetSpooler.swift b/Sources/ICCery/Print/NativeTargetSpooler.swift new file mode 100644 index 0000000..127904a --- /dev/null +++ b/Sources/ICCery/Print/NativeTargetSpooler.swift @@ -0,0 +1,317 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// One page to draw — the TIFF plus its printtarg manifest size for +/// the drift check (#201 D9). +struct TargetPrintPage: Equatable { + let url: URL + let expectedWidthMm: Double? + let expectedHeightMm: Double? +} + +/// One spool job — a single-element `pages` in per-page mode, all +/// pages when `singleJobForAllPages` is on (#201 D5). +struct TargetPrintRequest { + let queue: String + let displayName: String? + let pages: [TargetPrintPage] + let jobTitle: String // "ICCery Target - " + /// nil → bind + PM defaults + suppression only (the ticket carries + /// the captured PDE state; without it the job is Stage 2 writes + /// over queue defaults). + let ticket: PrintTicket? + let overrides: TargetPrintOverrides + /// The queue's `lpoptions -l` key roster — feeds vendor media-key + /// and driver-bypass detection. + let optionKeys: Set + /// Test-only: `.save` to this URL instead of `.spool` (#201 D8). + var savePDFTo: URL? +} + +enum TargetSpoolError: LocalizedError, Equatable { + case noPages + case printerUnresolved(queue: String) + case operationFailed(queue: String, jobTitle: String) + + var errorDescription: String? { + switch self { + case .noPages: + return "Nothing to print." + case .printerUnresolved(let queue): + return "The print queue '\(queue)' could not be resolved." + case .operationFailed(_, let jobTitle): + return "The print operation for '\(jobTitle)' failed." + } + } +} + +@MainActor +protocol TargetSpooling { + /// Warn-only diagnostics (manifest drift, oversize page, rejected + /// key) are delivered here; the throw path is reserved for real + /// failures. + var diagnostics: ((Notice) -> Void)? { get set } + func spool(_ request: TargetPrintRequest) throws +} + +/// Production spooler — rehydrates the captured ticket, applies the +/// resolved Stage 2 writes, and draws each TIFF 1:1 through a silent +/// `NSPrintOperation` (#201, the S1–S14 trace in the M12 megaplan). +@MainActor +final class NativeTargetSpooler: TargetSpooling { + var suppressor = ColorSyncSuppressor() + var diagnostics: ((Notice) -> Void)? + var log: (LogLevel, String) -> Void = { AppLogger.shared.log($0, $1) } + + func spool(_ request: TargetPrintRequest) throws { + guard !request.pages.isEmpty else { throw TargetSpoolError.noPages } + + // The PM printer handle is held for the whole operation and + // released on every path (S14). + let printer = PMTicketBridge.makePrinter(queue: request.queue) + defer { if let printer { PMTicketBridge.release(printer) } } + + let (printInfo, _) = try makePrintInfo(for: request, boundTo: printer) + + // S10 — decode every page 1:1. Warn-only findings ride the + // `diagnostics` seam, never the throw path (D9). + var rasters: [TargetPageRaster] = [] + rasters.reserveCapacity(request.pages.count) + for page in request.pages { + let raster = try TargetRasterLoader.load( + tiff: page.url, + expectedWidthMm: page.expectedWidthMm, + expectedHeightMm: page.expectedHeightMm) + if let drift = raster.manifestDrift { + diagnostics?(Notice(kind: .warning, text: drift)) + } + if raster.pointSize.width > printInfo.paperSize.width + 0.5 + || raster.pointSize.height + > printInfo.paperSize.height + 0.5 { + diagnostics?(Notice( + kind: .warning, + text: "\(page.url.lastPathComponent) exceeds the " + + "paper size — it will be clipped, not scaled.")) + } + rasters.append(raster) + } + + // S11–S13 — headless canvas + silent operation (D7). + let canvas = TargetPageCanvasView( + pages: rasters, paperSize: printInfo.paperSize) + let operation = NSPrintOperation(view: canvas, printInfo: printInfo) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.canSpawnSeparateThread = false + operation.jobTitle = request.jobTitle + guard operation.run() else { + throw TargetSpoolError.operationFailed( + queue: request.queue, jobTitle: request.jobTitle) + } + } + + /// S1–S9 — a fully configured `NSPrintInfo` plus the resolved + /// write list. Extracted for the PDF harness + unit tests; + /// `spool(_:)` is the production entry point (it keeps the PM + /// printer handle alive through `run()`). + func makePrintInfo( + for request: TargetPrintRequest + ) throws -> (NSPrintInfo, ResolvedTicketWrites) { + let printer = PMTicketBridge.makePrinter(queue: request.queue) + defer { if let printer { PMTicketBridge.release(printer) } } + return try makePrintInfo(for: request, boundTo: printer) + } + + private func makePrintInfo( + for request: TargetPrintRequest, + boundTo printer: PMPrinter? + ) throws -> (NSPrintInfo, ResolvedTicketWrites) { + // S1 + let printInfo = NSPrintInfo() + + // S2 — Cocoa-level printer first: it runs before binding and + // ticket restore, so a printer-driven settings reset cannot + // clobber the ticket (R9). Best effort; PM binding is + // authoritative. + var resolvedNSPrinter = false + if let nsPrinter = NSPrinter(name: request.queue) + ?? request.displayName.flatMap({ NSPrinter(name: $0) }) { + printInfo.printer = nsPrinter + resolvedNSPrinter = true + } + + // S3 — PM binding + session defaults. A failed bind degrades + // to the NSPrinter fallback; with neither, the job would land + // on the default queue — a wrong answer, so it throws. + var boundViaPM = false + if let printer { + do { + try PMTicketBridge.bind(printer: printer, to: printInfo) + boundViaPM = true + } catch { + log(.warn, "Spool: PM bind failed for " + + "'\(request.queue)' — continuing on the " + + "NSPrinter fallback") + } + } + guard boundViaPM || resolvedNSPrinter else { + throw TargetSpoolError.printerUnresolved(queue: request.queue) + } + + // S4 — rehydrate the captured ticket. `restore` itself refuses + // a cross-queue replay (R3). + if let ticket = request.ticket { + try PMTicketBridge.restore(ticket, into: printInfo) + } + + // S5 — Stage 2 writes (AP_* + Quartz vocabularies + media / + // quality / bypass, D2/D6) onto the live PMPrintSettings. + let resolved = TicketWriteResolver.resolve( + overrides: request.overrides, optionKeys: request.optionKeys) + let settings = PMTicketBridge.settings(printInfo) + for write in resolved.writes { + // R4 — write only when the value differs, and log old→new + // so a vendor companion-key desync is diagnosable. + let current = PMTicketBridge.stringValue( + forKey: write.key, in: settings) + guard current != write.value else { continue } + if let current { + log(.info, "Spool: \(write.key): '\(current)' " + + "→ '\(write.value)'") + } + PMTicketBridge.setValue( + write.value, forKey: write.key, locked: write.locked, + in: settings, context: "Spool") + } + printInfo.updateFromPMPrintSettings() + mirror(resolved.writes, into: printInfo) + + // S6 — paper override: `PMPaper` match on the bound printer; + // `Custom.x` tokens (already points) set the Cocoa paper + // size directly. + if let token = resolved.paperToken { + if let printer, boundViaPM { + PMTicketBridge.applyPaper( + token: token, printer: printer, + session: PMTicketBridge.session(printInfo), + printInfo: printInfo) + } else if let custom = PMTicketBridge + .customPaperDimensions(from: token) { + printInfo.paperSize = NSSize( + width: custom.width, height: custom.height) + } + } + + // S7 — orientation on the Cocoa page format; the + // `orientation-requested` write above is the driver half. + if let orientation = resolved.orientation { + printInfo.orientation = orientation == "landscape" + ? .landscape : .portrait + } + + // S8 — private SPI on the live session; only meaningful with + // a bound printer (same constraint as the panel path). + if boundViaPM { + suppressor.applySPIMode(to: PMTicketBridge.session(printInfo)) + } + + // S9 — Cocoa geometry: margins 0, `.clip` pagination, 1:1 + // scale, no centring; `.spool`, or `.save` + jobSavingURL + // under the PDF harness (D8/D9). + printInfo.topMargin = 0 + printInfo.bottomMargin = 0 + printInfo.leftMargin = 0 + printInfo.rightMargin = 0 + printInfo.horizontalPagination = .clip + printInfo.verticalPagination = .clip + printInfo.scalingFactor = 1.0 + printInfo.isHorizontallyCentered = false + printInfo.isVerticallyCentered = false + if let saveURL = request.savePDFTo { + printInfo.jobDisposition = .save + printInfo.dictionary()[ + NSPrintInfo.AttributeKey.jobSavingURL] = saveURL + } else { + printInfo.jobDisposition = .spool + } + + return (printInfo, resolved) + } + + /// The resolved writes also land in `NSPrintInfo.printSettings` so + /// the driver sees them through Cocoa — the + /// `com.apple.print.printSettings` sub-dictionary carries the + /// colour keys (D2 / docs/14 §7). + private func mirror( + _ writes: [TicketWrite], into printInfo: NSPrintInfo + ) { + let settings = printInfo.printSettings + for write in writes { + settings[write.key as NSString] = write.value as NSString + } + let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString + let nested = (settings[nestedKey] as? NSMutableDictionary) + ?? NSMutableDictionary() + for write in writes where Self.isColourKey(write.key) { + nested[write.key] = write.value + } + settings[nestedKey] = nested + } + + private static func isColourKey(_ key: String) -> Bool { + ColorMatchingAttempts.printSettingsKeys.contains(key) + || key == ColorMatchingAttempts.quartzModeKey + || key == ColorMatchingAttempts.quartzProfileKey + || key == ColorMatchingAttempts.quartzLegacyModeKey + } +} + +#if DEBUG +/// UI-test seam (#201 D8). Runs the same `TicketWriteResolver` the +/// native spooler runs, appends one deterministic line per request to +/// `ICCERY_TEST_SPOOL_LOG`, and never touches the print system — so +/// UI tests need no queue, no driver and no PM binding. +/// +/// `ICCERY_TEST_SPOOL_FAIL=1` injects an `operationFailed` error for +/// the failure-notice tests. +@MainActor +final class RecordingTargetSpooler: TargetSpooling { + let logURL: URL + var diagnostics: ((Notice) -> Void)? + + init(logURL: URL) { + self.logURL = logURL + } + + /// `queue= title= page= pages= paper= + /// orientation= keys=` — keys sorted alphabetically + /// so assertions are order-free. + func spool(_ request: TargetPrintRequest) throws { + guard !request.pages.isEmpty else { throw TargetSpoolError.noPages } + if ProcessInfo.processInfo.environment["ICCERY_TEST_SPOOL_FAIL"] + == "1" { + throw TargetSpoolError.operationFailed( + queue: request.queue, jobTitle: request.jobTitle) + } + let resolved = TicketWriteResolver.resolve( + overrides: request.overrides, optionKeys: request.optionKeys) + let keys = resolved.writes + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + let line = "queue=\(request.queue)" + + " title=\(request.jobTitle)" + + " page=\(request.pages[0].url.lastPathComponent)" + + " pages=\(request.pages.count)" + + " paper=\(resolved.paperToken ?? "-")" + + " orientation=\(resolved.orientation ?? "-")" + + " keys=\(keys)\n" + // Atomic append — existing content + one line via the + // `.tmp`-then-rename convention (#213). + let existing = (try? String(contentsOf: logURL, encoding: .utf8)) + ?? "" + try AtomicFileWriter.write(existing + line, to: logURL) + } +} +#endif diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 22ed51c..613fea0 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -147,6 +147,7 @@ struct PrintPanelService { let session = PMTicketBridge.session(printInfo) suppressor.applySPIMode(to: session) // ② suppressor.applyLockedKeys(to: settings) // ③ + suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2) driverBypass = suppressor.applyDriverBypass( // ④ to: settings, optionKeys: optionKeys) suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 1c992d2..d6cb2cc 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -2,7 +2,8 @@ import Combine import Foundation import ICCeryCore -/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85). +/// CUPS queue selection, bound print panel, and native headless spool +/// (issues 12–15, 17 / #85, #201). @MainActor final class PrintSessionViewModel: ObservableObject { let wizard: WizardViewModel @@ -13,7 +14,15 @@ final class PrintSessionViewModel: ObservableObject { weak var workflow: TargetWorkflowViewModel? @Published var printers: [Printer] = [] - @Published var selectedPrinter = "" + @Published var selectedPrinter = "" { + didSet { + // Tickets are queue- and driver-version-specific — a stale + // ticket must never replay onto a re-selected queue (R3). + if selectedPrinter != oldValue { + capturedTickets[oldValue] = nil + } + } + } @Published var printerCaps = PrinterCapabilities() @Published var selectedTray: Int? @Published var selectedMediaType: String? @@ -28,11 +37,39 @@ final class PrintSessionViewModel: ObservableObject { @Published var capturedTickets: [String: PrintTicket] = [:] @Published var printNotice: Notice? @Published var isPrinting = false + /// Session-only job granularity (#201 D5): off → one job per page + /// (per-page notices + error attribution); on → a single job. + @Published var singleJobForAllPages = false + /// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam + /// under UI testing, `NSPrintOperation` otherwise (#201 D8). + /// `internal` so unit tests inject. + var spooler: TargetSpooling private var printTask: Task? init(wizard: WizardViewModel, environment: AppEnvironment) { self.wizard = wizard self.environment = environment + #if DEBUG + if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL { + spooler = RecordingTargetSpooler(logURL: logURL) + } else { + spooler = NativeTargetSpooler() + } + #else + spooler = NativeTargetSpooler() + #endif + attachDiagnostics() + } + + /// Warn-only spooler diagnostics (manifest drift, oversize page) + /// surface as the in-panel notice. Re-attached per request so an + /// injected spooler picks it up too — and so `spooler` needs no + /// `didSet` (mutating `diagnostics` through the existential would + /// re-fire the observer and recurse). + private func attachDiagnostics() { + spooler.diagnostics = { [weak self] notice in + self?.printNotice = notice + } } private var printerEnumTask: Task<[Printer]?, Never>? @@ -134,7 +171,8 @@ final class PrintSessionViewModel: ObservableObject { /// The CUPS `PageSize` token for the current Stage 2 pick — live /// `Custom.x` for the synthetic entry, else the capability - /// name. This is what `lp -o PageSize=` sees. + /// name. Resolved into the `PageSize` ticket write and the + /// `PMPaper` match on the native spool path (#201). var selectedPaperSizeToken: String? { guard let id = selectedPaperSize else { return nil } if id == 0 { return customPaperToken() } @@ -216,10 +254,31 @@ final class PrintSessionViewModel: ObservableObject { guard let self else { return } // `defer` cannot mutate isolated state under Swift 5.7 // (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113). + if singleJobForAllPages { + // D5 — one job carries every page; a failure names the + // job, not a page (R14). + do { + try await spoolAll(result) + printNotice = Notice( + kind: .info, + text: "Sent \(result.pages.count) page(s) in " + + "one job to \(selectedPrinter).", + autoHideAfter: nil + ) + } catch { + printNotice = Notice( + kind: .error, + text: "Print failed: \(error.localizedDescription)" + ) + } + isPrinting = false + self.printTask = nil + return + } var printed = 0 for page in result.pages { do { - try await spool(page, index: page.index) + try await spool(page) printed += 1 } catch { printNotice = Notice( @@ -249,7 +308,7 @@ final class PrintSessionViewModel: ObservableObject { let task = Task { @MainActor [weak self] in guard let self else { return } do { - try await spool(page, index: page.index) + try await spool(page) printNotice = Notice( kind: .info, text: "Sent \(page.page.filename) to \(selectedPrinter).", @@ -267,25 +326,55 @@ final class PrintSessionViewModel: ObservableObject { printTask = task } - private func spool(_ page: GalleryPage, index: Int) async throws { + private func spool(_ page: GalleryPage) async throws { + let request = try await makeRequest( + pages: [page], title: page.page.filename) + try spooler.spool(request) + wizard.printerName = selectedPrinter + } + + private func spoolAll(_ result: PrinttargResult) async throws { + let request = try await makeRequest( + pages: result.pages, + title: result.ti2URL.deletingPathExtension() + .lastPathComponent) + try spooler.spool(request) + wizard.printerName = selectedPrinter + } + + /// Assemble the deterministic spool request: the captured ticket + /// (when the panel produced one) plus the Stage 2 overrides and + /// the queue's option-key roster for vendor-key detection (#201). + /// The Stage 2 paper token feeds the `PageSize` write; + /// `workflow.pageSize` remains the printtarg layout input only + /// (#183). + private func makeRequest( + pages: [GalleryPage], title: String + ) async throws -> TargetPrintRequest { guard !selectedPrinter.isEmpty else { throw CupsError.noPrinterSelected } - // The Stage 2 paper token is what `lp -o PageSize=` sees; - // `workflow.pageSize` remains the printtarg layout input only - // (#183). - let options = PrintOptions( - orientation: printOrientation, - paperSize: selectedPaperSizeToken, - mediaType: selectedMediaType, - quality: selectedQuality, - ppdUncorrectedPassthrough: true, - cupsOptions: capturedCupsOptions[selectedPrinter]) - try await environment.cupsService.printTarget( - queue: selectedPrinter, - tiffPath: page.fileURL.path, - options: options, - page: index) - wizard.printerName = selectedPrinter + let queue = selectedPrinter + let optionKeys = (try? await environment.cupsService + .optionKeys(for: queue)) ?? [] + attachDiagnostics() + return TargetPrintRequest( + queue: queue, + displayName: printers.first { $0.name == queue }?.displayName, + pages: pages.map { + TargetPrintPage( + url: $0.fileURL, + expectedWidthMm: $0.page.widthMm, + expectedHeightMm: $0.page.heightMm) + }, + jobTitle: "ICCery Target - \(title)", + ticket: capturedTickets[queue], + overrides: TargetPrintOverrides( + paperSize: selectedPaperSizeToken, + mediaType: selectedMediaType, + qualityKey: printerCaps.qualityKey, + quality: selectedQuality, + orientation: printOrientation), + optionKeys: optionKeys) } } diff --git a/Sources/ICCery/Print/TicketWriteResolver.swift b/Sources/ICCery/Print/TicketWriteResolver.swift new file mode 100644 index 0000000..7af2b53 --- /dev/null +++ b/Sources/ICCery/Print/TicketWriteResolver.swift @@ -0,0 +1,127 @@ +import Foundation +import ICCeryCore + +/// Stage 2 inline overrides applied on top of a rehydrated ticket +/// (#201 AC3 / D6 — Stage 2 always wins). +struct TargetPrintOverrides: Equatable { + var paperSize: String? // CUPS `PageSize` token or `Custom.x` + var mediaType: String? + var qualityKey: String? // PrinterCapabilities.qualityKey + var quality: String? + var orientation: String? // "portrait" | "landscape" +} + +/// One resolved ticket write. +struct TicketWrite: Equatable { + let key: String + let value: String + let locked: Bool +} + +/// The fully-resolved write list for one job — a **pure value**, +/// computed with zero Core Printing calls so it is unit- and +/// UI-testable without a live queue. Replaces the old `lp` argv +/// builder (#201 D4/D8). +struct ResolvedTicketWrites: Equatable { + var writes: [TicketWrite] + var paperToken: String? + var orientation: String? +} + +enum TicketWriteResolver { + + /// Quartz vocabulary constants (docs/14 §7) live on + /// `ColorMatchingAttempts` next to the AP_* half — written + /// alongside because there is no `lp` path left (D2). + /// + /// Order is locked and is the contract the tests assert: + /// 1 `AP_ColorMatchingMode` = AP_ApplicationColorMatching (locked) + /// 2 `AP.ColorMatchingMode` = AP_ApplicationColorMatching (locked) + /// 3 `PMColorMatchingMode` = APCustomColorMatching (locked) + /// 4 `PMCustomColorMatchingProfile` = "" (unlocked) + /// 5 `com.apple.print.PrintSettings.PMColorMatchingMode` + /// = APCustomColorMatching (unlocked) + /// 6 `PageSize` = overrides.paperSize + /// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType + /// 8 overrides.qualityKey = overrides.quality + /// 9 detectDriverColorBypass(optionKeys) (unlocked) + /// 10 `orientation-requested` = 3 | 4 + /// + /// Invariants: `raw` can never appear (there is no `lp`); nothing + /// is suppressed because a captured key already exists (D6 — the + /// ticket carries the captured keys and Stage 2 writes land on + /// top unconditionally); an override with a `nil` value emits no + /// write. + static func resolve( + overrides: TargetPrintOverrides, + optionKeys: Set + ) -> ResolvedTicketWrites { + var writes: [TicketWrite] = [] + + // 1–2 — locked AP_* pair (layer ③). + for key in ColorMatchingAttempts.printSettingsKeys { + writes.append(TicketWrite( + key: key, + value: ColorMatchingAttempts.applicationMatchingValue, + locked: true)) + } + // 3–5 — Quartz/`NSPrintOperation` vocabulary (D2). + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzModeKey, + value: ColorMatchingAttempts.quartzCustomMatching, + locked: true)) + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzProfileKey, + value: "", + locked: false)) + writes.append(TicketWrite( + key: ColorMatchingAttempts.quartzLegacyModeKey, + value: ColorMatchingAttempts.quartzCustomMatching, + locked: false)) + + // 6 — the Stage 2 paper token. + var paperToken: String? + if let paperSize = overrides.paperSize, !paperSize.isEmpty { + paperToken = paperSize + writes.append(TicketWrite( + key: "PageSize", value: paperSize, locked: false)) + } + + // 7 — media type via the queue's detected vendor key. + if let mediaType = overrides.mediaType, + let mediaKey = CupsParsers.detectMediaTypeKey( + optionKeys: optionKeys) { + writes.append(TicketWrite( + key: mediaKey, value: mediaType, locked: false)) + } + + // 8 — print quality; the detected enumeration key travels in + // the overrides (`PrinterCapabilities.qualityKey`, #183). + if let qualityKey = overrides.qualityKey, + let quality = overrides.quality { + writes.append(TicketWrite( + key: qualityKey, value: quality, locked: false)) + } + + // 9 — driver "no colour adjustment" bypass. Never gated on + // `ppdUncorrectedPassthrough` — macOS always bypasses. + if let bypass = CupsParsers.detectDriverColorBypass( + optionKeys: optionKeys) { + writes.append(TicketWrite( + key: bypass.key, value: bypass.value, locked: false)) + } + + // 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. + if let orientation = overrides.orientation { + writes.append(TicketWrite( + key: "orientation-requested", + value: orientation == "landscape" ? "4" : "3", + locked: false)) + } + + return ResolvedTicketWrites( + writes: writes.filter { $0.key.lowercased() != "raw" }, + paperToken: paperToken, + orientation: overrides.orientation) + } +} diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 115015f..3461d06 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -218,7 +218,7 @@ struct Stage2View: View { } } - // MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path + // MARK: - Raw print panel (#rawPrintPanel) — native spool path (#201) private var printPanel: some View { VStack(alignment: .leading, spacing: 10) { @@ -267,6 +267,11 @@ struct Stage2View: View { .clipShape(Capsule()) .accessibilityIdentifier("printerStatusBadge") } + // Placeholder — Phase 5 (AirPrint detection, M12) + // replaces this with the live unmanaged-colour + // warning badge for AirPrint queues. + EmptyView() + .accessibilityIdentifier("airPrintWarningBadge") Button(action: workflow.print.refreshPrinters) { Image(systemName: "arrow.clockwise") } @@ -325,26 +330,7 @@ struct Stage2View: View { // Stage 1 owns the custom dimensions — the caption lives // inside `paperSizeGroup` (#183). - HStack(spacing: 8) { - Button(action: { - if let result = workflow.printtargResult { - workflow.print.printAllPages(from: result) - } - }) { - Label(workflow.print.isPrinting ? "Printing…" : "Print All", - systemImage: "printer") - } - .controlSize(.large) - .disabled(workflow.print.isPrinting - || workflow.printtargResult == nil - || workflow.print.selectedPrinter.isEmpty) - .accessibilityIdentifier("btnPrintAll") - Spacer() - Button("Advance to Stage 3") { workflow.advanceToStage3() } - .accessibilityIdentifier("btnAdvanceToStage3") - .disabled(workflow.printtargResult == nil - || !workflow.wizard.isUnlocked(.measure)) - } + printAllRow } .padding(12) .background(Theme.panel) @@ -361,6 +347,37 @@ struct Stage2View: View { } } + /// Print All + single-job granularity (#201 D5) + advance. + /// Extracted so every ViewBuilder body stays ≤10 children (R13 — + /// Xcode 14.2 has no `buildPartialBlock`). + private var printAllRow: some View { + HStack(spacing: 8) { + Button(action: { + if let result = workflow.printtargResult { + workflow.print.printAllPages(from: result) + } + }) { + Label(workflow.print.isPrinting ? "Printing…" : "Print All", + systemImage: "printer") + } + .controlSize(.large) + .disabled(workflow.print.isPrinting + || workflow.printtargResult == nil + || workflow.print.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrintAll") + Toggle("Single spool job", + isOn: $workflow.print.singleJobForAllPages) + .help("Send all pages as one print job instead of " + + "one job per page") + .accessibilityIdentifier("chkSingleSpoolJob") + Spacer() + Button("Advance to Stage 3") { workflow.advanceToStage3() } + .accessibilityIdentifier("btnAdvanceToStage3") + .disabled(workflow.printtargResult == nil + || !workflow.wizard.isUnlocked(.measure)) + } + } + /// Paper picker + custom-size caption under the `paperSizeGroup` /// container (#183). `caps.paperSizes` plus the synthetic custom /// entry (`id: 0`, shown as `Custom (W×H mm)`). diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index 3acb4e2..a14977b 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -25,8 +25,7 @@ final class CupsOptionsFilterTests: XCTestCase { } /// #180 — a captured `EPIJ_Qual` (and the other canonical quality - /// keys) survives the filter so it wins over the Stage 2 explicit - /// quality in `LpArgs`. + /// keys) survives the filter so it reaches the Stage 2 mirror. func testKeepsQualityKeys() { let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 " + "cupsPrintQuality=High Quality=Best " diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift deleted file mode 100644 index b8bddd5..0000000 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -import XCTest -import Foundation -@testable import ICCeryCore - -/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`). -/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover -/// flag order, captured-option precedence, and sanitisation. -final class LpArgsTests: XCTestCase { - - private let tiff = "/tmp/work/target_001.tif" - private let queue = "EPSON_XP_55_Series" - - private func build( - options: PrintOptions = PrintOptions(), - optionKeys: Set = [] - ) throws -> [String] { - try LpArgs.build( - queue: queue, tiffPath: tiff, - options: options, optionKeys: optionKeys) - } - - func testHeader() throws { - let argv = try build() - XCTAssertEqual(Array(argv[0...1]), ["-d", queue]) - XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"]) - XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) - XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) - XCTAssertEqual(argv.last, tiff) - XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" }) - } - - func testNeverRaw() throws { - let argv = try build(options: PrintOptions( - cupsOptions: "raw=true MediaType=Photo")) - for (i, arg) in argv.enumerated() where arg == "-o" { - XCTAssertNotEqual(argv[i + 1], "raw") - XCTAssertNotEqual(argv[i + 1], "raw=true") - } - XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") }) - XCTAssertTrue(argv.contains("MediaType=Photo")) - } - - func testCapturedReplay() throws { - let argv = try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear MediaType=Photo")) - let rear = argv.firstIndex(of: "InputSlot=Rear")! - let apFirst = argv.firstIndex(of: - "AP_ColorMatchingMode=AP_ApplicationColorMatching")! - XCTAssertTrue(rear > apFirst) - } - - func testCapturedWinsMedia() throws { - let argv = try build( - options: PrintOptions( - mediaType: "Plain", - cupsOptions: "MediaType=Glossy"), - optionKeys: ["MediaType"]) - XCTAssertTrue(argv.contains("MediaType=Glossy")) - XCTAssertFalse(argv.contains("MediaType=Plain")) - } - - func testMediaDerived() throws { - let argv = try build( - options: PrintOptions(mediaType: "SemiGloss"), - optionKeys: ["CNIJMediaType", "MediaType"]) - // CNIJMediaType wins over MediaType in detection order. - XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss")) - XCTAssertFalse(argv.contains("MediaType=SemiGloss")) - } - - func testBypassRules() throws { - let withBypass = try build( - optionKeys: ["EPIJ_CMat"]) - XCTAssertTrue(withBypass.contains("EPIJ_CMat=3")) - - let captured = try build( - options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), - optionKeys: ["EPIJ_CMat"]) - // Captured value kept, detection not re-applied. - XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"]) - } - - func testOrientation() throws { - XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait")) - .contains("orientation-requested=3")) - XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape")) - .contains("orientation-requested=4")) - let capturedOrients = try build(options: PrintOptions( - orientation: "landscape", - cupsOptions: "orientation-requested=5")) - XCTAssertFalse(capturedOrients.contains("orientation-requested=4")) - XCTAssertTrue(capturedOrients.contains("orientation-requested=5")) - } - - func testPageSize() throws { - XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4")) - .contains("PageSize=A4")) - let capturedSize = try build(options: PrintOptions( - paperSize: "A4", cupsOptions: "PageSize=Letter")) - XCTAssertFalse(capturedSize.contains("PageSize=A4")) - XCTAssertTrue(capturedSize.contains("PageSize=Letter")) - } - - // MARK: - #183 print quality - - func testQualityDerived() throws { - let argv = try build( - options: PrintOptions( - orientation: "portrait", mediaType: "Photo", quality: "305"), - optionKeys: ["EPIJ_Qual", "MediaType"]) - XCTAssertTrue(argv.contains("EPIJ_Qual=305")) - // Emit order: after the media option, before orientation. - let media = argv.firstIndex(of: "MediaType=Photo")! - let quality = argv.firstIndex(of: "EPIJ_Qual=305")! - let orient = argv.firstIndex(of: "orientation-requested=3")! - XCTAssertTrue(media < quality && quality < orient) - } - - func testQualityDetectedKey() throws { - // The detected queue key is used, not a hardcoded one. - let argv = try build( - options: PrintOptions(quality: "High"), - optionKeys: ["cupsPrintQuality"]) - XCTAssertTrue(argv.contains("cupsPrintQuality=High")) - } - - func testCapturedWinsQuality() throws { - let argv = try build( - options: PrintOptions( - quality: "303", - cupsOptions: "EPIJ_Qual=308"), - optionKeys: ["EPIJ_Qual"]) - XCTAssertTrue(argv.contains("EPIJ_Qual=308")) - XCTAssertFalse(argv.contains("EPIJ_Qual=303")) - } - - func testCapturedQualityCaseInsensitiveDedup() throws { - let argv = try build( - options: PrintOptions( - quality: "303", - cupsOptions: "epij_qual=308"), - optionKeys: ["EPIJ_Qual"]) - XCTAssertFalse(argv.contains("EPIJ_Qual=303")) - XCTAssertTrue(argv.contains("epij_qual=308")) - } - - func testQualityNilNoEmit() throws { - let argv = try build( - options: PrintOptions(mediaType: "Photo"), - optionKeys: ["EPIJ_Qual", "MediaType"]) - XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") }) - // No quality key on the queue → no emit either. - let noKey = try build( - options: PrintOptions(quality: "303"), - optionKeys: ["MediaType"]) - XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") }) - } - - func testSanitise() throws { - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in - XCTAssertTrue(error is LpArgsError) - } - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in - XCTAssertTrue(error is LpArgsError) - } - XCTAssertThrowsError(try build(options: PrintOptions( - cupsOptions: "InputSlot=$(whoami)"))) { error in - XCTAssertTrue(error is LpArgsError) - } - } -} diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift index c58694d..272e951 100644 --- a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -5,33 +5,35 @@ import XCTest /// Issue #183 — Stage 2 paper-size / quality selection: seeding from /// Stage 1 `pageSize`, the synthetic `Custom.x` entry, re-mirror -/// triggers, and `PrintOptions` wiring into `lp` argv. -/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no +/// triggers, and `TargetPrintOverrides` wiring into the resolved +/// ticket writes (#201 — the `RecordingTargetSpooler` seam replaces +/// the deleted `lp` argv log). +/// `lpoptions` is a mock script in the test env's `cups-bin` — no /// live CUPS is touched. @MainActor final class PrintSessionViewModelTests: XCTestCase { private var env: TestAppEnvironment! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { env = try TestAppEnvironment.make() - lpArgvURL = env.root.appendingPathComponent("lp-argv.log") + spoolLogURL = env.root.appendingPathComponent("spool.log") try writeCupsFixtures() } override func tearDown() async throws { env?.cleanup() env = nil - lpArgvURL = nil + spoolLogURL = nil } private var binDir: URL { env.root.appendingPathComponent("cups-bin") } - /// Mock `lpoptions -l` advertises paper sizes + a quality key; - /// mock `lp` appends its argv to `lpArgvURL` for assertions. + /// Mock `lpoptions -l` advertises paper sizes + a quality key — + /// the queue's option-key roster feeds media/quality detection. private func writeCupsFixtures() throws { try FileManager.default.createDirectory( at: binDir, withIntermediateDirectories: true) @@ -56,17 +58,10 @@ final class PrintSessionViewModelTests: XCTestCase { printf "printer-info='Mock %s' printer-type=42\\n" "$queue" exit 0 """ - let lp = """ - #!/bin/sh - printf '%s\\n' "$*" >> "\(lpArgvURL.path)" - exit 0 - """ - for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] { - let url = binDir.appendingPathComponent(name) - try body.write(to: url, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: url.path) - } + let url = binDir.appendingPathComponent("lpoptions") + try lpoptions.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) } private func makeWorkflow() -> TargetWorkflowViewModel { @@ -185,14 +180,17 @@ final class PrintSessionViewModelTests: XCTestCase { // MARK: - Spool wiring - /// `spool` emits the Stage 2 paper token and quality through - /// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o =`). + /// `spool` resolves the Stage 2 paper token and quality into the + /// ticket writes (`PageSize=`, `=`) recorded by the + /// DEBUG spool seam (#201 D8). func testSpoolPassesPaperTokenAndQuality() async throws { let workflow = makeWorkflow() workflow.pageSize = .a4 await loadCaps(workflow.print) workflow.print.selectedPaperSize = 4 // Letter workflow.print.selectedQuality = "301" + workflow.print.spooler = RecordingTargetSpooler( + logURL: spoolLogURL) let tiff = env.root.appendingPathComponent("page1.tif") try Data([0x49, 0x49]).write(to: tiff) @@ -208,9 +206,46 @@ final class PrintSessionViewModelTests: XCTestCase { pages: [page]) workflow.print.printAllPages(from: result) - let argv = await waitForFile(lpArgvURL) - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv) + let log = await waitForFile(spoolLogURL) + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) + } + + /// #201 — the stubbed panel never produces a `PrintTicket`, so a + /// spool with `capturedTickets` empty must still resolve every + /// Stage 2 write (the default UI-test path). + func testSpoolWithoutTicketResolvesStage2Writes() async throws { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + workflow.print.spooler = RecordingTargetSpooler( + logURL: spoolLogURL) + XCTAssertTrue(workflow.print.capturedTickets.isEmpty) + + let tiff = env.root.appendingPathComponent("page1.tif") + try Data([0x49, 0x49]).write(to: tiff) + let page = GalleryPage( + index: 0, + page: PrinttargPage( + filename: "page1.tif", patches: 10, + widthMm: 210, heightMm: 297), + fileURL: tiff, previewPNG: nil, previewError: nil) + let result = PrinttargResult( + ti2URL: env.root.appendingPathComponent("target.ti2"), + manifest: PrinttargManifest(pages: [page.page]), + pages: [page]) + workflow.print.printAllPages(from: result) + + let log = await waitForFile(spoolLogURL) + XCTAssertTrue(log.contains("pages=1"), log) + XCTAssertTrue(log.contains("PageSize=A4"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) + XCTAssertTrue(log.contains("MediaType=Stationery"), log) + XCTAssertTrue(log.contains("orientation-requested=3"), log) + XCTAssertTrue(log.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains( + "PMColorMatchingMode=APCustomColorMatching"), log) } // MARK: - Panel apply-back (stubbed NSPrintPanel) diff --git a/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift new file mode 100644 index 0000000..ae42841 --- /dev/null +++ b/Tests/ICCeryCoreTests/TicketWriteResolverTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #201 Phase 4 — the resolved ticket-write list that replaces +/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always +/// wins), `raw` can never appear. +final class TicketWriteResolverTests: XCTestCase { + + private func resolve( + overrides: TargetPrintOverrides = TargetPrintOverrides(), + optionKeys: Set = [] + ) -> ResolvedTicketWrites { + TicketWriteResolver.resolve( + overrides: overrides, optionKeys: optionKeys) + } + + private func keys(_ r: ResolvedTicketWrites) -> [String] { + r.writes.map(\.key) + } + + private func value( + _ key: String, in r: ResolvedTicketWrites + ) -> String? { + r.writes.first { $0.key == key }?.value + } + + /// Both AP_* spellings are always present and locked; all three + /// Quartz keys are always present (D2 dual vocabulary). + func testColourKeysAlwaysPresent() { + let r = resolve() + let ap = r.writes.filter { + ColorMatchingAttempts.printSettingsKeys.contains($0.key) + } + XCTAssertEqual(ap.count, 2) + XCTAssertTrue(ap.allSatisfy { + $0.locked + && $0.value == "AP_ApplicationColorMatching" + }) + XCTAssertEqual(value("PMColorMatchingMode", in: r), + "APCustomColorMatching") + XCTAssertEqual(value("PMCustomColorMatchingProfile", in: r), "") + XCTAssertEqual( + value("com.apple.print.PrintSettings.PMColorMatchingMode", + in: r), + "APCustomColorMatching") + // The Quartz mode key is locked; profile + legacy key are not. + XCTAssertTrue(r.writes.first { $0.key == "PMColorMatchingMode" }! + .locked) + XCTAssertFalse(r.writes.first { + $0.key == "PMCustomColorMatchingProfile" }!.locked) + XCTAssertFalse(r.writes.first { + $0.key == "com.apple.print.PrintSettings.PMColorMatchingMode" + }!.locked) + } + + /// `raw` can never appear — there is no `lp` and the resolver has + /// no captured-options input that could smuggle it in (#92). + func testRawNeverAppears() { + for optionKeys in [Set(), ["raw"], + ["MediaType", "raw"]] { + let r = resolve( + overrides: TargetPrintOverrides( + paperSize: "raw", mediaType: "raw", + qualityKey: "raw", quality: "raw", + orientation: "raw"), + optionKeys: optionKeys) + XCTAssertFalse(r.writes.contains { + $0.key.lowercased() == "raw" + }) + } + } + + /// D6 inversion — the deleted argv builder suppressed the Stage 2 + /// value when the captured string already carried the key (the old + /// media branch at `build`'s line 81). There is no captured-wins + /// path any more: + /// the explicit Stage 2 value is emitted unconditionally. + func testStage2MediaTypeAlwaysWins() { + let r = resolve( + overrides: TargetPrintOverrides(mediaType: "Matte"), + optionKeys: ["MediaType"]) + XCTAssertEqual(value("MediaType", in: r), "Matte") + // …and likewise for a vendor key — nothing consults captured + // options before writing. + let epson = resolve( + overrides: TargetPrintOverrides(mediaType: "Matte"), + optionKeys: ["EPIJ_Medi"]) + XCTAssertEqual(value("EPIJ_Medi", in: epson), "Matte") + } + + /// Vendor media-key selection follows `CupsParsers.mediaTypeKeys`: + /// `EPIJ_Medi` is picked when present; `CNIJMediaType` beats + /// `MediaType`. + func testVendorMediaKeySelection() { + let epson = resolve( + overrides: TargetPrintOverrides(mediaType: "Photo"), + optionKeys: ["EPIJ_Medi", "MediaType"]) + XCTAssertEqual(value("EPIJ_Medi", in: epson), "Photo") + XCTAssertNil(value("MediaType", in: epson)) + + let canon = resolve( + overrides: TargetPrintOverrides(mediaType: "Photo"), + optionKeys: ["CNIJMediaType", "MediaType"]) + XCTAssertEqual(value("CNIJMediaType", in: canon), "Photo") + XCTAssertNil(value("MediaType", in: canon)) + } + + /// Driver "no colour adjustment" bypass — Canon, Epson (CCor + /// preferred over CMat), Gutenprint — always unlocked and never + /// gated on `ppdUncorrectedPassthrough`. + func testDriverBypass() { + let canon = resolve(optionKeys: ["CNIJIntent2"]) + XCTAssertEqual(value("CNIJIntent2", in: canon), "4") + XCTAssertFalse(canon.writes.first { $0.key == "CNIJIntent2" }! + .locked) + + let epson = resolve(optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) + XCTAssertEqual(value("EPIJ_CCor", in: epson), "0") + XCTAssertNil(value("EPIJ_CMat", in: epson)) + + let epsonMat = resolve(optionKeys: ["EPIJ_CMat"]) + XCTAssertEqual(value("EPIJ_CMat", in: epsonMat), "3") + + let gutenprint = resolve(optionKeys: ["StpColorCorrection"]) + XCTAssertEqual(value("StpColorCorrection", in: gutenprint), + "Uncorrected") + + XCTAssertNil(value("EPIJ_CMat", in: resolve())) + } + + /// Orientation: landscape → 4, portrait → 3, nil → no write. + func testOrientation() { + XCTAssertEqual( + value("orientation-requested", in: resolve( + overrides: TargetPrintOverrides( + orientation: "landscape"))), + "4") + XCTAssertEqual( + value("orientation-requested", in: resolve( + overrides: TargetPrintOverrides( + orientation: "portrait"))), + "3") + XCTAssertNil(value("orientation-requested", in: resolve())) + } + + /// A `nil` override emits no write for that key; quality needs + /// **both** the detected key and the value. + func testNilOverridesEmitNoWrite() { + let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) + XCTAssertNil(value("PageSize", in: r)) + XCTAssertNil(value("MediaType", in: r)) + XCTAssertNil(value("EPIJ_Qual", in: r)) + XCTAssertNil(value("orientation-requested", in: r)) + XCTAssertNil(r.paperToken) + XCTAssertNil(r.orientation) + + let keyOnly = resolve( + overrides: TargetPrintOverrides(qualityKey: "EPIJ_Qual"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertNil(value("EPIJ_Qual", in: keyOnly)) + let valueOnly = resolve( + overrides: TargetPrintOverrides(quality: "305"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertNil(value("EPIJ_Qual", in: valueOnly)) + // An empty paper token emits no write and no paperToken. + let emptyPaper = resolve( + overrides: TargetPrintOverrides(paperSize: "")) + XCTAssertNil(value("PageSize", in: emptyPaper)) + XCTAssertNil(emptyPaper.paperToken) + } + + /// The write list equals the locked 1–10 order, exactly. + func testLockedWriteOrder() { + let r = resolve( + overrides: TargetPrintOverrides( + paperSize: "A4", mediaType: "Photo", + qualityKey: "EPIJ_Qual", quality: "305", + orientation: "landscape"), + optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) + XCTAssertEqual(keys(r), [ + "AP_ColorMatchingMode", + "AP.ColorMatchingMode", + "PMColorMatchingMode", + "PMCustomColorMatchingProfile", + "com.apple.print.PrintSettings.PMColorMatchingMode", + "PageSize", + "MediaType", + "EPIJ_Qual", + "EPIJ_CMat", + "orientation-requested", + ]) + XCTAssertEqual(r.paperToken, "A4") + XCTAssertEqual(r.orientation, "landscape") + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/lp b/Tests/ICCeryUITests/Fixtures/bin/lp deleted file mode 100755 index 6e091f1..0000000 --- a/Tests/ICCeryUITests/Fixtures/bin/lp +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Mock lp for Milestone3UITests. Appends its full argv to -# ICCERY_TEST_LP_ARGV so the test can assert flag order and option -# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection). -{ - printf 'lp' - for arg in "$@"; do printf ' %s' "$arg"; done - printf '\n' -} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}" -if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then - echo "mock lp failure" >&2 - exit "$ICCERY_MOCK_LP_EXIT" -fi -exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/printtarg b/Tests/ICCeryUITests/Fixtures/bin/printtarg index 16f482d..e0c248c 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/printtarg +++ b/Tests/ICCeryUITests/Fixtures/bin/printtarg @@ -1,6 +1,8 @@ #!/bin/sh -# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty +# Mock printtarg for Milestone2UITests. Writes N 2x2 TIFFs, a pretty # manifest on stdout, and a fake .ti2 next to the basename (last argv). +# N defaults to 1; ICCERY_MOCK_PRINTTARG_PAGES raises it for the +# single-spool-job granularity test (#201 D5). # Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT. last="" for arg in "$@"; do last="$arg"; done @@ -8,7 +10,16 @@ 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' +pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}" +i=1 +printf '{\n "event": "manifest",\n "pages": [\n' +while [ "$i" -le "$pages" ]; do + echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page$i.tif" + sep="," + if [ "$i" -eq "$pages" ]; then sep=""; fi + printf ' {"filename": "page%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}%s\n' "$i" "$sep" + i=$((i + 1)) +done +printf ' ]\n}\n' touch "$last.ti2" exit 0 diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift index d458361..dcb59b9 100644 --- a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -11,7 +11,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { private var testRoot: URL! private var binDir: URL! private var workDir: URL! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { continueAfterFailure = false @@ -21,7 +21,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { .deletingLastPathComponent() .appendingPathComponent("Fixtures/bin") workDir = testRoot.appendingPathComponent("work") - lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + spoolLogURL = testRoot.appendingPathComponent("spool.log") try FileManager.default.createDirectory( at: workDir, withIntermediateDirectories: true) @@ -34,7 +34,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase { "ICCERY_TEST_SAVE_TARGET": workDir.appendingPathComponent("mytarget.ti1").path, "ICCERY_TEST_WORKDIR": workDir.path, - "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, ] } @@ -75,14 +75,33 @@ final class Milestone11PrintSettingsUITests: XCTestCase { _ = waitFor("galleryPage-0", timeout: 25) } - private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + private func spoolLogLines() -> [String] { + ((try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "") + .split(separator: "\n").map(String.init) + } + + private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" - if !out.isEmpty { return out } + let lines = spoolLogLines() + if !lines.isEmpty { return lines.joined(separator: "\n") } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } - return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + return spoolLogLines().joined(separator: "\n") + } + + /// Poll until the spool log holds at least `count` lines. + @discardableResult + private func waitForSpoolLineCount( + _ count: Int, timeout: TimeInterval = 15 + ) -> [String] { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let lines = spoolLogLines() + if lines.count >= count { return lines } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return spoolLogLines() } // MARK: - Tests @@ -163,8 +182,8 @@ final class Milestone11PrintSettingsUITests: XCTestCase { } /// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=` - /// apply back into the Stage 2 pickers and reach the `lp` argv - /// (R15 — the real modal is never driven). + /// apply back into the Stage 2 pickers and reach the recorded + /// ticket writes (#201 — the real modal is never driven). func testPanelResultAppliesBackToPickers() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = @@ -183,14 +202,14 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertEqual(selection(of: "printerQualitySelect"), "305") app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) } /// #186 — the stubbed panel result's `orientation-requested=` / /// `MediaType=` apply back to the Stage 2 selections and reach the - /// `lp` argv through the captured `cupsOptions` replay. + /// recorded ticket writes via the Stage 2 overrides (#201 D6). func testPanelResultAppliesBackOrientationAndMedia() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = @@ -225,11 +244,38 @@ final class Milestone11PrintSettingsUITests: XCTestCase { XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter") app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("orientation-requested=4"), argv) - XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) - XCTAssertTrue(argv.contains("PageSize=Letter"), argv) - XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("orientation-requested=4"), log) + XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) + XCTAssertTrue(log.contains("PageSize=Letter"), log) + XCTAssertTrue(log.contains("EPIJ_Qual=305"), log) + } + + /// #201 D5 — per-page mode records one line per page; the + /// `chkSingleSpoolJob` toggle collapses the job into a single + /// request logged once with `pages=N`. + func testSingleSpoolJobTogglesGranularity() throws { + app.launchEnvironment["ICCERY_MOCK_PRINTTARG_PAGES"] = "2" + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + XCTAssertTrue(element("chkSingleSpoolJob").exists) + + // Default off → one request per page → one line per page. + app.buttons["btnPrintAll"].click() + var lines = waitForSpoolLineCount(2) + XCTAssertEqual(lines.count, 2) + XCTAssertTrue(lines.allSatisfy { $0.contains("pages=1") }, + lines.joined(separator: "\n")) + + // Toggle on → one request for all pages → a single line. + element("chkSingleSpoolJob").click() + app.buttons["btnPrintAll"].click() + lines = waitForSpoolLineCount(3) + XCTAssertEqual(lines.count, 3) + XCTAssertEqual( + lines.filter { $0.contains("pages=2") }.count, 1, + lines.joined(separator: "\n")) } private func launchAppWithDefaults() { diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift index bb20e1d..bf1d29c 100644 --- a/Tests/ICCeryUITests/Milestone3UITests.swift +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -3,9 +3,10 @@ import XCTest /// Milestone 3 UI tests — issue #17 print panel end-to-end with mock /// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a /// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` -/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends -/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the -/// evidence that captured options are replayed (docs/11 §tests). +/// returns a canned `PrintPropertiesResult` instead. The DEBUG +/// `RecordingTargetSpooler` appends one resolved-ticket line per +/// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the +/// evidence that the Stage 2 selections reach the spool. @MainActor final class Milestone3UITests: XCTestCase { @@ -13,7 +14,7 @@ final class Milestone3UITests: XCTestCase { private var testRoot: URL! private var binDir: URL! private var workDir: URL! - private var lpArgvURL: URL! + private var spoolLogURL: URL! override func setUp() async throws { continueAfterFailure = false @@ -23,7 +24,7 @@ final class Milestone3UITests: XCTestCase { .deletingLastPathComponent() .appendingPathComponent("Fixtures/bin") workDir = testRoot.appendingPathComponent("work") - lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + spoolLogURL = testRoot.appendingPathComponent("spool.log") try FileManager.default.createDirectory( at: workDir, withIntermediateDirectories: true) @@ -36,7 +37,7 @@ final class Milestone3UITests: XCTestCase { "ICCERY_TEST_SAVE_TARGET": workDir.appendingPathComponent("mytarget.ti1").path, "ICCERY_TEST_WORKDIR": workDir.path, - "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, ] } @@ -99,18 +100,18 @@ final class Milestone3UITests: XCTestCase { _ = waitFor("galleryPage-0", timeout: 25) } - private func recordedLpArgv() -> String { - (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + private func recordedSpoolLog() -> String { + (try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "" } - private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let out = recordedLpArgv() + let out = recordedSpoolLog() if !out.isEmpty { return out } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } - return recordedLpArgv() + return recordedSpoolLog() } /// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button @@ -203,10 +204,13 @@ final class Milestone3UITests: XCTestCase { XCTAssertEqual(element("printNotificationIcon").value as? String, "info") } - /// Preferences OK → captured options are replayed verbatim in the - /// `lp` argv alongside the two mandatory AP_* headers (issue 17's - /// acceptance test: "captured options replayed in argv"). - func testCapturedOptionsReplayedInLpArgv() throws { + /// Preferences OK → the captured media selection applies back to + /// Stage 2 and reaches the recorded ticket writes alongside the + /// mandatory colour keys (issue 17's acceptance test, ported to + /// the native spool seam in #201). The captured `InputSlot` is no + /// longer replayed — captured vendor state lives inside the + /// `PrintTicket`, which the stub deliberately does not produce. + func testPanelSelectionsApplyBackToSpoolWrites() throws { app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = "InputSlot=Rear MediaType=PhotographicGlossy" @@ -221,20 +225,17 @@ final class Milestone3UITests: XCTestCase { .contains("Settings captured")) app.buttons["btnPrintAll"].click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains( - "AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) - XCTAssertTrue(argv.contains( - "AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) - XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) - XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains( + "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) + XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) // Detected bypass for the mock queue (EPIJ_CMat present in - // lpoptions -l) is appended when not captured. - XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) - XCTAssertTrue(argv.contains("orientation-requested=3"), argv) - // Last token is the TIFF. - XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines) - .hasSuffix("page1.tif"), argv) + // lpoptions -l) is always written. + XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) + XCTAssertTrue(log.contains("orientation-requested=3"), log) + XCTAssertTrue(log.contains("page=page1.tif"), log) } /// Per-page print uses the same spool path (btnPrintPage-N). @@ -262,14 +263,16 @@ final class Milestone3UITests: XCTestCase { return } printPage.click() - let argv = waitForLpLine() - XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) - XCTAssertTrue(argv.contains("page1.tif"), argv) + let log = waitForSpoolLine() + XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log) + XCTAssertTrue(log.contains("page=page1.tif"), log) } - /// lp failure surfaces in the in-panel notice, not the wizard banner. - func testLpFailureShowsPrintNotice() throws { - app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" + /// A spool failure surfaces in the in-panel notice, not the + /// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording + /// spooler throw `TargetSpoolError.operationFailed`). + func testSpoolFailureShowsPrintNotice() throws { + app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1" launchApp() reachPrintPanel() _ = waitFor("printerStatusBadge") @@ -290,7 +293,7 @@ final class Milestone3UITests: XCTestCase { _ = waitFor("printerStatusBadge") app.buttons["btnPrintAll"].click() - _ = waitForLpLine() + _ = waitForSpoolLine() let stateURL = testRoot .appendingPathComponent("AppData") .appendingPathComponent("wizard_state.json") -- 2.39.5