import XCTest import UIKit /// PDF 翻页后白页回归。 /// /// 判定方式:每次翻页稳定后截屏,统计画面中部的深色像素占比。正常 PDF 正文页 /// 含文字与线条,占比明显高于阈值;页面图片没有装配到视图上时几乎全白。 final class PDFPageTurnBlankPageTests: XCTestCase { private let app = XCUIApplication() /// 深色像素占比低于该值即视为白页。样张实测:白页为 0.0000, /// 内容最少的扉页约 0.0020,正文页在 0.0100 以上。 private let blankInkRatioThreshold = 0.0005 override func setUpWithError() throws { continueAfterFailure = false } func testPageCurlForwardTurnsRenderPageContent() { openDictionaryPDF(displayType: "pagecurl") assertForwardTurnsRenderContent(turns: 8, mode: "仿真翻页") } func testHorizontalScrollForwardTurnsRenderPageContent() { openDictionaryPDF(displayType: "horizontalscroll") assertForwardTurnsRenderContent(turns: 8, mode: "横向滚动") } /// 回翻同样依赖 `viewControllerBefore` 预建的页面视图,需要单独覆盖。 func testPageCurlBackwardTurnsRenderPageContent() { openDictionaryPDF(displayType: "pagecurl") assertForwardTurnsRenderContent(turns: 5, mode: "仿真翻页") assertBackwardTurnsRenderContent(from: 6, turns: 5, mode: "仿真回翻") } // MARK: - 步骤 private func openDictionaryPDF(displayType: String) { app.launchAndOpenSampleBook( bookTitleQuery: "英汉大词典", displayType: displayType, resetsReaderState: true ) app.waitForReader(timeout: 30) app.waitForReaderPage(1, timeout: 30) // 首页首次渲染需要等待异步解码完成,避免把加载中误判成白页。 RunLoop.current.run(until: Date().addingTimeInterval(2.5)) } private func assertForwardTurnsRenderContent(turns: Int, mode: String) { var blankPages: [Int] = [] recordInk(page: 1, mode: mode, blankPages: &blankPages) for step in 1...turns { let expectedPage = step + 1 turnToNextPage() app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") { $0.page == expectedPage } // 翻页动画结束后再取样,排除卷页过程中的中间帧。 RunLoop.current.run(until: Date().addingTimeInterval(1.2)) recordInk(page: expectedPage, mode: mode, blankPages: &blankPages) } XCTAssertTrue( blankPages.isEmpty, "\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))" ) } private func assertBackwardTurnsRenderContent(from startPage: Int, turns: Int, mode: String) { var blankPages: [Int] = [] for step in 1...turns { let expectedPage = startPage - step guard expectedPage >= 1 else { break } turnPage(forward: false) app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") { $0.page == expectedPage } RunLoop.current.run(until: Date().addingTimeInterval(1.2)) recordInk(page: expectedPage, mode: mode, blankPages: &blankPages) } XCTAssertTrue( blankPages.isEmpty, "\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))" ) } private func turnToNextPage() { turnPage(forward: true) } private func turnPage(forward: Bool) { let content = app.otherElements[IDs.readerContent].firstMatch let target: XCUIElement = content.exists ? content : app.windows.firstMatch let leading = CGVector(dx: 0.08, dy: 0.5) let trailing = CGVector(dx: 0.88, dy: 0.5) let start = target.coordinate(withNormalizedOffset: forward ? trailing : leading) let end = target.coordinate(withNormalizedOffset: forward ? leading : trailing) start.press(forDuration: 0.05, thenDragTo: end) } private func recordInk(page: Int, mode: String, blankPages: inout [Int]) { let screenshot = XCUIScreen.main.screenshot() let ratio = inkRatio(of: screenshot.image) let attachment = XCTAttachment(screenshot: screenshot) attachment.name = "\(mode)-page-\(page)-ink-\(String(format: "%.4f", ratio))" attachment.lifetime = .keepAlways add(attachment) if ratio < blankInkRatioThreshold { blankPages.append(page) } } /// 画面中部深色像素占比。裁掉上下边缘以排除状态栏、工具栏和页面外底色。 private func inkRatio(of image: UIImage) -> Double { guard let cgImage = image.cgImage else { return 0 } let cropRect = CGRect( x: CGFloat(cgImage.width) * 0.12, y: CGFloat(cgImage.height) * 0.16, width: CGFloat(cgImage.width) * 0.76, height: CGFloat(cgImage.height) * 0.68 ).integral guard let cropped = cgImage.cropping(to: cropRect) else { return 0 } let side = 400 var pixels = [UInt8](repeating: 255, count: side * side) guard let context = CGContext( data: &pixels, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue ) else { return 0 } context.draw(cropped, in: CGRect(x: 0, y: 0, width: side, height: side)) let darkCount = pixels.reduce(into: 0) { partial, value in if value < 150 { partial += 1 } } return Double(darkCount) / Double(side * side) } }