8 changed files with 405 additions and 25 deletions
@@ -266,13 +266,20 @@ public enum CupsParsers {
}
/// 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? {
let pairs = lpoptions(options)
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
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,
+142 -16
View File
@@ -18,18 +18,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.
/// opens (#183/#186). Every write is warn-only the panel still
/// opens when a driver ignores a key.
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?
/// 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
/// The selected media token written to the queue's detected
/// 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
@@ -109,11 +112,25 @@ struct PrintPanelService {
_ = PMSessionDefaultPrintSettings(session, settings)
_ = PMSessionDefaultPageFormat(session, pageFormat)
// Initial selections after `PMSessionDefault*`, before
// ColorSync suppression (locked write order, #183).
applyInitialSelections(initialSelections, to: settings)
// ColorSync suppression (locked write order,
// #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
} else {
// 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,
let nsPrinter = NSPrinter(name: displayName)
else {
@@ -192,21 +209,130 @@ struct PrintPanelService {
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.
/// Initial-selection `PMPrintSettings` writes paper, quality,
/// media type, orientation. All warn-only: a driver that ignores
/// a key must not keep the panel from opening (R12 surfaces via
/// the capture echo instead).
private func applyInitialSelections(
_ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings
to settings: PMPrintSettings,
optionKeys: Set<String>
) {
if let paperSize = selections.paperSize {
_ = PMPrintSettingsSetValue(
warnOnFailure(PMPrintSettingsSetValue(
settings, "PageSize" as CFString,
paperSize as CFString, false)
paperSize as CFString, false), key: "PageSize")
}
if let key = selections.qualityKey, let value = selections.quality {
_ = PMPrintSettingsSetValue(
settings, key as CFString, value as CFString, false)
warnOnFailure(PMPrintSettingsSetValue(
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,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
mediaType: nil,
orientation: nil)
mediaType: selectedMediaType,
orientation: printOrientation)
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
@@ -176,9 +176,10 @@ 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).
// Capture-return (#183/#186): a dialog paper/quality/
// orientation 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 }) {
@@ -187,6 +188,9 @@ final class PrintSessionViewModel: ObservableObject {
if let quality = result.options.quality {
selectedQuality = quality
}
if let orientation = result.options.orientation {
printOrientation = orientation
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
@@ -232,6 +232,42 @@ final class CupsParserTests: XCTestCase {
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 pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
@@ -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 {
try withEnv([
"ICCERY_UI_TESTING": "1",
@@ -244,4 +244,149 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
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)
}
/// #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() {
app.launch()
app.activate()
File diff suppressed because one or more lines are too long