Files
ReadViewSDK/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift
T
shenlei 3e60bf1869 修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
2026-07-30 20:52:21 +09:00

472 lines
19 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()
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
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()
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块应存在")
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)
}
}
}