Files
iccery-v2-mac/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift
gronod 5389e4c2bd
macOS CI / build-and-test (push) Skipped
feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5)
- CupsParsers: lpstatDeviceURIs / lpoptionsMakeAndModel /
  detectAirPrint — the six docs/14 §10.2 rules (apple-airprint:// URI,
  *APAirPrint: True, Apple+AirPrint make-and-model, ipps://+PPD
  mention, ipp:// + _universal mDNS subtype, cupsFilter2→image/urf).
- CupsService.listPrinters: one tolerated lpstat -v spawn feeds
  detection; lpoptions -p yields displayName + makeAndModel in a
  single spawn; Printer.isAirPrint populated per queue.
- Printer.isAirPrint defaults false with a tolerant decoder.
- Stage2View: live airPrintWarningBadge — "AirPrint queue —
  unmanaged colour cannot be guaranteed."
- Fixtures: lpstat -v marks Mock_Canon_Pro via _universal subtype;
  lpoptions emits printer-make-and-model.
- Tests: six rule fixtures + USB negative + a no-fallback-respawn
  listPrinters case; Milestone12AirPrintUITests tracks the badge
  across queue selection.

Gate: full xcodebuild test green (515 unit + 70 UI).
2026-09-17 17:20:25 +01:00

133 lines
5.0 KiB
Swift

import XCTest
/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock
/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted
/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS
/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster
/// queue — so the badge must track the picker selection.
@MainActor
final class Milestone12AirPrintUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var spoolLogURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui12-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
spoolLogURL = testRoot.appendingPathComponent("spool.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_SPOOL_LOG": spoolLogURL.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
}
/// Poll until `id` no longer resolves — the badge is conditionally
/// rendered, so absence is only meaningful after a settle window.
private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !element(id).exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(element(id).exists, "Element \(id) should be absent")
}
/// 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)
}
/// Select the printer-picker menu item whose title contains
/// `needle` (display names come from the `lpoptions` fixture).
private func selectPrinter(containing needle: String) {
let picker = app.popUpButtons["printerSelect"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
picker.click()
let item = app.menuItems
.matching(NSPredicate(format: "title CONTAINS %@", needle))
.firstMatch
XCTAssertTrue(item.waitForExistence(timeout: 5),
"No printer menu item containing \(needle)")
item.click()
}
// MARK: - Tests
/// The badge appears while the AirPrint fixture queue is selected
/// and is absent for the USB Epson — in both directions.
func testAirPrintBadgeTracksSelectedQueue() throws {
app.launch()
app.activate()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
// Default queue is the USB Epson — no badge.
waitForAbsence("airPrintWarningBadge")
selectPrinter(containing: "Canon")
let badge = element("airPrintWarningBadge")
XCTAssertTrue(badge.waitForExistence(timeout: 10))
// StaticText exposes its content via AXValue, not the label.
let badgeText = [badge.value as? String, badge.label, badge.title]
.compactMap { $0 }
.first { !$0.isEmpty } ?? ""
XCTAssertTrue(
badgeText.contains(
"AirPrint queue — unmanaged colour cannot be guaranteed."),
"Unexpected badge text: \(badgeText)")
selectPrinter(containing: "Epson")
waitForAbsence("airPrintWarningBadge")
}
}