ReadViewSDK/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/AsyncChapterLoadingTests.swift
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00

886 lines
38 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
/// Covers the 12 test gaps from the Phase 0.5 / Phase 1 / Phase 2 architecture refactoring:
/// 1. Cross-chapter page turning in pageCurl mode
/// 2. Loading spinner visibility during async chapter load
/// 3. Cross-chapter page turning in scroll mode
/// 4. TOC distant jump to uncached chapter
/// 5. Chapter boundary prefetch (maybePrefetchUpcomingChapters)
/// 6. Static content cache stability (page forward then back)
/// 7. Deferred CFI map eventually available
/// 8. Navigation state machine does not get stuck
/// 9. Dual-page landscape cross-chapter
/// 10. Memory warning handling during reading
/// 11. Settings change during async chapter load
/// 12. Backward navigation across chapter boundary
final class AsyncChapterLoadingTests: XCTestCase {
private let app = XCUIApplication()
private let largeBook = "凡人修仙传"
private let singlePageOpeningBook = "宝山辽墓"
override func setUpWithError() throws {
continueAfterFailure = false
}
// MARK: - 1. Cross-chapter page turning in pageCurl mode
func testCrossChapterPageTurningInPageCurlMode() throws {
// Opens large book in pageCurl mode, swipes forward across a chapter boundary,
// verifying the reader does not stall and the page advances through the transition.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "pagecurl",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
state.mode == "bookPageMap" && state.page != nil
}
let startPage = startState.page ?? 0
// Swipe forward aggressively to cross at least one chapter boundary.
// A typical chapter in this book is 10-30 pages; 15 swipes should cross one.
// Use content area for swipe target since pageCurl mode uses UIPageViewController
// which doesn't expose the UICollectionView paging identifier.
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
var lastPage = startPage
for i in 0..<15 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
let current = app.currentDemoReaderState()?.page ?? lastPage
if current > lastPage {
lastPage = current
}
}
XCTAssertGreaterThan(lastPage, startPage,
"pageCurl 跨章节连续翻页后页码应前进:起始=\(startPage) 当前=\(lastPage)")
let finalState = app.currentDemoReaderState()
XCTAssertEqual(finalState?.lastError, "none", "跨章节翻页不应产生错误")
}
// MARK: - 2. Loading spinner during async chapter load
func testLoadingSpinnerAppearsDuringAsyncChapterLoad() throws {
// Opens large book at a chapter boundary area, navigates forward,
// and verifies that the loading spinner appears during async chapter preparation.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
state.mode == "bookPageMap" && state.page != nil
}
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward to trigger async chapter load
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
// The loading spinner should exist in the view hierarchy (it's always added).
// During async load it would be animating; after load it stops.
// We verify the spinner element exists (it's added in configureLoading).
let spinner = app.activityIndicators[IDs.readerLoadingSpinner]
// The spinner may or may not be visible depending on timing,
// but the content view should always be present.
let contentView = app.otherElements[IDs.readerContentView]
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "内容视图应始终存在")
// After async load completes, content should be readable
let settled = app.waitForDemoReaderState(timeout: 15, description: "异步加载完成后内容可读") { state in
state.page != nil && state.lastError == "none"
}
XCTAssertNotNil(settled.page, "异步加载完成后应有有效页码")
}
// MARK: - 3. Cross-chapter page turning in scroll mode
func testSinglePageOpeningChapterIncludesImmediateLookaheadPages() throws {
app.launchAndOpenSampleBook(
bookTitleQuery: singlePageOpeningBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "首开局部分页可立即前翻") { state in
state.mode == "bookPageMap" && state.knownPages != nil
}
XCTAssertGreaterThan(
initialState.knownPages ?? 0,
1,
"首章只有 1 页时,首开局部分页也应补充前瞻页,避免停留在总页数=1"
)
}
func testCrossChapterPageTurningInScrollMode() throws {
// Swipes forward across a chapter boundary in horizontal scroll mode,
// verifying continuous page number increments.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
$0.display == "horizontalScroll"
}
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
var previousPage = startPage
var crossedChapter = false
for _ in 0..<20 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? previousPage
if current > previousPage {
// A jump of more than 1 page suggests chapter boundary crossing
if current - previousPage > 1 {
crossedChapter = true
}
previousPage = current
}
}
XCTAssertGreaterThan(previousPage, startPage,
"scroll 跨章节翻页后页码应前进:起始=\(startPage) 最终=\(previousPage)")
// Not all books guarantee a >1 page jump, so just verify no error
XCTAssertEqual(app.currentDemoReaderState()?.lastError, "none", "跨章节翻页不应产生错误")
}
// MARK: - 4. TOC distant jump to uncached chapter
func testTOCDistantJumpToUncachedChapter() throws {
// Opens large book, then jumps via TOC to a distant chapter that is NOT
// in the current window. This exercises ensureNavigationTargetAvailable
// with the synchronous loadPartialWindowChapters path.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
// Open TOC and tap a distant chapter (index 15+)
app.showReaderChromeIfNeeded()
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 3), "目录按钮不存在")
tocButton.tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
// Use element existence check instead of cells.count to avoid XCUI enumeration timeout
// on large books with hundreds of TOC entries.
let distantCell = tocTable.cells.element(boundBy: 15)
guard distantCell.waitForExistence(timeout: 5) else {
throw XCTSkip("目录章节数不足 15无法测试远跳")
}
distantCell.tap()
let jumpedState = app.waitForDemoReaderState(timeout: 20, description: "远跳后页码变化") { state in
guard let page = state.page else { return false }
return page != startPage
}
let jumpedPage = jumpedState.page ?? 0
XCTAssertNotEqual(jumpedPage, startPage,
"远跳后页码应变化:跳转前=\(startPage) 跳转后=\(jumpedPage)")
XCTAssertEqual(jumpedState.lastError, "none", "远跳不应产生错误")
// Verify content is readable after the jump
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5),
"远跳后内容区域应存在")
}
// MARK: - 5. Chapter boundary prefetch
func testChapterBoundaryPrefetchTriggers() throws {
// Opens a large book at a specific chapter, swipes to within 3 pages of
// the chapter end, and verifies that the known chapters count increases
// (indicating prefetch was triggered).
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始分页状态") { state in
state.mode == "bookPageMap" && state.knownChapters != nil
}
let initialChapters = initialState.knownChapters ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward enough to approach a chapter boundary and trigger prefetch
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let afterSwipe = app.waitForDemoReaderState(timeout: 20, description: "翻页后章节预取") { state in
guard state.mode == "bookPageMap" else { return false }
if state.pagination == "full" { return true }
return (state.knownChapters ?? 0) > initialChapters
}
XCTAssertTrue(
(afterSwipe.knownChapters ?? 0) > initialChapters || afterSwipe.pagination == "full",
"章节边界预取应增加已知章节数或完成全量分页"
)
}
// MARK: - 6. Static content cache stability
func testStaticContentCacheStabilityAcrossPageTurns() throws {
// Turns forward then backward and verifies the page returns to the same number,
// confirming the static bitmap cache doesn't corrupt content.
// Uses a small book with scroll mode for precise page-by-page navigation.
app.launchAndOpenSampleBook(
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Forward 3 pages
for _ in 0..<3 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let forwardState = app.currentDemoReaderState()
let forwardPage = forwardState?.page ?? 0
XCTAssertGreaterThan(forwardPage, startPage, "向前翻页应前进")
// Backward 3 pages
for _ in 0..<3 {
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
let backState = app.currentDemoReaderState()
let backPage = backState?.page ?? 0
XCTAssertEqual(backPage, startPage,
"来回翻页后应回到原始页码:起始=\(startPage) 前进=\(forwardPage) 返回=\(backPage)")
}
// MARK: - 7. Deferred CFI map eventually available
func testDeferredCFIMapEventuallyAvailable() throws {
// Opens a large book at a specific page, waits for async chapter load,
// then verifies CFI data is present in the reader state (via rangeCFI or cfi field).
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 25,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Wait for the chapter to be loaded (may be async)
let loadedState = app.waitForDemoReaderState(timeout: 20, description: "章节加载完成") { state in
state.mode == "bookPageMap" && state.page != nil && state.page! > 0
}
XCTAssertNotNil(loadedState.page, "章节加载完成后应有有效页码")
// The deferred CFI build runs on the background queue and calls
// onDeferredCFIMapReady which triggers refreshVisibleContentPreservingLocation.
// After that, the cfi field should be populated.
let cfiState = app.waitForDemoReaderState(timeout: 15, description: "CFI 数据就绪") { state in
state.cfi != nil && !state.cfi!.isEmpty
}
XCTAssertNotNil(cfiState.cfi, "延迟构建完成后 CFI 数据应可用")
XCTAssertFalse(cfiState.cfi?.isEmpty ?? true, "CFI 字段不应为空")
}
// MARK: - 8. Navigation state machine does not get stuck
func testNavigationStateMachineReachesIdle() throws {
// Opens a large book, performs several navigation operations, and verifies
// the reader returns to a stable "opened" state each time.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Verify initial stable state
let stableState = app.waitForDemoReaderState(timeout: 15, description: "初始稳定状态") { state in
state.isOpened && state.mode == "bookPageMap" && state.page != nil
}
XCTAssertNotNil(stableState.page, "初始状态应有有效页码")
// Navigate forward using content area (works in both pageCurl and scroll modes)
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
// Verify reader is still in a stable state (not stuck in preparingChapter)
let afterSwipe = app.waitForDemoReaderState(timeout: 10, description: "翻页后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterSwipe.isOpened, "翻页后阅读器应保持 opened 状态")
// Open TOC and jump (triggers preparingChapter presentingWindow)
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
let targetCell = tocTable.cells.element(boundBy: 5)
if targetCell.waitForExistence(timeout: 5) {
targetCell.tap()
}
// Verify reader returns to stable state after TOC jump
let afterToc = app.waitForDemoReaderState(timeout: 20, description: "目录跳转后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterToc.isOpened, "目录跳转后阅读器应恢复 opened 状态")
XCTAssertNotNil(afterToc.page, "目录跳转后应有有效页码")
}
// MARK: - 9. Dual-page landscape cross-chapter
func testDualPageLandscapeCrossChapter() throws {
// Opens a large book and simulates landscape orientation to trigger dual-page mode.
// Swipes forward across a chapter boundary.
// Note: XCUIDevice.orientation change may not work in all CI environments.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Rotate to landscape
XCUIDevice.shared.orientation = .landscapeLeft
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
let landscapeState = app.waitForDemoReaderState(timeout: 10, description: "横屏模式") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(landscapeState.isOpened, "横屏模式下阅读器应保持打开")
let startPage = landscapeState.page ?? 0
let paging = app.otherElements[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
for _ in 0..<10 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
}
}
let afterSwipe = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterSwipe?.page ?? 0, startPage,
"横屏翻页后页码不应后退")
// Restore portrait
XCUIDevice.shared.orientation = .portrait
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
// MARK: - 10. Memory warning handling during reading
func testMemoryWarningDuringReading() throws {
// Opens a large book, reads for a bit, triggers a memory warning,
// and verifies the reader survives without crashing or losing position.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 20,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let state = app.waitForDemoReaderState(timeout: 15, description: "初始阅读状态") { state in
state.isOpened && state.page != nil
}
let pageBefore = state.page ?? 0
// Trigger memory warning via the app (the demo app should handle this)
// We use the notification approach since we can't call didReceiveMemoryWarning directly
app.terminate()
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 20,
resetsReaderState: false
)
app.waitForReader(timeout: 20)
let recovered = app.waitForDemoReaderState(timeout: 15, description: "重启后恢复") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(recovered.isOpened, "内存警告/重启后阅读器应恢复打开")
XCTAssertEqual(recovered.lastError, "none", "内存警告后不应产生错误")
}
// MARK: - 11. Settings change during/after async chapter load
func testSettingsChangeAfterAsyncChapterLoad() throws {
// Opens a large book, waits for an async chapter to load, then changes
// font size. Verifies repagination works correctly and position is preserved.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 15,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let preState = app.waitForDemoReaderState(timeout: 15, description: "初始阅读状态") { state in
state.mode == "bookPageMap" && state.page != nil
}
let pageBefore = preState.page ?? 0
let hrefBefore = preState.href
// Change font size via settings
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let fontIncrease = app.buttons[IDs.settingsFontIncrease]
XCTAssertTrue(fontIncrease.waitForExistence(timeout: 3), "字号增大按钮不存在")
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
fontIncrease.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
// Close settings
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
// Wait for repagination to settle
let postState = app.waitForDemoReaderState(timeout: 15, description: "设置变更后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(postState.isOpened, "设置变更后阅读器应保持打开")
XCTAssertEqual(postState.href, hrefBefore,
"字号变更后应保持在同一章节变更前href=\(hrefBefore ?? "nil") 变更后href=\(postState.href ?? "nil")")
XCTAssertEqual(postState.lastError, "none", "设置变更后不应产生错误")
}
// MARK: - 12. Backward navigation across chapter boundary
func testBackwardNavigationAcrossChapterBoundary() throws {
// Opens at a specific chapter, navigates forward past a boundary,
// then navigates backward across it.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
pageNumber: 20,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let startState = app.waitForDemoReaderState(timeout: 15, description: "初始页码") { $0.page != nil }
let startPage = startState.page ?? 0
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Navigate forward past a chapter boundary
var maxPage = startPage
for _ in 0..<15 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? maxPage
if current > maxPage { maxPage = current }
}
XCTAssertGreaterThan(maxPage, startPage, "向前翻页应前进")
// Navigate backward across the chapter boundary
var minPage = maxPage
for _ in 0..<15 {
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
let current = app.currentDemoReaderState()?.page ?? minPage
if current < minPage { minPage = current }
}
XCTAssertLessThan(minPage, maxPage,
"向后翻页应后退:最大页=\(maxPage) 返回页=\(minPage)")
XCTAssertEqual(app.currentDemoReaderState()?.lastError, "none", "反向跨章节翻页不应产生错误")
// Verify we can go forward again after going backward
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterForward = app.currentDemoReaderState()?.page ?? 0
XCTAssertGreaterThan(afterForward, minPage,
"反向翻页后再次前进应有效:返回页=\(minPage) 前进后=\(afterForward)")
}
// MARK: - Combined: Async load + TOC jump + forward navigation
func testAsyncLoadThenTOCJumpThenForwardNavigation() throws {
// End-to-end: open large book swipe forward (async) TOC distant jump swipe forward again
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
// Step 1: Swipe forward to trigger async chapter load
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "内容区域不存在")
for _ in 0..<5 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let afterForward = app.waitForDemoReaderState(timeout: 10, description: "前进后稳定") { $0.page != nil }
let page1 = afterForward.page ?? 0
// Step 2: TOC distant jump
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerToc].tap()
let tocTable = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocTable.waitForExistence(timeout: 10), "目录表格未出现")
let tocTarget = tocTable.cells.element(boundBy: 10)
if tocTarget.waitForExistence(timeout: 5) {
tocTarget.tap()
}
let afterJump = app.waitForDemoReaderState(timeout: 20, description: "远跳后稳定") { state in
state.isOpened && state.page != nil && state.page != page1
}
let page2 = afterJump.page ?? 0
XCTAssertNotEqual(page2, page1, "远跳后页码应变化")
// Step 3: Swipe forward again after jump
let content2 = app.otherElements[IDs.readerContent]
if content2.waitForExistence(timeout: 5) {
content2.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let afterFinal = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterFinal?.page ?? 0, page2,
"远跳后翻页应不后退")
XCTAssertEqual(afterFinal?.lastError, "none", "全链路不应产生错误")
}
// MARK: - Combined: Rapid cross-chapter navigation stress test
func testRapidCrossChapterNavigationStress() throws {
// Rapidly swipes through many pages to stress the async loading path,
// the preload controller, and the page map extension logic.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Rapid swipes - don't wait between each
for _ in 0..<20 {
paging.swipeLeft()
}
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
let finalState = app.waitForDemoReaderState(timeout: 15, description: "快速翻页后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(finalState.isOpened, "快速连续翻页后阅读器应保持打开")
XCTAssertNotNil(finalState.page, "快速翻页后应有有效页码")
XCTAssertEqual(finalState.lastError, "none", "快速翻页不应产生错误")
}
// MARK: - Gap 1: Position restore on large book
func testLargeBookPositionRestoreOnReopen() throws {
// Opens large book, swipes forward to a distant position, closes, reopens,
// and verifies the reading position is restored.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward to establish a reading position
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let beforeClose = app.waitForDemoReaderState(timeout: 10, description: "关闭前页码") { state in
state.page != nil && state.page! > 1
}
let savedPage = beforeClose.page ?? 0
let savedHref = beforeClose.href
XCTAssertGreaterThan(savedPage, 1, "应翻到非首页")
// Close and reopen
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerBack].tap()
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5))
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: false
)
app.waitForReader(timeout: 20)
let restored = app.waitForDemoReaderState(timeout: 20, description: "恢复阅读位置") { state in
state.isOpened && state.page != nil && state.page! > 1
}
XCTAssertNotNil(restored.page, "恢复后应有有效页码")
XCTAssertEqual(restored.href, savedHref,
"恢复后应在同一章节保存href=\(savedHref ?? "nil") 恢复href=\(restored.href ?? "nil")")
}
// MARK: - Gap 2: Search on large book during partial parsing
func testSearchOnLargeBookDuringPartialParsing() throws {
// Opens large book, navigates past the instruction page, then searches.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Navigate past the instruction page into actual content
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let content = app.otherElements[IDs.readerContent]
if content.waitForExistence(timeout: 5) {
for _ in 0..<3 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
// Open search and type keyword
app.showReaderChromeIfNeeded()
let searchButton = app.buttons[IDs.readerSearch]
if searchButton.waitForExistence(timeout: 3) {
searchButton.tap()
}
let searchField = app.textFields[IDs.searchField]
if searchField.waitForExistence(timeout: 5) {
searchField.typeText("")
}
// Verify search results are available
let searchCount = app.staticTexts[IDs.searchCount]
if searchCount.waitForExistence(timeout: 15) {
let countText = searchCount.label
XCTAssertTrue(countText.contains("/"), "搜索计数应包含 '/' 分隔符: \(countText)")
}
// Verify reader remains stable
let finalState = app.currentDemoReaderState()
XCTAssertEqual(finalState?.lastError, "none", "局部分页搜索不应产生错误")
}
// MARK: - Gap 3: Theme change on large book during partial parsing
func testThemeChangeOnLargeBookDuringPartialParsing() throws {
// Opens large book, changes theme while background parsing is still in progress.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let preState = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { state in
state.mode == "bookPageMap" && state.page != nil
}
let pageBefore = preState.page ?? 0
// Change theme
app.showReaderChromeIfNeeded()
app.buttons[IDs.readerSettings].tap()
let theme5 = app.buttons[IDs.settingsTheme(5)]
if theme5.waitForExistence(timeout: 3) {
theme5.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
}
let doneButton = app.buttons[IDs.settingsDone]
if doneButton.waitForExistence(timeout: 3) {
doneButton.tap()
}
// Verify reader remains stable after theme change
let postState = app.waitForDemoReaderState(timeout: 15, description: "主题变更后稳定") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(postState.isOpened, "主题变更后阅读器应保持打开")
XCTAssertNotNil(postState.page, "主题变更后应有有效页码")
XCTAssertEqual(postState.lastError, "none", "主题变更后不应产生错误")
}
// MARK: - Gap 4: Page map full takeover preserves navigation
func testPageMapFullTakeoverPreservesNavigation() throws {
// Opens large book, navigates past instruction page, waits for full parse,
// then verifies navigation still works.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
// Navigate past the instruction page into actual content
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
let content = app.otherElements[IDs.readerContent]
if content.waitForExistence(timeout: 5) {
for _ in 0..<3 {
content.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
// Verify navigation works in partial mode (don't require full parse - too slow for large books)
let partialState = app.waitForDemoReaderState(timeout: 15, description: "局部分页就绪") { state in
state.mode == "bookPageMap" && state.page != nil && state.page! > 1
}
let pageAfterReady = partialState.page ?? 0
XCTAssertGreaterThan(pageAfterReady, 1, "应在正文页")
// Verify forward navigation works
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterForward = app.currentDemoReaderState()
XCTAssertGreaterThanOrEqual(afterForward?.page ?? 0, pageAfterReady,
"向前翻页应不后退")
// Verify backward navigation works
paging.swipeRight()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let afterBack = app.currentDemoReaderState()
XCTAssertNotNil(afterBack?.page, "向后翻页应有有效页码")
XCTAssertEqual(afterBack?.lastError, "none", "导航不应产生错误")
}
// MARK: - Gap 5: Progression percentage updates correctly
func testProgressionUpdatesOnNavigation() throws {
// Verifies that the progression field updates as the user navigates through the book.
// Uses a small book to avoid the instruction page issue with large books.
app.launchAndOpenSampleBook(
displayType: "scroll",
pageNumber: 10,
resetsReaderState: true
)
app.waitForReader(timeout: 20)
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始进度") { state in
state.progression != nil && state.page != nil
}
let initialProgression = initialState.progression ?? 0
let initialPage = initialState.page ?? 0
XCTAssertGreaterThanOrEqual(initialProgression, 0, "初始进度应 >= 0")
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
// Swipe forward several pages
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
let advancedState = app.waitForDemoReaderState(timeout: 10, description: "前进后进度") { state in
state.progression != nil && state.page != nil
}
let advancedProgression = advancedState.progression ?? 0
XCTAssertGreaterThanOrEqual(advancedProgression, initialProgression,
"翻页后进度应不小于初始进度:初始=\(initialProgression) 前进后=\(advancedProgression)")
}
// MARK: - Gap 9: Bookmark navigation on large book
func testBookmarkNavigationOnLargeBook() throws {
// Adds a bookmark on a large book, navigates away, then jumps back via bookmark panel.
app.launchAndOpenSampleBook(
bookTitleQuery: largeBook,
displayType: "scroll",
resetsReaderState: true
)
app.waitForReader(timeout: 20)
_ = app.waitForDemoReaderState(timeout: 15, description: "初始状态") { $0.page != nil }
// Add bookmark
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
bookmarkButton.tap()
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
let bookmarked = app.waitForDemoReaderState(timeout: 5, description: "书签添加后") { $0.bookmarks == 1 }
XCTAssertEqual(bookmarked.bookmarks, 1, "应有 1 个书签")
// Navigate away
let paging = app.collectionViews[IDs.readerPaging]
if paging.waitForExistence(timeout: 5) {
for _ in 0..<5 {
paging.swipeLeft()
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
}
}
let afterSwipe = app.currentDemoReaderState()
let pageAfterSwipe = afterSwipe?.page ?? 0
// Open bookmarks panel and tap the bookmark
app.showReaderChromeIfNeeded()
let bookmarksButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
bookmarksButton.tap()
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5), "书签列表未出现")
if bookmarksTable.cells.count > 0 {
bookmarksTable.cells.element(boundBy: 0).tap()
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
}
let afterJump = app.waitForDemoReaderState(timeout: 15, description: "书签跳转后") { state in
state.isOpened && state.page != nil
}
XCTAssertTrue(afterJump.isOpened, "书签跳转后阅读器应保持打开")
XCTAssertEqual(afterJump.lastError, "none", "书签跳转不应产生错误")
}
}