ReadViewSDK/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift
shenlei 83dfa40299 完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
2026-07-09 18:46:04 +09:00

478 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import XCTest
final class SearchTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
// MARK: -
func testSearchButtonExistsInToolbar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应出现在顶部工具栏")
}
// MARK: - /
func testTapSearchButtonShowsSearchBar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5), "搜索按钮应存在")
searchButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "点击搜索按钮后搜索栏应出现")
}
func testSearchBarAutoFocusesTextField() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 5))
searchButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 3), "搜索栏出现后键盘应弹出")
}
func testCloseButtonHidesSearchBar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
openSearchBar()
let closeButton = app.buttons[IDs.searchClose]
XCTAssertTrue(closeButton.waitForExistence(timeout: 3), "关闭按钮应存在")
closeButton.tap()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "点击关闭后搜索栏应消失")
}
// MARK: - launch argument
func testSearchFindsMatchesAndShowsCount() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数标签应出现")
let countText = countLabel.label
XCTAssertTrue(countText.contains("/"), "计数格式应为 'N/M',实际:\(countText)")
let parts = countText.split(separator: "/")
XCTAssertEqual(parts.count, 2, "计数应包含两部分,实际:\(countText)")
if let total = Int(parts[1]) {
XCTAssertGreaterThan(total, 0, "关键词 '的' 应有匹配结果")
}
}
func testSearchNavigatesToFirstMatch() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "匹配计数应出现")
let countText = countLabel.label
XCTAssertTrue(countText.hasPrefix("1/"), "搜索后应自动跳转到第一个匹配,当前:\(countText)")
}
func testSearchNextButtonAdvancesMatch() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "初始应在第 1 个匹配")
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("2/"), "点击下一个后应变为第 2 个匹配,当前:\(countLabel.label)")
}
func testSearchPreviousButtonGoesBack() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
// 2
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("2/"))
// 退 1
let previousButton = app.buttons[IDs.searchPrevious]
XCTAssertTrue(previousButton.waitForExistence(timeout: 3))
previousButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "点击上一个后应回到第 1 个匹配,当前:\(countLabel.label)")
}
func testSearchNextWrapsAround() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
let countText = countLabel.label
let parts = countText.split(separator: "/")
guard parts.count == 2, let total = Int(parts[1]), total > 1 else {
XCTSkip("需要至少 2 个匹配才能测试循环")
return
}
//
let nextButton = app.buttons[IDs.searchNext]
for _ in 0..<(total - 1) {
nextButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
XCTAssertTrue(countLabel.label.hasPrefix("\(total)/"), "应到达最后一个匹配")
// 1
nextButton.tap()
XCTAssertTrue(countLabel.label.hasPrefix("1/"), "超过最后一个后应循环到第 1 个,当前:\(countLabel.label)")
}
// MARK: -
func testSearchWithNoResultsShowsZeroCount() {
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "计数标签应出现")
XCTAssertEqual(countLabel.label, "0/0", "无匹配时应显示 0/0")
}
func testSearchNavigationDisabledWhenNoResults() {
app.launchAndOpenSampleBook(searchKeyword: "xyzzy_nonexistent_keyword_12345")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertEqual(countLabel.label, "0/0")
let prevButton = app.buttons[IDs.searchPrevious]
let nextButton = app.buttons[IDs.searchNext]
XCTAssertTrue(prevButton.waitForExistence(timeout: 3))
XCTAssertTrue(nextButton.waitForExistence(timeout: 3))
XCTAssertFalse(prevButton.isEnabled, "无结果时上一个按钮应禁用")
XCTAssertFalse(nextButton.isEnabled, "无结果时下一个按钮应禁用")
}
// MARK: -
func testCloseSearchClearsState() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10))
XCTAssertNotEqual(countLabel.label, "0/0", "应有匹配结果")
//
let closeButton = app.buttons[IDs.searchClose]
XCTAssertTrue(closeButton.waitForExistence(timeout: 3))
closeButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
//
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
}
// MARK: -
// chrome
// chrome /
func testSearchBarHidesWhenToolbarsHide() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索面板应可见")
//
selectSearchResult(at: 0)
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "选中结果后搜索面板应关闭")
//
app.hideReaderChromeIfNeeded()
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索面板应保持关闭")
}
func testSearchBarRestoresWhenToolbarsShow() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
//
selectSearchResult(at: 0)
app.hideReaderChromeIfNeeded()
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
//
app.showReaderChromeIfNeeded()
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索面板也应恢复")
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 3))
XCTAssertNotEqual(countLabel.label, "0/0", "搜索状态应在工具栏恢复后保留")
}
// MARK: -
///
///
func testSearchMatchTextEqualsKeyword() {
let keyword = ""
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '\(keyword)' 应有匹配")
//
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText exists") {
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
}
XCTAssertEqual(state.searchMatchText, keyword,
"当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
XCTAssertNotNil(state.rangeCFI, "搜索命中应暴露 rangeCFI 以支持重排恢复")
}
///
func testSearchMatchTextAfterRepagination() {
let keyword = ""
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword)
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
let stateBefore = app.waitForDemoReaderState(timeout: 5, description: "search rangeCFI before repagination") {
$0.rangeCFI?.isEmpty == false
}
let rangeCFIBefore = stateBefore.rangeCFI
//
selectSearchResult(at: 0)
//
app.showReaderChromeIfNeeded()
let settingsButton = app.buttons[IDs.readerSettings]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3))
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3))
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
Thread.sleep(forTimeInterval: 1)
//
reopenSearchPanel()
let prevButton = app.buttons[IDs.searchPrevious]
let nextButton = app.buttons[IDs.searchNext]
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
prevButton.tap()
Thread.sleep(forTimeInterval: 0.5)
}
if nextButton.waitForExistence(timeout: 3), nextButton.isEnabled {
nextButton.tap()
Thread.sleep(forTimeInterval: 0.5)
}
//
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText after repagination") {
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
}
XCTAssertEqual(state.searchMatchText, keyword,
"重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
XCTAssertEqual(state.rangeCFI, rangeCFIBefore, "重排后当前搜索命中的 rangeCFI 应保持一致")
}
// MARK: -
///
/// chapterOffset(for:) 使 row/column chapterOffset
///
func testSearchNavigationAfterRepagination() {
app.launchAndOpenSampleBook(searchKeyword: "")
app.waitForReader()
app.showReaderChromeIfNeeded()
//
let countLabel = app.staticTexts[IDs.searchCount]
XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现")
XCTAssertNotEqual(countLabel.label, "0/0", "关键词 '的' 应有匹配")
//
let countText = countLabel.label
let parts = countText.split(separator: "/")
guard parts.count == 2, let total = Int(parts[1]), total >= 3 else {
XCTSkip("需要至少 3 个匹配才能测试多页跳转")
return
}
//
selectSearchResult(at: 0)
//
app.showReaderChromeIfNeeded()
let settingsButton = app.buttons[IDs.readerSettings]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 3), "设置按钮应存在")
settingsButton.tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮应存在")
fontIncrease.tap()
fontIncrease.tap()
Thread.sleep(forTimeInterval: 0.5)
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
Thread.sleep(forTimeInterval: 1)
// 2
reopenSearchPanel()
selectSearchResult(at: 1)
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
}
let pageAtMatch2 = stateMatch2.page ?? 1
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
//
swipeAwayFromCurrentPage()
// 3
reopenSearchPanel()
selectSearchResult(at: 2)
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
}
let pageAtMatch3 = stateMatch3.page ?? 1
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
//
swipeAwayFromCurrentPage()
// 2
reopenSearchPanel()
selectSearchResult(at: 1)
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
}
XCTAssertEqual(stateBack2.page, pageAtMatch2,
"重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)")
XCTAssertEqual(stateBack2.rangeCFI, rangeCFIAtMatch2,
"重排后搜索跳回第 2 个匹配应命中同一段文本")
//
swipeAwayFromCurrentPage()
// 3
reopenSearchPanel()
selectSearchResult(at: 2)
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
}
XCTAssertEqual(stateBack3.page, pageAtMatch3,
"重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)")
XCTAssertEqual(stateBack3.rangeCFI, rangeCFIAtMatch3,
"重排后搜索跳到第 3 个匹配应命中同一段文本")
}
// MARK: -
private func openSearchBar() {
let searchButton = app.buttons[IDs.readerSearch]
if searchButton.waitForExistence(timeout: 5) {
searchButton.tap()
}
let searchBar = app.otherElements[IDs.searchBar]
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
}
///
private func selectSearchResult(at matchIndex: Int) {
let cell = app.cells["epub.reader.search.result.\(matchIndex)"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "搜索结果第 \(matchIndex) 项应存在")
cell.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
/// chrome
/// chrome
private func reopenSearchPanel() {
app.showReaderChromeIfNeeded()
let searchBar = app.otherElements[IDs.searchBar]
if searchBar.waitForExistence(timeout: 2) {
return
}
let searchButton = app.buttons[IDs.readerSearch]
XCTAssertTrue(searchButton.waitForExistence(timeout: 3), "搜索按钮应存在")
searchButton.tap()
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索面板应重新打开")
}
/// chrome 使
private func swipeAwayFromCurrentPage() {
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerContent].firstMatch
if content.waitForExistence(timeout: 3) {
for _ in 0..<4 {
content.swipeLeft()
Thread.sleep(forTimeInterval: 0.3)
}
Thread.sleep(forTimeInterval: 0.5)
}
}
}