feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4) #206
@@ -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)" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) // ⑤
|
||||||
|
|||||||
@@ -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(
|
||||||
paperSize: selectedPaperSizeToken,
|
queue: queue,
|
||||||
mediaType: selectedMediaType,
|
displayName: printers.first { $0.name == queue }?.displayName,
|
||||||
quality: selectedQuality,
|
pages: pages.map {
|
||||||
ppdUncorrectedPassthrough: true,
|
TargetPrintPage(
|
||||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
url: $0.fileURL,
|
||||||
try await environment.cupsService.printTarget(
|
expectedWidthMm: $0.page.widthMm,
|
||||||
queue: selectedPrinter,
|
expectedHeightMm: $0.page.heightMm)
|
||||||
tiffPath: page.fileURL.path,
|
},
|
||||||
options: options,
|
jobTitle: "ICCery Target - \(title)",
|
||||||
page: index)
|
ticket: capturedTickets[queue],
|
||||||
wizard.printerName = selectedPrinter
|
overrides: TargetPrintOverrides(
|
||||||
|
paperSize: selectedPaperSizeToken,
|
||||||
|
mediaType: selectedMediaType,
|
||||||
|
qualityKey: printerCaps.qualityKey,
|
||||||
|
quality: selectedQuality,
|
||||||
|
orientation: printOrientation),
|
||||||
|
optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,26 +330,7 @@ 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).
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
printAllRow
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Theme.panel)
|
.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`
|
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||||
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||||
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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,17 +58,10 @@ 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)"
|
try FileManager.default.setAttributes(
|
||||||
exit 0
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
"""
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeWorkflow() -> TargetWorkflowViewModel {
|
private func makeWorkflow() -> TargetWorkflowViewModel {
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user