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: - 搜索栏与工具栏联动 func testSearchBarHidesWhenToolbarsHide() { app.launchAndOpenSampleBook(searchKeyword: "的") app.waitForReader() app.showReaderChromeIfNeeded() let searchBar = app.otherElements[IDs.searchBar] XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见") // 隐藏工具栏 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)) // 隐藏工具栏 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" } XCTAssertEqual(state.searchMatchText, keyword, "当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")") } /// 重排后搜索匹配文本仍应与关键字一致 func testSearchMatchTextAfterRepagination() { let keyword = "辽" app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读", searchKeyword: keyword) app.waitForReader() app.showReaderChromeIfNeeded() // 等待搜索结果 let countLabel = app.staticTexts[IDs.searchCount] XCTAssertTrue(countLabel.waitForExistence(timeout: 10), "搜索计数应出现") // 改变字体触发重排 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) // 重新打开搜索并导航到匹配 app.showReaderChromeIfNeeded() 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" } XCTAssertEqual(state.searchMatchText, keyword, "重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")") } // 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 } // 改变字体大小触发重新排版 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) // 测试多个匹配位置的跳转正确性 let nextButton = app.buttons[IDs.searchNext] let prevButton = app.buttons[IDs.searchPrevious] XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在") XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在") // 导航到第 2 个匹配,记录页码 nextButton.tap() Thread.sleep(forTimeInterval: 0.5) let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") { ($0.page ?? 0) > 0 } let pageAtMatch2 = stateMatch2.page ?? 1 // 翻到其他页 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) } // 跳转到第 3 个匹配 nextButton.tap() Thread.sleep(forTimeInterval: 0.5) let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") { ($0.page ?? 0) > 0 } let pageAtMatch3 = stateMatch3.page ?? 1 // 再翻到其他页 if content.waitForExistence(timeout: 3) { for _ in 0..<4 { content.swipeLeft() Thread.sleep(forTimeInterval: 0.3) } Thread.sleep(forTimeInterval: 0.5) } // 跳回第 2 个匹配,验证页码不变 prevButton.tap() Thread.sleep(forTimeInterval: 0.5) let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") { $0.page == pageAtMatch2 } XCTAssertEqual(stateBack2.page, pageAtMatch2, "重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)") // 再翻到其他页 if content.waitForExistence(timeout: 3) { for _ in 0..<4 { content.swipeLeft() Thread.sleep(forTimeInterval: 0.3) } Thread.sleep(forTimeInterval: 0.5) } // 跳到第 3 个匹配,验证页码不变 nextButton.tap() Thread.sleep(forTimeInterval: 0.5) let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") { $0.page == pageAtMatch3 } XCTAssertEqual(stateBack3.page, pageAtMatch3, "重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)") } // 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), "搜索栏应出现") } }