Vendor-neutral Quick Actions: driver profiles, generic fallback, ppdreport

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:22:19 -05:00
parent 3af6a4e88f
commit 8d39bafb6f
12 changed files with 496 additions and 159 deletions

View File

@@ -1,61 +1,127 @@
import CupsKit
@testable import CupsKit
import Foundation
import Testing
private func available(_ id: String, _ ppd: PPD?) -> Bool {
QuickAction.named(id)!.isAvailable(for: ppd)
private func context(_ ppdFile: String) -> DriverContext? {
vendorPPD(ppdFile).map { DriverContext(ppd: $0, attributes: nil) }
}
@Suite struct QuickActionAvailability {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohPSHasEverything() throws {
let ppd = try #require(vendorPPD("RICOH IM C4500"))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] {
#expect(available(id, ppd), "\(id)")
private func available(_ id: String, _ context: DriverContext) -> Bool {
QuickAction.named(id)!.isAvailable(in: context)
}
private func profileID(_ id: String, _ context: DriverContext) -> String? {
guard case .success(let resolved) = QuickAction.named(id)!.resolve(context, value: "0") else { return nil }
return resolved.profile.id
}
@Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "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())")
for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
}
}
let letter = try QuickAction.named("letter")!.changes(for: ppd).get()
}
}
/// One test per Ricoh PPD documented in docs/ricoh-ppd-options.md (vendor PPDs; skipped when not installed).
@Suite struct RicohProfileMatching {
static let postScript = ["RICOH IM C2000", "RICOH IM C4500", "RICOH MP C2004ex", "RICOH MP C3004ex",
"RICOH MP C307", "RICOH MP C3504"]
@Test(.enabled(if: postScript.allSatisfy(hasVendorPPD)), arguments: postScript)
func ricohColorPostScript(_ file: String) throws {
let c = try #require(context(file))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter"] {
#expect(profileID(id, c) == "ricoh", "\(file) \(id)")
}
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)])
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get()
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")!.changes(for: ppd, value: "").get()
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
#expect(clear.map(\.value) == ["False", "None"])
}
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd")))
func otherDriversOnlyGetWhatTheirPPDHas() throws {
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex.
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd"))
#expect(!available("color", sp) && !available("bw", sp))
#expect(!available("letter", sp) && !available("usercode", sp))
#expect(available("duplex", sp) && available("simplex", sp))
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
func ricohMonoPostScript() throws {
let c = try #require(context("RICOH MP 5055"))
#expect(!available("color", c) && !available("bw", c)) // mono: no ColorModel
#expect(QuickAction.named("color")!.unavailableReason(in: c)?.hasPrefix("Not available for RICOH MP 5055 PS") == true)
for id in ["usercode", "duplex", "simplex", "letter"] { #expect(profileID(id, c) == "ricoh", "\(id)") }
}
// M C251FW: color via HQColorMode.
let fw = try #require(vendorPPD("RICOH M C251FW.ppd"))
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
@Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
func ricohMC251FWUsesHQColorMode() throws {
let c = try #require(context("RICOH M C251FW.ppd"))
#expect(profileID("color", c) == "ricoh-m-c251fw")
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
#expect(!available("usercode", c))
#expect(profileID("letter", c) == "ricoh-m-c251fw")
}
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no.
let airprint = PPD(text: """
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd")))
func ricohPCLFallsBackToGenericLetter() throws {
let c = try #require(context("RICOH SP 3710DN.ppd"))
#expect(!available("color", c) && !available("usercode", c))
#expect(profileID("duplex", c) == "ricoh")
// 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)])
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func userCodeValidationUsesThePPDLimit() throws {
let c = try #require(context("RICOH IM C4500"))
let action = QuickAction.named("usercode")!
#expect(action.validationError("12345", context: c) == nil)
#expect(action.validationError("12a", context: c) == "Digits only")
#expect(action.validationError("123456789", context: c) == "At most 8 digits")
}
}
@Suite struct GenericProfile {
static let genericPPD = "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Resources/Generic.ppd"
@Test(.enabled(if: FileManager.default.fileExists(atPath: GenericProfile.genericPPD)))
func genericPostScriptPPD() throws {
let text = try String(contentsOfFile: Self.genericPPD, encoding: .isoLatin1)
let c = DriverContext(ppd: PPD(text: text), attributes: nil)
#expect(DriverProfiles.matching(c).map(\.id) == ["generic"])
for id in ["duplex", "simplex", "letter"] { #expect(profileID(id, c) == "generic", "\(id)") }
#expect(!available("color", c) && !available("bw", c) && !available("usercode", c))
#expect(QuickAction.named("default")!.isAvailable(in: c))
}
@Test func ippEverywhereUsesIPPAttributes() throws {
let attributes = IPPGroup(tag: .printerAttributes, attributes: [
.string("printer-make-and-model", .textWithoutLanguage, ["Example Laser 5000"]),
.string("print-color-mode-supported", .keyword, ["auto", "color", "monochrome"]),
.string("sides-supported", .keyword, ["one-sided", "two-sided-long-edge"]),
.string("media-supported", .keyword, ["na_letter_8.5x11in", "iso_a4_210x297mm"]),
])
let c = DriverContext(ppd: nil, attributes: attributes)
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "print-color-mode-default", value: "color", isSecret: false)])
#expect(try QuickAction.named("duplex")!.resolve(c).get().changes == [OptionChange(key: "sides-default", value: "two-sided-long-edge", isSecret: false)])
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "media-default", value: "na_letter_8.5x11in", isSecret: false)])
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Not available for Example Laser 5000 (no driver profile maps usercode)")
// Grayscale-only AirPrint PPD: black & white yes, color no.
let gray = DriverContext(ppd: PPD(text: """
*OpenUI *ColorModel/Color Mode: PickOne
*DefaultColorModel: Gray
*ColorModel Gray/Grayscale: ""
*CloseUI: *ColorModel
*OpenUI *PageSize/Media Size: PickOne
*DefaultPageSize: Letter
*PageSize Letter/US Letter: ""
*CloseUI: *PageSize
""")
#expect(!available("color", airprint))
#expect(available("bw", airprint))
#expect(!available("duplex", airprint))
}
@Test func userCodeValidation() {
let action = QuickAction.named("usercode")!
let ppd = vendorPPD("RICOH IM C4500")
#expect(action.validationError("12345", ppd: ppd) == nil)
#expect(action.validationError("12a", ppd: ppd) == "Digits only")
#expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits")
"""), attributes: nil)
#expect(available("bw", gray) && !available("color", gray))
}
}
@@ -68,21 +134,24 @@ struct LiveQuickActions {
@Test func everyActionOnRicohQueue() async throws {
try #require(queues.count == 2)
let client = CupsClient()
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"),
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client)
for (id, value, clearing) in [("letter", nil, false), ("bw", nil, false), ("color", nil, false), ("usercode", "2468", false),
("usercode", nil, true), ("simplex", nil, false), ("duplex", nil, false)] as [(String, String?, Bool)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, clearing: clearing, client: client)
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
#expect(outcome.profile?.id == "ricoh", "\(id)")
}
}
@Test func testPageDefaultAndDelete() async throws {
@Test func genericQueueTestPageDefaultAndDelete() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
#expect(page.succeeded && jobID != nil, "\(page)")
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing
let letter = try await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(letter.succeeded && letter.profile?.id == "generic", "\(letter.message ?? "")")
let color = try? await QuickAction.named("usercode")!.run(queue: queues[1], value: "1", client: client)
#expect(color == nil) // generic PPD: no user code mapping, throws, writes nothing
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
#expect(made.succeeded, "\(made.message ?? "")")