Files
ReadViewSDK/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift
T
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 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)
}
}
}