feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword navigation, and match counting integrated into the reader controller - Restructure docs: replace scattered design docs with consolidated BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md - Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests - Add scripts/run_ui_regression.sh and summarize_ui_results.py for automated UI test execution and reporting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d20196ee34
commit
0e7c0577e3
@@ -27,6 +27,14 @@ enum IDs {
|
||||
static let readerSelectionText = "epub.reader.selection.text"
|
||||
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
||||
|
||||
static let readerSearch = "epub.reader.search"
|
||||
static let searchBar = "epub.reader.search.bar"
|
||||
static let searchField = "epub.reader.search.field"
|
||||
static let searchPrevious = "epub.reader.search.previous"
|
||||
static let searchNext = "epub.reader.search.next"
|
||||
static let searchClose = "epub.reader.search.close"
|
||||
static let searchCount = "epub.reader.search.count"
|
||||
|
||||
static let settingsScroll = "epub.reader.settings.scroll"
|
||||
static let settingsBrightness = "epub.reader.settings.brightness"
|
||||
static let settingsFontIncrease = "epub.reader.settings.font.increase"
|
||||
|
||||
@@ -8,7 +8,8 @@ extension XCUIApplication {
|
||||
resetsReaderState: Bool = true,
|
||||
windowSize: Int? = nil,
|
||||
concurrency: Int? = nil,
|
||||
clearsCache: Bool = false
|
||||
clearsCache: Bool = false,
|
||||
searchKeyword: String? = nil
|
||||
) {
|
||||
var args = ["--demo-book-title", bookTitleQuery]
|
||||
if resetsReaderState {
|
||||
@@ -29,6 +30,9 @@ extension XCUIApplication {
|
||||
if let concurrency {
|
||||
args += ["--demo-concurrency", "\(concurrency)"]
|
||||
}
|
||||
if let searchKeyword {
|
||||
args += ["--demo-search-keyword", searchKeyword]
|
||||
}
|
||||
launchArguments = args
|
||||
launch()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import XCTest
|
||||
|
||||
final class FanrenParseTimeTest: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
/// 测试凡人修仙传后台解析耗时
|
||||
func testFanrenParseTime() throws {
|
||||
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
||||
print("[Fanren] 开始测试 - 设备CPU核心数: \(cpuCount)")
|
||||
|
||||
// 打开凡人修仙传,清缓存,使用默认并发数
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: "凡人修仙传",
|
||||
resetsReaderState: true,
|
||||
concurrency: cpuCount,
|
||||
clearsCache: true
|
||||
)
|
||||
|
||||
// 等待阅读器打开
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
// 等待首屏可读
|
||||
_ = app.waitForDemoReaderState(timeout: 30, description: "首屏加载") { state in
|
||||
state.mode == "bookPageMap" && (state.page ?? 0) >= 1
|
||||
}
|
||||
print("[Fanren] 首屏加载完成")
|
||||
|
||||
// 等待后台解析完成(parseMs > 0)
|
||||
let completed = app.waitForDemoReaderState(timeout: 900, description: "后台解析完成") { state in
|
||||
(state.parseMs ?? 0) > 0
|
||||
}
|
||||
|
||||
let parseMs = completed.parseMs ?? 0
|
||||
let concurrency = completed.parseConcurrency ?? 0
|
||||
|
||||
print("[Fanren] ---- 测试结果 ----")
|
||||
print("[Fanren] 后台解析耗时: \(parseMs)ms")
|
||||
print("[Fanren] 并发数: \(concurrency)")
|
||||
print("[Fanren] CPU核心数: \(cpuCount)")
|
||||
|
||||
// 验证
|
||||
XCTAssertGreaterThan(parseMs, 0, "parseMs应大于0")
|
||||
|
||||
// 返回书架
|
||||
app.showReaderChromeIfNeeded()
|
||||
if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) {
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,79 @@ final class LargeBookOnDemandTests: XCTestCase {
|
||||
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
||||
}
|
||||
|
||||
func testMiddleChapterOpenPositionStableAfterFullParse() throws {
|
||||
// 从中间章节打开,等后台解析完成,验证阅读位置不跳转
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
pageNumber: 50,
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let partialState = app.waitForDemoReaderState(timeout: 15, description: "中间章节首开进入局部分页") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "partial" && (state.page ?? 0) >= 1
|
||||
}
|
||||
let pageAfterOpen = partialState.page ?? 0
|
||||
XCTAssertGreaterThan(pageAfterOpen, 1, "应从中间章节打开,当前页=\(pageAfterOpen)")
|
||||
|
||||
// 等待后台解析完成
|
||||
let fullState = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
|
||||
state.pagination == "full"
|
||||
}
|
||||
XCTAssertEqual(fullState.pagination, "full", "后台解析应完成")
|
||||
|
||||
// 验证页码未跳转(方案 C:pending map 不会在解析完成时应用)
|
||||
let pageAfterParse = fullState.page ?? 0
|
||||
XCTAssertEqual(pageAfterParse, pageAfterOpen,
|
||||
"后台解析完成后页码不应跳转:解析前=\(pageAfterOpen) 解析后=\(pageAfterParse)")
|
||||
|
||||
// 验证翻页功能正常(触发 pending map 应用)
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) {
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let afterSwipe = app.waitForDemoReaderState(timeout: 10, description: "翻页后页码推进") { state in
|
||||
state.page != nil && state.page != pageAfterParse
|
||||
}
|
||||
XCTAssertGreaterThan(afterSwipe.page ?? 0, pageAfterParse,
|
||||
"翻页后页码应前进:翻页前=\(pageAfterParse) 翻页后=\(afterSwipe.page ?? 0)")
|
||||
}
|
||||
|
||||
func testMiddleChapterOpenSwipeAfterFullParseResolvesCorrectly() throws {
|
||||
// 从中间章节打开,等解析完成,连续翻页验证页码连续性
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
pageNumber: 30,
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "partial"
|
||||
}
|
||||
|
||||
// 等待解析完成
|
||||
_ = app.waitForDemoReaderState(timeout: 90, description: "后台解析完成") { state in
|
||||
state.pagination == "full"
|
||||
}
|
||||
|
||||
// 连续翻 3 页,验证页码连续递增
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
|
||||
|
||||
var previousPage = app.currentDemoReaderState()?.page ?? 0
|
||||
for i in 0..<3 {
|
||||
paging.swipeLeft()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(1))
|
||||
let currentState = app.currentDemoReaderState()
|
||||
let currentPage = currentState?.page ?? 0
|
||||
XCTAssertGreaterThan(currentPage, previousPage,
|
||||
"第 \(i + 1) 次翻页后页码应递增:前=\(previousPage) 后=\(currentPage)")
|
||||
previousPage = currentPage
|
||||
}
|
||||
}
|
||||
|
||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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: - 辅助方法
|
||||
|
||||
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), "搜索栏应出现")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user