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..894960f 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -174,8 +174,13 @@ public enum CupsParsers { /// PPD `* /:` lines → `id → label` map. /// Language-qualified forms (`*en_US. id/Label:`) also match. + /// Precedence is deterministic, not positional (#181): unqualified + /// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing + /// locale block (Canon `th.CNIJMediaType`) can never overwrite the + /// base English labels — and a qualified-only id still gets its + /// first-seen qualified label (R9). public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] { - var map: [String: String] = [:] + var hits: [String: [(qualifier: String?, label: String)]] = [:] for rawLine in ppd.split(separator: "\n") { var line = rawLine.trimmingCharacters(in: .whitespaces) guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue } @@ -183,7 +188,9 @@ public enum CupsParsers { // Optional locale qualifier: `en_US.InputSlot` → `InputSlot`. // Only strip when the part before the first `.` looks like // a locale (short `xx`/`xx_YY`); real keys containing dots - // are left alone. + // are left alone. The qualifier is recorded for precedence + // rather than dropped (#181). + var qualifier: String? if let dot = line.firstIndex(of: ".") { let prefix = line[../` — human label after the last `/`. guard let slash = rest.firstIndex(of: "/") else { continue } - let id = String(rest[..` 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).. 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, + /// 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 diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 63eeb60..b70b52f 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 @@ -167,7 +187,11 @@ public struct CupsService: Sendable { private func loadPPD(for queue: String) -> String? { let url = ppdDir.appendingPathComponent("\(queue).ppd") - return try? String(contentsOf: url, encoding: .utf8) + // UTF-8 first — the Canon Thai labels are UTF-8 and a blanket + // Latin-1 read would mojibake them (#181, R10). Latin-1 only + // when UTF-8 decoding fails outright. + return (try? String(contentsOf: url, encoding: .utf8)) + ?? (try? String(contentsOf: url, encoding: .isoLatin1)) } // MARK: - Spawn 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..d20c4a4 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -17,6 +17,24 @@ enum PrintPanelError: LocalizedError { } } +/// Stage 2 selections pre-applied to the bound print panel before it +/// 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"`. 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? + /// 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 /// queue (issue 13, docs/11). /// @@ -41,7 +59,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 +76,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 +85,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,9 +111,26 @@ 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/#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 { @@ -138,7 +177,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 +189,7 @@ struct PrintPanelService { cupsOptions = captured.cupsOptions mediaType = captured.mediaType } + let capturedOptions = cupsOptions ?? "" return PrintPropertiesResult( selectedPrinter: boundViaPM ? Self.currentPrinterID( @@ -156,11 +198,144 @@ 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, 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, + optionKeys: Set + ) { + if let paperSize = selections.paperSize { + warnOnFailure(PMPrintSettingsSetValue( + settings, "PageSize" as CFString, + paperSize as CFString, false), key: "PageSize") + } + if let key = selections.qualityKey, let value = selections.quality { + 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))") + } + } + // 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..7f684af 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: selectedMediaType, + orientation: printOrientation) 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,21 @@ final class PrintSessionViewModel: ObservableObject { if let media = result.options.mediaType { selectedMediaType = media } + // 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 }) { + selectedPaperSize = match.id + } + 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).", @@ -122,7 +202,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 +212,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 +236,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 +260,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/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift index b4fe1ba..3acb4e2 100644 --- a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -24,6 +24,18 @@ final class CupsOptionsFilterTests: XCTestCase { XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) } + /// #180 — a captured `EPIJ_Qual` (and the other canonical quality + /// keys) survives the filter so it wins over the Stage 2 explicit + /// quality in `LpArgs`. + func testKeepsQualityKeys() { + let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 " + + "cupsPrintQuality=High Quality=Best " + + "com.apple.print.JobTicket.PMTotalSidesImaged=0" + XCTAssertEqual(CupsOptionsFilter.filter(raw), + "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 " + + "cupsPrintQuality=High Quality=Best") + } + func testKeepsUnknown() { let raw = "VendorFooBar=baz MediaType=Plain" XCTAssertEqual(CupsOptionsFilter.filter(raw), raw) diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index a4be264..b61b8cd 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 = """ @@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase { MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope ColorModel/Output Mode: *RGB Gray Duplex/Duplex: *None DuplexNoTumble DuplexTumble - cupsPrintQuality/cupsPrintQuality: Draft *Normal High + EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307 """ func testDestinations() { @@ -117,6 +117,157 @@ final class CupsParsersTests: XCTestCase { XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"])) } + // MARK: - #183 quality key + option extraction + + func testQualityKeyRosterOrder() { + // Vendor keys beat the generic ones; OutputMode/Resolution sit + // last (they are colour-ish keys on some drivers — #183/#180). + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual") + // A full Epson key set — EPIJ_Qual wins over the colour-mode + // key, the generic keys, and Resolution (#180, R11). + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution", + "cupsPrintQuality", "PrintQuality", + "ColorModel"]), "EPIJ_Qual") + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality") + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["cupsPrintQuality", "CNIJQuality"]), + "CNIJQuality") + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["OutputMode", "Resolution"]), "OutputMode") + XCTAssertEqual(CupsParsers.detectQualityKey( + optionKeys: ["Resolution"]), "Resolution") + XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"])) + } + + func testCapabilitiesQuality() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(lpoptionsL) + let caps = service.capabilities(from: listings, ppd: nil) + + // EPIJ_Qual is the roster member — all seven Epson codes + // enumerate in the driver's own (non-sorted) order (#180). + XCTAssertEqual(caps.qualityKey, "EPIJ_Qual") + XCTAssertEqual(caps.qualities.map(\.id), + ["301", "302", "303", "308", "304", "305", "307"]) + XCTAssertEqual(caps.qualityDefault, "303") + } + + func testCapabilitiesQualityPpdLabels() { + // Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label` + // lines in the driver's own order (#180). + let ppd = """ + *OpenUI *EPIJ_Qual/Print Quality: PickOne + *DefaultEPIJ_Qual: 303 + *EPIJ_Qual 301/Fast Economy: "" + *EPIJ_Qual 302/Economy: "" + *EPIJ_Qual 303/Normal: "" + *EPIJ_Qual 308/Draft: "" + *EPIJ_Qual 304/Fine: "" + *EPIJ_Qual 305/Quality: "" + *EPIJ_Qual 307/Best Quality: "" + *CloseUI: *EPIJ_Qual + """ + let service = CupsService() + let listings = CupsParsers.lpoptionsList( + "EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n") + let caps = service.capabilities(from: listings, ppd: ppd) + + XCTAssertEqual(caps.qualityKey, "EPIJ_Qual") + XCTAssertEqual(caps.qualities, [ + PrinterQuality(id: "301", name: "Fast Economy"), + PrinterQuality(id: "302", name: "Economy"), + PrinterQuality(id: "303", name: "Normal"), + PrinterQuality(id: "308", name: "Draft"), + PrinterQuality(id: "304", name: "Fine"), + PrinterQuality(id: "305", name: "Quality"), + PrinterQuality(id: "307", name: "Best Quality"), + ]) + XCTAssertEqual(caps.qualityDefault, "303") + } + + /// #180 — the Epson listing also carries `OutputMode` (a colour + /// mode) and `Resolution`; detection must still pick `EPIJ_Qual`. + func testCapabilitiesQualityEpsonDetection() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(""" + PageSize/Media Size: *A4 Letter + EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307 + OutputMode/Color Mode: *Color Mono + Resolution/Resolution: *360dpi 720dpi + """) + let caps = service.capabilities(from: listings, ppd: nil) + + XCTAssertEqual(caps.qualityKey, "EPIJ_Qual") + XCTAssertEqual(caps.qualities.count, 7) + XCTAssertEqual(caps.qualityDefault, "303") + XCTAssertFalse(caps.qualities.contains { $0.id == "Color" }) + } + + func testExtractOption() { + let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'" + XCTAssertEqual(CupsParsers.extractOption( + named: "PageSize", fromOptionsString: options), "A4") + // Case-insensitive key match. + XCTAssertEqual(CupsParsers.extractOption( + named: "epij_qual", fromOptionsString: options), "303") + // Quoted values come back unquoted. + XCTAssertEqual(CupsParsers.extractOption( + named: "printer-info", fromOptionsString: options), "EPSON XP-55") + XCTAssertNil(CupsParsers.extractOption( + named: "InputSlot", fromOptionsString: options)) + } + + func testExtractQualityAndOrientation() { + let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305" + XCTAssertEqual(CupsParsers.extractQuality( + fromOptionsString: options), "305") + XCTAssertEqual(CupsParsers.extractOrientation( + fromOptionsString: options), "landscape") + XCTAssertNil(CupsParsers.extractQuality( + fromOptionsString: "PageSize=A4")) + XCTAssertNil(CupsParsers.extractOrientation( + fromOptionsString: "PageSize=A4")) + } + + // 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) @@ -131,4 +282,177 @@ final class CupsParsersTests: XCTestCase { XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off") XCTAssertNil(pair(["PageSize"])) } + + // MARK: - #181 Canon media locale precedence + PPD encoding + + /// The 18 Canon Pro9500 media types named in the issue — the ids + /// are the numeric codes the driver enumerates via `lpoptions -l`. + private let canonMedia: [(id: String, label: String)] = [ + ("0", "Plain Paper"), + ("1", "Photo Paper Plus Glossy II"), + ("2", "Photo Paper Pro Platinum N"), + ("3", "Photo Paper Pro Platinum"), + ("4", "Photo Paper Pro Luster"), + ("5", "Photo Paper Plus Semi-gloss"), + ("6", "Matte Photo Paper"), + ("7", "Fine Art \"Photo Rag\""), + ("8", "Fine Art \"Museum Etching\""), + ("9", "Photo Paper Pro Premium Matte"), + ("10", "Fine Art Premium Matte"), + ("11", "Other Fine Art Paper"), + ("12", "Canvas"), + ("13", "Board Paper"), + ("14", "Ink Jet Hagaki"), + ("15", "Hagaki"), + ("16", "Printable disc"), + ("17", "Printable disc (bleed-proof)"), + ] + + /// Canon Pro9500-shaped fragment: the unqualified base block comes + /// early and the `th.` block trails at the end — the ordering that + /// let Thai overwrite English under last-write-wins (#181). + private var canonPPD: String { + var lines = [ + "*OpenUI *CNIJMediaType/Media Type: PickOne", + "*DefaultCNIJMediaType: 0", + ] + for media in canonMedia { + lines.append( + "*CNIJMediaType \(media.id)/\(media.label): \"\"") + } + lines.append("*CloseUI: *CNIJMediaType") + for media in canonMedia { + lines.append( + "*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"") + } + return lines.joined(separator: "\n") + } + + func testPpdLabelsUnqualifiedSurvivesTrailingThai() { + let labels = CupsParsers.ppdChoiceLabels( + canonPPD, key: "CNIJMediaType") + XCTAssertEqual(labels["0"], "Plain Paper") + XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)") + } + + func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() { + // `th.` block first — precedence is deterministic, not + // positional (#181, E3). + let ppd = """ + *th.CNIJMediaType 0/กระดาษธรรมดา: "" + *CNIJMediaType 0/Plain Paper: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["0"], "Plain Paper") + } + + func testPpdLabelsQualifiedFallbackOrder() { + // en_US > en > first-qualified-seen (#181, E3). + let ppd = """ + *en.CNIJMediaType 1/English Label: "" + *en_US.CNIJMediaType 1/US English Label: "" + *th.CNIJMediaType 1/กระดาษ: "" + *fr.CNIJMediaType 2/Français: "" + *de.CNIJMediaType 2/Deutsch: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["1"], "US English Label") + // A qualified-only id still gets its first-seen qualified + // label — never left unlabeled (R9). + XCTAssertEqual(labels["2"], "Français") + } + + func testPpdLabelsHexEscapeDecoding() { + let ppd = """ + *CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: "" + *CNIJMediaType 4/Plain<20>Paper: "" + *CNIJMediaType 5/BadEscape: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte") + XCTAssertEqual(labels["4"], "Plain Paper") + XCTAssertEqual(labels["5"], "BadEscape") + } + + /// 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) + } } diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift index 893aa96..b8bddd5 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: "305"), + optionKeys: ["EPIJ_Qual", "MediaType"]) + XCTAssertTrue(argv.contains("EPIJ_Qual=305")) + // Emit order: after the media option, before orientation. + let media = argv.firstIndex(of: "MediaType=Photo")! + let quality = argv.firstIndex(of: "EPIJ_Qual=305")! + let orient = argv.firstIndex(of: "orientation-requested=3")! + XCTAssertTrue(media < quality && quality < orient) + } + + func testQualityDetectedKey() throws { + // The detected queue key is used, not a hardcoded one. + let argv = try build( + options: PrintOptions(quality: "High"), + optionKeys: ["cupsPrintQuality"]) + XCTAssertTrue(argv.contains("cupsPrintQuality=High")) + } + + func testCapturedWinsQuality() throws { + let argv = try build( + options: PrintOptions( + quality: "303", + cupsOptions: "EPIJ_Qual=308"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertTrue(argv.contains("EPIJ_Qual=308")) + XCTAssertFalse(argv.contains("EPIJ_Qual=303")) + } + + func testCapturedQualityCaseInsensitiveDedup() throws { + let argv = try build( + options: PrintOptions( + quality: "303", + cupsOptions: "epij_qual=308"), + optionKeys: ["EPIJ_Qual"]) + XCTAssertFalse(argv.contains("EPIJ_Qual=303")) + XCTAssertTrue(argv.contains("epij_qual=308")) + } + + func testQualityNilNoEmit() throws { + let argv = try build( + options: PrintOptions(mediaType: "Photo"), + optionKeys: ["EPIJ_Qual", "MediaType"]) + XCTAssertFalse(argv.contains { $0.hasPrefix("EPIJ_Qual=") }) + // No quality key on the queue → no emit either. + let noKey = try build( + options: PrintOptions(quality: "303"), + optionKeys: ["MediaType"]) + XCTAssertFalse(noKey.contains { $0.hasPrefix("EPIJ_Qual=") }) + } + func testSanitise() throws { XCTAssertThrowsError(try build(options: PrintOptions( cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift index 5a92936..52738a2 100644 --- a/Tests/ICCeryCoreTests/PrintPanelTests.swift +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -52,6 +52,41 @@ 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") + } + } + + /// #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 new file mode 100644 index 0000000..c58694d --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintSessionViewModelTests.swift @@ -0,0 +1,392 @@ +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 308 304 305 307\\n' + exit 0 + fi + printf "printer-info='Mock %s' printer-type=42\\n" "$queue" + exit 0 + """ + let lp = """ + #!/bin/sh + printf '%s\\n' "$*" >> "\(lpArgvURL.path)" + exit 0 + """ + for (name, body) in [("lpoptions", lpoptions), ("lp", lp)] { + let url = binDir.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + } + + private func makeWorkflow() -> TargetWorkflowViewModel { + TargetWorkflowViewModel(environment: env.environment) + } + + private func loadCaps( + _ vm: PrintSessionViewModel, queue: String = "Mock_Q" + ) async { + vm.selectedPrinter = queue + await vm.reloadSelectedCapabilities() + } + + private func waitForFile( + _ url: URL, timeout: TimeInterval = 10 + ) async -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let text = try? String(contentsOf: url, encoding: .utf8), + !text.isEmpty { + return text + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return (try? String(contentsOf: url, encoding: .utf8)) ?? "" + } + + // MARK: - Seeding / mirror + + /// `pageSize = .a4` matches the capability named "A4" → its id. + func testSeedMatchesPageSizeRawValue() async { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + + XCTAssertEqual(workflow.pageSize, .a4) + XCTAssertEqual(workflow.print.selectedPaperSize, 3) + // Quality seeds from the `*` default on caps load. + XCTAssertEqual(workflow.print.selectedQuality, "303") + // All seven Epson codes enumerate in driver order (#180). + XCTAssertEqual(workflow.print.printerCaps.qualities.map(\.id), + ["301", "302", "303", "308", "304", "305", "307"]) + } + + /// `.custom` → synthetic `id: 0` entry whose token is the + /// dimensions in **points** (mm × 72/25.4): 210×297 → `Custom.595x842`. + func testSeedCustomPageSizeSyntheticEntry() async { + let workflow = makeWorkflow() + workflow.pageSize = .custom + workflow.customPageW = 210 + workflow.customPageH = 297 + await loadCaps(workflow.print) + + XCTAssertEqual(workflow.print.selectedPaperSize, 0) + let synthetic = workflow.print.printerCaps.paperSizes + .first { $0.id == 0 } + XCTAssertEqual(synthetic?.name, "Custom.595x842") + XCTAssertEqual(workflow.print.selectedPaperSizeToken, "Custom.595x842") + } + + /// A `workflow.pageSize` change re-mirrors the picker. + func testReseedOnPageSizeChange() async { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + XCTAssertEqual(workflow.print.selectedPaperSize, 3) + + workflow.pageSize = .letter + workflow.print.seedPaperSelection() + XCTAssertEqual(workflow.print.selectedPaperSize, 4) + } + + /// A user pick survives unrelated publishes — re-seed only fires + /// on pageSize / printer / caps triggers (#183, R14). + func testUserEditPreservedAcrossUnrelatedPublishes() async { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + workflow.print.selectedPaperSize = 5 + + workflow.print.selectedTray = 2 + workflow.print.printOrientation = "landscape" + workflow.print.printNotice = Notice(kind: .info, text: "x") + + XCTAssertEqual(workflow.print.selectedPaperSize, 5) + } + + /// A printer change re-seeds from `workflow.pageSize` after the + /// capabilities reload (the picker re-mirrors, not guesses). + func testPrinterChangeReseeds() async { + let workflow = makeWorkflow() + workflow.pageSize = .a4 + await loadCaps(workflow.print) + workflow.print.selectedPaperSize = 5 + workflow.print.selectedQuality = "301" + + // The view nils quality on printer change before reloading — + // the VM re-seeds `when nil` only (#183 contract). + workflow.print.selectedPrinter = "Other_Q" + workflow.print.selectedQuality = nil + await workflow.print.reloadSelectedCapabilities() + + XCTAssertEqual(workflow.print.selectedPaperSize, 3) + XCTAssertEqual(workflow.print.selectedQuality, "303") + } + + /// A pageSize with no capability match leaves the pick nil — + /// never a guessed id. + func testSeedNoMatchLeavesNil() async { + let workflow = makeWorkflow() + workflow.pageSize = .a2 + await loadCaps(workflow.print) + XCTAssertNil(workflow.print.selectedPaperSize) + XCTAssertNil(workflow.print.selectedPaperSizeToken) + } + + // MARK: - Spool wiring + + /// `spool` emits the Stage 2 paper token and quality through + /// `PrintOptions` → `lp` argv (`-o PageSize=`, `-o =`). + 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") + } + + /// #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/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions index d274f3b..0110476 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpoptions +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -1,6 +1,8 @@ #!/bin/sh # Mock lpoptions for Milestone3UITests. `-p ` prints printer-info; -# `-p -l` prints Key/Label listings incl. Epson bypass keys. +# `-p -l` prints Key/Label listings incl. Epson bypass keys and the +# recorded XP-55 EPIJ_Qual line — the driver's own non-sorted order +# (308 sits between 303 and 304, #180). queue="" list=0 for arg in "$@"; do @@ -15,6 +17,7 @@ if [ "$list" = "1" ]; then printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' printf 'InputSlot/Media Source: Auto *Main Rear\n' printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' + printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n' printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' printf 'ColorModel/Output Mode: *RGB Gray\n' exit 0 diff --git a/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift new file mode 100644 index 0000000..d458361 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone11PrintSettingsUITests.swift @@ -0,0 +1,239 @@ +import XCTest + +/// Milestone 11 UI tests — issue #183 Stage 2 paper size + print +/// quality pickers. Runs against the same mock CUPS fixture binaries +/// as `Milestone3UITests`; the `NSPrintPanel` stays stubbed through +/// `ICCERY_TEST_PRINT_PANEL` (XCUITest cannot drive the system modal). +@MainActor +final class Milestone11PrintSettingsUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var lpArgvURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui11-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + /// Drive the app through targen + printtarg so the print panel is + /// live with a manifest. + private func reachPrintPanel() { + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 25) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 25) + } + + private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let out = (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + if !out.isEmpty { return out } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + } + + // MARK: - Tests + + /// The paper-size and quality pickers exist with their new ids; + /// the existing tray / media / orientation ids are unchanged (#183). + func testPaperAndQualityPickersExist() throws { + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + XCTAssertTrue(element("printerPaperSizeSelect").exists) + XCTAssertTrue(element("printerQualitySelect").exists) + XCTAssertTrue(element("paperSizeGroup").exists) + XCTAssertTrue(element("qualityGroup").exists) + + // Existing ids untouched. (`mediaTypeGroup` is not asserted — + // stacked `.accessibilityIdentifier` modifiers collapse to the + // last one, so it never resolved even before this change.) + XCTAssertTrue(element("printerSelect").exists) + XCTAssertTrue(element("printerTraySelect").exists) + XCTAssertTrue(element("printerMediaTypeSelect").exists) + XCTAssertTrue(element("btnOrientPortrait").exists) + XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(element("btnPrinterProperties").exists) + } + + /// The displayed selection of a picker — `AXTitle` for a popup + /// button, falling back to label/value depending on how AppKit + /// exposes the current item. + private func selection(of id: String) -> String { + let el = element(id) + for candidate in [el.title, el.label, el.value as? String ?? ""] { + if !candidate.isEmpty, candidate != el.identifier { + return candidate + } + } + return el.title + } + + /// The paper picker seeds from Stage 1's `pageSize` (A4 default) + /// and the quality picker from the driver's `*` default choice. + func testPickersSeedFromStage1AndDriverDefault() throws { + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + _ = waitFor("printerPaperSizeSelect") + _ = waitFor("printerQualitySelect") + XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "A4") + XCTAssertEqual(selection(of: "printerQualitySelect"), "303") + } + + /// #180 — the quality picker lists all seven Epson `EPIJ_Qual` + /// codes in the driver's own order (308 between 303 and 304); no + /// PPD is injected under UI testing so items show raw tokens. + func testQualityPickerListsAllSevenDriverOptions() throws { + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + let picker = app.popUpButtons["printerQualitySelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.click() + + let expected = ["301", "302", "303", "308", "304", "305", "307"] + for token in expected { + XCTAssertTrue( + app.menuItems[token].waitForExistence(timeout: 5), + "Missing quality menu item \(token)") + } + let titles = app.menuItems.allElementsBoundByIndex + .map(\.title) + .filter { expected.contains($0) } + XCTAssertEqual(titles, expected) + + app.typeKey(XCUIKeyboardKey.escape, modifierFlags: []) + } + + /// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=` + /// apply back into the Stage 2 pickers and reach the `lp` argv + /// (R15 — the real modal is never driven). + func testPanelResultAppliesBackToPickers() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" + app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = + "PageSize=Letter EPIJ_Qual=305" + launchAppWithDefaults() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Settings captured")) + + XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter") + XCTAssertEqual(selection(of: "printerQualitySelect"), "305") + + app.buttons["btnPrintAll"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains("PageSize=Letter"), argv) + XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) + } + + /// #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() + } +} diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 07acf38..55e3f86 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -44,7 +44,7 @@ Collision: `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calR ## Stage 2 ids -`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`. +`cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`. `#cupsOptionsGroup` is **hidden on Windows** (#48). `#chkPpdFallback` is Linux-only ColorModel=Gray + cm-calibration. @@ -86,4 +86,4 @@ Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). ## Complete `id=` roster (358) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalReturn`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `paperSizeGroup`, `printerPaperSizeSelect`, `qualityGroup`, `printerQualitySelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `settingsDeltaEGood`, `settingsDeltaEWarning`, `settingsCalStaleDays`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`, `btnSpotRead`, `spotReadView`, `btnCloseSpotRead`, `spotSidecarMissing`, `btnSpotDetectInstruments`, `spotDetectError`, `spotInstrumentSelect`, `spotDefaultMissing`, `spotSetDefault`, `spotXYHint`, `spotPrompt`, `spotLastError`, `spotLogContainer`, `spotLog`, `btnSpotStart`, `btnSpotCalibrate`, `btnSpotTrigger`, `btnSpotStop`, `spotLastSample`, `spotLastEmpty`, `spotLabL`, `spotLabA`, `spotLabB`, `spotXYZ`, `spotSwatch`, `spotDeltaE`, `spotLastInstrument`, `spotLabImplausible`, `spotHistoryTable`, `spotHistoryEmpty`, `spotHistoryRow-{uuid}`, `btnSpotCopyLab`, `btnSpotExportCsv`, `btnViewGamut`, `gamutView`, `gamutStatusText`, `gamutNoticeText`, `btnResetGamutCamera`, `gamutLayer-sRGB`, `gamutLayer-profile`, `gamutLayer-compare`, `btnGamutAddCompare`, `btnGamutOpenGam`, `btnGamutOpenProfile`, `btnGamutRemoveCompare`, `btnGamutSampleTiff`, `gamutInspectPanel`, `gamutInspectIdle`, `gamutInspectL`, `gamutInspectA`, `gamutInspectB`, `gamutInspect-sRGB`, `gamutInspect-profile`, `gamutInspect-compare`, `gamutInspectSwatch`, `gamutInspectApprox`, `gamutLabEntryL`, `gamutLabEntryA`, `gamutLabEntryB`, `btnGamutInspectLab`, `gamutTiffPreview`, `btnCloseGamutTiffPreview`, `gamutViewerUnavailable`, `menuProjectNew`, `menuProjectOpen`, `menuProjectRecents`, `projectRecent-{id}`, `menuProjectRecentsClear`, `menuProjectSave`, `menuProjectSaveAs`, `menuProjectReport`, `menuProjectClose`, `projectChip`, `projectChipName`, `projectChipPath`, `projectChipStale`, `btnProjectOpen`, `btnProjectSave`, `btnProjectReveal`, `projectNewAlert`, `btnProjectNewCancel`, `btnProjectNewConfirm`, `btnProjectDirtySave`, `btnProjectDirtyDiscard`, `btnProjectDirtyCancel`, `projectRelocateSheet`, `btnProjectRelocate`, `btnProjectRelocateCancel`.