将 PDF 成品阅读控制器下沉为 RDPDFReaderView SDK,并修复手机横屏单页连续竖滑

SDK 化收尾:
- 新增 RDPDFReaderView 本地 pod(阅读容器、成品控制器、划线/注释、画笔、
  目录/设置面板、主题与持久化契约),Demo 精简为 PDFKit 页面提供者 + 持久化宿主
- Podfile/工程接入 RDPDFReaderView 与 SnapKit,示例书改用 PDF 阅读器示例文件

手机横屏单页竖滑修复:
- cell 高度改为宽度适配后的纸张高度(sizeForItemAt + 宿主按页提供纵横比),
  纵向超出一屏的内容由外层 collectionView 连续滚动承接,不再被裁掉
- 页面内部不再开启同向嵌套的兜底竖向拖动,修复外层滚动被吞、无法滚到下一页
- 修复 applyContentSize 先评估拖动开关后写 contentSize 的顺序缺陷
- 页码按视口中心命中 cell 计算,补 didEndDragging 兜底;旋转重锚点与
  transitionToPage 改用真实布局位置
- 横竖屏同为竖滑时也重建 cell 并刷新全部可见页的适配配置
- 横屏单页点击不再跳页,只显隐工具栏;翻页交给连续竖滑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-15 18:49:44 +09:00
co-authored by Claude Fable 5
parent a17164d1a9
commit 1422461224
347 changed files with 9912 additions and 3941 deletions
@@ -38,6 +38,30 @@ enum IDs {
static let readerNoteReturn = "epub.reader.note.return"
static let readerNoteOpen = "epub.reader.note.open"
// MARK: - Image PDF text selection and annotations
/// PDF demo currently retains the legacy EPUB content identifier for compatibility.
/// The text layer is the precise, image-coordinate based interaction surface.
static let pdfTextLayer = "pdf.reader.text.layer"
static let pdfOCRStatus = "pdf.reader.ocr.status"
/// PDF EPUB 使//
static let pdfSelectionMenuCopy = "复制"
static let pdfSelectionMenuHighlight = "高亮"
static let pdfSelectionMenuAnnotate = "注释"
static let pdfCopyStatus = "pdf.reader.copy.status"
static let pdfAnnotations = "pdf.reader.annotations"
static let pdfAnnotationsPanel = "pdf.reader.annotations.panel"
static let pdfAnnotationsTable = "pdf.reader.annotations.table"
static let pdfAnnotationsFilter = "pdf.reader.annotations.filter"
static let pdfAnnotationsEmpty = "pdf.reader.annotations.empty"
static func pdfAnnotationRow(_ id: String) -> String { "pdf.reader.annotations.row.\(id)" }
static let pdfAnnotationEditor = "pdf.reader.annotation.editor"
static let pdfAnnotationInput = "pdf.reader.annotation.input"
static let pdfAnnotationSave = "pdf.reader.annotation.save"
static let pdfAnnotationCancel = "pdf.reader.annotation.cancel"
static let readerSearch = "epub.reader.search"
static let searchBar = "epub.reader.search.bar"
static let searchField = "epub.reader.search.field"
@@ -1,6 +1,19 @@
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,
@@ -12,7 +25,8 @@ extension XCUIApplication {
corruptsCache: Bool = false,
allowsInspectableWebViews: Bool = false,
enablesVerboseWebLogs: Bool = false,
searchKeyword: String? = nil
searchKeyword: String? = nil,
pdfTextSource: String? = nil
) {
var args = ["--demo-book-title", bookTitleQuery]
if resetsReaderState {
@@ -45,6 +59,9 @@ extension XCUIApplication {
if let searchKeyword {
args += ["--demo-search-keyword", searchKeyword]
}
if let pdfTextSource {
args += ["--demo-pdf-text-source", pdfTextSource]
}
launchArguments = args
launch()
}
@@ -74,7 +91,7 @@ extension XCUIApplication {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let content = otherElements[IDs.readerContent]
let content = otherElements[IDs.readerContent].firstMatch
if content.waitForExistence(timeout: 1) {
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
} else {
@@ -92,7 +109,7 @@ extension XCUIApplication {
for _ in 0..<3 {
guard buttons[IDs.readerBack].exists else { return }
let content = otherElements[IDs.readerContent]
let content = otherElements[IDs.readerContent].firstMatch
if content.waitForExistence(timeout: 2) {
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
}
@@ -139,7 +156,7 @@ extension XCUIApplication {
return selectionText
}
let content = otherElements[IDs.readerContent]
let content = otherElements[IDs.readerContent].firstMatch
if content.exists {
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
@@ -36,6 +36,44 @@ final class FixedLayoutRotationTests: XCTestCase {
app.waitForReader()
}
/// PDF
/// flow layout 使 itemSize
///
func testPDFLandscapeImmediatelyUsesSingleFullWidthPage() {
app.launchAndOpenSampleBook(
bookTitleQuery: "英汉大词典",
displayType: "pagecurl"
)
app.waitForReader()
XCUIDevice.shared.orientation = .landscapeLeft
let content = app.otherElements[IDs.readerContent].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 8), "PDF 阅读区域未出现")
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
let windowWidth = app.windows.firstMatch.frame.width
if windowWidth > 0, abs(content.frame.width - windowWidth) <= 1 {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
// PDF
RunLoop.current.run(until: Date().addingTimeInterval(1.5))
let windowFrame = app.windows.firstMatch.frame
XCTAssertGreaterThan(windowFrame.width, windowFrame.height, "设备没有进入横屏")
XCTAssertEqual(content.frame.width, windowFrame.width, accuracy: 1, "横屏首帧仍在使用竖屏页面宽度")
// cell
//
XCTAssertGreaterThanOrEqual(
content.frame.height,
windowFrame.height - 1,
"横屏单页高度应不小于一屏(宽度适配后的纸张高度)"
)
}
func testFixedLayoutCachedPageCanReturnAfterPageTurn() {
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
app.waitForReader()
@@ -0,0 +1,240 @@
import XCTest
/// PDF
///
/// `host-fixture` 1
/// `(0.18, 0.20)...(0.65, 0.20)`
/// `none` /OCR 使 `(0.15, 0.70)...(0.60, 0.84)`
final class PDFAnnotationTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testPDFHostFixtureLongPressSelectsTextWithoutTurningPage() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let fields = waitForTextLayer(textLayer, description: "host text selection") {
$0["source"] == "text" && ($0["rects"].flatMap(Int.init) ?? 0) > 0
}
XCTAssertFalse(fields["text", default: ""].isEmpty, "文字选区应公开已选文本")
assertReaderPageRemains(1, duration: 0.6)
}
func testPDFHostFixtureCopiesSelectedText() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let copyMenuItem = app.menuItems[IDs.pdfSelectionMenuCopy]
XCTAssertTrue(copyMenuItem.waitForExistence(timeout: 5), "选区菜单缺少复制选项")
copyMenuItem.tap()
let state = app.waitForDemoReaderState(timeout: 5, description: "copy=success and copiedText") {
$0["copy"] == "success" && !($0["copiedText"] ?? "").isEmpty
}
XCTAssertFalse((state["copiedText"] ?? "").isEmpty, "复制成功后应记录被复制的文本")
}
func testPDFHostFixtureCreatesTextHighlight() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let highlightMenuItem = app.menuItems[IDs.pdfSelectionMenuHighlight]
XCTAssertTrue(highlightMenuItem.waitForExistence(timeout: 5), "选区菜单缺少高亮选项")
highlightMenuItem.tap()
let fields = waitForTextLayer(textLayer, description: "one saved text highlight") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
}
XCTAssertEqual(fields["notes"], "0", "纯高亮不应额外创建笔记")
}
func testPDFAnnotationPersistsAfterClosingAndReopeningBook() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let highlightMenuItem = app.menuItems[IDs.pdfSelectionMenuHighlight]
XCTAssertTrue(highlightMenuItem.waitForExistence(timeout: 5))
highlightMenuItem.tap()
_ = waitForTextLayer(textLayer, description: "saved annotation before reopening") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
}
// reset
app.terminate()
app.launchAndOpenSamplePDF(
bookTitleQuery: "PDF测试文件",
resetsReaderState: false,
pdfTextSource: "host-fixture"
)
app.waitForReader()
let reopenedTextLayer = app.otherElements[IDs.pdfTextLayer].firstMatch
XCTAssertTrue(reopenedTextLayer.waitForExistence(timeout: 8))
_ = waitForTextLayer(reopenedTextLayer, description: "persisted annotation after reopening") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
}
}
func testPDFAnnotationsPanelListsSavedHighlight() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let highlightMenuItem = app.menuItems[IDs.pdfSelectionMenuHighlight]
XCTAssertTrue(highlightMenuItem.waitForExistence(timeout: 5))
highlightMenuItem.tap()
_ = waitForTextLayer(textLayer, description: "saved highlight before opening annotations") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
}
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.pdfAnnotations])
let annotationsButton = app.buttons[IDs.pdfAnnotations]
XCTAssertTrue(annotationsButton.waitForExistence(timeout: 5), "底部工具栏缺少笔记入口")
annotationsButton.tap()
let table = app.tables[IDs.pdfAnnotationsTable]
XCTAssertTrue(table.waitForExistence(timeout: 5), "笔记列表页未打开")
XCTAssertGreaterThan(table.cells.count, 0, "笔记列表应显示刚创建的高亮")
}
func testPDFHostFixtureCreatesAnnotation() {
let textLayer = openPDF(textSource: "host-fixture")
selectFixtureText(in: textLayer)
let annotateMenuItem = app.menuItems[IDs.pdfSelectionMenuAnnotate]
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 5), "选区菜单缺少注释选项")
annotateMenuItem.tap()
let editor = app.otherElements[IDs.pdfAnnotationEditor]
XCTAssertTrue(editor.waitForExistence(timeout: 5), "注释编辑页未出现")
let input = app.textViews[IDs.pdfAnnotationInput]
XCTAssertTrue(input.waitForExistence(timeout: 5), "注释输入框未出现")
input.tap()
input.typeText("fixture note")
let saveButton = app.buttons[IDs.pdfAnnotationSave]
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "注释编辑页缺少保存按钮")
saveButton.tap()
let fields = waitForTextLayer(textLayer, description: "one saved note annotation") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
&& ($0["notes"].flatMap(Int.init) ?? 0) == 1
}
XCTAssertEqual(fields["notes"], "1", "保存注释后页面应显示笔记标记")
}
func testPDFWithoutOCRCreatesRegionHighlightWithoutCopy() {
let textLayer = openPDF(textSource: "none")
_ = waitForTextLayer(textLayer, description: "no OCR text runs") {
($0["textRuns"].flatMap(Int.init) ?? -1) == 0
}
let start = textLayer.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.70))
let end = textLayer.coordinate(withNormalizedOffset: CGVector(dx: 0.60, dy: 0.84))
start.press(forDuration: 0.55, thenDragTo: end)
_ = waitForTextLayer(textLayer, description: "region selection") {
$0["source"] == "region" && ($0["rects"].flatMap(Int.init) ?? 0) > 0
}
let highlightMenuItem = app.menuItems[IDs.pdfSelectionMenuHighlight]
XCTAssertTrue(highlightMenuItem.waitForExistence(timeout: 5), "区域选区应弹出带高亮选项的菜单")
XCTAssertFalse(
app.menuItems[IDs.pdfSelectionMenuCopy].waitForExistence(timeout: 1),
"无 OCR 的区域选区不应提供复制选项"
)
highlightMenuItem.tap()
_ = waitForTextLayer(textLayer, description: "one saved region highlight") {
($0["annotations"].flatMap(Int.init) ?? 0) == 1
}
assertReaderPageRemains(1, duration: 0.4)
}
@discardableResult
private func openPDF(textSource: String) -> XCUIElement {
app.launchAndOpenSamplePDF(
bookTitleQuery: "PDF测试文件",
resetsReaderState: true,
pdfTextSource: textSource
)
app.waitForReader()
app.waitForDemoReaderState(timeout: 8, description: "PDF page 1") {
$0.mode == "pdf" && $0.page == 1
}
let textLayer = app.otherElements[IDs.pdfTextLayer].firstMatch
XCTAssertTrue(textLayer.waitForExistence(timeout: 8), "PDF 图片文字层未出现")
return textLayer
}
private func selectFixtureText(in textLayer: XCUIElement) {
_ = waitForTextLayer(textLayer, description: "host fixture text runs") {
($0["textRuns"].flatMap(Int.init) ?? 0) >= 2
}
let start = textLayer.coordinate(withNormalizedOffset: CGVector(dx: 0.18, dy: 0.20))
let end = textLayer.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: 0.20))
start.press(forDuration: 0.55, thenDragTo: end)
XCTAssertTrue(
app.menuItems[IDs.pdfSelectionMenuHighlight].waitForExistence(timeout: 5),
"长按拖选文字后未弹出选区菜单"
)
}
@discardableResult
private func waitForTextLayer(
_ textLayer: XCUIElement,
timeout: TimeInterval = 6,
description: String,
where predicate: ([String: String]) -> Bool
) -> [String: String] {
let deadline = Date().addingTimeInterval(timeout)
var lastFields: [String: String] = [:]
while Date() < deadline {
lastFields = textLayerFields(textLayer)
if predicate(lastFields) {
return lastFields
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTFail("PDF 文本层未满足 \(description),当前:\(String(describing: textLayer.value))")
return lastFields
}
private func textLayerFields(_ textLayer: XCUIElement) -> [String: String] {
guard let rawValue = textLayer.value as? String else { return [:] }
return rawValue
.split(separator: ";", omittingEmptySubsequences: true)
.reduce(into: [:]) { fields, token in
let parts = token.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return }
fields[String(parts[0])] = String(parts[1])
}
}
private func assertReaderPageRemains(
_ page: Int,
duration: TimeInterval,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(duration)
repeat {
guard app.currentDemoReaderState()?.page == page else {
XCTFail(
"PDF 选择手势意外翻页,当前状态:\(app.currentDemoReaderState()?.rawValue ?? "<missing>")",
file: file,
line: line
)
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} while Date() < deadline
}
}
@@ -28,4 +28,14 @@ final class ReaderOpenCloseTests: XCTestCase {
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
}
func testPDFBackButtonReturnsToBookList() {
app.launchAndOpenSamplePDF()
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "mode=pdf") { $0.mode == "pdf" }
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
}
}
@@ -18,4 +18,226 @@ final class ReaderToolbarTests: XCTestCase {
XCTAssertTrue(app.buttons[IDs.readerBack].exists)
XCTAssertTrue(app.buttons[IDs.readerSettings].exists)
}
func testPDFTapCenterShowsAndHidesToolbars() {
app.launchAndOpenSamplePDF()
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "mode=pdf") { $0.mode == "pdf" }
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3))
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3))
app.hideReaderChromeIfNeeded()
XCTAssertFalse(app.buttons[IDs.readerBack].waitForExistence(timeout: 2), "PDF 点击内容区后工具栏应隐藏")
app.waitForDemoReaderState(timeout: 5, description: "toolbar=hidden") { $0.toolbar == "hidden" }
}
func testPDFTocButtonOpensNavigationSheet() {
app.launchAndOpenSamplePDF()
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "mode=pdf") { $0.mode == "pdf" }
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerToc])
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
tocButton.tap()
XCTAssertTrue(app.tables[IDs.readerTocTable].waitForExistence(timeout: 5), "PDF 目录面板未出现")
}
func testPDFSettingsPanelProvidesUsableOptions() {
app.launchAndOpenSamplePDF()
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "mode=pdf") { $0.mode == "pdf" }
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerSettings])
let settingsButton = app.buttons[IDs.readerSettings]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5))
settingsButton.tap()
let settingsPanel = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(settingsPanel.waitForExistence(timeout: 5), "PDF 设置面板未出现")
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3), "PDF 亮度设置未出现")
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3), "PDF 翻页方式设置未出现")
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 3), "PDF 主题设置未出现")
app.segmentedControls[IDs.settingsDisplayType].buttons["竖滑"].tap()
app.buttons[IDs.settingsTheme(5)].tap()
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 5, description: "display changed") { $0.display == "verticalscroll" }
app.waitForDemoReaderState(timeout: 5, description: "theme changed") { $0.fields["theme"] == "5" }
}
func testPDFLaunchHonorsDisplayTypeArgument() {
app.launchAndOpenSampleBook(bookTitleQuery: "PDF测试文件", displayType: "verticalscroll")
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "PDF vertical launch mode") {
$0.mode == "pdf" && $0.display == "verticalscroll"
}
}
func testPDFPageCurlSupportsPinchAndDraggingAfterDoubleTapZoom() {
app.launchAndOpenSamplePDF(bookTitleQuery: "PDF测试文件")
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "PDF page curl") {
$0.mode == "pdf" && $0.display == "pagecurl"
}
let content = app.otherElements[IDs.readerContent].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "PDF 阅读页面未出现")
XCTAssertTrue(waitForViewport(content, timeout: 3) { $0.scale <= 1.05 }, "PDF 初始页面不是适配状态")
content.pinch(withScale: 1.8, velocity: 1)
XCTAssertTrue(waitForViewport(content, timeout: 3) { $0.scale >= 1.4 }, "仿真翻页下双指缩放未生效")
assertReaderPageRemains(1, duration: 0.4)
content.doubleTap()
XCTAssertTrue(waitForViewport(content, timeout: 3) { $0.scale <= 1.05 }, "双击未能还原 PDF 页面")
content.doubleTap()
guard let beforeDrag = waitForStableViewport(content, timeout: 3, stableDuration: 0.3, where: { $0.scale >= 1.8 }) else {
XCTFail("双击后 PDF 未进入稳定的放大状态")
return
}
let dragStart = content.coordinate(withNormalizedOffset: CGVector(dx: 0.75, dy: 0.5))
let dragEnd = content.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.5))
dragStart.press(forDuration: 0.05, thenDragTo: dragEnd)
let didMoveContent = waitForViewport(content, timeout: 3) {
$0.scale >= 1.8 && $0.offsetX - beforeDrag.offsetX > 30
}
XCTAssertTrue(
didMoveContent,
"PDF 双击放大后无法拖动内容;拖动前=\(beforeDrag),拖动后=\(String(describing: content.value))"
)
assertReaderPageRemains(1, duration: 0.4)
}
func testPDFPageCurlSingleFingerTurnsPageAtFittedScale() {
app.launchAndOpenSamplePDF(bookTitleQuery: "PDF测试文件")
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "PDF page curl at page 1") {
$0.mode == "pdf" && $0.display == "pagecurl" && $0.page == 1
}
let content = app.otherElements[IDs.readerContent].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "PDF 阅读页面未出现")
XCTAssertTrue(waitForViewport(content, timeout: 3) { $0.scale <= 1.05 }, "PDF 初始页面不是适配状态")
let dragStart = content.coordinate(withNormalizedOffset: CGVector(dx: 0.60, dy: 0.5))
let dragEnd = content.coordinate(withNormalizedOffset: CGVector(dx: 0.20, dy: 0.5))
dragStart.press(forDuration: 0.05, thenDragTo: dragEnd)
app.waitForReaderPage(2, timeout: 5)
}
func testPDFSwitchingFromZoomedCurlKeepsScrollModeInteractive() {
app.launchAndOpenSamplePDF(bookTitleQuery: "PDF测试文件")
app.waitForReader()
app.waitForDemoReaderState(timeout: 5, description: "PDF page curl at page 1") {
$0.mode == "pdf" && $0.display == "pagecurl" && $0.page == 1
}
let content = app.otherElements[IDs.readerContent].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "PDF 阅读页面未出现")
content.doubleTap()
XCTAssertNotNil(
waitForStableViewport(content, timeout: 3, stableDuration: 0.3, where: { $0.scale >= 1.8 }),
"PDF 双击后未放大"
)
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.readerSettings])
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "PDF 设置面板未出现")
app.segmentedControls[IDs.settingsDisplayType].buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap()
app.waitForDemoReaderState(timeout: 5, description: "display=horizontalscroll") {
$0.display == "horizontalscroll" && $0.page == 1
}
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "PDF 横滑视图未出现")
paging.swipeLeft()
app.waitForReaderPage(2, timeout: 5)
}
private func waitForStableViewport(
_ element: XCUIElement,
timeout: TimeInterval,
stableDuration: TimeInterval,
where predicate: ((scale: Double, offsetX: Double, offsetY: Double)) -> Bool
) -> (scale: Double, offsetX: Double, offsetY: Double)? {
let deadline = Date().addingTimeInterval(timeout)
var previous: (scale: Double, offsetX: Double, offsetY: Double)?
var stableSince: Date?
repeat {
guard let current = viewport(of: element), predicate(current) else {
previous = nil
stableSince = nil
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
continue
}
if let previous,
abs(current.scale - previous.scale) <= 0.01,
abs(current.offsetX - previous.offsetX) <= 1,
abs(current.offsetY - previous.offsetY) <= 1 {
if let stableSince, Date().timeIntervalSince(stableSince) >= stableDuration {
return current
}
} else {
stableSince = Date()
}
previous = current
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} while Date() < deadline
return nil
}
private func assertReaderPageRemains(
_ page: Int,
duration: TimeInterval,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(duration)
repeat {
guard let state = app.currentDemoReaderState(), state.page == page else {
XCTFail(
"PDF 手势后发生了意外翻页,当前状态:\(app.currentDemoReaderState()?.rawValue ?? "<missing>")",
file: file,
line: line
)
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} while Date() < deadline
}
private func waitForViewport(
_ element: XCUIElement,
timeout: TimeInterval,
predicate: ((scale: Double, offsetX: Double, offsetY: Double)) -> Bool
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
repeat {
if let viewport = viewport(of: element), predicate(viewport) { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
} while Date() < deadline
return false
}
private func viewport(of element: XCUIElement) -> (scale: Double, offsetX: Double, offsetY: Double)? {
guard let value = element.value as? String else { return nil }
let pattern = #"zoom=([0-9.]+) offset=(-?[0-9.]+),(-?[0-9.]+)"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
let scaleRange = Range(match.range(at: 1), in: value),
let xRange = Range(match.range(at: 2), in: value),
let yRange = Range(match.range(at: 3), in: value),
let scale = Double(value[scaleRange]),
let offsetX = Double(value[xRange]),
let offsetY = Double(value[yRange]) else { return nil }
return (scale, offsetX, offsetY)
}
}