diff --git a/CLAUDE.md b/CLAUDE.md index 22112d5..9f75512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,8 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored). 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. 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 + the reason an action can't be done with that driver (e.g. Canon department IDs); its `inputs` map relabels + the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). 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. diff --git a/README.md b/README.md index cf4c363..2d7ebc0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The things admins actually do, without knowing the driver's option keywords: cupsadmin quick color # always print in color cupsadmin quick bw # always print black & white cupsadmin quick usercode 12345 # set a Ricoh user code (enables it too) +cupsadmin quick usercode jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID cupsadmin quick duplex # default to two-sided cupsadmin quick simplex cupsadmin quick letter # Letter paper; also fit to nearest size where the driver can (Ricoh) @@ -67,13 +68,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re | Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) | | 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) | +| Xerox (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) | +| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD | +| Others | generic only — add a profile (below) | ### Adding your printer's driver 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, or explain why under `unavailable`. 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`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). 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/QuickActionSheet.swift b/Sources/CUPSAdminApp/QuickActionSheet.swift index 4c21205..290f426 100644 --- a/Sources/CUPSAdminApp/QuickActionSheet.swift +++ b/Sources/CUPSAdminApp/QuickActionSheet.swift @@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable { let action: QuickAction let queue: String var code = "" + /// Optional second value (Xerox account ID), when the driver profile has one. + var secondValue = "" var showCurrentCode = false private(set) var phase: Phase = .loading private(set) var state: OptionState? @@ -50,9 +52,15 @@ final class QuickActionModel: Identifiable { var validationError: String? { action.validationError(code, context: context) } + var inputLabel: String { action.inputLabel(context) } + var secondInputLabel: String? { action.secondInputLabel(context) } + var currentSecondValue: String? { action.currentSecondValue(context) } + var secondValidationError: String? { action.secondValidationError(secondValue, context: context) } + /// What will be written, or why the action can't run here. func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> { - action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes) + action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue, + clearing: clearing).map(\.changes) } /// "Ricoh", "Generic (…)" — which driver profile supplied the keywords. @@ -83,7 +91,9 @@ final class QuickActionModel: Identifiable { phase = .running let started = Date() do { - let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil, + let typed = action.input == .userCode && !clearing + let outcome = try await action.run(queue: queue, value: typed ? code : nil, + secondValue: typed && !secondValue.isEmpty ? secondValue : nil, clearing: clearing, client: store.client) readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) }) state = try? await OptionState.load(client: store.client, queue: queue) @@ -161,7 +171,7 @@ struct QuickActionSheet: View { private var canApply: Bool { guard model.phase == .ready else { return false } if model.action.input == .userCode { - return !model.code.isEmpty && model.validationError == nil + return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil } if case .failure = model.plannedChanges() { return false } return true @@ -170,7 +180,7 @@ struct QuickActionSheet: View { @ViewBuilder private var content: some View { if model.action.input == .userCode { Form { - LabeledContent("Current code") { + LabeledContent("Current \(model.inputLabel.lowercased())") { HStack(spacing: 8) { if let current = model.currentCode { Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count)) @@ -184,22 +194,39 @@ struct QuickActionSheet: View { } } LabeledContent { - TextField("User code", text: $model.code, prompt: Text("Digits")) + TextField(model.inputLabel, text: $model.code, + prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil) .labelsHidden() .textFieldStyle(.roundedBorder) .monospacedDigit() .frame(width: 160) .disabled(model.phase != .ready) } label: { - Text("New code") + Text("New \(model.inputLabel.lowercased())") if let error = model.validationError { Text(error).foregroundStyle(.red) } } + if let secondLabel = model.secondInputLabel { + LabeledContent { + TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) }) + .labelsHidden() + .textFieldStyle(.roundedBorder) + .frame(width: 160) + .disabled(model.phase != .ready) + } label: { + Text(secondLabel) + if let error = model.secondValidationError { + Text(error).foregroundStyle(.red) + } else if model.currentSecondValue != nil { + Text("Current value shown in the field").foregroundStyle(.secondary) + } + } + } } .formStyle(.grouped) .scrollDisabled(true) - .frame(height: 130) + .frame(height: model.secondInputLabel == nil ? 130 : 190) } if model.action.isDefaultPrinter { diff --git a/Sources/CupsKit/DriverProfiles.swift b/Sources/CupsKit/DriverProfiles.swift index 430fd50..09b7e23 100644 --- a/Sources/CupsKit/DriverProfiles.swift +++ b/Sources/CupsKit/DriverProfiles.swift @@ -54,8 +54,25 @@ public struct DriverProfile: Decodable, Identifiable { 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] + /// Action id -> how its typed value is labeled and checked, plus an optional second value. + public let inputs: [String: Input] - enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable } + public struct Input: Decodable { + /// "User ID"; nil keeps the default "User code". + public let label: String? + /// Default true (Ricoh user codes). Xerox user IDs are strings. + public let digitsOnly: Bool? + public let second: SecondInput? + } + + /// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}` + /// and are added only when the user fills it in. + public struct SecondInput: Decodable { + public let label: String + public let pairs: [String] + } + + enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -68,6 +85,7 @@ public struct DriverProfile: Decodable, Identifiable { for (action, pairs) in raw { actions[action] = pairs.alternatives } self.actions = actions unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:] + inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:] } private struct FlexiblePairs: Decodable { diff --git a/Sources/CupsKit/QuickActions.swift b/Sources/CupsKit/QuickActions.swift index f4a03af..dfac9e8 100644 --- a/Sources/CupsKit/QuickActions.swift +++ b/Sources/CupsKit/QuickActions.swift @@ -25,7 +25,7 @@ public struct QuickAction: Identifiable, Hashable { QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none, summary: "Black & white by default"), QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode, - summary: "User/department code sent with every job (--clear to remove)"), + summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"), QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none, summary: "Two-sided, long edge"), QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, @@ -51,7 +51,7 @@ public struct QuickAction: Identifiable, Hashable { 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, + public func resolve(_ context: DriverContext, value: String? = nil, secondValue: String? = nil, clearing: Bool = false, profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result { let key = clearing ? id + "-clear" : id var firstMissing: String? @@ -67,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable { if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" } continue } - let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) } + var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) } + if !clearing, let secondValue, !secondValue.isEmpty { + guard let second = profile.inputs[id]?.second else { + return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two")) + } + for pair in second.pairs.compactMap(Self.parse) { + let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue) + guard context.supports(key: pair.key, value: value) else { + return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))")) + } + changes.append(OptionChange(key: pair.key, value: value, isSecret: false)) + } + } return .success(ResolvedQuickAction(profile: profile, changes: changes)) } } @@ -86,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable { public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil } - /// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid. + /// The profile's input settings for this action on this queue (label, digits, second value). + public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? { + guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil } + return resolved.profile.inputs[id] + } + + /// "User code" (Ricoh) or the profile's label ("User ID" for Xerox). + public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" } + + /// The optional second value's label ("Account ID (optional)"), or nil when the profile has none. + public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label } + + public func digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true } + + /// For Set User Code: digits only unless the profile allows text, within the PPD's limit. nil when valid. public func validationError(_ value: String, context: DriverContext) -> String? { guard input == .userCode, !value.isEmpty else { return nil } - guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } - if let keyword = codeKeyword(context), - let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { - return "At most \(max) digits" + return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context) + } + + /// For the optional second value (text, within the PPD's limit). nil when valid. + public func secondValidationError(_ value: String, context: DriverContext) -> String? { + guard input == .userCode, !value.isEmpty else { return nil } + return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context) + } + + private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? { + if digitsOnly { + guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } + } else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) { + return "No quotes, backslashes or line breaks" + } + if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { + return "At most \(max) \(digitsOnly ? "digits" : "characters")" } return nil } @@ -104,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable { return String(value.dropFirst("Custom.".count)) } + /// The second value currently set on the queue (e.g. the Xerox account ID), without Custom. + public func currentSecondValue(_ context: DriverContext) -> String? { + guard let keyword = secondKeyword(context), + let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil } + return String(value.dropFirst("Custom.".count)) + } + + private func secondKeyword(_ context: DriverContext) -> String? { + inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key + } + /// The option that receives `{value}` for this queue's profile (e.g. RIUserCode). private func codeKeyword(_ context: DriverContext) -> String? { guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil } @@ -139,14 +189,15 @@ public struct QuickActionOutcome { extension QuickAction { /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits. - public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome { + public func run(queue: String, value: String? = nil, secondValue: String? = nil, clearing: Bool = false, + client: CupsClient) async throws -> QuickActionOutcome { if isDefaultPrinter { let outcome = try await QueueActions.setDefault(queue: queue, client: client) return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil, readBack: [], changes: [], message: outcome.message) } let context = try await DriverContext.load(client: client, queue: queue) - let resolved = try resolve(context, value: value, clearing: clearing).get() + let resolved = try resolve(context, value: value, secondValue: secondValue, clearing: clearing).get() let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes) let failed = readBack.filter { !$0.applied } return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes), diff --git a/Sources/CupsKit/Resources/driver-profiles.json b/Sources/CupsKit/Resources/driver-profiles.json index 541f261..1fa8255 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. '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 ", + "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. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. '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", @@ -54,6 +54,37 @@ "usercode": "HP drivers have no accounting code option" } }, + { + "id": "xerox", + "name": "Xerox", + "match": { "manufacturer": "^Xerox$" }, + "actions": { + "color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]], + "bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]], + "usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"], + "usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"] + }, + "inputs": { + "usercode": { + "label": "User ID", + "digitsOnly": false, + "second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] } + } + } + }, + { + "id": "konica-minolta", + "name": "Konica Minolta", + "match": { "manufacturer": "^KONICA MINOLTA$" }, + "actions": { + "duplex": ["KMDuplex=Double"], + "simplex": ["KMDuplex=Single"], + "letter": ["PageSize=8.5x11"] + }, + "unavailable": { + "usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD" + } + }, { "id": "generic", "name": "Generic (standard PPD keywords, IPP Everywhere)", diff --git a/Sources/cupsadmin/Commands/QuickCommand.swift b/Sources/cupsadmin/Commands/QuickCommand.swift index 33b1cd2..ac7b2b5 100644 --- a/Sources/cupsadmin/Commands/QuickCommand.swift +++ b/Sources/cupsadmin/Commands/QuickCommand.swift @@ -5,7 +5,7 @@ enum QuickCommand { static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String { guard let name = args.first else { printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map { - [$0.id, $0.input == .userCode ? " | --clear" : "", $0.summary] + [$0.id, $0.input == .userCode ? " [account] | --clear" : "", $0.summary] }) print("\nDriver profiles (first match wins, generic last): " + DriverProfiles.builtIn.map(\.id).joined(separator: ", ")) @@ -18,6 +18,7 @@ enum QuickCommand { guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") } let queue = args[1] let value: String? + var secondValue: String? switch action.input { case .none: guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") } @@ -27,8 +28,11 @@ enum QuickCommand { guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") } value = nil } else { - guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } + guard args.count == 3 || args.count == 4 else { + throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear") + } value = args[2] + secondValue = args.count == 4 ? args[3] : nil } } guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") } @@ -48,10 +52,13 @@ enum QuickCommand { let before = try await OptionState.load(client: client, queue: queue) let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes) if let value, let error = action.validationError(value, context: context) { - throw CupsAdminError.usage("user code \(value): \(error)") + throw CupsAdminError.usage("\(action.inputLabel(context).lowercased()) \(value): \(error)") + } + if let secondValue, let error = action.secondValidationError(secondValue, context: context) { + throw CupsAdminError.usage("second value \(secondValue): \(error)") } let resolved: ResolvedQuickAction - switch action.resolve(context, value: value, clearing: clear) { + switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) { case .success(let r): resolved = r case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)") } diff --git a/Sources/cupsadmin/main.swift b/Sources/cupsadmin/main.swift index 41479cb..4e2bc25 100644 --- a/Sources/cupsadmin/main.swift +++ b/Sources/cupsadmin/main.swift @@ -40,7 +40,7 @@ let usage = """ set [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd] change a queue via lpadmin, then show before/after quick list quick actions (task-named queue defaults) - quick [code | --clear] + quick [code [account] | --clear] apply one via lpadmin, then verify ppdreport every PPD group and option: keyword, label, type, default, choices diff --git a/Tests/CupsKitTests/QuickActionTests.swift b/Tests/CupsKitTests/QuickActionTests.swift index 89e85aa..79a389b 100644 --- a/Tests/CupsKitTests/QuickActionTests.swift +++ b/Tests/CupsKitTests/QuickActionTests.swift @@ -18,13 +18,17 @@ 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", "canon", "hp", "generic"]) + #expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "xerox", "konica-minolta", "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())") + #expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id)))) + for input in profile.inputs.values { + #expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true) + } for alternatives in profile.actions.values { #expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } }) } @@ -51,6 +55,10 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? { #expect(code.map(\.value) == ["True", "Custom.4321"]) let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes #expect(clear.map(\.value) == ["False", "None"]) + // Ricoh takes one value: digits, no second input. + #expect(QuickAction.named("usercode")!.inputLabel(c) == "User code") + #expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil) + #expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() } } @Test(.enabled(if: hasVendorPPD("RICOH MP 5055"))) diff --git a/Tests/CupsKitTests/VendorProfileTests.swift b/Tests/CupsKitTests/VendorProfileTests.swift index 54d0807..57a4946 100644 --- a/Tests/CupsKitTests/VendorProfileTests.swift +++ b/Tests/CupsKitTests/VendorProfileTests.swift @@ -146,3 +146,98 @@ private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String) #expect(hpColor > 0) } } + +@Suite struct XeroxProfile { + static let altaLink = "Xerox AltaLink C8170.gz" + static let monoVersaLink = "Xerox VersaLink B620 Printer.gz" + + @Test(.enabled(if: ExtraPPDs.path(altaLink) != nil)) + func altaLinkColorAndStandardAccounting() throws { + let c = try #require(ExtraPPDs.context(Self.altaLink)) + #expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"]) + #expect(pairs("color", c) == ["XROutputColor=PrintAsColor"]) + #expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"]) + #expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"]) + #expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"]) + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper") + + let usercode = QuickAction.named("usercode")! + #expect(usercode.inputLabel(c) == "User ID") + #expect(usercode.secondInputLabel(c) == "Account ID (optional)") + #expect(!usercode.digitsOnly(c)) + #expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" } + == ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"]) + #expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2) + #expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" } + == ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"]) + #expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" } + == ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]) + + #expect(usercode.validationError("jdoe", context: c) == nil) + #expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters") + #expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks") + #expect(usercode.secondValidationError("ART101", context: c) == nil) + #expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters") + } + + @Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil)) + func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws { + let c = try #require(ExtraPPDs.context(Self.monoVersaLink)) + #expect(pairs("bw", c) == ["XRColorCorrection=Gray"]) + #expect(resolved("bw", c)?.profile.id == "xerox") + #expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice + #expect(QuickAction.named("usercode")!.isAvailable(in: c)) + } + + /// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs. + @Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100)) + func everyXeroxPPD() throws { + var accounting = 0 + for file in ExtraPPDs.files(prefix: "Xerox ") { + let c = try #require(ExtraPPDs.context(path: file), "\(file)") + let name = (file as NSString).lastPathComponent + #expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)") + #expect(QuickAction.named("color")!.isAvailable(in: c) + == (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)") + #expect(QuickAction.named("bw")!.isAvailable(in: c) + == (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)") + let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false + #expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)") + if xsa { accounting += 1 } + } + #expect(accounting > 0) + } +} + +@Suite struct KonicaMinoltaProfile { + static let bizhub = "KONICAMINOLTAC651i.gz" + static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD" + + @Test(.enabled(if: ExtraPPDs.path(bizhub) != nil)) + func bizhubC651i() throws { + let c = try #require(ExtraPPDs.context(Self.bizhub)) + #expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"]) + #expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic") + #expect(pairs("bw", c) == ["ColorModel=Gray"]) + #expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta") + #expect(pairs("simplex", c) == ["KMDuplex=Single"]) + #expect(pairs("letter", c) == ["PageSize=8.5x11"]) + #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason) + } + + /// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants). + @Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty)) + func everyKonicaMinoltaPPD() throws { + for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") { + let c = try #require(ExtraPPDs.context(path: file), "\(file)") + let name = (file as NSString).lastPathComponent + #expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)") + #expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)") + #expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)") + #expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)") + #expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)") + #expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)") + } + } +}