241 lines
10 KiB
Swift
241 lines
10 KiB
Swift
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: "英汉大词典",
|
||
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: "英汉大词典",
|
||
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
|
||
}
|
||
}
|