import XCTest extension XCUIApplication { func launchAndOpenSamplePDF( bookTitleQuery: String = "PDF阅读器示例", resetsReaderState: Bool = true, pdfTextSource: String? = nil ) { launchAndOpenSampleBook( bookTitleQuery: bookTitleQuery, displayType: "pagecurl", resetsReaderState: resetsReaderState, pdfTextSource: pdfTextSource ) } func launchAndOpenSampleBook( bookTitleQuery: String = "回归验证样本", displayType: String? = nil, pageNumber: Int? = nil, resetsReaderState: Bool = true, windowSize: Int? = nil, concurrency: Int? = nil, clearsCache: Bool = false, corruptsCache: Bool = false, allowsInspectableWebViews: Bool = false, enablesVerboseWebLogs: Bool = false, searchKeyword: String? = nil, pdfTextSource: String? = nil ) { var args = ["--demo-book-title", bookTitleQuery] if resetsReaderState { args.append("--demo-reset-state") } if clearsCache { args.append("--demo-clear-cache") } if corruptsCache { args.append("--demo-corrupt-cache") } if allowsInspectableWebViews { args.append("--demo-allow-inspectable-webviews") } if enablesVerboseWebLogs { args.append("--demo-enable-verbose-weblogs") } if let displayType { args += ["--demo-display-type", displayType] } if let pageNumber { args += ["--demo-page", "\(pageNumber)"] } if let windowSize { args += ["--demo-window-size", "\(windowSize)"] } if let concurrency { args += ["--demo-concurrency", "\(concurrency)"] } if let searchKeyword { args += ["--demo-search-keyword", searchKeyword] } if let pdfTextSource { args += ["--demo-pdf-text-source", pdfTextSource] } launchArguments = args launch() } @discardableResult func waitForReader(timeout: TimeInterval = 12) -> XCUIElement { let state = staticTexts[IDs.demoReaderState] XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现") let parsed = DemoReaderState(rawValue: state.label) XCTAssertTrue(parsed.isOpened, "阅读器未进入 opened 状态:\(state.label)") return state } func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) { _ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in state.page == pageNumber } } func showReaderChromeIfNeeded( requiredButtonIDs: [String] = [IDs.readerBack, IDs.readerSettings], timeout: TimeInterval = 5 ) { if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) { return } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { let content = otherElements[IDs.readerContent].firstMatch if content.waitForExistence(timeout: 1) { content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } else { windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(0.35)) if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) { return } } } func hideReaderChromeIfNeeded() { for _ in 0..<3 { guard buttons[IDs.readerBack].exists else { return } let content = otherElements[IDs.readerContent].firstMatch if content.waitForExistence(timeout: 2) { content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } } func waitForReaderState(containing expectedText: String, timeout: TimeInterval = 8) { let state = staticTexts[IDs.demoReaderState] let deadline = Date().addingTimeInterval(timeout) var lastLabel = "" while Date() < deadline { if state.exists { lastLabel = state.label if lastLabel.contains(expectedText) { return } } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)") } @discardableResult func requireSelectionText( expectedPage: Int? = nil, timeout: TimeInterval = 12 ) throws -> XCUIElement { let selectionText = otherElements[IDs.readerSelectionText].firstMatch let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if let expectedPage { let state = currentDemoReaderState() if state?.page != expectedPage { RunLoop.current.run(until: Date().addingTimeInterval(0.1)) continue } } if selectionText.exists { return selectionText } let content = otherElements[IDs.readerContent].firstMatch if content.exists { content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } throw XCTSkip("文本选区元素未出现,当前渲染路径未暴露 \(IDs.readerSelectionText)") } private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool { requiredButtonIDs.allSatisfy { buttons[$0].exists } } // MARK: - 设置面板 /// 等 EPUB 设置面板出现。面板已改成固定高度布局,所有控件一屏可见, /// 既没有 scrollView 也不需要滚动才能触达。 @discardableResult func waitForSettingsPanel(timeout: TimeInterval = 5) -> Bool { if otherElements[IDs.settingsPanel].waitForExistence(timeout: timeout) { return true } // 面板根视图偶尔不作为 otherElement 暴露,退回用「完成」按钮判定 return buttons[IDs.settingsDone].waitForExistence(timeout: 1) } /// 设置面板的字体/行距/翻页方式都是 pill 按钮 + action sheet,不是 /// segmentedControl:点开 pill 后再点选项标题。 @discardableResult func selectSettingsPillOption( pillIdentifier: String, optionTitle: String, timeout: TimeInterval = 5 ) -> Bool { let pill = buttons[pillIdentifier] guard pill.waitForExistence(timeout: timeout) else { return false } pill.tap() // action sheet 的选项在 iOS 上是 button,取消项同名风险由标题唯一性保证 let option = sheets.buttons[optionTitle].exists ? sheets.buttons[optionTitle] : buttons[optionTitle] guard option.waitForExistence(timeout: timeout) else { // 收起 sheet,避免污染后续断言 if sheets.buttons["取消"].exists { sheets.buttons["取消"].tap() } return false } option.tap() return true } /// 字号是 slider(`epub.reader.settings.font.size`),没有增大/减小按钮。 /// 把滑块推到指定归一化位置以触发重排;返回是否成功。 @discardableResult func adjustSettingsFontSize(toNormalizedPosition position: CGFloat, timeout: TimeInterval = 5) -> Bool { let slider = sliders[IDs.settingsFontSize] guard slider.waitForExistence(timeout: timeout) else { return false } slider.adjust(toNormalizedSliderPosition: position) return true } /// 在当前值基础上增减字号,替代早已不存在的 font.increase / font.decrease 按钮。 @discardableResult func nudgeSettingsFontSize(by delta: CGFloat, timeout: TimeInterval = 5) -> Bool { let slider = sliders[IDs.settingsFontSize] guard slider.waitForExistence(timeout: timeout) else { return false } let target = min(max(slider.normalizedSliderPosition + delta, 0), 1) slider.adjust(toNormalizedSliderPosition: target) return true } @discardableResult func increaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool { nudgeSettingsFontSize(by: 0.25, timeout: timeout) } @discardableResult func decreaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool { nudgeSettingsFontSize(by: -0.25, timeout: timeout) } }