diff --git a/CLAUDE.md b/CLAUDE.md index 4b07e0a..22112d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,10 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored). from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and - value; otherwise the action is unavailable with the reason. `cupsadmin ppdreport ` shows a - driver's keywords and which actions its profile enables. + value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives + the reason an action can't be done with that driver (e.g. Canon department IDs). Use Letter Paper is + titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport + ` shows a driver's keywords and which actions its profile enables. ## Build and test ``` @@ -67,7 +69,8 @@ swift build # debug build of everything - Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=`, `CUPSKIT_LIVE_JOBS=,`, `CUPSKIT_LIVE_QUICK=,`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from - `/Library/Printers/PPDs` and skip when they aren't installed. + `CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not + installed) and `/Library/Printers/PPDs`, and skip when they aren't there. ## Conventions - **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray). diff --git a/README.md b/README.md index 0ffaa5a..cf4c363 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ cupsadmin options # PPD options and IPP *-default values, like lp cupsadmin add -v [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...] cupsadmin set -o k=v ... # change queue defaults, then read them back and report cupsadmin quick [value] # task-named shortcuts, see below -cupsadmin ppdreport # every PPD group and option: keyword, label, type, default, choices +cupsadmin ppdreport # every PPD group and option: keyword, label, type, default, choices ``` Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly. @@ -50,7 +50,7 @@ cupsadmin quick bw # always print black & white cupsadmin quick usercode 12345 # set a Ricoh user code (enables it too) cupsadmin quick duplex # default to two-sided cupsadmin quick simplex -cupsadmin quick letter # Letter paper + fit to nearest size (no "prompt user" at the printer) +cupsadmin quick letter # Letter paper; also fit to nearest size where the driver can (Ricoh) cupsadmin quick default # make this the server default printer ``` @@ -65,13 +65,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re | Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) | | Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) | | Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) | -| Canon, HP, Xerox, Konica Minolta user/department codes | no built-in profile — add one (below) | +| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD | +| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option | +| Xerox, Konica Minolta and others | generic only — add a profile (below) | ### Adding your printer's driver -1. `cupsadmin ppdreport ` lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values. +1. `cupsadmin ppdreport ` (or `ppdreport ` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values. 2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report). -3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`. +3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. Keep your profile above `generic`. 4. `swift build && .build/debug/cupsadmin ppdreport ` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request. ## How it works diff --git a/Sources/CUPSAdminApp/ContentView.swift b/Sources/CUPSAdminApp/ContentView.swift index 4033736..334a748 100644 --- a/Sources/CUPSAdminApp/ContentView.swift +++ b/Sources/CUPSAdminApp/ContentView.swift @@ -300,7 +300,7 @@ struct QuickActionItems: View { ForEach(QuickAction.all) { action in let reason = store.unavailableReason(action, queue: queue) Button { choose(action) } label: { - Label(action.title, systemImage: action.systemImage) + Label(action.title(in: store.drivers[queue]), systemImage: action.systemImage) } .disabled(reason != nil) .help(reason ?? action.summary) diff --git a/Sources/CUPSAdminApp/QuickActionSheet.swift b/Sources/CUPSAdminApp/QuickActionSheet.swift index 469a171..4c21205 100644 --- a/Sources/CUPSAdminApp/QuickActionSheet.swift +++ b/Sources/CUPSAdminApp/QuickActionSheet.swift @@ -45,6 +45,9 @@ final class QuickActionModel: Identifiable { var currentCode: String? { action.currentCode(context) } + /// "Use Letter Paper" or "Use Letter Paper, Fit to Nearest Size", depending on the driver. + var title: String { action.title(in: state == nil ? nil : context).replacingOccurrences(of: "…", with: "") } + var validationError: String? { action.validationError(code, context: context) } /// What will be written, or why the action can't run here. @@ -85,7 +88,7 @@ final class QuickActionModel: Identifiable { readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) }) state = try? await OptionState.load(client: store.client, queue: queue) let seconds = elapsedText(since: started) - let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "") + let title = clearing ? "Cleared user code" : self.title if outcome.succeeded { resultMessage = nil store.report("\(title) on \(queue) in \(seconds)") @@ -99,7 +102,7 @@ final class QuickActionModel: Identifiable { phase = .finished(succeeded: false) } catch { resultMessage = PrinterStore.explain(String(describing: error)) - store.report("ERROR: \(action.title) on \(queue): \(error)") + store.report("ERROR: \(title) on \(queue): \(error)") phase = .finished(succeeded: false) } store.noteChange(queue: queue) @@ -114,7 +117,7 @@ struct QuickActionSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { - Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage) + Label(model.title, systemImage: model.action.systemImage) .font(.title3.weight(.semibold)) Text("\(model.queue) · saved as the queue default for every user on this Mac") .foregroundStyle(.secondary) diff --git a/Sources/CupsKit/DriverProfiles.swift b/Sources/CupsKit/DriverProfiles.swift index 64c0443..430fd50 100644 --- a/Sources/CupsKit/DriverProfiles.swift +++ b/Sources/CupsKit/DriverProfiles.swift @@ -52,8 +52,10 @@ public struct DriverProfile: Decodable, Identifiable { public let match: Match /// Action id -> alternatives, each a list of `keyword=value` pairs applied together. public let actions: [String: [[String]]] + /// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD). + public let unavailable: [String: String] - enum CodingKeys: String, CodingKey { case id, name, match, actions } + enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -65,6 +67,7 @@ public struct DriverProfile: Decodable, Identifiable { let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions) for (action, pairs) in raw { actions[action] = pairs.alternatives } self.actions = actions + unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:] } private struct FlexiblePairs: Decodable { diff --git a/Sources/CupsKit/QuickActions.swift b/Sources/CupsKit/QuickActions.swift index 7d151aa..f4a03af 100644 --- a/Sources/CupsKit/QuickActions.swift +++ b/Sources/CupsKit/QuickActions.swift @@ -30,8 +30,8 @@ public struct QuickAction: Identifiable, Hashable { summary: "Two-sided, long edge"), QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, summary: "One-sided"), - QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right", - input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"), + QuickAction(id: "letter", title: "Use Letter Paper", systemImage: "arrow.up.left.and.arrow.down.right", + input: .none, summary: "Letter paper, and fit to the nearest size where the driver has that option (never prompt at the printer)"), QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, summary: "Server default printer (lpadmin -d)"), ] @@ -40,12 +40,25 @@ public struct QuickAction: Identifiable, Hashable { all.first { $0.id == id } } + /// The title for a queue: Use Letter Paper gains ", Fit to Nearest Size" only when its driver + /// profile also sets a fit-to-paper option (e.g. Ricoh RIPaperPolicy), not just the page size. + public func title(in context: DriverContext?) -> String { + guard id == "letter", let context, case .success(let resolved) = resolve(context), + resolved.changes.contains(where: { !Self.paperSizeKeys.contains($0.key) }) else { return title } + return title + ", Fit to Nearest Size" + } + + static let paperSizeKeys: Set = ["PageSize", "media-default"] + /// The profile and option changes this action would write on a queue, or why it can't. public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false, profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result { let key = clearing ? id + "-clear" : id var firstMissing: String? for profile in DriverProfiles.matching(context, in: profiles) { + if profile.actions[key] == nil, let reason = profile.unavailable[id] { + return .failure(QuickActionUnavailable(reason: reason)) + } guard let alternatives = profile.actions[key] else { continue } for pairs in alternatives { let parsed = pairs.compactMap(Self.parse) diff --git a/Sources/CupsKit/Resources/driver-profiles.json b/Sources/CupsKit/Resources/driver-profiles.json index 3d1b5da..541f261 100644 --- a/Sources/CupsKit/Resources/driver-profiles.json +++ b/Sources/CupsKit/Resources/driver-profiles.json @@ -1,5 +1,5 @@ { - "comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport ", + "comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport ", "profiles": [ { "id": "ricoh-m-c251fw", @@ -27,6 +27,33 @@ "letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"] } }, + { + "id": "canon", + "name": "Canon UFR II / LIPSLX / CARPS2", + "match": { "manufacturer": "^Canon$" }, + "actions": { + "color": ["CNColorMode=color"], + "bw": ["CNColorMode=mono"], + "duplex": ["CNDuplex=DuplexFront"], + "simplex": ["CNDuplex=None"], + "letter": ["PageSize=Letter"] + }, + "unavailable": { + "usercode": "Canon department IDs are set by the Canon driver, not the PPD" + } + }, + { + "id": "hp", + "name": "HP", + "match": { "manufacturer": "^HP$" }, + "actions": { + "color": [["HPColorAsGray=False"], ["HPColorMode=colorsmart"]], + "bw": [["HPColorAsGray=True"], ["HPColorMode=grayscale"]] + }, + "unavailable": { + "usercode": "HP drivers have no accounting code option" + } + }, { "id": "generic", "name": "Generic (standard PPD keywords, IPP Everywhere)", diff --git a/Tests/CupsKitTests/QuickActionTests.swift b/Tests/CupsKitTests/QuickActionTests.swift index ffb52f1..89e85aa 100644 --- a/Tests/CupsKitTests/QuickActionTests.swift +++ b/Tests/CupsKitTests/QuickActionTests.swift @@ -18,12 +18,13 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? { @Suite struct DriverProfileFile { @Test func builtInProfilesAreWellFormed() { let profiles = DriverProfiles.builtIn - #expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"]) + #expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"]) #expect(profiles.last?.isGeneric == true) #expect(profiles.dropLast().allSatisfy { !$0.isGeneric }) let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"]) for profile in profiles { #expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())") + #expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())") for alternatives in profile.actions.values { #expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } }) } @@ -45,6 +46,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? { let letter = try QuickAction.named("letter")!.resolve(c).get().changes #expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false), OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)]) + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper, Fit to Nearest Size") let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes #expect(code.map(\.value) == ["True", "Custom.4321"]) let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes @@ -76,6 +78,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? { // No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does. #expect(profileID("letter", c) == "generic") #expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)]) + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper") } @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) diff --git a/Tests/CupsKitTests/VendorProfileTests.swift b/Tests/CupsKitTests/VendorProfileTests.swift new file mode 100644 index 0000000..54d0807 --- /dev/null +++ b/Tests/CupsKitTests/VendorProfileTests.swift @@ -0,0 +1,148 @@ +@testable import CupsKit +import Foundation +import Testing + +/// Canon and HP profile tests read the vendors' gzipped PPDs, read-only. They look in CUPSKIT_PPD_DIRS +/// (colon-separated, e.g. PPDs extracted with `pkgutil --expand-full` from a driver package you haven't +/// installed) and then /Library/Printers/PPDs; each test is skipped when its PPDs aren't found. +enum ExtraPPDs { + static let directories: [String] = + (ProcessInfo.processInfo.environment["CUPSKIT_PPD_DIRS"] ?? "").split(separator: ":").map(String.init) + [vendorPPDs] + + static func path(_ name: String) -> String? { + directories.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.fileExists(atPath: $0) } + } + + static func files(prefix: String) -> [String] { + var seen: Set = [] + return directories.flatMap { dir in + ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []) + .filter { $0.hasPrefix(prefix) && seen.insert($0).inserted } + .sorted() + .map { (dir as NSString).appendingPathComponent($0) } + } + } + + static func context(path: String) -> DriverContext? { + (try? PPD.text(contentsOfFile: path)).map { DriverContext(ppd: PPD(text: $0), attributes: nil) } + } + + static func context(_ name: String) -> DriverContext? { path(name).flatMap(context(path:)) } +} + +private func resolved(_ id: String, _ c: DriverContext) -> ResolvedQuickAction? { + try? QuickAction.named(id)!.resolve(c, value: "0").get() +} + +private func pairs(_ id: String, _ c: DriverContext) -> [String]? { + resolved(id, c)?.changes.map { "\($0.key)=\($0.value)" } +} + +private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String) -> Bool { + c.ppd?.option(keyword)?.choices.contains(choice) == true +} + +@Suite struct CanonProfile { + static let adv = "CNPZUIRAC3530IIIZU.ppd.gz" + + @Test(.enabled(if: ExtraPPDs.path(adv) != nil)) + func imageRunnerAdvance() throws { + let c = try #require(ExtraPPDs.context(Self.adv)) + #expect(DriverProfiles.matching(c).map(\.id) == ["canon", "generic"]) + #expect(pairs("color", c) == ["CNColorMode=color"]) + #expect(pairs("bw", c) == ["CNColorMode=mono"]) + #expect(pairs("duplex", c) == ["CNDuplex=DuplexFront"]) + #expect(pairs("simplex", c) == ["CNDuplex=None"]) + #expect(pairs("letter", c) == ["PageSize=Letter"]) + for id in ["color", "bw", "duplex", "simplex", "letter"] { #expect(resolved(id, c)?.profile.id == "canon", "\(id)") } + // No fit-to-paper option in Canon's PPDs: Letter sets the page size only, and the title says so. + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD") + #expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, clearing: true).get() } + } + + /// Every Canon PPD in the package: an action is offered exactly when the PPD has its keyword and choice. + @Test(.enabled(if: !ExtraPPDs.files(prefix: "CNPZU").isEmpty)) + func everyCanonPPD() throws { + let files = ExtraPPDs.files(prefix: "CNPZU") + var mono = 0 + for file in files { + let c = try #require(ExtraPPDs.context(path: file), "\(file)") + let name = (file as NSString).lastPathComponent + #expect(DriverProfiles.matching(c).first?.id == "canon", "\(name)") + #expect(QuickAction.named("color")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "color"), "\(name)") + #expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "mono"), "\(name)") + #expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "DuplexFront"), "\(name)") + #expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "None"), "\(name)") + #expect(pairs("letter", c) == ["PageSize=Letter"], "\(name)") + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD") + if !hasChoice(c, "CNColorMode", "color") { mono += 1 } + } + #expect(files.count - mono > 0 && mono > 0) + } +} + +@Suite struct HPProfile { + static let colorLaser = "HP Color LaserJet M750.gz" + static let officejet = "HP Officejet Pro 8600.ppd.gz" + static let monoLaser = "HP LaserJet 600 M601 M602 M603.gz" + static let reason = "HP drivers have no accounting code option" + + @Test(.enabled(if: ExtraPPDs.path(colorLaser) != nil)) + func colorLaserJetEnterprise() throws { + let c = try #require(ExtraPPDs.context(Self.colorLaser)) + #expect(DriverProfiles.matching(c).map(\.id) == ["hp", "generic"]) + #expect(pairs("color", c) == ["HPColorAsGray=False"]) + #expect(pairs("bw", c) == ["HPColorAsGray=True"]) + #expect(resolved("color", c)?.profile.id == "hp") + #expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"]) + #expect(pairs("simplex", c) == ["Duplex=None"]) + #expect(resolved("duplex", c)?.profile.id == "generic") + #expect(pairs("letter", c) == ["PageSize=Letter"]) + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason) + } + + @Test(.enabled(if: ExtraPPDs.path(officejet) != nil)) + func officejetProUsesHPColorMode() throws { + let c = try #require(ExtraPPDs.context(Self.officejet)) + #expect(pairs("color", c) == ["HPColorMode=colorsmart"]) + #expect(pairs("bw", c) == ["HPColorMode=grayscale"]) + #expect(resolved("bw", c)?.profile.id == "hp") + #expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"]) + #expect(pairs("letter", c) == ["PageSize=Letter"]) + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason) + } + + @Test(.enabled(if: ExtraPPDs.path(monoLaser) != nil)) + func monoLaserJet() throws { + let c = try #require(ExtraPPDs.context(Self.monoLaser)) + #expect(!QuickAction.named("color")!.isAvailable(in: c) && !QuickAction.named("bw")!.isAvailable(in: c)) + #expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"]) + #expect(pairs("letter", c) == ["PageSize=Letter"]) + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason) + } + + /// Every HP PPD: color and black & white come from the HP profile whenever the PPD has HPColorAsGray or HPColorMode. + @Test(.enabled(if: ExtraPPDs.files(prefix: "HP ").count > 100)) + func everyHPPPD() throws { + var hpColor = 0 + for file in ExtraPPDs.files(prefix: "HP ") { + let c = try #require(ExtraPPDs.context(path: file), "\(file)") + let name = (file as NSString).lastPathComponent + guard c.manufacturer == "HP" else { continue } + #expect(DriverProfiles.matching(c).first?.id == "hp", "\(name)") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)") + if hasChoice(c, "HPColorAsGray", "True") || hasChoice(c, "HPColorMode", "grayscale") { + #expect(resolved("bw", c)?.profile.id == "hp", "\(name)") + hpColor += 1 + } + if hasChoice(c, "HPColorAsGray", "False") || hasChoice(c, "HPColorMode", "colorsmart") { + #expect(resolved("color", c)?.profile.id == "hp", "\(name)") + } + if hasChoice(c, "PageSize", "Letter") { #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)") } + } + #expect(hpColor > 0) + } +}