diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift index 149f4e5..40e43cb 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -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", ] diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 22221f7..9b49fd3 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -232,6 +232,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? { + 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`; diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 63eeb60..0857209 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift index 3f0ed61..0cc354c 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift @@ -20,6 +20,7 @@ public enum LpArgsError: LocalizedError, Equatable { /// -o AP.ColorMatchingMode=AP_ApplicationColorMatching /// /// +/// (#183) /// /// /// @@ -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( diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index 2b95f8d..fd5b614 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -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 =` (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 } diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 88d0bd5..a3ccbcd 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -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: diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 3436efe..6a047bb 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -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 + optionKeys: Set, + 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 diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 3eabd2f..1e40b1e 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -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.x` 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.x` 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.x` 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( diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index e04b162..115015f 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -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)") diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 2e9af7f..ec009cb 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -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, diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index a4be264..1ffe635 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -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 = """ @@ -117,6 +117,82 @@ 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") + 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) + + // The fixture's only roster member is cupsPrintQuality. + XCTAssertEqual(caps.qualityKey, "cupsPrintQuality") + XCTAssertEqual(caps.qualities.map(\.id), ["Draft", "Normal", "High"]) + XCTAssertEqual(caps.qualityDefault, "Normal") + } + + func testCapabilitiesQualityPpdLabels() { + let ppd = """ + *EPIJ_Qual 301/Draft: "" + *EPIJ_Qual 303/Normal: "" + *EPIJ_Qual 308/High Speed: "" + """ + let service = CupsService() + let listings = CupsParsers.lpoptionsList( + "EPIJ_Qual/Print Quality: 301 *303 308\n") + let caps = service.capabilities(from: listings, ppd: ppd) + + XCTAssertEqual(caps.qualityKey, "EPIJ_Qual") + XCTAssertEqual(caps.qualities, [ + PrinterQuality(id: "301", name: "Draft"), + PrinterQuality(id: "303", name: "Normal"), + PrinterQuality(id: "308", name: "High Speed"), + ]) + XCTAssertEqual(caps.qualityDefault, "303") + } + + 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? { CupsParsers.detectDriverColorBypass(optionKeys: keys) diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift index 893aa96..7d80f4f 100644 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -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: "303"), + optionKeys: ["EPIJ_Qual", "MediaType"]) + XCTAssertTrue(argv.contains("EPIJ_Qual=303")) + // Emit order: after the media option, before orientation. + let media = argv.firstIndex(of: "MediaType=Photo")! + let quality = argv.firstIndex(of: "EPIJ_Qual=303")! + 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 diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift index 5a92936..d60db4c 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -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", diff --git a/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift new file mode 100644 index 0000000..349f391 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -0,0 +1,244 @@ +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.x` 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 304\\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") + } + + /// `.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 =`). + 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") + } +} diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions index d274f3b..35a5dd2 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpoptions +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -15,6 +15,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 304 305 307 308\n' printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' printf 'ColorModel/Output Mode: *RGB Gray\n' exit 0 diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift new file mode 100644 index 0000000..79df20d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -0,0 +1,169 @@ +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") + } + + /// 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() + } +}