test: 增强 UI 测试基础设施与阅读器自动化测试

- 新增 BookmarkTests/PageNavigationTests/TableOfContentsTests 等阅读器功能测试
- 扩展 AccessibilityIdentifiers 支持更多 UI 元素定位
- XCUIApplication+Launch: 增加启动参数支持(reset state、自定义书籍)
- ReaderAnnotationTests: 完善标注测试覆盖
- ViewController: 支持 --demo-reset-state 参数清理持久化状态
- 新增凡人修仙传测试样本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-02 21:17:04 +08:00
parent c76beed03c
commit 9801af05d3
11 changed files with 907 additions and 14 deletions

View File

@ -16,6 +16,11 @@
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */; };
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */; };
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */; };
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */; };
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -42,6 +47,11 @@
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsExtendedTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PageNavigationTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationExtendedTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -152,11 +162,16 @@
EACA0D274176D594361EF6B9 /* ReaderUITests */ = {
isa = PBXGroup;
children = (
1A2B3C4D00000002AABBCC01 /* BookmarkTests.swift */,
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */,
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
);
path = ReaderUITests;
sourceTree = "<group>";
@ -309,11 +324,16 @@
files = (
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */,
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -12,6 +12,7 @@ final class ViewController: UIViewController {
let bookTitleQuery: String
let displayType: RDReaderView.DisplayType?
let pageNumber: Int?
let resetsReaderState: Bool
let displaySequence: [RDReaderView.DisplayType]
let stepDelay: TimeInterval
@ -43,6 +44,7 @@ final class ViewController: UIViewController {
let displayType = value(after: "--demo-display-type").flatMap(parseDisplayType)
let pageNumber = value(after: "--demo-page").flatMap(Int.init)
let resetsReaderState = arguments.contains("--demo-reset-state")
let displaySequence = value(after: "--demo-display-sequence")
.map { raw in
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
@ -53,6 +55,7 @@ final class ViewController: UIViewController {
bookTitleQuery: bookTitleQuery,
displayType: displayType,
pageNumber: pageNumber,
resetsReaderState: resetsReaderState,
displaySequence: displaySequence,
stepDelay: stepDelay
)
@ -714,6 +717,10 @@ final class ViewController: UIViewController {
return
}
if launchAutomationPlan.resetsReaderState {
resetPersistedReaderState()
}
var configuration = RDEPUBReaderConfiguration.default
if let displayType = launchAutomationPlan.displayType {
configuration.displayType = displayType
@ -721,6 +728,22 @@ final class ViewController: UIViewController {
_ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan)
}
private func resetPersistedReaderState() {
let defaults = UserDefaults.standard
let prefixes = [
"ssreader.epub.location.",
"ssreader.epub.bookmarks.",
"ssreader.epub.highlights."
]
let settingsKey = "ssreader.epub.settings"
for key in defaults.dictionaryRepresentation().keys {
if prefixes.contains(where: { key.hasPrefix($0) }) || key == settingsKey {
defaults.removeObject(forKey: key)
}
}
}
}
extension ViewController: UITableViewDataSource {

View File

@ -5,7 +5,491 @@
它用于覆盖 RDURLReaderController 与 RDPlainTextBookBuilder 的 TXT 路径,
确认纯文本分页、打开主流程,以及后续 reader 壳层回归不会失效。
为了让 UI 自动化可以稳定验证翻页、长按选区、书签和目录行为,
这个样本会刻意保持为“可稳定分页”的长度,而不是一屏就显示完的短文本。
第一节 多页验证
阅读器在处理纯文本时,会根据当前设备尺寸、字体大小、行距和内边距进行重新分页。
这意味着同一份文本在不同设备上看到的“第 2 页、第 3 页”内容可能略有差异,
但只要整体篇幅足够长,跳转到后续页码、执行选区手势、验证状态标签,就依然具备稳定性。
第二节 自动化约束
自动化测试并不追求复杂叙事,它更在意内容是否连续、段落是否足够、字符分布是否均匀。
因此这里使用连续的中文段落,让文本布局更接近真实阅读场景,也方便测试长按选词时命中正文区域。
第三节 翻页前提
如果样本文本过短,阅读器即使工作正常,也只能停留在第一页。
这会让依赖 page=2 的测试误以为是“启动参数失效”,实际上是目标页根本不存在。
为了避免这种误判,样本内容应该明确保证至少能覆盖数页。
第四节 文本密度
当前章节继续补充说明:段落长度适中、标点分布自然、句子结构重复但不完全相同。
这样既能帮助验证分页器的稳定性,也能帮助标注测试在不同屏幕密度下复现相近的触控位置。
第五节 选区交互
长按后拖拽选区,通常依赖可选中的连续文字块。
如果页面上只有很少文字,或者文字被标题、空白和分隔线稀释,选区命中率会显著下降。
因此这里增加多个连续段落,让第二页和后续页面都具备足够的正文密度。
第六节 书签行为
书签测试需要在当前位置创建标记,再验证底部工具栏中的书签列表入口是否可用。
如果页面状态更新稍慢,测试就需要等待按钮从“出现”切换到“可点击”。
这类状态同步问题和可访问性问题不同,但都应该被自动化显式观察到。
第七节 目录与章节
虽然 TXT 没有像 EPUB 那样的正式目录树,但阅读器仍然会以章节标题和文本位置组织阅读内容。
在更复杂的样本书中,目录面板与书签面板都是重要的回归点,因此本样本也会保留明显的章节与小节结构。
第二章 稳定性
如果这个样本能被成功发现、分页并进入阅读器,
就说明 TXT 支持仍然保留在当前收敛后的架构中。
第一节 持续验证
稳定性不是指“永不变化”,而是指在进行架构收敛、依赖升级、分页算法调整之后,
仍然可以通过一组明确的自动化测试迅速判断主要阅读能力是否保持正常。
第二节 页面状态
测试会观察隐藏的状态标签,确认 reader=opened、page=2、toolbar=hidden、selection=1、
highlights=1 等关键信号。这些信号本身不是面向用户的 UI但对于自动化诊断非常有帮助。
第三节 页面长度
为了确保这份 TXT 在手机模拟器上可靠地产生多页,
这里继续补充几段自然文本。文字越接近真实段落,分页器越能暴露真实问题,
比起简单重复同一句话,更容易发现孤行、断段和换页边界上的细节缺陷。
第四节 回归节奏
每次修改阅读器入口、分页器、选区菜单或工具栏逻辑时,
都可以重新运行这组围绕本样本构建的测试。
如果样本依旧能稳定跳到第二页、完成高亮、打开书签面板,
那就说明最核心的 TXT 阅读链路依然是健康的。
第五节 补充段落 A
这是一段用于扩充篇幅的说明文字。它描述了阅读器在不同字号下的视觉密度变化,
也提醒我们不要把单一设备上的页码结果绝对化,而应确保测试样本足够长以适配常见机型。
第六节 补充段落 B
这是一段用于扩充篇幅的说明文字。它强调自动化测试需要稳定样本、稳定入口、稳定查询标识,
只有这样,失败时我们才能快速区分是产品逻辑缺陷,还是测试前提本身不成立。
第七节 补充段落 C
这是一段用于扩充篇幅的说明文字。它补充说明,若未来字体默认值、边距或行距发生变化,
只要这份样本文本仍然明显长于一页,依赖 page=2 的测试就仍然具备合理性。
第八节 补充段落 D
这是一段用于扩充篇幅的说明文字。它重复强调:自动化要避免建立在偶然时序之上,
应等待明确状态,再执行点击、长按或断言,这能显著提升 UI 回归的解释力。
第九节 补充段落 E
这是一段用于扩充篇幅的说明文字。它说明目录、书签、标注、设置和翻页并不是孤立功能,
而是阅读体验中的连续路径,因此测试设计也应围绕连续操作来展开。
第十节 收束
当你读到这里时,这份样本文本的篇幅已经足以覆盖多页。
如果阅读器仍然无法跳到第二页,那么问题就更可能出在跳转实现、状态同步或分页结果映射上,
而不是样本文本长度不足。
附录 扩展段落 01
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 02
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 03
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 04
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 05
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 06
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 07
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 08
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 09
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 10
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 11
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 12
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 13
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 14
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 15
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 16
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 17
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 18
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 19
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
附录 扩展段落 20
这里追加一段较长的正文,用来进一步扩大样本文本的总体篇幅。正文保持自然语言形态,
避免使用极短的机械重复句,从而让分页结果更接近真实阅读材料,也让选区交互更容易命中连续文字。
第三章 页面容量保障
本章的唯一目的是确保样本文本在所有常见 iOS 模拟器上都能产生至少三页。
iPhone 17 Pro 的屏幕较大,默认字号下一屏可以容纳较多文字,
因此需要更多段落来保证 page=2、page=3 始终存在。
第一节 模拟器差异
不同型号的 iPhone 模拟器屏幕尺寸差异明显。iPhone SE 系列屏幕较小,同样长度的文本能产生更多页;
而 iPhone Pro Max 和 iPhone 17 Pro 等大屏设备,一屏可以显示更多行,
需要更长的文本才能保证至少两页。这就是为什么样本文本需要远超一屏容量的原因。
第二节 分页器工作原理
阅读器的分页器会根据当前可用区域(屏幕尺寸减去安全区域和内边距)计算每页可显示的行数,
然后将整篇文本按行切分为多页。字号越大、行距越宽,每页能显示的文字越少,总页数越多。
在默认字号和行距下iPhone 17 Pro 大约需要四千到五千个中文字符才能确保至少两页。
第三节 选区测试的前置条件
文本选区测试需要在第二页执行长按拖拽手势。如果文本只有一页,
那么所有依赖 page=2 的选区测试都会在跳转阶段失败,根本无法到达选区交互步骤。
因此保证至少两页是选区测试能够执行的最低前提。
第四节 书签与标注
书签测试在当前页添加标记后验证按钮状态变化。标注测试则需要先跳转到第二页,
再执行选区和高亮操作。两者都依赖页面导航的正确性,也都需要样本文本足够长。
第五节 内容质量
与简单重复同一句话相比,使用自然语言段落能更好地模拟真实阅读场景。
分页器在处理不同长度的段落、标题和空行时可能表现出不同的边界行为,
因此样本文本应包含多样化的段落长度和结构。
第六节 持续维护
当阅读器的默认字号、行距或内边距发生变化时,可能需要重新评估样本文本的长度。
如果未来默认设置导致每页显示更多文字,则需要相应增加样本文本以维持至少三页的保障。
这是一个需要持续关注的维护点。
第七节 历史教训
在之前的测试运行中,样本文本仅有 207 行约 11000 字节,在 iPhone 17 Pro 上仅产生一页。
这导致两个标注测试testSelectionMenuCreatesHighlight 和 testLongPressSelectionDoesNotShowToolbars
因为无法跳转到第二页而失败。这个教训直接促成了本章内容的添加。
第四章 额外内容填充
以下段落用于进一步扩充文本总量,确保在最大屏幕的模拟器上也能稳定分页。
第一节 技术架构
ReadViewSDK 采用分层架构设计。底层是文本解析和分页引擎,负责将 EPUB 或 TXT 文件
转换为结构化的页面数据。中间层是阅读器控制器,管理页面渲染、手势交互和状态同步。
顶层是 UI 层,包括工具栏、设置面板、目录和书签等用户可见的界面元素。
第二节 测试策略
自动化测试分为多个层次。单元测试覆盖分页算法和数据模型的正确性。
UI 测试覆盖用户可见的交互流程,包括打开书籍、翻页、选区、书签和设置。
回归测试则在每次代码变更后运行,确保已有功能不被破坏。
第三节 中文排版
中文排版有其特殊性。字符等宽、标点禁则、段首缩进等规则都需要分页器正确处理。
此外,中英文混排时的间距处理、数字和标点的断行行为也是分页器需要考虑的细节。
第四节 无障碍支持
阅读器的无障碍支持包括 VoiceOver 朗读、动态字体大小和高对比度模式。
自动化测试中的 accessibilityIdentifier 既服务于 UI 测试的元素定位,
也间接验证了无障碍标识的完整性。
第五节 性能考量
大文本文件的分页性能是一个重要指标。分页算法的时间复杂度应该是线性的,
即处理时间与文本长度成正比。对于超长文本(如百万字小说),
分页过程应该在合理时间内完成,不影响用户的打开体验。
第六节 国际化
虽然当前样本文本是中文,但阅读器框架需要支持多种语言。
不同语言的分页规则可能有所不同,例如阿拉伯语的从右到左排版、
日语的竖排模式等。这些都需要在分页器设计中预留扩展点。
第七节 数据持久化
阅读器需要持久化用户的阅读进度、书签、标注和设置。
这些数据通常存储在 UserDefaults 或本地数据库中。
当用户重新打开同一本书时,阅读器应该恢复到上次的阅读位置。
第八节 网络与离线
虽然当前 Demo 应用只支持本地书籍,但完整的阅读器产品通常需要支持
在线书库、云端同步和离线缓存。这些功能不在当前测试范围内,
但架构设计应该为未来的网络功能预留接口。
第九节 安全性
阅读器处理用户上传的 EPUB 文件时,需要防范恶意文件的攻击。
EPUB 本质上是 ZIP 压缩包,包含 HTML、CSS 和 JavaScript 文件。
解析器需要对这些内容进行安全检查防止路径遍历、XSS 等安全漏洞。
第十节 版本兼容
EPUB 格式有多个版本EPUB 2、EPUB 3不同版本的规范差异需要解析器正确处理。
此外,不同阅读器生成的 EPUB 文件可能有各种非标准扩展,
解析器需要具备足够的容错能力来处理这些变体。
第五章 最终保障
本章再次追加大量段落,作为最终的页面容量保障。
第一节 设备矩阵
测试需要覆盖的设备矩阵包括iPhone SE小屏、iPhone 16中屏
iPhone 17 Pro大屏、iPhone 17 Pro Max超大屏和 iPad平板
每种设备的屏幕尺寸和安全区域都不同,分页结果也会有差异。
第二节 测试稳定性
UI 自动化测试的一个常见问题是不稳定flaky test
造成不稳定的因素包括:网络延迟、动画未完成、异步操作未同步、
设备性能差异等。为了提高测试稳定性,应该使用明确的状态等待而不是固定延时。
第三节 断言策略
测试断言应该足够具体以便定位问题,又不能过于脆弱导致误报。
例如,验证"页面包含某个文本"比验证"页面的精确布局坐标"更稳定,
因为后者容易因设备差异或字体渲染变化而失败。
第四节 测试数据管理
测试使用的数据(如样本书籍)应该与测试代码一起版本控制。
这样可以确保测试环境的一致性,也方便在不同分支上复现问题。
第五节 持续集成
自动化测试应该集成到 CI/CD 流程中。每次提交代码后自动运行测试,
可以尽早发现问题。测试结果应该有清晰的报告,包括失败的截图和日志。
第六节 测试覆盖率
测试覆盖率是衡量测试质量的一个指标,但不是唯一指标。
高覆盖率不一定意味着高质量的测试。更重要的是测试是否覆盖了关键路径和边界情况。
第七节 代码审查
测试代码本身也需要代码审查。审查的重点包括:测试逻辑是否正确、
断言是否有意义、测试数据是否合理、是否有遗漏的边界情况等。
第八节 文档维护
测试文档应该与代码同步更新。当测试用例发生变化时,
文档应该及时反映这些变化。过时的文档比没有文档更有害。
第九节 团队协作
自动化测试需要团队的共同维护。开发人员在修改功能代码时应该同步更新测试,
测试人员在发现新的测试场景时应该及时添加测试用例。
第十节 本章结语
以上内容的唯一目的是确保回归验证样本在所有目标设备上都能产生足够的页面。
如果你正在阅读这段文字,说明样本的篇幅已经达到了预期目标。
后续的 UI 自动化测试可以安全地依赖 page=2 的存在性。
附录 补充内容 A
本段落继续扩充文本。阅读器在渲染纯文本时,会逐行计算文本高度,
并与可用区域进行比较。当累计高度超过可用区域时,就会开始新的一页。
这个过程对用户是透明的,但对自动化测试来说,理解这个机制有助于设计更可靠的测试。
附录 补充内容 B
阅读器的状态标签是一个隐藏的 UILabel专门用于自动化测试。
它以结构化的格式报告阅读器的内部状态,包括:是否打开、当前页码、显示模式、
工具栏可见性、高亮数量和选区状态。测试可以通过轮询这个标签来等待特定状态的出现。
附录 补充内容 C
手势交互是阅读器的核心体验之一。轻触屏幕中央可以切换工具栏的显示和隐藏。
左滑和右滑可以翻页。长按并拖拽可以选中文本。这些手势的正确实现和测试
是阅读器质量保证的重要组成部分。
附录 补充内容 D
设置面板允许用户自定义阅读体验。可配置的选项包括:字体大小、字体类型、
行距、分栏数、显示模式和主题。每次设置变更都会触发页面重新分页,
因此测试需要等待分页完成后才能进行后续操作。
附录 补充内容 E
目录面板显示书籍的章节目录。用户可以通过点击目录项快速跳转到指定章节。
对于 EPUB 书籍,目录数据来自 OPF 文件中的 NCX 或 nav 文件。
对于 TXT 书籍,目录则通过正则表达式匹配章节标题来生成。
附录 补充内容 F
书签功能允许用户标记感兴趣的页面。书签数据包括页码、添加时间和可选的备注。
书签面板以列表形式展示所有书签,用户可以点击书签快速跳转到对应页面。
删除书签需要用户确认,防止误操作。
附录 补充内容 G
标注功能(高亮)允许用户选中文本并添加高亮标记。高亮数据包括选中文本的范围、
颜色和添加时间。高亮面板以列表形式展示所有高亮,用户可以点击高亮跳转到对应位置。
支持多种高亮颜色,方便用户分类管理。
附录 补充内容 H
阅读器的翻页动画有三种模式:仿真翻页(模拟纸张翻动效果)、
水平滑动(左右平移切换页面)和垂直滚动(连续滚动浏览内容)。
用户可以根据个人喜好在设置中选择。不同模式下的分页逻辑略有不同。
附录 补充内容 I
夜间模式是阅读器的重要功能之一。在夜间模式下,背景色变为深色,
文字色变为浅色,减少屏幕对眼睛的刺激。主题切换需要重新渲染页面内容,
因为文字颜色和背景色的变化会影响分页结果。
附录 补充内容 J
字号调整是另一个常用功能。增大字号可以让文字更清晰易读,
但同时会减少每页显示的文字量,增加总页数。阅读器需要在字号变化后
重新分页并尝试保持当前阅读位置不变。
附录 补充内容 K
行距调整影响行与行之间的距离。较大的行距可以提高阅读舒适度,
但同样会减少每页显示的行数。行距通常以倍数表示,如 1.2 倍、1.5 倍等。
附录 补充内容 L
分栏模式在 iPad 等大屏设备上特别有用。双栏模式可以在横屏时
同时显示两页内容,提高大屏设备的空间利用率。分栏数的切换也需要重新分页。
附录 补充内容 M
阅读进度是用户关心的核心信息之一。阅读器通常通过进度条或页码
来显示当前阅读位置。对于 EPUB 书籍,还可以显示章节内的百分比进度。
附录 补充内容 N
搜索功能允许用户在书籍中查找特定文本。搜索结果以列表形式展示,
用户可以点击结果跳转到对应位置。搜索功能的实现需要考虑性能问题,
特别是对于大文本文件的搜索。
附录 补充内容 O
分享功能允许用户将感兴趣的段落分享到社交媒体或发送给朋友。
分享内容通常包括选中的文本、书籍信息和阅读位置。分享功能的实现
需要考虑不同平台的分享接口差异。
附录 补充内容 P
阅读统计功能记录用户的阅读习惯,包括每日阅读时长、阅读页数和阅读速度。
这些数据可以帮助用户了解自己的阅读习惯,也可以用于推荐系统。
附录 补充内容 Q
云同步功能允许用户在不同设备间同步阅读进度、书签和标注。
云同步的实现需要考虑数据冲突解决、增量同步和离线支持等问题。
附录 补充内容 R
离线缓存功能允许用户在没有网络连接时继续阅读已下载的书籍。
缓存策略需要考虑存储空间管理和缓存更新等问题。
附录 补充内容 S
阅读器的启动性能是一个重要指标。用户期望阅读器能够快速打开并显示内容。
优化启动性能的方法包括:延迟加载非关键组件、预加载常用数据、
使用缓存减少重复计算等。
附录 补充内容 T
内存管理对于阅读器来说非常重要。大文本文件的渲染需要大量内存,
如果不当管理可能导致内存警告甚至应用崩溃。
阅读器应该使用分页加载和及时释放不可见页面的策略来控制内存使用。
附录 最终段落
本样本的最终目标是为 ReadViewSDK 的 UI 自动化测试提供一个可靠的基础。
通过确保样本在所有目标设备上都能产生足够的页面,
我们可以建立一套稳定的回归测试体系,为阅读器的质量保驾护航。

View File

@ -12,15 +12,30 @@ enum IDs {
static let readerBottomToolbar = "epub.reader.bottomToolbar"
static let readerContent = "epub.reader.content"
static let readerPaging = "epub.reader.paging"
static let readerBookmark = "epub.reader.bookmark"
static let readerToc = "epub.reader.toc"
static let readerBookmarks = "epub.reader.bookmarks"
static let readerHighlights = "epub.reader.highlights"
static let readerAddHighlight = "epub.reader.add-highlight"
static let readerSettings = "epub.reader.settings"
static let readerTocPanel = "epub.reader.toc.panel"
static let readerTocTable = "epub.reader.toc.table"
static let readerTocNavBar = "epub.reader.toc.navbar"
static let readerBookmarksPanel = "epub.reader.bookmarks.panel"
static let readerBookmarksTable = "epub.reader.bookmarks.table"
static let readerBookmarksNavBar = "epub.reader.bookmarks.navbar"
static let readerSelectionText = "epub.reader.selection.text"
static let readerSelectionHighlight = "epub.reader.selection.高亮"
static let settingsScroll = "epub.reader.settings.scroll"
static let settingsBrightness = "epub.reader.settings.brightness"
static let settingsFontIncrease = "epub.reader.settings.font.increase"
static let settingsFontDecrease = "epub.reader.settings.font.decrease"
static let settingsFontValue = "epub.reader.settings.font.value"
static let settingsFontChoice = "epub.reader.settings.font.choice"
static let settingsLineHeight = "epub.reader.settings.lineHeight"
static let settingsColumns = "epub.reader.settings.columns"
static let settingsDisplayType = "epub.reader.settings.displayType"
static let settingsDone = "epub.reader.settings.done"
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
}

View File

@ -1,8 +1,16 @@
import XCTest
extension XCUIApplication {
func launchAndOpenSampleBook(displayType: String? = nil, pageNumber: Int? = nil) {
var args = ["--demo-book-title", "回归验证样本"]
func launchAndOpenSampleBook(
bookTitleQuery: String = "回归验证样本",
displayType: String? = nil,
pageNumber: Int? = nil,
resetsReaderState: Bool = true
) {
var args = ["--demo-book-title", bookTitleQuery]
if resetsReaderState {
args.append("--demo-reset-state")
}
if let displayType {
args += ["--demo-display-type", displayType]
}
@ -21,6 +29,10 @@ extension XCUIApplication {
return state
}
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
waitForReaderState(containing: "page=\(pageNumber)", timeout: timeout)
}
func showReaderChromeIfNeeded() {
if buttons[IDs.readerBack].exists && buttons[IDs.readerSettings].exists {
return
@ -34,18 +46,33 @@ extension XCUIApplication {
}
}
func hideReaderChromeIfNeeded() {
for _ in 0..<3 {
guard buttons[IDs.readerBack].exists else { return }
let content = otherElements[IDs.readerContent]
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 && state.label.contains(expectedText) {
return
if state.exists {
lastLabel = state.label
if lastLabel.contains(expectedText) {
return
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let currentLabel = state.exists ? state.label : "<missing>"
XCTFail("阅读器状态未包含 \(expectedText),当前:\(currentLabel)")
XCTFail("阅读器状态未包含 \(expectedText),当前:\(lastLabel)")
}
}

View File

@ -0,0 +1,82 @@
import XCTest
final class BookmarkTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBookmarkButtonExistsInToolbar() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
}
func testToggleBookmark() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
bookmarkButton.tap()
Thread.sleep(forTimeInterval: 1)
app.showReaderChromeIfNeeded()
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮再次点击后未出现")
bookmarkButton.tap()
}
func testBookmarksButtonVisibleAfterBookmarkAdded() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
bookmarkButton.tap()
Thread.sleep(forTimeInterval: 1)
app.showReaderChromeIfNeeded()
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
}
func testBookmarksListButtonExists() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
}
func testBookmarksPanelIsAccessible() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let bookmarkButton = app.buttons[IDs.readerBookmark]
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 5), "书签按钮未出现")
bookmarkButton.tap()
app.showReaderChromeIfNeeded()
let bookmarksListButton = app.buttons[IDs.readerBookmarks]
XCTAssertTrue(bookmarksListButton.waitForExistence(timeout: 5), "书签列表按钮未出现")
let enabledPredicate = NSPredicate(format: "isEnabled == true")
expectation(for: enabledPredicate, evaluatedWith: bookmarksListButton)
waitForExpectations(timeout: 10)
XCTAssertTrue(bookmarksListButton.isEnabled, "书签列表按钮未启用")
bookmarksListButton.tap()
let bookmarksPanel = app.tables[IDs.readerBookmarksTable]
XCTAssertTrue(bookmarksPanel.waitForExistence(timeout: 5), "书签面板未出现")
XCTAssertTrue(app.navigationBars[IDs.readerBookmarksNavBar].waitForExistence(timeout: 2), "书签导航栏未暴露")
}
}

View File

@ -0,0 +1,55 @@
import XCTest
final class PageNavigationTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testContentAreaExistsForNavigation() {
app.launchAndOpenSampleBook()
app.waitForReader()
let content = app.otherElements[IDs.readerContent]
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
}
func testPagingViewExistsInScrollMode() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
}
func testPagingViewExistsInVerticalScrollMode() {
app.launchAndOpenSampleBook(displayType: "vertical")
app.waitForReader()
app.waitForReaderState(containing: "display=verticalScroll", timeout: 5)
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
}
func testSwipeInHorizontalScrollMode() {
app.launchAndOpenSampleBook(displayType: "scroll")
app.waitForReader()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
let paging = app.collectionViews[IDs.readerPaging]
XCTAssertTrue(paging.waitForExistence(timeout: 5))
let state = app.staticTexts[IDs.demoReaderState]
let beforeLabel = state.label
paging.swipeLeft()
let deadline = Date().addingTimeInterval(5)
while Date() < deadline {
if state.exists && state.label != beforeLabel { break }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertNotEqual(beforeLabel, state.label, "水平滑动后页码未变化")
}
}

View File

@ -0,0 +1,67 @@
import XCTest
final class ReaderAnnotationExtendedTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testBottomToolbarHasAddHighlightButton() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 5), "底部工具栏标注按钮未出现")
}
func testBottomToolbarHasHighlightsButton() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
let highlightsButton = app.buttons[IDs.readerHighlights]
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 5), "底部工具栏高亮列表按钮未出现")
}
func testBottomToolbarButtonsLayout() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerToc].waitForExistence(timeout: 5), "目录按钮未出现")
XCTAssertTrue(app.buttons[IDs.readerBookmarks].exists, "书签按钮未出现")
XCTAssertTrue(app.buttons[IDs.readerHighlights].exists, "高亮按钮未出现")
XCTAssertTrue(app.buttons[IDs.readerAddHighlight].exists, "标注按钮未出现")
XCTAssertTrue(app.buttons[IDs.readerSettings].exists, "设置按钮未出现")
}
func testSelectionTextElementExists() {
app.launchAndOpenSampleBook()
app.waitForReader()
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(selectionText.waitForExistence(timeout: 5), "文本选区元素未出现")
}
func testReaderStateReportsPageInfo() {
app.launchAndOpenSampleBook()
app.waitForReader()
let state = app.staticTexts[IDs.demoReaderState]
XCTAssertTrue(state.waitForExistence(timeout: 5))
XCTAssertTrue(state.label.contains("reader=opened"), "状态未包含 reader=opened")
XCTAssertTrue(state.label.contains("page="), "状态未包含 page 信息")
XCTAssertTrue(state.label.contains("display="), "状态未包含 display 信息")
}
func testReaderStateReportsHighlightCount() {
app.launchAndOpenSampleBook()
app.waitForReader()
let state = app.staticTexts[IDs.demoReaderState]
XCTAssertTrue(state.waitForExistence(timeout: 5))
XCTAssertTrue(state.label.contains("highlights=0"), "初始高亮数应为 0")
}
}

View File

@ -10,13 +10,17 @@ final class ReaderAnnotationTests: XCTestCase {
func testSelectionMenuCreatesHighlight() {
app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
start.press(forDuration: 0.6, thenDragTo: end)
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
start.press(forDuration: 0.8, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 5)
@ -36,15 +40,19 @@ final class ReaderAnnotationTests: XCTestCase {
func testLongPressSelectionDoesNotShowToolbars() {
app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader()
app.waitForReaderPage(2)
app.hideReaderChromeIfNeeded()
let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
start.press(forDuration: 0.6, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 5)
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.45))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.45))
start.press(forDuration: 0.8, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 8)
app.waitForReaderState(containing: "toolbar=hidden", timeout: 3)
}
}

View File

@ -0,0 +1,79 @@
import XCTest
final class SettingsExtendedTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
private func openSettings() {
app.launchAndOpenSampleBook()
app.waitForReader()
app.showReaderChromeIfNeeded()
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5))
app.buttons[IDs.readerSettings].tap()
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
}
func testBrightnessSliderExists() {
openSettings()
let slider = app.sliders[IDs.settingsBrightness]
XCTAssertTrue(slider.waitForExistence(timeout: 3), "亮度滑块未出现")
}
func testLineHeightControl() {
openSettings()
let lineHeight = app.segmentedControls[IDs.settingsLineHeight]
XCTAssertTrue(lineHeight.waitForExistence(timeout: 3), "行距控件未出现")
lineHeight.buttons["紧凑"].tap()
lineHeight.buttons["标准"].tap()
lineHeight.buttons["宽松"].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
func testColumnCountControl() {
openSettings()
let columns = app.segmentedControls[IDs.settingsColumns]
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
columns.buttons["单栏"].tap()
columns.buttons["双栏"].tap()
app.buttons[IDs.settingsDone].tap()
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
}
func testDisplayTypeFromSettings() {
openSettings()
let displayType = app.segmentedControls[IDs.settingsDisplayType]
XCTAssertTrue(displayType.waitForExistence(timeout: 3), "翻页方式控件未出现")
displayType.buttons["横滑"].tap()
app.buttons[IDs.settingsDone].tap()
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
}
func testAllThemeButtonsExist() {
openSettings()
for index in 0...5 {
let themeButton = app.buttons[IDs.settingsTheme(index)]
XCTAssertTrue(themeButton.waitForExistence(timeout: 3), "主题按钮 \(index) 未出现")
}
}
func testScrollSettingsToReachAllControls() {
openSettings()
let scroll = app.scrollViews[IDs.settingsScroll]
XCTAssertTrue(scroll.waitForExistence(timeout: 3))
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
}
}

View File

@ -0,0 +1,33 @@
import XCTest
final class TableOfContentsTests: XCTestCase {
private let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
}
func testTocButtonExists() {
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
app.waitForReader()
app.showReaderChromeIfNeeded()
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 5), "目录按钮未出现")
}
func testTocButtonTappable() {
app.launchAndOpenSampleBook(bookTitleQuery: "宝山辽墓材料与释读")
app.waitForReader()
app.showReaderChromeIfNeeded()
let tocButton = app.buttons[IDs.readerToc]
XCTAssertTrue(tocButton.waitForExistence(timeout: 5))
XCTAssertTrue(tocButton.isEnabled, "目录按钮不可点击")
tocButton.tap()
let tocPanel = app.tables[IDs.readerTocTable]
XCTAssertTrue(tocPanel.waitForExistence(timeout: 5), "目录面板未出现")
XCTAssertTrue(app.navigationBars[IDs.readerTocNavBar].waitForExistence(timeout: 2), "目录导航栏未暴露")
}
}