diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index eafddf4..22221f7 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -91,7 +91,18 @@ public enum CupsParsers { index = output.index(after: index) } let key = String(output[tokenStart.. - ) -> ColorSyncSuppressor { + /// Call log — static since `@convention(c)` can't capture. The + /// resolver sets `currentSymbol` right before each call, so the C + /// function records (symbol, mode) without capturing `name`. + private static var recorded: [(String, String)] = [] + private static var currentSymbol = "" + private static var succeeding: (String, String)? + private static var missing: Set = [] + + private func makeSuppressor() -> ColorSyncSuppressor { var s = ColorSyncSuppressor() s.log = { _ in } s.modeResolver = { name in - // Missing symbol → nil (older macOS path). - if name == "PMSessionSetColorMatchingModeLock" && symbol == nil { - return nil - } + if Self.missing.contains(name) { return nil } + Self.currentSymbol = name return { _, modeArg in - calls.pointee.append((name, modeArg as String)) - return (name == symbol && (modeArg as String) == mode) ? 0 : 1 + Self.recorded.append((Self.currentSymbol, modeArg as String)) + if let ok = Self.succeeding, + Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { + return 0 + } + return 1 } } return s @@ -84,68 +92,53 @@ struct ColorSyncSuppressorTests { @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") func attemptOrder() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - let s = suppressor(succeeding: nil, calls: calls) + Self.recorded = [] + Self.succeeding = nil + Self.missing = ["PMSessionSetColorMatchingModeLock"] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession) == false) - #expect(calls.pointee == ColorMatchingAttempts.attempts - .map { ($0.symbol, $0.mode) } - .filter { $0.0 != "PMSessionSetColorMatchingModeLock" }) + // Lock is unresolvable → skipped; the rest plays out in order. + #expect(Self.recorded.map { "\($0.0)|\($0.1)" } + == ColorMatchingAttempts.attempts + .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } + .map { "\($0.symbol)|\($0.mode)" }) } - @Test("First zero wins — later symbols not called") + @Test("First zero wins — later symbols/modes not called") func firstZeroWins() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - let s = suppressor( - succeeding: "PMSessionSetColorMatchingMode", calls: calls) + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "AP_ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession)) - // Lock symbol missing → skipped; Mode tried AP_ then plain? No — - // Mode succeeds on the first mode → 2 calls total. - #expect(calls.pointee == [ - ("PMSessionSetColorMatchingMode", "AP_ApplicationColorMatching"), + #expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ + "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", ]) - // NoLock never attempted. - #expect(!calls.pointee.contains { $0.0 == "PMSessionSetColorMatchingModeNoLock" }) } @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") func modeFallback() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - var s = suppressor( - succeeding: "PMSessionSetColorMatchingModeLock", - mode: "ApplicationColorMatching", - calls: calls) - // Make the Lock symbol resolvable this time. - let record: (String) -> ColorMatchingModeFunction? = { name in - { _, modeArg in - calls.pointee.append((name, modeArg as String)) - return (modeArg as String) == "ApplicationColorMatching" ? 0 : 1 - } - } - s.modeResolver = record + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession)) - #expect(calls.pointee.first - == ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching")) - #expect(calls.pointee.last - == ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching")) + #expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") + #expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") + #expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") + #expect(Self.recorded[1].1 == "ApplicationColorMatching") + #expect(Self.recorded.count == 2) } @Test("All symbols missing → false, no calls") func allMissing() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - var s = suppressor(succeeding: nil, calls: calls) - s.modeResolver = { _ in nil } + Self.recorded = [] + Self.succeeding = nil + Self.missing = Set(ColorMatchingAttempts.symbols) + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession) == false) - #expect(calls.pointee.isEmpty) + #expect(Self.recorded.isEmpty) } } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index f2d42fc..fdf948f 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -129,21 +129,17 @@ struct CupsParsersTests { @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") func driverBypass() { - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["CNIJIntent2", "CNIJIntent"]) - == ("CNIJIntent2", "4")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"]) - == ("CNIJIntent", "4")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"]) - == ("EPIJ_CMat", "3")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil) + func pair(_ keys: Set) -> String? { + CupsParsers.detectDriverColorBypass(optionKeys: keys) + .map { "\($0.key)=\($0.value)" } + } + #expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") + #expect(pair(["CNIJIntent"]) == "CNIJIntent=4") + #expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") + #expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") + #expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") + #expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") + #expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") + #expect(pair(["PageSize"]) == nil) } } diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift index 1f2d175..4dcb22a 100644 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -96,19 +96,21 @@ struct LpArgsTests { .contains("orientation-requested=3")) #expect(try build(options: PrintOptions(orientation: "landscape")) .contains("orientation-requested=4")) - #expect(!try build(options: PrintOptions( + let capturedOrients = try build(options: PrintOptions( orientation: "landscape", cupsOptions: "orientation-requested=5")) - .contains("orientation-requested=4")) + #expect(!capturedOrients.contains("orientation-requested=4")) + #expect(capturedOrients.contains("orientation-requested=5")) } @Test("PageSize emitted unless captured") func pageSize() throws { #expect(try build(options: PrintOptions(paperSize: "A4")) .contains("PageSize=A4")) - #expect(!try build(options: PrintOptions( + let capturedSize = try build(options: PrintOptions( paperSize: "A4", cupsOptions: "PageSize=Letter")) - .contains("PageSize=A4")) + #expect(!capturedSize.contains("PageSize=A4")) + #expect(capturedSize.contains("PageSize=Letter")) } @Test("Sanitise rejects `;`, newline, and shell metachars") diff --git a/Tests/ICCeryUITests/Fixtures/bin/lp b/Tests/ICCeryUITests/Fixtures/bin/lp new file mode 100755 index 0000000..6e091f1 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lp @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock lp for Milestone3UITests. Appends its full argv to +# ICCERY_TEST_LP_ARGV so the test can assert flag order and option +# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection). +{ + printf 'lp' + for arg in "$@"; do printf ' %s' "$arg"; done + printf '\n' +} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}" +if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then + echo "mock lp failure" >&2 + exit "$ICCERY_MOCK_LP_EXIT" +fi +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions new file mode 100755 index 0000000..d274f3b --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -0,0 +1,23 @@ +#!/bin/sh +# Mock lpoptions for Milestone3UITests. `-p ` prints printer-info; +# `-p -l` prints Key/Label listings incl. Epson bypass keys. +queue="" +list=0 +for arg in "$@"; do + case "$arg" in + -p) shift_flag=1 ;; + -l) list=1 ;; + -*) ;; + *) queue="$arg" ;; + esac +done +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_CMat/Color Adjust: *0 1 2 3\n' + printf 'ColorModel/Output Mode: *RGB Gray\n' + exit 0 +fi +printf "printer-info='Mock %s' printer-type=42\n" "$queue" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpstat b/Tests/ICCeryUITests/Fixtures/bin/lpstat new file mode 100755 index 0000000..28a7347 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpstat @@ -0,0 +1,19 @@ +#!/bin/sh +# Mock lpstat for Milestone3UITests. Emits two canned queues so the UI +# can exercise select/refresh/status-badge without real CUPS. +case "$1" in + -e) + printf 'Mock_Epson_7450\nMock_Canon_Pro\n' + ;; + -p) + printf 'printer Mock_Epson_7450 is idle. enabled since Mon Sep 7 21:50:25 2026\n' + printf 'printer Mock_Canon_Pro disabled since Tue Sep 8 09:00:00 2026 -\n\tPaused\n' + ;; + -d) + printf 'system default destination: Mock_Epson_7450\n' + ;; + *) + exit 1 + ;; +esac +exit 0 diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift new file mode 100644 index 0000000..a674288 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -0,0 +1,219 @@ +import XCTest + +/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock +/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a +/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` +/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends +/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the +/// evidence that captured options are replayed (docs/11 §tests). +@MainActor +final class Milestone3UITests: 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-ui3-\(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 launchApp() { + app.launch() + app.activate() + } + + 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 recordedLpArgv() -> String { + (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + } + + private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let out = recordedLpArgv() + if !out.isEmpty { return out } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return recordedLpArgv() + } + + // MARK: - Tests + + /// Panel appears after the manifest; refresh populates the printer + /// select with the mock queues and shows a status badge. + func testPrintPanelEnumeratesPrinters() throws { + launchApp() + reachPrintPanel() + + XCTAssertTrue(waitFor("rawPrintPanel").exists) + // The panel auto-refreshes on appear; the default mock queue is + // selected and its status badge shows. + XCTAssertTrue(element("printerSelect").waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerStatusBadge") + .waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerTraySelect").exists) + XCTAssertTrue(element("printerMediaTypeSelect").exists) + XCTAssertTrue(element("btnOrientPortrait").exists) + XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) + } + + /// Preferences cancel → info notice, no error, no cache mutation. + func testPreferencesCancelIsInfo() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "cancel" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("cancelled")) + } + + /// Preferences OK → captured options are replayed verbatim in the + /// `lp` argv alongside the two mandatory AP_* headers (issue 17's + /// acceptance test: "captured options replayed in argv"). + func testCapturedOptionsReplayedInLpArgv() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" + app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = + "InputSlot=Rear MediaType=PhotographicGlossy" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Settings captured")) + + app.buttons["btnPrintAll"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains( + "AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) + XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) + // Detected bypass for the mock queue (EPIJ_CMat present in + // lpoptions -l) is appended when not captured. + XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) + XCTAssertTrue(argv.contains("orientation-requested=3"), argv) + // Last token is the TIFF. + XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines) + .hasSuffix("page1.tif"), argv) + } + + /// Per-page print uses the same spool path (btnPrintPage-N). + func testPerPagePrint() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintPage-0"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) + XCTAssertTrue(argv.contains("page1.tif"), argv) + } + + /// lp failure surfaces in the in-panel notice, not the wizard banner. + func testLpFailureShowsPrintNotice() throws { + app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Print failed")) + } + + /// wizardState.printerName records the queue used for spooling (#95). + func testPrinterNamePersistedOnSpool() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + _ = waitForLpLine() + let stateURL = testRoot + .appendingPathComponent("AppData") + .appendingPathComponent("wizard_state.json") + XCTAssertTrue(waitForFile(stateURL)) + let data = try Data(contentsOf: stateURL) + let state = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(state.contains("Mock_Epson_7450"), state) + } + + private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: url.path) { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return false + } +}