feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
+1848
-1724
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,17 @@ enum IDs {
|
||||
static let readerBookmarksNavBar = "epub.reader.bookmarks.navbar"
|
||||
static let readerSelectionText = "epub.reader.selection.text"
|
||||
static let readerSelectionHighlight = "epub.reader.selection.高亮"
|
||||
static let readerContentView = "epub.reader.content.view"
|
||||
static let readerHighlightsPanel = "epub.reader.highlights.panel"
|
||||
static let readerHighlightsTable = "epub.reader.highlights.table"
|
||||
static let readerHighlightsFilter = "epub.reader.highlights.filter"
|
||||
static let readerHighlightsEmptyLabel = "epub.reader.highlights.empty"
|
||||
static let readerHighlightsNavBar = "epub.reader.highlights.navbar"
|
||||
static let readerBookmarksEmptyLabel = "epub.reader.bookmarks.empty"
|
||||
static let readerNoteText = "epub.reader.note.text"
|
||||
static let readerNoteActions = "epub.reader.note.actions"
|
||||
static let readerNoteReturn = "epub.reader.note.return"
|
||||
static let readerNoteOpen = "epub.reader.note.open"
|
||||
|
||||
static let readerSearch = "epub.reader.search"
|
||||
static let searchBar = "epub.reader.search.bar"
|
||||
|
||||
@@ -21,6 +21,11 @@ struct DemoReaderState {
|
||||
fields[key]
|
||||
}
|
||||
|
||||
private func decodedField(_ key: String) -> String? {
|
||||
guard let rawValue = fields[key], rawValue != "nil" else { return nil }
|
||||
return rawValue.removingPercentEncoding ?? rawValue
|
||||
}
|
||||
|
||||
var isOpened: Bool { fields["reader"] == "opened" }
|
||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||
var display: String? { fields["display"] }
|
||||
@@ -28,8 +33,11 @@ struct DemoReaderState {
|
||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||
var bookmarks: Int? { fields["bookmarks"].flatMap(Int.init) }
|
||||
var selection: Int? { fields["selection"].flatMap(Int.init) }
|
||||
var href: String? { fields["href"] }
|
||||
var href: String? { decodedField("href") }
|
||||
var progression: Double? { fields["progression"].flatMap(Double.init) }
|
||||
var cfi: String? { decodedField("cfi") }
|
||||
var lastCFI: String? { decodedField("lastCFI") }
|
||||
var rangeCFI: String? { decodedField("rangeCFI") }
|
||||
var mode: String? { fields["mode"] }
|
||||
var pagination: String? { fields["pagination"] }
|
||||
var knownPages: Int? { fields["knownPages"].flatMap(Int.init) }
|
||||
@@ -47,9 +55,9 @@ struct DemoReaderState {
|
||||
var cacheFiles: Int? { fields["cacheFiles"].flatMap(Int.init) }
|
||||
var cacheBytes: Int? { fields["cacheBytes"].flatMap(Int.init) }
|
||||
var externalLinks: Int? { fields["externalLinks"].flatMap(Int.init) }
|
||||
var lastExternalURL: String? { fields["lastExternalURL"] }
|
||||
var lastError: String? { fields["lastError"] }
|
||||
var searchMatchText: String? { fields["searchMatchText"] }
|
||||
var lastExternalURL: String? { decodedField("lastExternalURL") }
|
||||
var lastError: String? { decodedField("lastError") }
|
||||
var searchMatchText: String? { decodedField("searchMatchText") }
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
@@ -73,6 +73,54 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
||||
}
|
||||
|
||||
func testPositionSurvivesFontChoiceAndLineHeightChange() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) {
|
||||
paging.swipeLeft()
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let stateBefore = app.waitForDemoReaderState(timeout: 8, description: "翻页后定位稳定") { state in
|
||||
(state.page ?? 0) >= 2 && state.cfi?.isEmpty == false
|
||||
}
|
||||
let pageBefore = stateBefore.page ?? 0
|
||||
let hrefBefore = stateBefore.href
|
||||
let cfiBefore = stateBefore.cfi
|
||||
XCTAssertGreaterThanOrEqual(pageBefore, 2, "测试前应先进入第 2 页或之后")
|
||||
XCTAssertNotNil(hrefBefore, "重排前应存在当前位置 href")
|
||||
XCTAssertNotNil(cfiBefore, "重排前应存在当前位置 CFI")
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3))
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
|
||||
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
|
||||
if fontChoiceControl.buttons.count > 1 {
|
||||
fontChoiceControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
|
||||
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
|
||||
XCTAssertTrue(lineHeightControl.waitForExistence(timeout: 5), "行距控件应出现")
|
||||
if lineHeightControl.buttons.count > 1 {
|
||||
lineHeightControl.buttons.element(boundBy: min(1, lineHeightControl.buttons.count - 1)).tap()
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3))
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
let stateAfter = app.waitForDemoReaderState(timeout: 10, description: "改字体与行距后页码保持") { state in
|
||||
state.page != nil && state.cfi?.isEmpty == false
|
||||
}
|
||||
let pageAfter = stateAfter.page ?? 0
|
||||
XCTAssertEqual(pageAfter, pageBefore, "字体与行距变化后,应尽量保留当前阅读页")
|
||||
XCTAssertEqual(stateAfter.href, hrefBefore, "重排后应仍定位在同一章节")
|
||||
XCTAssertEqual(stateAfter.cfi, cfiBefore, "重排后应保持当前阅读锚点的 CFI 不变")
|
||||
}
|
||||
|
||||
func testBookmarkAndHighlightPersistAcrossReopen() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
@@ -103,4 +151,57 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
XCTAssertEqual(state.bookmarks, 1)
|
||||
XCTAssertEqual(state.highlights, 1)
|
||||
}
|
||||
|
||||
func testHighlightNavigationSurvivesFontChoiceChange() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true, pageNumber: 3)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 8, description: "初始位于第 3 页") { state in
|
||||
state.page == 3
|
||||
}
|
||||
XCTAssertEqual(initialState.page, 3)
|
||||
|
||||
app.hideReaderChromeIfNeeded()
|
||||
let selectionText = try app.requireSelectionText(expectedPage: 3)
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.25, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightItem = app.menuItems["高亮"]
|
||||
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
||||
highlightItem.tap()
|
||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
let fontChoiceControl = app.segmentedControls[IDs.settingsFontChoice]
|
||||
XCTAssertTrue(fontChoiceControl.waitForExistence(timeout: 5), "字体选择控件应出现")
|
||||
if fontChoiceControl.buttons.count > 1 {
|
||||
fontChoiceControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
content.swipeLeft()
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮面板按钮应存在")
|
||||
highlightsButton.tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮列表应出现")
|
||||
let firstCell = highlightsTable.cells.firstMatch
|
||||
XCTAssertTrue(firstCell.waitForExistence(timeout: 3), "高亮列表应至少有一个条目")
|
||||
firstCell.tap()
|
||||
|
||||
let restoredState = app.waitForDemoReaderState(timeout: 10, description: "字体变化后高亮跳回原页") { state in
|
||||
state.page == 3
|
||||
}
|
||||
XCTAssertEqual(restoredState.page, 3, "字体变化后,通过高亮面板跳转仍应回到原始高亮页")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,10 +264,11 @@ final class SearchTests: XCTestCase {
|
||||
|
||||
// 验证当前匹配的文本就是搜索关键字
|
||||
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText exists") {
|
||||
$0.searchMatchText != nil && $0.searchMatchText != "none"
|
||||
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
XCTAssertEqual(state.searchMatchText, keyword,
|
||||
"当前搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
|
||||
XCTAssertNotNil(state.rangeCFI, "搜索命中应暴露 rangeCFI 以支持重排恢复")
|
||||
}
|
||||
|
||||
/// 重排后搜索匹配文本仍应与关键字一致
|
||||
@@ -280,6 +281,10 @@ final class SearchTests: XCTestCase {
|
||||
// 等待搜索结果
|
||||
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
|
||||
|
||||
// 改变字体触发重排
|
||||
app.showReaderChromeIfNeeded()
|
||||
@@ -314,10 +319,11 @@ final class SearchTests: XCTestCase {
|
||||
|
||||
// 验证重排后匹配文本仍正确
|
||||
let state = app.waitForDemoReaderState(timeout: 5, description: "searchMatchText after repagination") {
|
||||
$0.searchMatchText != nil && $0.searchMatchText != "none"
|
||||
$0.searchMatchText != nil && $0.searchMatchText != "none" && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
XCTAssertEqual(state.searchMatchText, keyword,
|
||||
"重排后搜索匹配文本应为 '\(keyword)',实际:\(state.searchMatchText ?? "nil")")
|
||||
XCTAssertEqual(state.rangeCFI, rangeCFIBefore, "重排后当前搜索命中的 rangeCFI 应保持一致")
|
||||
}
|
||||
|
||||
// MARK: - 重排后搜索跳转正确性
|
||||
@@ -371,9 +377,10 @@ final class SearchTests: XCTestCase {
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
||||
($0.page ?? 0) > 0
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
let pageAtMatch2 = stateMatch2.page ?? 1
|
||||
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
|
||||
|
||||
// 翻到其他页
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
@@ -389,9 +396,10 @@ final class SearchTests: XCTestCase {
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
||||
($0.page ?? 0) > 0
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
let pageAtMatch3 = stateMatch3.page ?? 1
|
||||
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
@@ -406,10 +414,12 @@ final class SearchTests: XCTestCase {
|
||||
prevButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
||||
$0.page == pageAtMatch2
|
||||
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
|
||||
}
|
||||
XCTAssertEqual(stateBack2.page, pageAtMatch2,
|
||||
"重排后搜索跳回第 2 个匹配应回到第 \(pageAtMatch2) 页,实际:\(stateBack2.page ?? -1)")
|
||||
XCTAssertEqual(stateBack2.rangeCFI, rangeCFIAtMatch2,
|
||||
"重排后搜索跳回第 2 个匹配应命中同一段文本")
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
@@ -424,10 +434,12 @@ final class SearchTests: XCTestCase {
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
||||
$0.page == pageAtMatch3
|
||||
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
|
||||
}
|
||||
XCTAssertEqual(stateBack3.page, pageAtMatch3,
|
||||
"重排后搜索跳到第 3 个匹配应回到第 \(pageAtMatch3) 页,实际:\(stateBack3.page ?? -1)")
|
||||
XCTAssertEqual(stateBack3.rangeCFI, rangeCFIAtMatch3,
|
||||
"重排后搜索跳到第 3 个匹配应命中同一段文本")
|
||||
}
|
||||
|
||||
// MARK: - 辅助方法
|
||||
|
||||
Reference in New Issue
Block a user