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 ?? "")", file: file, line: line ) return } RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } while Date() < deadline } }