diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 214772e..894960f 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -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, diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 6a047bb..d20c4a4 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -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 ) { 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.x` 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? + 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..? + 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.x` → 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))") } } diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 1e40b1e..7f684af 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -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).", diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index dd6a526..b61b8cd 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -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? { CupsParsers.detectDriverColorBypass(optionKeys: keys) diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift index d60db4c..52738a2 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -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", diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift index 7a98509..c58694d 100644 --- a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -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)'") + } } diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift index d42052b..d458361 100644 --- a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -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()