Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f466ae46 | ||
|
|
d228594bd3 | ||
|
|
6adbf99bd9 | ||
|
|
d8c41444b9 | ||
|
|
1767792f75 | ||
|
|
e8678586bd | ||
|
|
c9b423b229 |
@@ -174,8 +174,13 @@ public enum CupsParsers {
|
|||||||
|
|
||||||
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
|
||||||
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
|
/// 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] {
|
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") {
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
var line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
|
||||||
@@ -183,7 +188,9 @@ public enum CupsParsers {
|
|||||||
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
// Optional locale qualifier: `en_US.InputSlot` → `InputSlot`.
|
||||||
// Only strip when the part before the first `.` looks like
|
// Only strip when the part before the first `.` looks like
|
||||||
// a locale (short `xx`/`xx_YY`); real keys containing dots
|
// 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: ".") {
|
if let dot = line.firstIndex(of: ".") {
|
||||||
let prefix = line[..<dot]
|
let prefix = line[..<dot]
|
||||||
let looksLikeLocale = (2...5).contains(prefix.count)
|
let looksLikeLocale = (2...5).contains(prefix.count)
|
||||||
@@ -191,6 +198,7 @@ public enum CupsParsers {
|
|||||||
&& (prefix.count == 2 || prefix.contains("_"))
|
&& (prefix.count == 2 || prefix.contains("_"))
|
||||||
let candidate = line[line.index(after: dot)...]
|
let candidate = line[line.index(after: dot)...]
|
||||||
if looksLikeLocale && candidate.hasPrefix(key) {
|
if looksLikeLocale && candidate.hasPrefix(key) {
|
||||||
|
qualifier = prefix.lowercased()
|
||||||
line = String(candidate)
|
line = String(candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,15 +209,50 @@ public enum CupsParsers {
|
|||||||
rest = String(rest[..<colon])
|
rest = String(rest[..<colon])
|
||||||
// `<id>/<Human label>` — human label after the last `/`.
|
// `<id>/<Human label>` — human label after the last `/`.
|
||||||
guard let slash = rest.firstIndex(of: "/") else { continue }
|
guard let slash = rest.firstIndex(of: "/") else { continue }
|
||||||
let id = String(rest[..<slash])
|
let id = ppdUnescape(String(rest[..<slash])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
let human = String(rest[rest.index(after: slash)...])
|
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
|
||||||
.trimmingCharacters(in: .whitespaces)
|
.trimmingCharacters(in: .whitespaces))
|
||||||
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
|
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
|
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)
|
// MARK: - Detection (docs/11)
|
||||||
|
|
||||||
/// Media-type option key in preference order — used both to read a
|
/// Media-type option key in preference order — used both to read a
|
||||||
@@ -223,13 +266,20 @@ public enum CupsParsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media type from a captured `key=value key=value` options string.
|
/// Media type from a captured `key=value key=value` options string.
|
||||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
|
||||||
|
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
|
||||||
|
/// `StpMediaType` (#186 capture-return).
|
||||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||||
let pairs = lpoptions(options)
|
let pairs = lpoptions(options)
|
||||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
for key in mediaTypeKeys where key != "MediaType" {
|
||||||
|
if let v = pairs.first(where: { $0.key == key })?.value {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print-quality option key in preference order — vendor-first,
|
/// Print-quality option key in preference order — vendor-first,
|
||||||
|
|||||||
@@ -187,7 +187,11 @@ public struct CupsService: Sendable {
|
|||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
let url = ppdDir.appendingPathComponent("\(queue).ppd")
|
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
|
// MARK: - Spawn
|
||||||
|
|||||||
@@ -18,18 +18,21 @@ enum PrintPanelError: LocalizedError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stage 2 selections pre-applied to the bound print panel before it
|
/// Stage 2 selections pre-applied to the bound print panel before it
|
||||||
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/
|
/// opens (#183/#186). Every write is warn-only — the panel still
|
||||||
/// `quality` only; `mediaType` and `orientation` preselect — and the
|
/// opens when a driver ignores a key.
|
||||||
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
|
|
||||||
struct PrintPanelInitialSelections {
|
struct PrintPanelInitialSelections {
|
||||||
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`.
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
||||||
|
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
||||||
var paperSize: String?
|
var paperSize: String?
|
||||||
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
||||||
var qualityKey: String?
|
var qualityKey: String?
|
||||||
/// The selected quality token.
|
/// The selected quality token.
|
||||||
var quality: String?
|
var quality: String?
|
||||||
var mediaType: String? // #186 consumes
|
/// The selected media token — written to the queue's detected
|
||||||
var orientation: String? // #186 consumes
|
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
||||||
|
var mediaType: String?
|
||||||
|
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
||||||
|
var orientation: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||||
@@ -109,11 +112,25 @@ struct PrintPanelService {
|
|||||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||||
// Initial selections — after `PMSessionDefault*`, before
|
// Initial selections — after `PMSessionDefault*`, before
|
||||||
// ColorSync suppression ②–⑤ (locked write order, #183).
|
// ColorSync suppression ②–⑤ (locked write order,
|
||||||
applyInitialSelections(initialSelections, to: settings)
|
// #183/#186). Paper is TWO writes (E1): the `PageSize`
|
||||||
|
// print-settings value drivers/capture read AND the
|
||||||
|
// `PMPageFormat` paper the panel's dropdown reflects.
|
||||||
|
applyInitialSelections(
|
||||||
|
initialSelections, to: settings, optionKeys: optionKeys)
|
||||||
|
if let paperToken = initialSelections.paperSize {
|
||||||
|
applyPaperPageFormat(
|
||||||
|
paperToken, printer: printer, session: session,
|
||||||
|
printInfo: printInfo)
|
||||||
|
}
|
||||||
boundViaPM = true
|
boundViaPM = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||||
|
// Warn — the display name can resolve a *different* queue
|
||||||
|
// (#186 E2: diagnosable, not a proven defect).
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PM binding unavailable for '\(queue)' — "
|
||||||
|
+ "falling back to NSPrinter(displayName)")
|
||||||
guard let displayName,
|
guard let displayName,
|
||||||
let nsPrinter = NSPrinter(name: displayName)
|
let nsPrinter = NSPrinter(name: displayName)
|
||||||
else {
|
else {
|
||||||
@@ -192,21 +209,130 @@ struct PrintPanelService {
|
|||||||
cupsOptions: cupsOptions))
|
cupsOptions: cupsOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial-selection `PMPrintSettings` writes — paper size and
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||||
/// quality only this phase; media type / orientation and the
|
/// media type, orientation. All warn-only: a driver that ignores
|
||||||
/// `PMPageFormat`/`PMPaper` paper half are #186's contract.
|
/// a key must not keep the panel from opening (R12 surfaces via
|
||||||
|
/// the capture echo instead).
|
||||||
private func applyInitialSelections(
|
private func applyInitialSelections(
|
||||||
_ selections: PrintPanelInitialSelections,
|
_ selections: PrintPanelInitialSelections,
|
||||||
to settings: PMPrintSettings
|
to settings: PMPrintSettings,
|
||||||
|
optionKeys: Set<String>
|
||||||
) {
|
) {
|
||||||
if let paperSize = selections.paperSize {
|
if let paperSize = selections.paperSize {
|
||||||
_ = PMPrintSettingsSetValue(
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
settings, "PageSize" as CFString,
|
settings, "PageSize" as CFString,
|
||||||
paperSize as CFString, false)
|
paperSize as CFString, false), key: "PageSize")
|
||||||
}
|
}
|
||||||
if let key = selections.qualityKey, let value = selections.quality {
|
if let key = selections.qualityKey, let value = selections.quality {
|
||||||
_ = PMPrintSettingsSetValue(
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
settings, key as CFString, value as CFString, false)
|
settings, key as CFString,
|
||||||
|
value as CFString, false), key: key)
|
||||||
|
}
|
||||||
|
// Media type via the queue's detected vendor key (#186).
|
||||||
|
if let mediaType = selections.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||||
|
optionKeys: optionKeys) {
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, mediaKey as CFString,
|
||||||
|
mediaType as CFString, false), key: mediaKey)
|
||||||
|
}
|
||||||
|
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
||||||
|
if let orientation = selections.orientation {
|
||||||
|
let code = orientation == "landscape" ? "4" : "3"
|
||||||
|
warnOnFailure(PMPrintSettingsSetValue(
|
||||||
|
settings, "orientation-requested" as CFString,
|
||||||
|
code as CFString, false), key: "orientation-requested")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `PMPageFormat` half of paper preselect (#186 E1): the
|
||||||
|
/// panel's paper dropdown reflects the page format's `PMPaper`,
|
||||||
|
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
|
||||||
|
/// paper from `PMPrinterGetPaperList`, rebuild the page format
|
||||||
|
/// around it, and copy it into the printInfo's format (TN2248:
|
||||||
|
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
|
||||||
|
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
|
||||||
|
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
|
||||||
|
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
|
||||||
|
/// missed match must not keep the panel from opening.
|
||||||
|
private func applyPaperPageFormat(
|
||||||
|
_ token: String,
|
||||||
|
printer: PMPrinter,
|
||||||
|
session: PMPrintSession,
|
||||||
|
printInfo: NSPrintInfo
|
||||||
|
) {
|
||||||
|
if let custom = Self.customPaperDimensions(from: token) {
|
||||||
|
printInfo.paperSize = NSSize(
|
||||||
|
width: custom.width, height: custom.height)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var paperList: Unmanaged<CFArray>?
|
||||||
|
guard PMPrinterGetPaperList(printer, &paperList) == 0,
|
||||||
|
let papers = paperList?.takeUnretainedValue()
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrinterGetPaperList failed — "
|
||||||
|
+ "paper preselect skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The list (and its elements) is owned by the printer —
|
||||||
|
// borrowed, never released.
|
||||||
|
var match: PMPaper?
|
||||||
|
for index in 0..<CFArrayGetCount(papers) {
|
||||||
|
let paper = unsafeBitCast(
|
||||||
|
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
|
||||||
|
var idRef: Unmanaged<CFString>?
|
||||||
|
guard PMPaperGetID(paper, &idRef) == 0,
|
||||||
|
let paperID = idRef?.takeUnretainedValue() as String?
|
||||||
|
else { continue }
|
||||||
|
if paperID == token {
|
||||||
|
match = paper
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let paper = match else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: no PMPaper id matches '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created: PMPageFormat?
|
||||||
|
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
|
||||||
|
let newFormat = created
|
||||||
|
else {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMCreatePageFormatWithPMPaper failed "
|
||||||
|
+ "for '\(token)'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) }
|
||||||
|
_ = PMSessionValidatePageFormat(session, newFormat, nil)
|
||||||
|
let destination = unsafeBitCast(
|
||||||
|
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||||
|
_ = PMCopyPageFormat(newFormat, destination)
|
||||||
|
printInfo.updateFromPMPageFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Custom.<w>x<h>` → dimensions in points (the token builder
|
||||||
|
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
|
||||||
|
/// malformed tokens — a malformed `Custom.*` then misses the
|
||||||
|
/// `PMPaper` match and logs instead of guessing a size.
|
||||||
|
static func customPaperDimensions(
|
||||||
|
from token: String
|
||||||
|
) -> (width: Double, height: Double)? {
|
||||||
|
guard token.hasPrefix("Custom.") else { return nil }
|
||||||
|
let dims = token.dropFirst("Custom.".count).split(separator: "x")
|
||||||
|
guard dims.count == 2,
|
||||||
|
let width = Double(dims[0]), let height = Double(dims[1]),
|
||||||
|
width > 0, height > 0
|
||||||
|
else { return nil }
|
||||||
|
return (width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func warnOnFailure(_ status: OSStatus, key: String) {
|
||||||
|
if status != 0 {
|
||||||
|
AppLogger.shared.warn(
|
||||||
|
"Print panel: PMPrintSettingsSetValue(\(key)) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
paperSize: selectedPaperSizeToken,
|
paperSize: selectedPaperSizeToken,
|
||||||
qualityKey: printerCaps.qualityKey,
|
qualityKey: printerCaps.qualityKey,
|
||||||
quality: selectedQuality,
|
quality: selectedQuality,
|
||||||
mediaType: nil,
|
mediaType: selectedMediaType,
|
||||||
orientation: nil)
|
orientation: printOrientation)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
guard let result = try await PrintPanelService()
|
guard let result = try await PrintPanelService()
|
||||||
@@ -176,9 +176,10 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if let media = result.options.mediaType {
|
if let media = result.options.mediaType {
|
||||||
selectedMediaType = media
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
// Capture-return (#183): a dialog paper/quality change
|
// Capture-return (#183/#186): a dialog paper/quality/
|
||||||
// updates the Stage 2 selections — never
|
// orientation change updates the Stage 2 selections —
|
||||||
// `workflow.pageSize` (printtarg layout is sacred).
|
// never `workflow.pageSize` (printtarg layout is
|
||||||
|
// sacred).
|
||||||
if let paper = result.options.paperSize,
|
if let paper = result.options.paperSize,
|
||||||
let match = printerCaps.paperSizes
|
let match = printerCaps.paperSizes
|
||||||
.first(where: { $0.name == paper }) {
|
.first(where: { $0.name == paper }) {
|
||||||
@@ -187,6 +188,9 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
if let quality = result.options.quality {
|
if let quality = result.options.quality {
|
||||||
selectedQuality = quality
|
selectedQuality = quality
|
||||||
}
|
}
|
||||||
|
if let orientation = result.options.orientation {
|
||||||
|
printOrientation = orientation
|
||||||
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
kind: .info,
|
kind: .info,
|
||||||
text: "Settings captured for \(selectedPrinter).",
|
text: "Settings captured for \(selectedPrinter).",
|
||||||
|
|||||||
@@ -232,6 +232,42 @@ final class CupsParserTests: XCTestCase {
|
|||||||
fromOptionsString: "PageSize=A4"))
|
fromOptionsString: "PageSize=A4"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #186 capture-return
|
||||||
|
|
||||||
|
/// A captured `k=v` string maps to all four `PrintOptions`
|
||||||
|
/// fields — `PageSize`, the detected quality key,
|
||||||
|
/// `orientation-requested`, and a vendor media key (#186).
|
||||||
|
func testCapturedStringMapsAllFields() {
|
||||||
|
let captured =
|
||||||
|
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
|
||||||
|
XCTAssertEqual(CupsParsers.extractOption(
|
||||||
|
named: "PageSize", fromOptionsString: captured), "A4")
|
||||||
|
XCTAssertEqual(CupsParsers.extractQuality(
|
||||||
|
fromOptionsString: captured), "305")
|
||||||
|
XCTAssertEqual(CupsParsers.extractOrientation(
|
||||||
|
fromOptionsString: captured), "landscape")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: captured), "Photo")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
|
||||||
|
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
|
||||||
|
/// (#186). `MediaType` still wins when present alongside them.
|
||||||
|
func testExtractMediaTypeRosterFallback() {
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
|
||||||
|
// `MediaType` keeps first precedence (docs/11 §tests).
|
||||||
|
XCTAssertEqual(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
|
||||||
|
"Plain")
|
||||||
|
XCTAssertNil(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4"))
|
||||||
|
}
|
||||||
|
|
||||||
func testDriverBypass() {
|
func testDriverBypass() {
|
||||||
func pair(_ keys: Set<String>) -> String? {
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
@@ -246,4 +282,177 @@ final class CupsParserTests: XCTestCase {
|
|||||||
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
|
||||||
XCTAssertNil(pair(["PageSize"]))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,24 @@ final class PrintPanelStubTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — a vendor media key in the captured string reaches
|
||||||
|
/// `options.mediaType` through the detection roster
|
||||||
|
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
|
||||||
|
func testOkResultExtractsVendorMediaKey() throws {
|
||||||
|
try withEnv([
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||||
|
"ICCERY_TEST_PANEL_OPTIONS":
|
||||||
|
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
|
||||||
|
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||||
|
]) {
|
||||||
|
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||||
|
XCTAssertEqual(result?.options.paperSize, "A4")
|
||||||
|
XCTAssertEqual(result?.options.orientation, "landscape")
|
||||||
|
XCTAssertEqual(result?.options.mediaType, "Photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testOkDefaultsPrinter() throws {
|
func testOkDefaultsPrinter() throws {
|
||||||
try withEnv([
|
try withEnv([
|
||||||
"ICCERY_UI_TESTING": "1",
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
|||||||
@@ -244,4 +244,149 @@ final class PrintSessionViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — the captured `orientation-requested`/media token apply
|
||||||
|
/// back to `printOrientation`/`selectedMediaType`, and a dialog
|
||||||
|
/// result never mutates `workflow.pageSize` (printtarg layout).
|
||||||
|
func testPanelResultAppliesBackOrientationAndMedia() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS",
|
||||||
|
"orientation-requested=4 MediaType=Glossy", 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.printOrientation, "portrait")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
|
||||||
|
XCTAssertEqual(workflow.pageSize, .a4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a captured `PageSize` token with no capability match
|
||||||
|
/// leaves `selectedPaperSize` unchanged (never a guessed id).
|
||||||
|
func testPanelResultUnknownPaperLeavesSelection() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 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)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a stub result pointing at another queue in `printers`
|
||||||
|
/// switches `selectedPrinter` and reloads its capabilities.
|
||||||
|
func testPanelResultSwitchesToKnownQueue() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
workflow.print.printers = [
|
||||||
|
Printer(name: "Mock_Q", isDefault: true),
|
||||||
|
Printer(name: "Other_Q"),
|
||||||
|
]
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(
|
||||||
|
workflow.print, containing: "Settings captured for Other_Q")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
|
||||||
|
// Caps reloaded for the new queue: paper re-seeded, then the
|
||||||
|
// captured PageSize applied back onto the new caps.
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
|
||||||
|
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
|
||||||
|
"PageSize=Letter")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — a stub result naming a queue absent from `printers`
|
||||||
|
/// leaves the selection on the opened queue.
|
||||||
|
func testPanelResultGhostQueueIgnored() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||||
|
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
unsetenv("ICCERY_TEST_PANEL_PRINTER")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(
|
||||||
|
workflow.print, containing: "Settings captured for Mock_Q")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #186 — cancel returns `nil`: info notice, no field changes.
|
||||||
|
func testPanelCancelLeavesSelections() async throws {
|
||||||
|
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||||
|
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
|
||||||
|
defer {
|
||||||
|
unsetenv("ICCERY_UI_TESTING")
|
||||||
|
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflow = makeWorkflow()
|
||||||
|
workflow.pageSize = .a4
|
||||||
|
await loadCaps(workflow.print)
|
||||||
|
workflow.print.printOrientation = "landscape"
|
||||||
|
|
||||||
|
workflow.print.openPrinterPreferences()
|
||||||
|
await waitForNotice(workflow.print, containing: "cancelled")
|
||||||
|
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
|
||||||
|
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||||
|
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||||
|
XCTAssertEqual(workflow.print.printOrientation, "landscape")
|
||||||
|
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until the panel task posts a notice whose text contains
|
||||||
|
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||||
|
private func waitForNotice(
|
||||||
|
_ vm: PrintSessionViewModel,
|
||||||
|
containing fragment: String,
|
||||||
|
timeout: TimeInterval = 10
|
||||||
|
) async {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if let text = vm.printNotice?.text, text.contains(fragment) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
XCTFail("Timed out waiting for notice containing '\(fragment)'")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,50 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
|||||||
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #186 — the stubbed panel result's `orientation-requested=` /
|
||||||
|
/// `MediaType=` apply back to the Stage 2 selections and reach the
|
||||||
|
/// `lp` argv through the captured `cupsOptions` replay.
|
||||||
|
func testPanelResultAppliesBackOrientationAndMedia() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||||
|
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
|
||||||
|
launchAppWithDefaults()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
element("btnPrinterProperties").click()
|
||||||
|
let notice = element("printNotificationText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Settings captured"))
|
||||||
|
|
||||||
|
// `printerMediaTypeSelect` is the group's id — the popup is a
|
||||||
|
// descendant (stacked identifiers collapse to the container).
|
||||||
|
// The popup's AX title lags the binding — poll for the
|
||||||
|
// apply-back value.
|
||||||
|
let mediaPopup = element("printerMediaTypeSelect")
|
||||||
|
.descendants(matching: .popUpButton).firstMatch
|
||||||
|
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||||
|
var mediaSelection = ""
|
||||||
|
let deadline = Date().addingTimeInterval(10)
|
||||||
|
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
|
||||||
|
mediaSelection = [
|
||||||
|
mediaPopup.title, mediaPopup.label,
|
||||||
|
mediaPopup.value as? String ?? "",
|
||||||
|
].first { !$0.isEmpty } ?? ""
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
|
||||||
|
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
|
||||||
|
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
let argv = waitForLpLine()
|
||||||
|
XCTAssertTrue(argv.contains("orientation-requested=4"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
private func launchAppWithDefaults() {
|
private func launchAppWithDefaults() {
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
app.activate()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user