Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 | ||
|
|
79209eb911 | ||
|
|
51c30737c7 | ||
|
|
37d1f7eb24 |
@@ -25,8 +25,9 @@ public enum CupsOptionsFilter {
|
||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||
"EpsonColorMode", "ColorModel",
|
||||
// Quality
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
||||
"CNIJQuality", "StpQuality", "OutputMode",
|
||||
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
|
||||
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
|
||||
"PrintQuality", "StpQuality", "OutputMode",
|
||||
// Duplex
|
||||
"Duplex", "sides",
|
||||
]
|
||||
|
||||
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
||||
|
||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
||||
/// Precedence is deterministic, not positional (#181): unqualified
|
||||
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
|
||||
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
|
||||
/// base English labels — and a qualified-only id still gets its
|
||||
/// first-seen qualified label (R9).
|
||||
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
var hits: [String: [(qualifier: String?, label: String)]] = [:]
|
||||
for rawLine in ppd.split(separator: "\n") {
|
||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
||||
// 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.
|
||||
// are left alone. The qualifier is recorded for precedence
|
||||
// rather than dropped (#181).
|
||||
var qualifier: String?
|
||||
if let dot = line.firstIndex(of: ".") {
|
||||
let prefix = line[..<dot]
|
||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
||||
&& (prefix.count == 2 || prefix.contains("_"))
|
||||
let candidate = line[line.index(after: dot)...]
|
||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||
qualifier = prefix.lowercased()
|
||||
line = String(candidate)
|
||||
}
|
||||
}
|
||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
||||
rest = String(rest[..<colon])
|
||||
// `<id>/<Human label>` — human label after the last `/`.
|
||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||
let id = String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let human = String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
||||
let id = ppdUnescape(String(rest[..<slash])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||
.trimmingCharacters(in: .whitespaces))
|
||||
guard !id.isEmpty else { continue }
|
||||
hits[id, default: []].append(
|
||||
(qualifier, human.isEmpty ? id : human))
|
||||
}
|
||||
var map: [String: String] = [:]
|
||||
for (id, candidates) in hits {
|
||||
// First occurrence wins inside each qualifier class, in
|
||||
// file order — same as the old sequential behaviour.
|
||||
map[id] = candidates.first { $0.qualifier == nil }?.label
|
||||
?? candidates.first { $0.qualifier == "en_us" }?.label
|
||||
?? candidates.first { $0.qualifier == "en" }?.label
|
||||
?? candidates[0].label
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
|
||||
/// `<20>` → space). Anything that is not `<` + two hex digits +
|
||||
/// `>` passes through untouched (#181).
|
||||
private static func ppdUnescape(_ text: String) -> String {
|
||||
var result = ""
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
guard text[index] == "<",
|
||||
let hexEnd = text.index(
|
||||
index, offsetBy: 3, limitedBy: text.endIndex),
|
||||
hexEnd < text.endIndex, text[hexEnd] == ">",
|
||||
let byte = UInt8(
|
||||
text[text.index(after: index)..<hexEnd], radix: 16)
|
||||
else {
|
||||
result.append(text[index])
|
||||
index = text.index(after: index)
|
||||
continue
|
||||
}
|
||||
result.append(Character(UnicodeScalar(byte)))
|
||||
index = text.index(after: hexEnd)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Detection (docs/11)
|
||||
|
||||
/// Media-type option key in preference order — used both to read a
|
||||
@@ -232,6 +275,47 @@ public enum CupsParsers {
|
||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||
}
|
||||
|
||||
/// Print-quality option key in preference order — vendor-first,
|
||||
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
|
||||
/// drivers they are colour-mode keys, not quality (#180).
|
||||
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
|
||||
public static let qualityKeys = [
|
||||
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
|
||||
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
|
||||
"EPIJ_Quality", "OutputMode", "Resolution",
|
||||
]
|
||||
|
||||
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
|
||||
qualityKeys.first { optionKeys.contains($0) }
|
||||
}
|
||||
|
||||
/// A single value from a captured `key=value key=value` options
|
||||
/// string — case-insensitive key match (#183 capture-return).
|
||||
public static func extractOption(
|
||||
named key: String,
|
||||
fromOptionsString options: String
|
||||
) -> String? {
|
||||
lpoptions(options).first {
|
||||
$0.key.caseInsensitiveCompare(key) == .orderedSame
|
||||
}?.value
|
||||
}
|
||||
|
||||
/// Print-quality token from a captured options string — the queue's
|
||||
/// quality key is detected from the roster before extracting (#183).
|
||||
public static func extractQuality(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
guard let key = detectQualityKey(
|
||||
optionKeys: Set(pairs.map(\.key)))
|
||||
else { return nil }
|
||||
return pairs.first(where: { $0.key == key })?.value
|
||||
}
|
||||
|
||||
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
|
||||
public static func extractOrientation(fromOptionsString options: String) -> String? {
|
||||
extractOption(named: "orientation-requested", fromOptionsString: options)
|
||||
.map { $0 == "4" ? "landscape" : "portrait" }
|
||||
}
|
||||
|
||||
/// 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`;
|
||||
|
||||
@@ -132,8 +132,28 @@ public struct CupsService: Sendable {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Print quality — the detected roster key's listing maps to
|
||||
// `PrinterQuality` with PPD labels and the `*` default (#183).
|
||||
var qualities: [PrinterQuality] = []
|
||||
var qualityDefault: String?
|
||||
let qualityKey = CupsParsers.detectQualityKey(
|
||||
optionKeys: Set(listings.map(\.key)))
|
||||
if let qualityKey,
|
||||
let listing = listings.first(where: { $0.key == qualityKey }) {
|
||||
let labels = ppd.map {
|
||||
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
|
||||
} ?? [:]
|
||||
qualities = listing.choices.map {
|
||||
PrinterQuality(id: $0, name: labels[$0] ?? $0)
|
||||
}
|
||||
qualityDefault = listing.defaultChoice
|
||||
}
|
||||
|
||||
return PrinterCapabilities(
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media)
|
||||
trays: trays, paperSizes: sizes, mediaTypes: media,
|
||||
qualityKey: qualityKey, qualities: qualities,
|
||||
qualityDefault: qualityDefault)
|
||||
}
|
||||
|
||||
/// The set of option keys a queue advertises — input to
|
||||
@@ -167,7 +187,11 @@ public struct CupsService: Sendable {
|
||||
|
||||
private func loadPPD(for queue: String) -> String? {
|
||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
||||
return try? String(contentsOf: url, encoding: .utf8)
|
||||
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
|
||||
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
|
||||
// when UTF-8 decoding fails outright.
|
||||
return (try? String(contentsOf: url, encoding: .utf8))
|
||||
?? (try? String(contentsOf: url, encoding: .isoLatin1))
|
||||
}
|
||||
|
||||
// MARK: - Spawn
|
||||
|
||||
@@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable {
|
||||
/// -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>
|
||||
@@ -82,6 +83,15 @@ public enum LpArgs {
|
||||
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(
|
||||
|
||||
@@ -66,6 +66,18 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
|
||||
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
|
||||
public struct PrinterQuality: 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]
|
||||
@@ -73,17 +85,29 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
/// Always `true` on macOS (spec parity — CUPS honours
|
||||
/// `orientation-requested`).
|
||||
public var supportsOrientation: Bool
|
||||
/// The queue's detected quality enumeration key
|
||||
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
|
||||
public var qualityKey: String?
|
||||
public var qualities: [PrinterQuality]
|
||||
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||
public var qualityDefault: String?
|
||||
|
||||
public init(
|
||||
trays: [PrinterTray] = [],
|
||||
paperSizes: [PrinterPaperSize] = [],
|
||||
mediaTypes: [PrinterMediaType] = [],
|
||||
supportsOrientation: Bool = true
|
||||
supportsOrientation: Bool = true,
|
||||
qualityKey: String? = nil,
|
||||
qualities: [PrinterQuality] = [],
|
||||
qualityDefault: String? = nil
|
||||
) {
|
||||
self.trays = trays
|
||||
self.paperSizes = paperSizes
|
||||
self.mediaTypes = mediaTypes
|
||||
self.supportsOrientation = supportsOrientation
|
||||
self.qualityKey = qualityKey
|
||||
self.qualities = qualities
|
||||
self.qualityDefault = qualityDefault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +120,12 @@ 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).
|
||||
/// Stage 2 paper token → `PageSize=` (skipped if captured, #183).
|
||||
public var paperSize: String?
|
||||
public var mediaType: String?
|
||||
/// Print-quality token → `-o <detectedQualityKey>=` (skipped if
|
||||
/// captured, #183).
|
||||
public var quality: String?
|
||||
public var ppdUncorrectedPassthrough: Bool?
|
||||
/// Space-separated `key=value` captured from
|
||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
|
||||
@@ -109,6 +136,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
orientation: String? = nil,
|
||||
paperSize: String? = nil,
|
||||
mediaType: String? = nil,
|
||||
quality: String? = nil,
|
||||
ppdUncorrectedPassthrough: Bool? = nil,
|
||||
cupsOptions: String? = nil
|
||||
) {
|
||||
@@ -116,6 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
||||
self.orientation = orientation
|
||||
self.paperSize = paperSize
|
||||
self.mediaType = mediaType
|
||||
self.quality = quality
|
||||
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
|
||||
self.cupsOptions = cupsOptions
|
||||
}
|
||||
|
||||
@@ -136,9 +136,19 @@ enum UITestHooks {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? queue,
|
||||
options: PrintOptions(
|
||||
orientation: options.flatMap {
|
||||
CupsParsers.extractOrientation(fromOptionsString: $0)
|
||||
},
|
||||
paperSize: options.flatMap {
|
||||
CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: $0)
|
||||
},
|
||||
mediaType: options.flatMap {
|
||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||
},
|
||||
quality: options.flatMap {
|
||||
CupsParsers.extractQuality(fromOptionsString: $0)
|
||||
},
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: options))
|
||||
default:
|
||||
|
||||
@@ -17,6 +17,21 @@ enum PrintPanelError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/
|
||||
/// `quality` only; `mediaType` and `orientation` preselect — and the
|
||||
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
|
||||
struct PrintPanelInitialSelections {
|
||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`.
|
||||
var paperSize: String?
|
||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||
var qualityKey: String?
|
||||
/// The selected quality token.
|
||||
var quality: String?
|
||||
var mediaType: String? // #186 consumes
|
||||
var orientation: String? // #186 consumes
|
||||
}
|
||||
|
||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
@@ -41,7 +56,9 @@ struct PrintPanelService {
|
||||
func showProperties(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
cupsService: CupsService
|
||||
cupsService: CupsService,
|
||||
initialSelections: PrintPanelInitialSelections =
|
||||
PrintPanelInitialSelections()
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
@@ -56,7 +73,8 @@ struct PrintPanelService {
|
||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||
?? []
|
||||
return try runNativePanel(
|
||||
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||
queue: queue, displayName: display, optionKeys: optionKeys,
|
||||
initialSelections: initialSelections)
|
||||
}
|
||||
|
||||
// MARK: - Panel
|
||||
@@ -64,7 +82,8 @@ struct PrintPanelService {
|
||||
private func runNativePanel(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
optionKeys: Set<String>
|
||||
optionKeys: Set<String>,
|
||||
initialSelections: PrintPanelInitialSelections
|
||||
) throws -> PrintPropertiesResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
@@ -89,6 +108,9 @@ struct PrintPanelService {
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||
// Initial selections — after `PMSessionDefault*`, before
|
||||
// ColorSync suppression ②–⑤ (locked write order, #183).
|
||||
applyInitialSelections(initialSelections, to: settings)
|
||||
boundViaPM = true
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
@@ -138,7 +160,9 @@ struct PrintPanelService {
|
||||
|
||||
// ⑥ Capture the user's choices — filtered replay options plus
|
||||
// the media type they picked. Re-fetch the settings handle so
|
||||
// we read back what the modal wrote.
|
||||
// we read back what the modal wrote. Paper size, quality, and
|
||||
// orientation ride back parsed from the captured `k=v` string
|
||||
// (#183); the PDE may rewrite or drop them (R12).
|
||||
var cupsOptions: String?
|
||||
var mediaType: String?
|
||||
if boundViaPM {
|
||||
@@ -148,6 +172,7 @@ struct PrintPanelService {
|
||||
cupsOptions = captured.cupsOptions
|
||||
mediaType = captured.mediaType
|
||||
}
|
||||
let capturedOptions = cupsOptions ?? ""
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: boundViaPM
|
||||
? Self.currentPrinterID(
|
||||
@@ -156,11 +181,35 @@ struct PrintPanelService {
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
orientation: CupsParsers.extractOrientation(
|
||||
fromOptionsString: capturedOptions),
|
||||
paperSize: CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: capturedOptions),
|
||||
mediaType: mediaType,
|
||||
quality: CupsParsers.extractQuality(
|
||||
fromOptionsString: capturedOptions),
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
}
|
||||
|
||||
/// Initial-selection `PMPrintSettings` writes — paper size and
|
||||
/// quality only this phase; media type / orientation and the
|
||||
/// `PMPageFormat`/`PMPaper` paper half are #186's contract.
|
||||
private func applyInitialSelections(
|
||||
_ selections: PrintPanelInitialSelections,
|
||||
to settings: PMPrintSettings
|
||||
) {
|
||||
if let paperSize = selections.paperSize {
|
||||
_ = PMPrintSettingsSetValue(
|
||||
settings, "PageSize" as CFString,
|
||||
paperSize as CFString, false)
|
||||
}
|
||||
if let key = selections.qualityKey, let value = selections.quality {
|
||||
_ = PMPrintSettingsSetValue(
|
||||
settings, key as CFString, value as CFString, false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PM helpers
|
||||
|
||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||
|
||||
@@ -7,12 +7,20 @@ import ICCeryCore
|
||||
final class PrintSessionViewModel: ObservableObject {
|
||||
let wizard: WizardViewModel
|
||||
let environment: AppEnvironment
|
||||
/// Stage 1/2 form state — read for paper seeding/mirroring only;
|
||||
/// `workflow.pageSize` is the printtarg layout and is never written
|
||||
/// back from the print side (#183).
|
||||
weak var workflow: TargetWorkflowViewModel?
|
||||
|
||||
@Published var printers: [Printer] = []
|
||||
@Published var selectedPrinter = ""
|
||||
@Published var printerCaps = PrinterCapabilities()
|
||||
@Published var selectedTray: Int?
|
||||
@Published var selectedMediaType: String?
|
||||
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||
@Published var selectedPaperSize: Int?
|
||||
/// Print-quality option token, e.g. `"303"` (#183).
|
||||
@Published var selectedQuality: String?
|
||||
@Published var printOrientation = "portrait"
|
||||
@Published var capturedCupsOptions: [String: String] = [:]
|
||||
@Published var printNotice: Notice?
|
||||
@@ -75,22 +83,79 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
if selectedQuality == nil {
|
||||
selectedQuality = printerCaps.qualityDefault
|
||||
?? printerCaps.qualities.first?.id
|
||||
}
|
||||
// Caps reload is a re-mirror trigger for the paper picker
|
||||
// (#183 E4) — pageSize + printer changes route here too.
|
||||
seedPaperSelection()
|
||||
} catch {
|
||||
printerCaps = PrinterCapabilities()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paper / quality selection (#183)
|
||||
|
||||
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||
/// no match → nil (never guess). Called only on pageSize / printer /
|
||||
/// caps triggers — never on unrelated publishes (R14).
|
||||
func seedPaperSelection() {
|
||||
guard let pageSize = workflow?.pageSize else { return }
|
||||
if pageSize == .custom {
|
||||
let token = customPaperToken()
|
||||
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
|
||||
printerCaps.paperSizes[index].name = token
|
||||
} else {
|
||||
printerCaps.paperSizes.append(
|
||||
PrinterPaperSize(id: 0, name: token))
|
||||
}
|
||||
selectedPaperSize = 0
|
||||
return
|
||||
}
|
||||
selectedPaperSize = printerCaps.paperSizes
|
||||
.first { $0.name == pageSize.rawValue }?.id
|
||||
}
|
||||
|
||||
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
|
||||
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
|
||||
func customPaperToken() -> String {
|
||||
let w = workflow?.customPageW ?? 0
|
||||
let h = workflow?.customPageH ?? 0
|
||||
let wPt = (w * 72.0 / 25.4).rounded()
|
||||
let hPt = (h * 72.0 / 25.4).rounded()
|
||||
return "Custom.\(Int(wPt))x\(Int(hPt))"
|
||||
}
|
||||
|
||||
/// The CUPS `PageSize` token for the current Stage 2 pick — live
|
||||
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
|
||||
/// name. This is what `lp -o PageSize=` sees.
|
||||
var selectedPaperSizeToken: String? {
|
||||
guard let id = selectedPaperSize else { return nil }
|
||||
if id == 0 { return customPaperToken() }
|
||||
return printerCaps.paperSizes.first { $0.id == id }?.name
|
||||
}
|
||||
|
||||
func openPrinterPreferences() {
|
||||
guard !selectedPrinter.isEmpty else { return }
|
||||
let queue = selectedPrinter
|
||||
let displayName = printers.first { $0.name == queue }?.displayName
|
||||
let cups = environment.cupsService
|
||||
let selections = PrintPanelInitialSelections(
|
||||
paperSize: selectedPaperSizeToken,
|
||||
qualityKey: printerCaps.qualityKey,
|
||||
quality: selectedQuality,
|
||||
mediaType: nil,
|
||||
orientation: nil)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
guard let result = try await PrintPanelService()
|
||||
.showProperties(
|
||||
queue: queue, displayName: displayName,
|
||||
cupsService: cups)
|
||||
cupsService: cups,
|
||||
initialSelections: selections)
|
||||
else {
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
@@ -111,6 +176,17 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if let media = result.options.mediaType {
|
||||
selectedMediaType = media
|
||||
}
|
||||
// Capture-return (#183): a dialog paper/quality change
|
||||
// updates the Stage 2 selections — never
|
||||
// `workflow.pageSize` (printtarg layout is sacred).
|
||||
if let paper = result.options.paperSize,
|
||||
let match = printerCaps.paperSizes
|
||||
.first(where: { $0.name == paper }) {
|
||||
selectedPaperSize = match.id
|
||||
}
|
||||
if let quality = result.options.quality {
|
||||
selectedQuality = quality
|
||||
}
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Settings captured for \(selectedPrinter).",
|
||||
@@ -122,7 +198,7 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
|
||||
func printAllPages(from result: PrinttargResult) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
@@ -132,7 +208,7 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
var printed = 0
|
||||
for page in result.pages {
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
try await spool(page, index: page.index)
|
||||
printed += 1
|
||||
} catch {
|
||||
printNotice = Notice(
|
||||
@@ -156,13 +232,13 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
func printPage(_ page: GalleryPage, pageSize: PageSize) {
|
||||
func printPage(_ page: GalleryPage) {
|
||||
guard !isPrinting else { return }
|
||||
isPrinting = true
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await spool(page, index: page.index, pageSize: pageSize)
|
||||
try await spool(page, index: page.index)
|
||||
printNotice = Notice(
|
||||
kind: .info,
|
||||
text: "Sent \(page.page.filename) to \(selectedPrinter).",
|
||||
@@ -180,14 +256,18 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
printTask = task
|
||||
}
|
||||
|
||||
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
|
||||
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||
guard !selectedPrinter.isEmpty else {
|
||||
throw CupsError.noPrinterSelected
|
||||
}
|
||||
// The Stage 2 paper token is what `lp -o PageSize=` sees;
|
||||
// `workflow.pageSize` remains the printtarg layout input only
|
||||
// (#183).
|
||||
let options = PrintOptions(
|
||||
orientation: printOrientation,
|
||||
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||
paperSize: selectedPaperSizeToken,
|
||||
mediaType: selectedMediaType,
|
||||
quality: selectedQuality,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||
try await environment.cupsService.printTarget(
|
||||
|
||||
@@ -255,6 +255,7 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.print.selectedPrinter) { _ in
|
||||
workflow.print.selectedTray = nil
|
||||
workflow.print.selectedMediaType = nil
|
||||
workflow.print.selectedQuality = nil
|
||||
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
|
||||
}
|
||||
if let selected = workflow.print.printers
|
||||
@@ -279,7 +280,9 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("btnPrinterProperties")
|
||||
}
|
||||
|
||||
// Tray / media / orientation — from queue capabilities.
|
||||
// Tray / media / paper / quality / orientation — from
|
||||
// queue capabilities. Extracted subviews keep every
|
||||
// ViewBuilder ≤10 children (R13).
|
||||
HStack(spacing: 14) {
|
||||
if !workflow.print.printerCaps.trays.isEmpty {
|
||||
Picker("Tray", selection: $workflow.print.selectedTray) {
|
||||
@@ -301,6 +304,12 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("mediaTypeGroup")
|
||||
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||
}
|
||||
if !workflow.print.printerCaps.paperSizes.isEmpty {
|
||||
paperSizeGroup
|
||||
}
|
||||
if !workflow.print.printerCaps.qualities.isEmpty {
|
||||
qualityGroup
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button("Portrait") { workflow.print.printOrientation = "portrait" }
|
||||
.buttonStyle(.bordered)
|
||||
@@ -313,11 +322,13 @@ struct Stage2View: View {
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
// Stage 1 owns the custom dimensions — the caption lives
|
||||
// inside `paperSizeGroup` (#183).
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
if let result = workflow.printtargResult {
|
||||
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
|
||||
workflow.print.printAllPages(from: result)
|
||||
}
|
||||
}) {
|
||||
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
|
||||
@@ -344,6 +355,50 @@ struct Stage2View: View {
|
||||
.onChange(of: workflow.printtargResult?.pages.count) { _ in
|
||||
schedulePrinterRefresh()
|
||||
}
|
||||
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
|
||||
.onChange(of: workflow.pageSize) { _ in
|
||||
workflow.print.seedPaperSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paper picker + custom-size caption under the `paperSizeGroup`
|
||||
/// container (#183). `caps.paperSizes` plus the synthetic custom
|
||||
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
|
||||
private var paperSizeGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Paper", selection: $workflow.print.selectedPaperSize) {
|
||||
ForEach(workflow.print.printerCaps.paperSizes, id: \.id) { size in
|
||||
Text(size.id == 0
|
||||
? "Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)"
|
||||
: size.name)
|
||||
.tag(Optional(size.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerPaperSizeSelect")
|
||||
if workflow.print.selectedPaperSize == 0 {
|
||||
Text("Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("paperSizeGroup")
|
||||
}
|
||||
|
||||
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
||||
private var qualityGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 200)
|
||||
.accessibilityIdentifier("printerQualitySelect")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("qualityGroup")
|
||||
}
|
||||
|
||||
/// Auto-enumerates printers once a manifest exists and whenever it
|
||||
@@ -392,7 +447,7 @@ private struct GalleryPageView: View {
|
||||
Text("\(page.page.patches) patches · " +
|
||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
|
||||
Button("Print") { workflow.print.printPage(page) }
|
||||
.disabled(workflow.print.isPrinting
|
||||
|| workflow.print.selectedPrinter.isEmpty)
|
||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||
|
||||
@@ -137,6 +137,9 @@ final class TargetWorkflowViewModel: ObservableObject {
|
||||
environment: environment
|
||||
)
|
||||
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
|
||||
// Paper-size seeding reads the Stage 1 form through this weak
|
||||
// back-reference; the print side never writes it (#183).
|
||||
self.print.workflow = self
|
||||
self.calibration = nil
|
||||
self.calibration = CalibrationViewModel(
|
||||
workflow: self,
|
||||
|
||||
@@ -24,6 +24,18 @@ final class CupsOptionsFilterTests: XCTestCase {
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
}
|
||||
|
||||
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
|
||||
/// keys) survives the filter so it wins over the Stage 2 explicit
|
||||
/// quality in `LpArgs`.
|
||||
func testKeepsQualityKeys() {
|
||||
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best "
|
||||
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw),
|
||||
"EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
|
||||
+ "cupsPrintQuality=High Quality=Best")
|
||||
}
|
||||
|
||||
func testKeepsUnknown() {
|
||||
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
|
||||
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
|
||||
final class CupsParsersTests: XCTestCase {
|
||||
final class CupsParserTests: XCTestCase {
|
||||
|
||||
// Recorded on an Epson XP-55 + Canon Pro9500 host.
|
||||
private let lpstatE = """
|
||||
@@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase {
|
||||
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
|
||||
ColorModel/Output Mode: *RGB Gray
|
||||
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
|
||||
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
"""
|
||||
|
||||
func testDestinations() {
|
||||
@@ -117,6 +117,121 @@ final class CupsParsersTests: XCTestCase {
|
||||
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #183 quality key + option extraction
|
||||
|
||||
func testQualityKeyRosterOrder() {
|
||||
// Vendor keys beat the generic ones; OutputMode/Resolution sit
|
||||
// last (they are colour-ish keys on some drivers — #183/#180).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
|
||||
// A full Epson key set — EPIJ_Qual wins over the colour-mode
|
||||
// key, the generic keys, and Resolution (#180, R11).
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
|
||||
"cupsPrintQuality", "PrintQuality",
|
||||
"ColorModel"]), "EPIJ_Qual")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
|
||||
"CNIJQuality")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
|
||||
XCTAssertEqual(CupsParsers.detectQualityKey(
|
||||
optionKeys: ["Resolution"]), "Resolution")
|
||||
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
|
||||
}
|
||||
|
||||
func testCapabilitiesQuality() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(lpoptionsL)
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
// EPIJ_Qual is the roster member — all seven Epson codes
|
||||
// enumerate in the driver's own (non-sorted) order (#180).
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
func testCapabilitiesQualityPpdLabels() {
|
||||
// Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label`
|
||||
// lines in the driver's own order (#180).
|
||||
let ppd = """
|
||||
*OpenUI *EPIJ_Qual/Print Quality: PickOne
|
||||
*DefaultEPIJ_Qual: 303
|
||||
*EPIJ_Qual 301/Fast Economy: ""
|
||||
*EPIJ_Qual 302/Economy: ""
|
||||
*EPIJ_Qual 303/Normal: ""
|
||||
*EPIJ_Qual 308/Draft: ""
|
||||
*EPIJ_Qual 304/Fine: ""
|
||||
*EPIJ_Qual 305/Quality: ""
|
||||
*EPIJ_Qual 307/Best Quality: ""
|
||||
*CloseUI: *EPIJ_Qual
|
||||
"""
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
|
||||
let caps = service.capabilities(from: listings, ppd: ppd)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities, [
|
||||
PrinterQuality(id: "301", name: "Fast Economy"),
|
||||
PrinterQuality(id: "302", name: "Economy"),
|
||||
PrinterQuality(id: "303", name: "Normal"),
|
||||
PrinterQuality(id: "308", name: "Draft"),
|
||||
PrinterQuality(id: "304", name: "Fine"),
|
||||
PrinterQuality(id: "305", name: "Quality"),
|
||||
PrinterQuality(id: "307", name: "Best Quality"),
|
||||
])
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
}
|
||||
|
||||
/// #180 — the Epson listing also carries `OutputMode` (a colour
|
||||
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
|
||||
func testCapabilitiesQualityEpsonDetection() {
|
||||
let service = CupsService()
|
||||
let listings = CupsParsers.lpoptionsList("""
|
||||
PageSize/Media Size: *A4 Letter
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
OutputMode/Color Mode: *Color Mono
|
||||
Resolution/Resolution: *360dpi 720dpi
|
||||
""")
|
||||
let caps = service.capabilities(from: listings, ppd: nil)
|
||||
|
||||
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
|
||||
XCTAssertEqual(caps.qualities.count, 7)
|
||||
XCTAssertEqual(caps.qualityDefault, "303")
|
||||
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
|
||||
}
|
||||
|
||||
func testExtractOption() {
|
||||
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "PageSize", fromOptionsString: options), "A4")
|
||||
// Case-insensitive key match.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "epij_qual", fromOptionsString: options), "303")
|
||||
// Quoted values come back unquoted.
|
||||
XCTAssertEqual(CupsParsers.extractOption(
|
||||
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
|
||||
XCTAssertNil(CupsParsers.extractOption(
|
||||
named: "InputSlot", fromOptionsString: options))
|
||||
}
|
||||
|
||||
func testExtractQualityAndOrientation() {
|
||||
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
|
||||
XCTAssertEqual(CupsParsers.extractQuality(
|
||||
fromOptionsString: options), "305")
|
||||
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||
fromOptionsString: options), "landscape")
|
||||
XCTAssertNil(CupsParsers.extractQuality(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
XCTAssertNil(CupsParsers.extractOrientation(
|
||||
fromOptionsString: "PageSize=A4"))
|
||||
}
|
||||
|
||||
func testDriverBypass() {
|
||||
func pair(_ keys: Set<String>) -> String? {
|
||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||
@@ -131,4 +246,177 @@ final class CupsParsersTests: XCTestCase {
|
||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||
XCTAssertNil(pair(["PageSize"]))
|
||||
}
|
||||
|
||||
// MARK: - #181 Canon media locale precedence + PPD encoding
|
||||
|
||||
/// The 18 Canon Pro9500 media types named in the issue — the ids
|
||||
/// are the numeric codes the driver enumerates via `lpoptions -l`.
|
||||
private let canonMedia: [(id: String, label: String)] = [
|
||||
("0", "Plain Paper"),
|
||||
("1", "Photo Paper Plus Glossy II"),
|
||||
("2", "Photo Paper Pro Platinum N"),
|
||||
("3", "Photo Paper Pro Platinum"),
|
||||
("4", "Photo Paper Pro Luster"),
|
||||
("5", "Photo Paper Plus Semi-gloss"),
|
||||
("6", "Matte Photo Paper"),
|
||||
("7", "Fine Art \"Photo Rag\""),
|
||||
("8", "Fine Art \"Museum Etching\""),
|
||||
("9", "Photo Paper Pro Premium Matte"),
|
||||
("10", "Fine Art Premium Matte"),
|
||||
("11", "Other Fine Art Paper"),
|
||||
("12", "Canvas"),
|
||||
("13", "Board Paper"),
|
||||
("14", "Ink Jet Hagaki"),
|
||||
("15", "Hagaki"),
|
||||
("16", "Printable disc"),
|
||||
("17", "Printable disc (bleed-proof)"),
|
||||
]
|
||||
|
||||
/// Canon Pro9500-shaped fragment: the unqualified base block comes
|
||||
/// early and the `th.` block trails at the end — the ordering that
|
||||
/// let Thai overwrite English under last-write-wins (#181).
|
||||
private var canonPPD: String {
|
||||
var lines = [
|
||||
"*OpenUI *CNIJMediaType/Media Type: PickOne",
|
||||
"*DefaultCNIJMediaType: 0",
|
||||
]
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
|
||||
}
|
||||
lines.append("*CloseUI: *CNIJMediaType")
|
||||
for media in canonMedia {
|
||||
lines.append(
|
||||
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
|
||||
let labels = CupsParsers.ppdChoiceLabels(
|
||||
canonPPD, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
|
||||
}
|
||||
|
||||
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
|
||||
// `th.` block first — precedence is deterministic, not
|
||||
// positional (#181, E3).
|
||||
let ppd = """
|
||||
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
|
||||
*CNIJMediaType 0/Plain Paper: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["0"], "Plain Paper")
|
||||
}
|
||||
|
||||
func testPpdLabelsQualifiedFallbackOrder() {
|
||||
// en_US > en > first-qualified-seen (#181, E3).
|
||||
let ppd = """
|
||||
*en.CNIJMediaType 1/English Label: ""
|
||||
*en_US.CNIJMediaType 1/US English Label: ""
|
||||
*th.CNIJMediaType 1/กระดาษ: ""
|
||||
*fr.CNIJMediaType 2/Français: ""
|
||||
*de.CNIJMediaType 2/Deutsch: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["1"], "US English Label")
|
||||
// A qualified-only id still gets its first-seen qualified
|
||||
// label — never left unlabeled (R9).
|
||||
XCTAssertEqual(labels["2"], "Français")
|
||||
}
|
||||
|
||||
func testPpdLabelsHexEscapeDecoding() {
|
||||
let ppd = """
|
||||
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
|
||||
*CNIJMediaType 4/Plain<20>Paper: ""
|
||||
*CNIJMediaType 5/Bad<ZZ>Escape: ""
|
||||
"""
|
||||
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
|
||||
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
|
||||
XCTAssertEqual(labels["4"], "Plain Paper")
|
||||
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
|
||||
}
|
||||
|
||||
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
|
||||
/// non-Thai label (E1 — the true count is the hardware gate's, so
|
||||
/// no count is hardcoded here); the 18 named AC labels are
|
||||
/// spot-checked.
|
||||
func testCapabilitiesCanonMediaAllNonThai() {
|
||||
var choices = canonMedia.map(\.id)
|
||||
choices[0] = "*\(choices[0])"
|
||||
let listings = CupsParsers.lpoptionsList(
|
||||
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
|
||||
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
|
||||
|
||||
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
|
||||
for type in caps.mediaTypes {
|
||||
XCTAssertFalse(type.name.unicodeScalars.contains {
|
||||
(0x0E00...0x0E7F).contains($0.value)
|
||||
}, "Thai label leaked into \(type.id): \(type.name)")
|
||||
}
|
||||
for media in canonMedia {
|
||||
XCTAssertEqual(
|
||||
caps.mediaTypes.first { $0.id == media.id }?.name,
|
||||
media.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 PPD carrying Thai labels decodes intact — the English
|
||||
/// base block wins precedence and no mojibake leaks through (#181,
|
||||
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
|
||||
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: Data(canonPPD.utf8),
|
||||
listing: "CNIJMediaType/Media Type: *0 1")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes.map(\.name),
|
||||
["Plain Paper", "Photo Paper Plus Glossy II"])
|
||||
}
|
||||
|
||||
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
|
||||
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
|
||||
func testLoadPPDLatin1Fallback() async throws {
|
||||
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
|
||||
let (service, root) = try makeCupsService(
|
||||
ppdData: ppd.data(using: .isoLatin1)!,
|
||||
listing: "CNIJMediaType/Media Type: *0")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let caps = try await service.capabilities(for: "Canon_Test")
|
||||
XCTAssertEqual(caps.mediaTypes,
|
||||
[PrinterMediaType(id: "0", name: "Papier Couché")])
|
||||
}
|
||||
|
||||
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
|
||||
/// the private `loadPPD` — same mock style as
|
||||
/// `MediaLibraryViewModelTests.installMockCups`.
|
||||
private func makeCupsService(
|
||||
ppdData: Data,
|
||||
listing: String,
|
||||
queue: String = "Canon_Test"
|
||||
) throws -> (CupsService, URL) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
|
||||
let bin = root.appendingPathComponent("bin")
|
||||
let ppdDir = root.appendingPathComponent("ppd")
|
||||
for dir in [bin, ppdDir] {
|
||||
try FileManager.default.createDirectory(
|
||||
at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
printf '%s\\n' '\(listing)'
|
||||
"""
|
||||
let scriptURL = bin.appendingPathComponent("lpoptions")
|
||||
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
|
||||
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
|
||||
return (CupsService(
|
||||
processManager: ProcessManager(),
|
||||
binaryDir: bin, ppdDir: ppdDir), root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,61 @@ final class LpArgsTests: XCTestCase {
|
||||
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
|
||||
|
||||
@@ -52,6 +52,23 @@ final class PrintPanelStubTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// #183 — the stub parses paper size / quality / orientation out of
|
||||
/// `ICCERY_TEST_PANEL_OPTIONS` so UI tests can verify apply-back.
|
||||
func testOkResultExtractsNewFields() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS":
|
||||
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4",
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
XCTAssertEqual(result?.options.paperSize, "Letter")
|
||||
XCTAssertEqual(result?.options.quality, "305")
|
||||
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||
}
|
||||
}
|
||||
|
||||
func testOkDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
|
||||
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
|
||||
/// triggers, and `PrintOptions` wiring into `lp` argv.
|
||||
/// `lpoptions`/`lp` are mock scripts in the test env's `cups-bin` — no
|
||||
/// live CUPS is touched.
|
||||
@MainActor
|
||||
final class PrintSessionViewModelTests: XCTestCase {
|
||||
|
||||
private var env: TestAppEnvironment!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
env = try TestAppEnvironment.make()
|
||||
lpArgvURL = env.root.appendingPathComponent("lp-argv.log")
|
||||
try writeCupsFixtures()
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
env?.cleanup()
|
||||
env = nil
|
||||
lpArgvURL = nil
|
||||
}
|
||||
|
||||
private var binDir: URL {
|
||||
env.root.appendingPathComponent("cups-bin")
|
||||
}
|
||||
|
||||
/// Mock `lpoptions -l` advertises paper sizes + a quality key;
|
||||
/// mock `lp` appends its argv to `lpArgvURL` for assertions.
|
||||
private func writeCupsFixtures() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: binDir, withIntermediateDirectories: true)
|
||||
let lpoptions = """
|
||||
#!/bin/sh
|
||||
list=0
|
||||
queue=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-l) list=1 ;;
|
||||
-*) ;;
|
||||
*) queue="$arg" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\\n'
|
||||
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
|
||||
exit 0
|
||||
"""
|
||||
let lp = """
|
||||
#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "\(lpArgvURL.path)"
|
||||
exit 0
|
||||
"""
|
||||
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 {
|
||||
TargetWorkflowViewModel(environment: env.environment)
|
||||
}
|
||||
|
||||
private func loadCaps(
|
||||
_ vm: PrintSessionViewModel, queue: String = "Mock_Q"
|
||||
) async {
|
||||
vm.selectedPrinter = queue
|
||||
await vm.reloadSelectedCapabilities()
|
||||
}
|
||||
|
||||
private func waitForFile(
|
||||
_ url: URL, timeout: TimeInterval = 10
|
||||
) async -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let text = try? String(contentsOf: url, encoding: .utf8),
|
||||
!text.isEmpty {
|
||||
return text
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Seeding / mirror
|
||||
|
||||
/// `pageSize = .a4` matches the capability named "A4" → its id.
|
||||
func testSeedMatchesPageSizeRawValue() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.pageSize, .a4)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
// Quality seeds from the `*` default on caps load.
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
// All seven Epson codes enumerate in driver order (#180).
|
||||
XCTAssertEqual(workflow.print.printerCaps.qualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
}
|
||||
|
||||
/// `.custom` → synthetic `id: 0` entry whose token is the
|
||||
/// dimensions in **points** (mm × 72/25.4): 210×297 → `Custom.595x842`.
|
||||
func testSeedCustomPageSizeSyntheticEntry() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .custom
|
||||
workflow.customPageW = 210
|
||||
workflow.customPageH = 297
|
||||
await loadCaps(workflow.print)
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 0)
|
||||
let synthetic = workflow.print.printerCaps.paperSizes
|
||||
.first { $0.id == 0 }
|
||||
XCTAssertEqual(synthetic?.name, "Custom.595x842")
|
||||
XCTAssertEqual(workflow.print.selectedPaperSizeToken, "Custom.595x842")
|
||||
}
|
||||
|
||||
/// A `workflow.pageSize` change re-mirrors the picker.
|
||||
func testReseedOnPageSizeChange() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
|
||||
workflow.pageSize = .letter
|
||||
workflow.print.seedPaperSelection()
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
}
|
||||
|
||||
/// A user pick survives unrelated publishes — re-seed only fires
|
||||
/// on pageSize / printer / caps triggers (#183, R14).
|
||||
func testUserEditPreservedAcrossUnrelatedPublishes() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
|
||||
workflow.print.selectedTray = 2
|
||||
workflow.print.printOrientation = "landscape"
|
||||
workflow.print.printNotice = Notice(kind: .info, text: "x")
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 5)
|
||||
}
|
||||
|
||||
/// A printer change re-seeds from `workflow.pageSize` after the
|
||||
/// capabilities reload (the picker re-mirrors, not guesses).
|
||||
func testPrinterChangeReseeds() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 5
|
||||
workflow.print.selectedQuality = "301"
|
||||
|
||||
// The view nils quality on printer change before reloading —
|
||||
// the VM re-seeds `when nil` only (#183 contract).
|
||||
workflow.print.selectedPrinter = "Other_Q"
|
||||
workflow.print.selectedQuality = nil
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// A pageSize with no capability match leaves the pick nil —
|
||||
/// never a guessed id.
|
||||
func testSeedNoMatchLeavesNil() async {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a2
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertNil(workflow.print.selectedPaperSize)
|
||||
XCTAssertNil(workflow.print.selectedPaperSizeToken)
|
||||
}
|
||||
|
||||
// MARK: - Spool wiring
|
||||
|
||||
/// `spool` emits the Stage 2 paper token and quality through
|
||||
/// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o <qualityKey>=`).
|
||||
func testSpoolPassesPaperTokenAndQuality() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedPaperSize = 4 // Letter
|
||||
workflow.print.selectedQuality = "301"
|
||||
|
||||
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 argv = await waitForFile(lpArgvURL)
|
||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=301"), argv)
|
||||
}
|
||||
|
||||
// MARK: - Panel apply-back (stubbed NSPrintPanel)
|
||||
|
||||
/// The stubbed panel's captured `PageSize=`/`EPIJ_Qual=` apply back
|
||||
/// to `selectedPaperSize`/`selectedQuality` (#183 capture-return).
|
||||
func testPanelResultAppliesBackSelections() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||
"PageSize=Letter EPIJ_Qual=305", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
workflow.pageSize = .a4
|
||||
await loadCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while Date() < deadline,
|
||||
workflow.print.selectedPaperSize != 4
|
||||
|| workflow.print.selectedQuality != "305" {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
||||
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys and the
|
||||
# recorded XP-55 EPIJ_Qual line — the driver's own non-sorted order
|
||||
# (308 sits between 303 and 304, #180).
|
||||
queue=""
|
||||
list=0
|
||||
for arg in "$@"; do
|
||||
@@ -15,6 +17,7 @@ if [ "$list" = "1" ]; then
|
||||
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
|
||||
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import XCTest
|
||||
|
||||
/// Milestone 11 UI tests — issue #183 Stage 2 paper size + print
|
||||
/// quality pickers. Runs against the same mock CUPS fixture binaries
|
||||
/// as `Milestone3UITests`; the `NSPrintPanel` stays stubbed through
|
||||
/// `ICCERY_TEST_PRINT_PANEL` (XCUITest cannot drive the system modal).
|
||||
@MainActor
|
||||
final class Milestone11PrintSettingsUITests: XCTestCase {
|
||||
|
||||
private var app: XCUIApplication!
|
||||
private var testRoot: URL!
|
||||
private var binDir: URL!
|
||||
private var workDir: URL!
|
||||
private var lpArgvURL: URL!
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
testRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("iccery-ui11-\(UUID().uuidString)")
|
||||
binDir = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures/bin")
|
||||
workDir = testRoot.appendingPathComponent("work")
|
||||
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
||||
try FileManager.default.createDirectory(
|
||||
at: workDir, withIntermediateDirectories: true)
|
||||
|
||||
app = XCUIApplication()
|
||||
app.launchEnvironment = [
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_ROOT": testRoot.path,
|
||||
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||
"ICCERY_TEST_SAVE_TARGET":
|
||||
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
||||
]
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
if let testRoot {
|
||||
try? FileManager.default.removeItem(at: testRoot)
|
||||
}
|
||||
testRoot = nil
|
||||
}
|
||||
|
||||
private func element(_ id: String) -> XCUIElement {
|
||||
let inApp = app.descendants(matching: .any)[id]
|
||||
if inApp.exists { return inApp }
|
||||
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||
}
|
||||
|
||||
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let el = element(id)
|
||||
if el.exists { return el }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
let el = element(id)
|
||||
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||
return el
|
||||
}
|
||||
|
||||
/// Drive the app through targen + printtarg so the print panel is
|
||||
/// live with a manifest.
|
||||
private func reachPrintPanel() {
|
||||
app.buttons["btnBrowse"].click()
|
||||
app.buttons["btnGenerate"].click()
|
||||
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||
app.buttons["btnCreateLayout"].click()
|
||||
_ = waitFor("galleryPage-0", timeout: 25)
|
||||
}
|
||||
|
||||
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
if !out.isEmpty { return out }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
/// The paper-size and quality pickers exist with their new ids;
|
||||
/// the existing tray / media / orientation ids are unchanged (#183).
|
||||
func testPaperAndQualityPickersExist() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
XCTAssertTrue(element("printerPaperSizeSelect").exists)
|
||||
XCTAssertTrue(element("printerQualitySelect").exists)
|
||||
XCTAssertTrue(element("paperSizeGroup").exists)
|
||||
XCTAssertTrue(element("qualityGroup").exists)
|
||||
|
||||
// Existing ids untouched. (`mediaTypeGroup` is not asserted —
|
||||
// stacked `.accessibilityIdentifier` modifiers collapse to the
|
||||
// last one, so it never resolved even before this change.)
|
||||
XCTAssertTrue(element("printerSelect").exists)
|
||||
XCTAssertTrue(element("printerTraySelect").exists)
|
||||
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||
XCTAssertTrue(element("btnOrientPortrait").exists)
|
||||
XCTAssertTrue(element("btnOrientLandscape").exists)
|
||||
XCTAssertTrue(element("btnPrinterProperties").exists)
|
||||
}
|
||||
|
||||
/// The displayed selection of a picker — `AXTitle` for a popup
|
||||
/// button, falling back to label/value depending on how AppKit
|
||||
/// exposes the current item.
|
||||
private func selection(of id: String) -> String {
|
||||
let el = element(id)
|
||||
for candidate in [el.title, el.label, el.value as? String ?? ""] {
|
||||
if !candidate.isEmpty, candidate != el.identifier {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return el.title
|
||||
}
|
||||
|
||||
/// The paper picker seeds from Stage 1's `pageSize` (A4 default)
|
||||
/// and the quality picker from the driver's `*` default choice.
|
||||
func testPickersSeedFromStage1AndDriverDefault() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
_ = waitFor("printerPaperSizeSelect")
|
||||
_ = waitFor("printerQualitySelect")
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "A4")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "303")
|
||||
}
|
||||
|
||||
/// #180 — the quality picker lists all seven Epson `EPIJ_Qual`
|
||||
/// codes in the driver's own order (308 between 303 and 304); no
|
||||
/// PPD is injected under UI testing so items show raw tokens.
|
||||
func testQualityPickerListsAllSevenDriverOptions() throws {
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
let picker = app.popUpButtons["printerQualitySelect"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||
picker.click()
|
||||
|
||||
let expected = ["301", "302", "303", "308", "304", "305", "307"]
|
||||
for token in expected {
|
||||
XCTAssertTrue(
|
||||
app.menuItems[token].waitForExistence(timeout: 5),
|
||||
"Missing quality menu item \(token)")
|
||||
}
|
||||
let titles = app.menuItems.allElementsBoundByIndex
|
||||
.map(\.title)
|
||||
.filter { expected.contains($0) }
|
||||
XCTAssertEqual(titles, expected)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
}
|
||||
|
||||
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
|
||||
/// apply back into the Stage 2 pickers and reach the `lp` argv
|
||||
/// (R15 — the real modal is never driven).
|
||||
func testPanelResultAppliesBackToPickers() throws {
|
||||
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||
"PageSize=Letter EPIJ_Qual=305"
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
element("btnPrinterProperties").click()
|
||||
let notice = element("printNotificationText")
|
||||
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||
XCTAssertTrue((notice.value as? String ?? "")
|
||||
.contains("Settings captured"))
|
||||
|
||||
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
|
||||
|
||||
app.buttons["btnPrintAll"].click()
|
||||
let argv = waitForLpLine()
|
||||
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||
}
|
||||
|
||||
private func launchAppWithDefaults() {
|
||||
app.launch()
|
||||
app.activate()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user