From 7c1303ac111257bc37525d6d09b14599dcd02b87 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:08:37 +0100 Subject: [PATCH] CUPS printer enumeration & capabilities (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Printer/PrinterStatus/PrinterTray/PrinterPaperSize/PrinterMediaType/ PrinterCapabilities/PrintOptions/PrintPropertiesResult models (docs/10 shared print types). - CupsParsers: lpstat -e/-p/-d, lpoptions -p (quoted printer-info), lpoptions -l Key/Label listings, PPD *Key id/Human: enrichment with locale-qualified key support. - detectMediaTypeKey (CNIJMediaType > EPIJ_Medi > StpMediaType > MediaType) and detectDriverColorBypass (Canon Intent2/Intent, Epson CCor/CMat, Gutenprint, generic, EpsonColorMode) — docs/11 layer 4. - CupsService over ProcessManager.runCaptured with injectable binaryDir/ppdDir; empty lpstat output is success. - ProcessID entries for lpstat/lpoptions/lp. Core tests: 8 parser suites on recorded Epson/Canon fixtures. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Print/CupsParsers.swift | 243 ++++++++++++++++++ .../ICCeryCore/Print/CupsService.swift | 167 ++++++++++++ .../ICCeryCore/Print/PrinterModels.swift | 136 ++++++++++ .../ICCeryCore/Process/ProcessID.swift | 6 + Tests/ICCeryCoreTests/CupsParserTests.swift | 149 +++++++++++ 5 files changed, 701 insertions(+) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift create mode 100644 Tests/ICCeryCoreTests/CupsParserTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift new file mode 100644 index 0000000..2faf6d4 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -0,0 +1,243 @@ +import Foundation + +/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`. +public struct CupsOptionListing: Equatable, Sendable { + /// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`. + public var key: String + /// Human label after `/`, e.g. `Media Source`. + public var label: String + /// All choices, `*` stripped. + public var choices: [String] + /// The `*`-prefixed default choice, if any. + public var defaultChoice: String? + + public init(key: String, label: String, choices: [String], defaultChoice: String?) { + self.key = key + self.label = label + self.choices = choices + self.defaultChoice = defaultChoice + } +} + +/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12, +/// docs/10–11). Recorded fixtures drive the tests — no live CUPS. +public enum CupsParsers { + + // MARK: - lpstat + + /// `lpstat -e` — one CUPS destination name per line. + public static func lpstatDestinations(_ output: String) -> [String] { + output.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// `lpstat -p` — `printer NAME is idle. enabled since …`, + /// `printer NAME now printing NAME-1. …`, `printer NAME disabled + /// since …` → queue → status. + public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] { + var result: [String: PrinterStatus] = [:] + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard text.hasPrefix("printer ") else { continue } + let rest = text.dropFirst("printer ".count) + guard let sep = rest.firstIndex(of: " ") else { continue } + let name = String(rest[.. String? { + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard let colon = text.firstIndex(of: ":") else { continue } + let name = text[text.index(after: colon)...] + .trimmingCharacters(in: .whitespaces) + if text.lowercased().hasPrefix("system default destination"), + !name.isEmpty { + return name + } + } + return nil + } + + // MARK: - lpoptions -p + + /// `lpoptions -p` — `key=value` pairs, values may be + /// single-quoted (`printer-info='EPSON XP-55 Series'`); bare + /// flags (`printer-location`) parse as present-with-empty-value. + public static func lpoptions(_ output: String) -> [(key: String, value: String)] { + var pairs: [(String, String)] = [] + var index = output.startIndex + while index < output.endIndex { + while index < output.endIndex && output[index].isWhitespace { + index = output.index(after: index) + } + guard index < output.endIndex else { break } + let tokenStart = index + while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace { + index = output.index(after: index) + } + let key = String(output[tokenStart.. String? { + guard let value = lpoptions(output) + .first(where: { $0.key == "printer-info" })?.value, + !value.isEmpty + else { return nil } + return value + } + + // MARK: - lpoptions -l + + /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. + /// A missing `/` label reuses the key. + public static func lpoptionsList(_ output: String) -> [CupsOptionListing] { + output.split(separator: "\n").compactMap { raw in + let line = raw.trimmingCharacters(in: .whitespaces) + guard let colon = line.firstIndex(of: ":") else { return nil } + let head = String(line[.. 1 + ? headParts[1].trimmingCharacters(in: .whitespaces) + : key + var choices: [String] = [] + var defaultChoice: String? + for token in body.split(separator: " ") { + if token.hasPrefix("*") { + let value = String(token.dropFirst()) + defaultChoice = value + choices.append(value) + } else { + choices.append(String(token)) + } + } + return CupsOptionListing( + key: key, label: label, + choices: choices, defaultChoice: defaultChoice) + } + } + + // MARK: - PPD enrichment + + /// PPD `* /:` lines → `id → label` map. + /// Language-qualified forms (`*en_US. id/Label:`) also match. + public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] { + var map: [String: String] = [:] + for rawLine in ppd.split(separator: "\n") { + var line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue } + line = String(line.dropFirst()) + // Optional locale qualifier: `en_US.InputSlot` → `InputSlot`. + // Only strip when the part before the first `.` looks like + // a locale (short `xx`/`xx_YY`); real keys containing dots + // are left alone. + if let dot = line.firstIndex(of: ".") { + let prefix = line[../` — human label after the last `/`. + guard let slash = rest.firstIndex(of: "/") else { continue } + let id = String(rest[..=`. + public static let mediaTypeKeys = [ + "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType" + ] + + public static func detectMediaTypeKey(optionKeys: Set) -> String? { + mediaTypeKeys.first { optionKeys.contains($0) } + } + + /// Driver "no colour adjustment" key=value for `lpoptions -l` keys + /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; + /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; + /// Gutenprint `StpColorCorrection=Uncorrected`; generic + /// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`. + public static func detectDriverColorBypass( + optionKeys: Set + ) -> (key: String, value: String)? { + if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") } + if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") } + if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") } + if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") } + if optionKeys.contains("StpColorCorrection") { + return ("StpColorCorrection", "Uncorrected") + } + if optionKeys.contains("ColorCorrection") { + return ("ColorCorrection", "Uncorrected") + } + if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") } + return nil + } + + /// The key=value pairs of colour-bypass keys — used to detect + /// whether captured options already carry a bypass. + public static let bypassKeys: Set = [ + "CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor", + "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", + "EpsonColorMode", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift new file mode 100644 index 0000000..f21d99f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -0,0 +1,167 @@ +import Foundation + +/// Errors from CUPS tool invocations. +public enum CupsError: LocalizedError, Equatable { + case toolFailed(tool: String, code: Int32, stderr: String) + case tiffMissing(String) + + public var errorDescription: String? { + switch self { + case .toolFailed(let tool, let code, let stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty + ? "\(tool) failed with exit code \(code)" + : "\(tool) failed (\(code)): \(detail)" + case .tiffMissing(let path): + return "Target TIFF does not exist: \(path)" + } + } +} + +/// CUPS command surface (issue 12): enumerates queues and reads +/// per-queue capabilities via `/usr/bin/lpstat` and +/// `/usr/bin/lpoptions`. Spawning goes through +/// `ProcessManager.runCaptured` so spawns are logged, get killAll +/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are +/// injectable so tests use fixture scripts and never touch real CUPS. +public struct CupsService: Sendable { + public let processManager: ProcessManager + /// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in + /// production, a fixture dir under test. + public let binaryDir: URL + /// `/etc/cups/ppd` in production. + public let ppdDir: URL + + public init( + processManager: ProcessManager = .shared, + binaryDir: URL = URL(fileURLWithPath: "/usr/bin"), + ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd") + ) { + self.processManager = processManager + self.binaryDir = binaryDir + self.ppdDir = ppdDir + } + + // MARK: - Enumeration (lpstat -e/-p/-d) + + /// All CUPS destinations with status and default flag. An empty + /// list is a valid result, not an error. + public func listPrinters() async throws -> [Printer] { + // lpstat exits non-zero when no destinations exist — an empty + // queue list is a valid result, not a failure (issue 12). + let destinationsOut = try await run( + "lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true) + let statusOut = try await run( + "lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true) + let defaultOut = try await run( + "lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true) + + let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) + let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) + let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) + + var printers: [Printer] = [] + for name in names { + let displayName = try? await displayName(for: name) + printers.append(Printer( + name: name, + status: statuses[name] ?? .unknown, + isDefault: name == defaultName, + displayName: displayName + )) + } + return printers + } + + /// `lpoptions -p ` → `printer-info` (the NSPrinter fallback + /// display name, docs/11 §binding). + public func displayName(for queue: String) async throws -> String? { + let result = try await run( + "lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) + return CupsParsers.lpoptionsDisplayName(result.stdout) + } + + // MARK: - Capabilities (lpoptions -l + PPD) + + /// Raw `Key/Label: choices` listings for a queue — also the input + /// to media-key and colour-bypass detection (docs/11 layer ④). + public func optionListings(for queue: String) async throws -> [CupsOptionListing] { + let result = try await run( + "lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l")) + return CupsParsers.lpoptionsList(result.stdout) + } + + /// Trays / paper sizes / media types for a queue, with PPD + /// `*Key id/Human:` enrichment when the queue's PPD is readable. + public func capabilities(for queue: String) async throws -> PrinterCapabilities { + let listings = try await optionListings(for: queue) + return capabilities(from: listings, ppd: loadPPD(for: queue)) + } + + /// Pure mapping — extracted so fixture tests need no process. + public func capabilities( + from listings: [CupsOptionListing], + ppd: String? + ) -> PrinterCapabilities { + var trays: [PrinterTray] = [] + var sizes: [PrinterPaperSize] = [] + var media: [PrinterMediaType] = [] + + for listing in listings { + switch listing.key { + case "InputSlot", "MediaSource": + trays = listing.choices.enumerated().map { + PrinterTray(id: $0.offset + 1, name: $0.element) + } + case "PageSize", "MediaSize": + sizes = listing.choices.enumerated().map { + PrinterPaperSize(id: $0.offset + 1, name: $0.element) + } + case let key where CupsParsers.mediaTypeKeys.contains(key): + guard media.isEmpty else { continue } + let labels = ppd.map { + CupsParsers.ppdChoiceLabels($0, key: key) + } ?? [:] + media = listing.choices.map { + PrinterMediaType(id: $0, name: labels[$0] ?? $0) + } + default: + continue + } + } + return PrinterCapabilities( + trays: trays, paperSizes: sizes, mediaTypes: media) + } + + /// The set of option keys a queue advertises — input to + /// `detectDriverColorBypass` / `detectMediaTypeKey`. + public func optionKeys(for queue: String) async throws -> Set { + Set(try await optionListings(for: queue).map(\.key)) + } + + // MARK: - PPD + + private func loadPPD(for queue: String) -> String? { + let url = ppdDir.appendingPathComponent("\(queue).ppd") + return try? String(contentsOf: url, encoding: .utf8) + } + + // MARK: - Spawn + + @discardableResult + func run( + _ tool: String, + _ arguments: [String], + id: String, + tolerateFailure: Bool = false + ) async throws -> CapturedResult { + let binary = binaryDir.appendingPathComponent(tool) + let result = try await processManager.runCaptured( + id: id, binary: binary, arguments: arguments) + if result.exitCode != 0, !tolerateFailure { + throw CupsError.toolFailed( + tool: tool, code: result.exitCode, stderr: result.stderr) + } + return result + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift new file mode 100644 index 0000000..2b95f8d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Queue status reported by `lpstat -p` (docs/10 §Printer). +public enum PrinterStatus: String, Codable, Sendable, CaseIterable { + case idle = "Idle" + case printing = "Printing" + case stopped = "Stopped" + case unknown = "Unknown" +} + +/// A CUPS destination. `name` is the queue id sent back to every +/// subsequent print command; `displayName` is the human label from +/// `printer-info` (used as the `NSPrinter` fallback when PM binding +/// fails — #188). +public struct Printer: Codable, Equatable, Sendable { + public var name: String + public var status: PrinterStatus + public var isDefault: Bool + public var displayName: String? + + public init( + name: String, + status: PrinterStatus = .unknown, + isDefault: Bool = false, + displayName: String? = nil + ) { + self.name = name + self.status = status + self.isDefault = isDefault + self.displayName = displayName + } +} + +/// Paper source. `id` is the 1-based index of the `InputSlot` / +/// `MediaSource` choice (not a PPD code) — docs/10. +public struct PrinterTray: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media size from `PageSize` / `MediaSize` choices (1-based index). +public struct PrinterPaperSize: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media type: `id` is the PPD machine token, `name` the human label +/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human). +public struct PrinterMediaType: Codable, Equatable, Sendable { + public var id: String + public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + +public struct PrinterCapabilities: Codable, Equatable, Sendable { + public var trays: [PrinterTray] + public var paperSizes: [PrinterPaperSize] + public var mediaTypes: [PrinterMediaType] + /// Always `true` on macOS (spec parity — CUPS honours + /// `orientation-requested`). + public var supportsOrientation: Bool + + public init( + trays: [PrinterTray] = [], + paperSizes: [PrinterPaperSize] = [], + mediaTypes: [PrinterMediaType] = [], + supportsOrientation: Bool = true + ) { + self.trays = trays + self.paperSizes = paperSizes + self.mediaTypes = mediaTypes + self.supportsOrientation = supportsOrientation + } +} + +/// Options carried into `lp` (docs/10 §PrintOptions). On macOS +/// `paperSource` is ignored unless already present inside captured +/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets +/// it on OK) but never gates the argv — macOS always bypasses driver +/// colour management. +public struct PrintOptions: Codable, Equatable, Sendable { + public var paperSource: Int? + /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. + public var orientation: String? + /// printtarg layout page size → `PageSize=` (skipped if captured). + public var paperSize: String? + public var mediaType: String? + public var ppdUncorrectedPassthrough: Bool? + /// Space-separated `key=value` captured from + /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥). + public var cupsOptions: String? + + public init( + paperSource: Int? = nil, + orientation: String? = nil, + paperSize: String? = nil, + mediaType: String? = nil, + ppdUncorrectedPassthrough: Bool? = nil, + cupsOptions: String? = nil + ) { + self.paperSource = paperSource + self.orientation = orientation + self.paperSize = paperSize + self.mediaType = mediaType + self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough + self.cupsOptions = cupsOptions + } +} + +/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult). +/// `nil` from the service means the user cancelled — never an error. +public struct PrintPropertiesResult: Codable, Equatable, Sendable { + /// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil` + /// when the `NSPrinter` fallback ran. + public var selectedPrinter: String? + public var options: PrintOptions + + public init(selectedPrinter: String?, options: PrintOptions) { + self.selectedPrinter = selectedPrinter + self.options = options + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 8377a51..80d6242 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -14,4 +14,10 @@ public enum ProcessID { public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" } public static func printcal(_ stem: String) -> String { "printcal_\(stem)" } public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } + + /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not + /// streaming Argyll children. + public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } + public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } + public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift new file mode 100644 index 0000000..f2d42fc --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -0,0 +1,149 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Issue 12 — CUPS enumeration parsers on recorded fixtures +/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here. +@Suite("CupsParsers") +struct CupsParsersTests { + + // Recorded on an Epson XP-55 + Canon Pro9500 host. + private let lpstatE = """ + Canon_Pro9500_II_series_XPS + Epson_XP_55_LPD + EPSON_XP_55_Series + """ + + private let lpstatP = """ + printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026 + printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026 + printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 - + Paused + """ + + private let lpoptionsP = """ + device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340 + """ + + private let lpoptionsL = """ + PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT + InputSlot/Media Source: Auto *Main Photo Rear + MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope + ColorModel/Output Mode: *RGB Gray + Duplex/Duplex: *None DuplexNoTumble DuplexTumble + cupsPrintQuality/cupsPrintQuality: Draft *Normal High + """ + + @Test("lpstat -e: one destination per line; empty = success") + func destinations() { + #expect(CupsParsers.lpstatDestinations(lpstatE) == [ + "Canon_Pro9500_II_series_XPS", + "Epson_XP_55_LPD", + "EPSON_XP_55_Series", + ]) + #expect(CupsParsers.lpstatDestinations("") == []) + } + + @Test("lpstat -p: idle / now-printing / disabled statuses") + func statuses() { + let s = CupsParsers.lpstatStatuses(lpstatP) + #expect(s["Canon_Pro9500_II_series_XPS"] == .idle) + #expect(s["Epson_XP_55_LPD"] == .printing) + #expect(s["EPSON_XP_55_Series"] == .stopped) + } + + @Test("lpstat -d: default destination or none") + func defaultDestination() { + #expect(CupsParsers.lpstatDefault( + "system default destination: Canon_Pro9500_II_series_XPS\n") + == "Canon_Pro9500_II_series_XPS") + #expect(CupsParsers.lpstatDefault("no system default destination\n") == nil) + } + + @Test("lpoptions -p: quoted printer-info, bare flags ignored") + func displayName() { + #expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") + #expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil) + } + + @Test("lpoptions -l: key/label split, * marks the default") + func optionListings() { + let listings = CupsParsers.lpoptionsList(lpoptionsL) + #expect(listings.count == 6) + + let page = listings[0] + #expect(page.key == "PageSize") + #expect(page.label == "Media Size") + #expect(page.defaultChoice == "A4") + #expect(page.choices.contains("Custom.WIDTHxHEIGHT")) + #expect(!page.choices.contains("*A4")) + + let slot = listings[1] + #expect(slot.key == "InputSlot") + #expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) + #expect(slot.defaultChoice == "Main") + } + + @Test("capabilities: trays/sizes index 1-based, media uses detected key") + func capabilities() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(lpoptionsL) + let caps = service.capabilities(from: listings, ppd: nil) + + #expect(caps.trays == [ + PrinterTray(id: 1, name: "Auto"), + PrinterTray(id: 2, name: "Main"), + PrinterTray(id: 3, name: "Photo"), + PrinterTray(id: 4, name: "Rear"), + ]) + #expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) + #expect(caps.paperSizes.count == 10) + #expect(caps.mediaTypes.map(\.id) == [ + "Stationery", "PhotographicHighGloss", "Photographic", + "PhotographicMatte", "Envelope", + ]) + #expect(caps.supportsOrientation) + } + + @Test("PPD enrichment maps id → human label") + func ppdLabels() { + let ppd = """ + *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<>" + *CNIJMediaType 0/Plain Paper: "" + *en_US.CNIJMediaType 13/Envelope: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + #expect(labels["42"] == "Photo Paper Plus Semi-gloss") + #expect(labels["0"] == "Plain Paper") + #expect(labels["13"] == "Envelope") + } + + @Test("detectMediaTypeKey prefers vendor keys in order") + func mediaTypeKey() { + #expect(CupsParsers.detectMediaTypeKey( + optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") + #expect(CupsParsers.detectMediaTypeKey( + optionKeys: ["PageSize", "MediaType"]) == "MediaType") + #expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil) + } + + @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") + func driverBypass() { + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["CNIJIntent2", "CNIJIntent"]) + == ("CNIJIntent2", "4")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"]) + == ("CNIJIntent", "4")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"]) + == ("EPIJ_CMat", "3")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil) + } +} -- 2.39.5