阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
243 lines
8.7 KiB
Swift
243 lines
8.7 KiB
Swift
import XCTest
|
||
|
||
extension XCUIApplication {
|
||
func launchAndOpenSamplePDF(
|
||
bookTitleQuery: String = "PDF阅读器示例",
|
||
resetsReaderState: Bool = true,
|
||
pdfTextSource: String? = nil
|
||
) {
|
||
launchAndOpenSampleBook(
|
||
bookTitleQuery: bookTitleQuery,
|
||
displayType: "pagecurl",
|
||
resetsReaderState: resetsReaderState,
|
||
pdfTextSource: pdfTextSource
|
||
)
|
||
}
|
||
|
||
func launchAndOpenSampleBook(
|
||
bookTitleQuery: String = "回归验证样本",
|
||
displayType: String? = nil,
|
||
pageNumber: Int? = nil,
|
||
resetsReaderState: Bool = true,
|
||
windowSize: Int? = nil,
|
||
concurrency: Int? = nil,
|
||
clearsCache: Bool = false,
|
||
corruptsCache: Bool = false,
|
||
allowsInspectableWebViews: Bool = false,
|
||
enablesVerboseWebLogs: Bool = false,
|
||
searchKeyword: String? = nil,
|
||
pdfTextSource: String? = nil
|
||
) {
|
||
var args = ["--demo-book-title", bookTitleQuery]
|
||
if resetsReaderState {
|
||
args.append("--demo-reset-state")
|
||
}
|
||
if clearsCache {
|
||
args.append("--demo-clear-cache")
|
||
}
|
||
if corruptsCache {
|
||
args.append("--demo-corrupt-cache")
|
||
}
|
||
if allowsInspectableWebViews {
|
||
args.append("--demo-allow-inspectable-webviews")
|
||
}
|
||
if enablesVerboseWebLogs {
|
||
args.append("--demo-enable-verbose-weblogs")
|
||
}
|
||
if let displayType {
|
||
args += ["--demo-display-type", displayType]
|
||
}
|
||
if let pageNumber {
|
||
args += ["--demo-page", "\(pageNumber)"]
|
||
}
|
||
if let windowSize {
|
||
args += ["--demo-window-size", "\(windowSize)"]
|
||
}
|
||
if let concurrency {
|
||
args += ["--demo-concurrency", "\(concurrency)"]
|
||
}
|
||
if let searchKeyword {
|
||
args += ["--demo-search-keyword", searchKeyword]
|
||
}
|
||
if let pdfTextSource {
|
||
args += ["--demo-pdf-text-source", pdfTextSource]
|
||
}
|
||
launchArguments = args
|
||
launch()
|
||
}
|
||
|
||
@discardableResult
|
||
func waitForReader(timeout: TimeInterval = 12) -> XCUIElement {
|
||
let state = staticTexts[IDs.demoReaderState]
|
||
XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现")
|
||
let parsed = DemoReaderState(rawValue: state.label)
|
||
XCTAssertTrue(parsed.isOpened, "阅读器未进入 opened 状态:\(state.label)")
|
||
return state
|
||
}
|
||
|
||
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
|
||
_ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in
|
||
state.page == pageNumber
|
||
}
|
||
}
|
||
|
||
func showReaderChromeIfNeeded(
|
||
requiredButtonIDs: [String] = [IDs.readerBack, IDs.readerSettings],
|
||
timeout: TimeInterval = 5
|
||
) {
|
||
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
|
||
return
|
||
}
|
||
|
||
let deadline = Date().addingTimeInterval(timeout)
|
||
while Date() < deadline {
|
||
let content = otherElements[IDs.readerContent].firstMatch
|
||
if content.waitForExistence(timeout: 1) {
|
||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||
} else {
|
||
windows.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||
}
|
||
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
|
||
if readerChromeIsVisible(requiredButtonIDs: requiredButtonIDs) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func hideReaderChromeIfNeeded() {
|
||
for _ in 0..<3 {
|
||
guard buttons[IDs.readerBack].exists else { return }
|
||
|
||
let content = otherElements[IDs.readerContent].firstMatch
|
||
if content.waitForExistence(timeout: 2) {
|
||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
|
||
}
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||
}
|
||
}
|
||
|
||
func waitForReaderState(containing expectedText: String, timeout: TimeInterval = 8) {
|
||
let state = staticTexts[IDs.demoReaderState]
|
||
let deadline = Date().addingTimeInterval(timeout)
|
||
var lastLabel = "<missing>"
|
||
|
||
while Date() < deadline {
|
||
if state.exists {
|
||
lastLabel = state.label
|
||
if lastLabel.contains(expectedText) {
|
||
return
|
||
}
|
||
}
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||
}
|
||
|
||
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
|
||
}
|
||
|
||
@discardableResult
|
||
func requireSelectionText(
|
||
expectedPage: Int? = nil,
|
||
timeout: TimeInterval = 12
|
||
) throws -> XCUIElement {
|
||
let selectionText = otherElements[IDs.readerSelectionText].firstMatch
|
||
let deadline = Date().addingTimeInterval(timeout)
|
||
|
||
while Date() < deadline {
|
||
if let expectedPage {
|
||
let state = currentDemoReaderState()
|
||
if state?.page != expectedPage {
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||
continue
|
||
}
|
||
}
|
||
|
||
if selectionText.exists {
|
||
return selectionText
|
||
}
|
||
|
||
let content = otherElements[IDs.readerContent].firstMatch
|
||
if content.exists {
|
||
content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||
}
|
||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||
}
|
||
|
||
throw XCTSkip("文本选区元素未出现,当前渲染路径未暴露 \(IDs.readerSelectionText)")
|
||
}
|
||
|
||
private func readerChromeIsVisible(requiredButtonIDs: [String]) -> Bool {
|
||
requiredButtonIDs.allSatisfy { buttons[$0].exists }
|
||
}
|
||
|
||
// MARK: - 设置面板
|
||
|
||
/// 等 EPUB 设置面板出现。面板已改成固定高度布局,所有控件一屏可见,
|
||
/// 既没有 scrollView 也不需要滚动才能触达。
|
||
@discardableResult
|
||
func waitForSettingsPanel(timeout: TimeInterval = 5) -> Bool {
|
||
if otherElements[IDs.settingsPanel].waitForExistence(timeout: timeout) {
|
||
return true
|
||
}
|
||
// 面板根视图偶尔不作为 otherElement 暴露,退回用「完成」按钮判定
|
||
return buttons[IDs.settingsDone].waitForExistence(timeout: 1)
|
||
}
|
||
|
||
/// 设置面板的字体/行距/翻页方式都是 pill 按钮 + action sheet,不是
|
||
/// segmentedControl:点开 pill 后再点选项标题。
|
||
@discardableResult
|
||
func selectSettingsPillOption(
|
||
pillIdentifier: String,
|
||
optionTitle: String,
|
||
timeout: TimeInterval = 5
|
||
) -> Bool {
|
||
let pill = buttons[pillIdentifier]
|
||
guard pill.waitForExistence(timeout: timeout) else { return false }
|
||
pill.tap()
|
||
|
||
// action sheet 的选项在 iOS 上是 button,取消项同名风险由标题唯一性保证
|
||
let option = sheets.buttons[optionTitle].exists
|
||
? sheets.buttons[optionTitle]
|
||
: buttons[optionTitle]
|
||
guard option.waitForExistence(timeout: timeout) else {
|
||
// 收起 sheet,避免污染后续断言
|
||
if sheets.buttons["取消"].exists {
|
||
sheets.buttons["取消"].tap()
|
||
}
|
||
return false
|
||
}
|
||
option.tap()
|
||
return true
|
||
}
|
||
|
||
/// 字号是 slider(`epub.reader.settings.font.size`),没有增大/减小按钮。
|
||
/// 把滑块推到指定归一化位置以触发重排;返回是否成功。
|
||
@discardableResult
|
||
func adjustSettingsFontSize(toNormalizedPosition position: CGFloat, timeout: TimeInterval = 5) -> Bool {
|
||
let slider = sliders[IDs.settingsFontSize]
|
||
guard slider.waitForExistence(timeout: timeout) else { return false }
|
||
slider.adjust(toNormalizedSliderPosition: position)
|
||
return true
|
||
}
|
||
|
||
/// 在当前值基础上增减字号,替代早已不存在的 font.increase / font.decrease 按钮。
|
||
@discardableResult
|
||
func nudgeSettingsFontSize(by delta: CGFloat, timeout: TimeInterval = 5) -> Bool {
|
||
let slider = sliders[IDs.settingsFontSize]
|
||
guard slider.waitForExistence(timeout: timeout) else { return false }
|
||
let target = min(max(slider.normalizedSliderPosition + delta, 0), 1)
|
||
slider.adjust(toNormalizedSliderPosition: target)
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
func increaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
|
||
nudgeSettingsFontSize(by: 0.25, timeout: timeout)
|
||
}
|
||
|
||
@discardableResult
|
||
func decreaseSettingsFontSize(timeout: TimeInterval = 5) -> Bool {
|
||
nudgeSettingsFontSize(by: -0.25, timeout: timeout)
|
||
}
|
||
}
|