Compare commits

...
Author SHA1 Message Date
gronod 4f3caf335d feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4)
macOS CI / build-and-test (push) Skipped
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.
2026-09-17 16:30:27 +01:00
gronod de142a9690 Merge pull request 'feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3)' (#205) from feat/201-raster-canvas into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 15:08:06 +01:00
20 changed files with 1064 additions and 494 deletions
@@ -46,4 +46,23 @@ public enum ColorMatchingAttempts {
"AP_ColorMatchingMode", "AP_ColorMatchingMode",
"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"
} }
@@ -3,7 +3,6 @@ import Foundation
/// Errors from CUPS tool invocations. /// Errors from CUPS tool invocations.
public enum CupsError: LocalizedError, Equatable { public enum CupsError: LocalizedError, Equatable {
case toolFailed(tool: String, code: Int32, stderr: String) case toolFailed(tool: String, code: Int32, stderr: String)
case tiffMissing(String)
case noPrinterSelected case noPrinterSelected
public var errorDescription: String? { public var errorDescription: String? {
@@ -13,8 +12,6 @@ public enum CupsError: LocalizedError, Equatable {
return detail.isEmpty return detail.isEmpty
? "\(tool) failed with exit code \(code)" ? "\(tool) failed with exit code \(code)"
: "\(tool) failed (\(code)): \(detail)" : "\(tool) failed (\(code)): \(detail)"
case .tiffMissing(let path):
return "Target TIFF does not exist: \(path)"
case .noPrinterSelected: case .noPrinterSelected:
return "No printer selected." return "No printer selected."
} }
@@ -27,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable {
/// `ProcessManager.runCaptured` so spawns are logged, get killAll /// `ProcessManager.runCaptured` so spawns are logged, get killAll
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are /// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
/// injectable so tests use fixture scripts and never touch real CUPS. /// 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 struct CupsService: Sendable {
public let processManager: ProcessManager 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. /// production, a fixture dir under test.
public let binaryDir: URL public let binaryDir: URL
/// `/etc/cups/ppd` in production. /// `/etc/cups/ppd` in production.
@@ -53,11 +52,11 @@ public struct CupsService: Sendable {
// lpstat exits non-zero when no destinations exist — an empty // lpstat exits non-zero when no destinations exist — an empty
// queue list is a valid result, not a failure (issue 12). // queue list is a valid result, not a failure (issue 12).
let destinationsOut = try await run( 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( 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( 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 names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
@@ -80,7 +79,7 @@ public struct CupsService: Sendable {
/// display name, docs/11 §binding). /// display name, docs/11 §binding).
public func displayName(for queue: String) async throws -> String? { public func displayName(for queue: String) async throws -> String? {
let result = try await run( let result = try await run(
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
return CupsParsers.lpoptionsDisplayName(result.stdout) return CupsParsers.lpoptionsDisplayName(result.stdout)
} }
@@ -90,7 +89,7 @@ public struct CupsService: Sendable {
/// to media-key and colour-bypass detection (docs/11 layer ④). /// to media-key and colour-bypass detection (docs/11 layer ④).
public func optionListings(for queue: String) async throws -> [CupsOptionListing] { public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
let result = try await run( 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) return CupsParsers.lpoptionsList(result.stdout)
} }
@@ -162,27 +161,6 @@ public struct CupsService: Sendable {
Set(try await optionListings(for: queue).map(\.key)) Set(try await optionListings(for: queue).map(\.key))
} }
// MARK: - Spool (issue 15)
/// `lp -d <queue> … <tiff>` — 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 // MARK: - PPD
private func loadPPD(for queue: String) -> String? { private func loadPPD(for queue: String) -> String? {
@@ -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 <queue> -t "ICCery Target - <file>"
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
/// <captured cups_options>
/// <media_type, if no media key already captured>
/// <quality, if no quality key already captured> (#183)
/// <driver bypass, if no bypass key captured>
/// <orientation-requested=3|4, unless captured>
/// <PageSize, unless captured>
/// <tiff>
/// ```
///
/// - **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<String>
) 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<String> = [
"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)")
}
}
}
@@ -111,24 +111,24 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
} }
} }
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS /// The Stage 2 mirror — panel selections and the captured `k=v`
/// `paperSource` is ignored unless already present inside captured /// string (docs/10 §PrintOptions). Since #201 removed the `lp` path
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets /// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6)
/// it on OK) but never gates the argv — macOS always bypasses driver /// and the mirror apply-back; the opaque vendor state now travels
/// colour management. /// inside the `PrintTicket`, not a flattened option string.
public struct PrintOptions: Codable, Equatable, Sendable { public struct PrintOptions: Codable, Equatable, Sendable {
public var paperSource: Int? public var paperSource: Int?
/// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`.
public var orientation: String? 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 paperSize: String?
public var mediaType: String? public var mediaType: String?
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if /// Print-quality token → `<detectedQualityKey>=` ticket write.
/// captured, #183).
public var quality: String? public var quality: String?
public var ppdUncorrectedPassthrough: Bool? public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from /// 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 var cupsOptions: String?
public init( public init(
@@ -18,8 +18,8 @@ public enum ProcessID {
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
/// CUPS system tools (`/usr/bin/…`) — captured one-shots, not /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not
/// streaming Argyll children. /// streaming Argyll children. Named `cups*` so no `lp*`-prefixed
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } /// identifier survives the target-print removal (#201).
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" }
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
} }
+5 -3
View File
@@ -106,12 +106,14 @@ enum UITestHooks {
// MARK: - Print panel / CUPS stubs (issue 13/17) // 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. /// `CupsService.binaryDir` under UI tests.
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") } static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
/// Path the mock `lp` script appends its argv to, for assertions. /// Path the `RecordingTargetSpooler` appends resolved ticket
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") } /// 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 — /// Whether the `NSPrintPanel` should be stubbed under UI testing —
/// separate from the stub's *result* so "cancel" (`nil`) does not /// separate from the stub's *result* so "cancel" (`nil`) does not
+54 -1
View File
@@ -84,6 +84,35 @@ struct ColorSyncSuppressor {
return applied 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 // MARK: - Layer ④ driver bypass
/// Pre-select the driver "no colour adjustment" option, unlocked — /// Pre-select the driver "no colour adjustment" option, unlocked —
@@ -112,7 +141,11 @@ struct ColorSyncSuppressor {
// MARK: - Layer ⑤ NSPrintInfo mirror // MARK: - Layer ⑤ NSPrintInfo mirror
/// Mirror the applied keys into `printSettings` so the PDE pick /// 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( func mirror(
into printInfo: NSPrintInfo, into printInfo: NSPrintInfo,
driverBypass: (key: String, value: String)? driverBypass: (key: String, value: String)?
@@ -121,9 +154,29 @@ struct ColorSyncSuppressor {
for key in ColorMatchingAttempts.printSettingsKeys { for key in ColorMatchingAttempts.printSettingsKeys {
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString 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 { if let driverBypass {
settings[driverBypass.key as NSString] = driverBypass.value as NSString 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 // MARK: - Layer ⑥ capture
@@ -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 - <basename>"
/// 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<String>
/// 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.<w>x<h>` 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=<q> title=<t> page=<basename> pages=<n> paper=<token|->
/// orientation=<o> keys=<k=v k=v …>` — 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
@@ -147,6 +147,7 @@ struct PrintPanelService {
let session = PMTicketBridge.session(printInfo) let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ② suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③ suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④ driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys) to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
+108 -19
View File
@@ -2,7 +2,8 @@ import Combine
import Foundation import Foundation
import ICCeryCore 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 @MainActor
final class PrintSessionViewModel: ObservableObject { final class PrintSessionViewModel: ObservableObject {
let wizard: WizardViewModel let wizard: WizardViewModel
@@ -13,7 +14,15 @@ final class PrintSessionViewModel: ObservableObject {
weak var workflow: TargetWorkflowViewModel? weak var workflow: TargetWorkflowViewModel?
@Published var printers: [Printer] = [] @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 printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? @Published var selectedTray: Int?
@Published var selectedMediaType: String? @Published var selectedMediaType: String?
@@ -28,11 +37,39 @@ final class PrintSessionViewModel: ObservableObject {
@Published var capturedTickets: [String: PrintTicket] = [:] @Published var capturedTickets: [String: PrintTicket] = [:]
@Published var printNotice: Notice? @Published var printNotice: Notice?
@Published var isPrinting = false @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<Void, Never>? private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) { init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard self.wizard = wizard
self.environment = environment 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>? 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 /// The CUPS `PageSize` token for the current Stage 2 pick — live
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability /// `Custom.<pt>x<pt>` 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? { var selectedPaperSizeToken: String? {
guard let id = selectedPaperSize else { return nil } guard let id = selectedPaperSize else { return nil }
if id == 0 { return customPaperToken() } if id == 0 { return customPaperToken() }
@@ -216,10 +254,31 @@ final class PrintSessionViewModel: ObservableObject {
guard let self else { return } guard let self else { return }
// `defer` cannot mutate isolated state under Swift 5.7 // `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113). // (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 var printed = 0
for page in result.pages { for page in result.pages {
do { do {
try await spool(page, index: page.index) try await spool(page)
printed += 1 printed += 1
} catch { } catch {
printNotice = Notice( printNotice = Notice(
@@ -249,7 +308,7 @@ final class PrintSessionViewModel: ObservableObject {
let task = Task { @MainActor [weak self] in let task = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
do { do {
try await spool(page, index: page.index) try await spool(page)
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).", text: "Sent \(page.page.filename) to \(selectedPrinter).",
@@ -267,25 +326,55 @@ final class PrintSessionViewModel: ObservableObject {
printTask = task 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 { guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected throw CupsError.noPrinterSelected
} }
// The Stage 2 paper token is what `lp -o PageSize=` sees; let queue = selectedPrinter
// `workflow.pageSize` remains the printtarg layout input only let optionKeys = (try? await environment.cupsService
// (#183). .optionKeys(for: queue)) ?? []
let options = PrintOptions( attachDiagnostics()
orientation: printOrientation, 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, paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType, mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
ppdUncorrectedPassthrough: true, orientation: printOrientation),
cupsOptions: capturedCupsOptions[selectedPrinter]) optionKeys: optionKeys)
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
wizard.printerName = selectedPrinter
} }
} }
@@ -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.<w>x<h>`
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<String>
) -> 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)
}
}
+32 -15
View File
@@ -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 { private var printPanel: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
@@ -267,6 +267,11 @@ struct Stage2View: View {
.clipShape(Capsule()) .clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge") .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) { Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise") Image(systemName: "arrow.clockwise")
} }
@@ -325,6 +330,27 @@ struct Stage2View: View {
// Stage 1 owns the custom dimensions — the caption lives // Stage 1 owns the custom dimensions — the caption lives
// inside `paperSizeGroup` (#183). // inside `paperSizeGroup` (#183).
printAllRow
}
.padding(12)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.onAppear { schedulePrinterRefresh() }
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
.onChange(of: workflow.pageSize) { _ in
workflow.print.seedPaperSelection()
}
}
/// 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) { HStack(spacing: 8) {
Button(action: { Button(action: {
if let result = workflow.printtargResult { if let result = workflow.printtargResult {
@@ -339,6 +365,11 @@ struct Stage2View: View {
|| workflow.printtargResult == nil || workflow.printtargResult == nil
|| workflow.print.selectedPrinter.isEmpty) || workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll") .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() Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() } Button("Advance to Stage 3") { workflow.advanceToStage3() }
.accessibilityIdentifier("btnAdvanceToStage3") .accessibilityIdentifier("btnAdvanceToStage3")
@@ -346,20 +377,6 @@ struct Stage2View: View {
|| !workflow.wizard.isUnlocked(.measure)) || !workflow.wizard.isUnlocked(.measure))
} }
} }
.padding(12)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.onAppear { schedulePrinterRefresh() }
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
.onChange(of: workflow.pageSize) { _ in
workflow.print.seedPaperSelection()
}
}
/// Paper picker + custom-size caption under the `paperSizeGroup` /// Paper picker + custom-size caption under the `paperSizeGroup`
/// container (#183). `caps.paperSizes` plus the synthetic custom /// container (#183). `caps.paperSizes` plus the synthetic custom
@@ -25,8 +25,7 @@ final class CupsOptionsFilterTests: XCTestCase {
} }
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality /// #180 — a captured `EPIJ_Qual` (and the other canonical quality
/// keys) survives the filter so it wins over the Stage 2 explicit /// keys) survives the filter so it reaches the Stage 2 mirror.
/// quality in `LpArgs`.
func testKeepsQualityKeys() { func testKeepsQualityKeys() {
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 " let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
+ "cupsPrintQuality=High Quality=Best " + "cupsPrintQuality=High Quality=Best "
-173
View File
@@ -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<String> = []
) 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)
}
}
}
@@ -5,33 +5,35 @@ import XCTest
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from /// Issue #183 — Stage 2 paper-size / quality selection: seeding from
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror /// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
/// triggers, and `PrintOptions` wiring into `lp` argv. /// triggers, and `TargetPrintOverrides` wiring into the resolved
/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no /// 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. /// live CUPS is touched.
@MainActor @MainActor
final class PrintSessionViewModelTests: XCTestCase { final class PrintSessionViewModelTests: XCTestCase {
private var env: TestAppEnvironment! private var env: TestAppEnvironment!
private var lpArgvURL: URL! private var spoolLogURL: URL!
override func setUp() async throws { override func setUp() async throws {
env = try TestAppEnvironment.make() env = try TestAppEnvironment.make()
lpArgvURL = env.root.appendingPathComponent("lp-argv.log") spoolLogURL = env.root.appendingPathComponent("spool.log")
try writeCupsFixtures() try writeCupsFixtures()
} }
override func tearDown() async throws { override func tearDown() async throws {
env?.cleanup() env?.cleanup()
env = nil env = nil
lpArgvURL = nil spoolLogURL = nil
} }
private var binDir: URL { private var binDir: URL {
env.root.appendingPathComponent("cups-bin") env.root.appendingPathComponent("cups-bin")
} }
/// Mock `lpoptions -l` advertises paper sizes + a quality key; /// Mock `lpoptions -l` advertises paper sizes + a quality key —
/// mock `lp` appends its argv to `lpArgvURL` for assertions. /// the queue's option-key roster feeds media/quality detection.
private func writeCupsFixtures() throws { private func writeCupsFixtures() throws {
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: binDir, withIntermediateDirectories: true) at: binDir, withIntermediateDirectories: true)
@@ -56,18 +58,11 @@ final class PrintSessionViewModelTests: XCTestCase {
printf "printer-info='Mock %s' printer-type=42\\n" "$queue" printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
exit 0 exit 0
""" """
let lp = """ let url = binDir.appendingPathComponent("lpoptions")
#!/bin/sh try lpoptions.write(to: url, atomically: true, encoding: .utf8)
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( try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path) [.posixPermissions: 0o755], ofItemAtPath: url.path)
} }
}
private func makeWorkflow() -> TargetWorkflowViewModel { private func makeWorkflow() -> TargetWorkflowViewModel {
TargetWorkflowViewModel(environment: env.environment) TargetWorkflowViewModel(environment: env.environment)
@@ -185,14 +180,17 @@ final class PrintSessionViewModelTests: XCTestCase {
// MARK: - Spool wiring // MARK: - Spool wiring
/// `spool` emits the Stage 2 paper token and quality through /// `spool` resolves the Stage 2 paper token and quality into the
/// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o <qualityKey>=`). /// ticket writes (`PageSize=`, `<qualityKey>=`) recorded by the
/// DEBUG spool seam (#201 D8).
func testSpoolPassesPaperTokenAndQuality() async throws { func testSpoolPassesPaperTokenAndQuality() async throws {
let workflow = makeWorkflow() let workflow = makeWorkflow()
workflow.pageSize = .a4 workflow.pageSize = .a4
await loadCaps(workflow.print) await loadCaps(workflow.print)
workflow.print.selectedPaperSize = 4 // Letter workflow.print.selectedPaperSize = 4 // Letter
workflow.print.selectedQuality = "301" workflow.print.selectedQuality = "301"
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif") let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff) try Data([0x49, 0x49]).write(to: tiff)
@@ -208,9 +206,46 @@ final class PrintSessionViewModelTests: XCTestCase {
pages: [page]) pages: [page])
workflow.print.printAllPages(from: result) workflow.print.printAllPages(from: result)
let argv = await waitForFile(lpArgvURL) let log = await waitForFile(spoolLogURL)
XCTAssertTrue(argv.contains("PageSize=Letter"), argv) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv) 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) // MARK: - Panel apply-back (stubbed NSPrintPanel)
@@ -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<String> = []
) -> 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<String>(), ["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")
}
}
-14
View File
@@ -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
+14 -3
View File
@@ -1,6 +1,8 @@
#!/bin/sh #!/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). # 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. # Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
last="" last=""
for arg in "$@"; do last="$arg"; done 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 echo "mock printtarg failure" >&2
exit "$ICCERY_MOCK_PRINTTARG_EXIT" exit "$ICCERY_MOCK_PRINTTARG_EXIT"
fi fi
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif" pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}"
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n' 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" touch "$last.ti2"
exit 0 exit 0
@@ -11,7 +11,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
private var testRoot: URL! private var testRoot: URL!
private var binDir: URL! private var binDir: URL!
private var workDir: URL! private var workDir: URL!
private var lpArgvURL: URL! private var spoolLogURL: URL!
override func setUp() async throws { override func setUp() async throws {
continueAfterFailure = false continueAfterFailure = false
@@ -21,7 +21,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin") .appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work") workDir = testRoot.appendingPathComponent("work")
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") spoolLogURL = testRoot.appendingPathComponent("spool.log")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true) at: workDir, withIntermediateDirectories: true)
@@ -34,7 +34,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
"ICCERY_TEST_SAVE_TARGET": "ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path, workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.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) _ = 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) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" let lines = spoolLogLines()
if !out.isEmpty { return out } if !lines.isEmpty { return lines.joined(separator: "\n") }
RunLoop.current.run(until: Date().addingTimeInterval(0.1)) 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 // MARK: - Tests
@@ -163,8 +182,8 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
} }
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=` /// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
/// apply back into the Stage 2 pickers and reach the `lp` argv /// apply back into the Stage 2 pickers and reach the recorded
/// (R15 — the real modal is never driven). /// ticket writes (#201 — the real modal is never driven).
func testPanelResultAppliesBackToPickers() throws { func testPanelResultAppliesBackToPickers() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
@@ -183,14 +202,14 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertEqual(selection(of: "printerQualitySelect"), "305") XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
app.buttons["btnPrintAll"].click() app.buttons["btnPrintAll"].click()
let argv = waitForLpLine() let log = waitForSpoolLine()
XCTAssertTrue(argv.contains("PageSize=Letter"), argv) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
} }
/// #186 — the stubbed panel result's `orientation-requested=` / /// #186 — the stubbed panel result's `orientation-requested=` /
/// `MediaType=` apply back to the Stage 2 selections and reach the /// `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 { func testPanelResultAppliesBackOrientationAndMedia() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
@@ -225,11 +244,38 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter") XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
app.buttons["btnPrintAll"].click() app.buttons["btnPrintAll"].click()
let argv = waitForLpLine() let log = waitForSpoolLine()
XCTAssertTrue(argv.contains("orientation-requested=4"), argv) XCTAssertTrue(log.contains("orientation-requested=4"), log)
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(argv.contains("PageSize=Letter"), argv) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) 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() { private func launchAppWithDefaults() {
+38 -35
View File
@@ -3,9 +3,10 @@ import XCTest
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock /// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a /// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` /// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends /// returns a canned `PrintPropertiesResult` instead. The DEBUG
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the /// `RecordingTargetSpooler` appends one resolved-ticket line per
/// evidence that captured options are replayed (docs/11 §tests). /// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the
/// evidence that the Stage 2 selections reach the spool.
@MainActor @MainActor
final class Milestone3UITests: XCTestCase { final class Milestone3UITests: XCTestCase {
@@ -13,7 +14,7 @@ final class Milestone3UITests: XCTestCase {
private var testRoot: URL! private var testRoot: URL!
private var binDir: URL! private var binDir: URL!
private var workDir: URL! private var workDir: URL!
private var lpArgvURL: URL! private var spoolLogURL: URL!
override func setUp() async throws { override func setUp() async throws {
continueAfterFailure = false continueAfterFailure = false
@@ -23,7 +24,7 @@ final class Milestone3UITests: XCTestCase {
.deletingLastPathComponent() .deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin") .appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work") workDir = testRoot.appendingPathComponent("work")
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") spoolLogURL = testRoot.appendingPathComponent("spool.log")
try FileManager.default.createDirectory( try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true) at: workDir, withIntermediateDirectories: true)
@@ -36,7 +37,7 @@ final class Milestone3UITests: XCTestCase {
"ICCERY_TEST_SAVE_TARGET": "ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path, workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.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) _ = waitFor("galleryPage-0", timeout: 25)
} }
private func recordedLpArgv() -> String { private func recordedSpoolLog() -> String {
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" (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) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
let out = recordedLpArgv() let out = recordedSpoolLog()
if !out.isEmpty { return out } if !out.isEmpty { return out }
RunLoop.current.run(until: Date().addingTimeInterval(0.1)) RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} }
return recordedLpArgv() return recordedSpoolLog()
} }
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button /// 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") XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
} }
/// Preferences OK → captured options are replayed verbatim in the /// Preferences OK → the captured media selection applies back to
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's /// Stage 2 and reaches the recorded ticket writes alongside the
/// acceptance test: "captured options replayed in argv"). /// mandatory colour keys (issue 17's acceptance test, ported to
func testCapturedOptionsReplayedInLpArgv() throws { /// 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_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"InputSlot=Rear MediaType=PhotographicGlossy" "InputSlot=Rear MediaType=PhotographicGlossy"
@@ -221,20 +225,17 @@ final class Milestone3UITests: XCTestCase {
.contains("Settings captured")) .contains("Settings captured"))
app.buttons["btnPrintAll"].click() app.buttons["btnPrintAll"].click()
let argv = waitForLpLine() let log = waitForSpoolLine()
XCTAssertTrue(argv.contains( XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(argv.contains( XCTAssertTrue(log.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
// Detected bypass for the mock queue (EPIJ_CMat present in // Detected bypass for the mock queue (EPIJ_CMat present in
// lpoptions -l) is appended when not captured. // lpoptions -l) is always written.
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
XCTAssertTrue(argv.contains("orientation-requested=3"), argv) XCTAssertTrue(log.contains("orientation-requested=3"), log)
// Last token is the TIFF. XCTAssertTrue(log.contains("page=page1.tif"), log)
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
.hasSuffix("page1.tif"), argv)
} }
/// Per-page print uses the same spool path (btnPrintPage-N). /// Per-page print uses the same spool path (btnPrintPage-N).
@@ -262,14 +263,16 @@ final class Milestone3UITests: XCTestCase {
return return
} }
printPage.click() printPage.click()
let argv = waitForLpLine() let log = waitForSpoolLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log)
XCTAssertTrue(argv.contains("page1.tif"), argv) XCTAssertTrue(log.contains("page=page1.tif"), log)
} }
/// lp failure surfaces in the in-panel notice, not the wizard banner. /// A spool failure surfaces in the in-panel notice, not the
func testLpFailureShowsPrintNotice() throws { /// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" /// spooler throw `TargetSpoolError.operationFailed`).
func testSpoolFailureShowsPrintNotice() throws {
app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1"
launchApp() launchApp()
reachPrintPanel() reachPrintPanel()
_ = waitFor("printerStatusBadge") _ = waitFor("printerStatusBadge")
@@ -290,7 +293,7 @@ final class Milestone3UITests: XCTestCase {
_ = waitFor("printerStatusBadge") _ = waitFor("printerStatusBadge")
app.buttons["btnPrintAll"].click() app.buttons["btnPrintAll"].click()
_ = waitForLpLine() _ = waitForSpoolLine()
let stateURL = testRoot let stateURL = testRoot
.appendingPathComponent("AppData") .appendingPathComponent("AppData")
.appendingPathComponent("wizard_state.json") .appendingPathComponent("wizard_state.json")