feat: complete large-book pagination runtime and UI coverage
This commit is contained in:
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
+9
-3
@@ -27,9 +27,15 @@
|
||||
"ZIPFoundation": [
|
||||
"~> 0.9"
|
||||
],
|
||||
"DTCoreText": [],
|
||||
"SnapKit": [],
|
||||
"SSAlertSwift": []
|
||||
"DTCoreText": [
|
||||
|
||||
],
|
||||
"SnapKit": [
|
||||
|
||||
],
|
||||
"SSAlertSwift": [
|
||||
|
||||
]
|
||||
},
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
|
||||
Generated
+1
-1
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
|
||||
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
+1370
-1293
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,11 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||
@@ -16,11 +21,6 @@
|
||||
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 */
|
||||
@@ -35,6 +35,11 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityIdentifiers.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>"; };
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -47,11 +52,6 @@
|
||||
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 */
|
||||
@@ -364,6 +364,7 @@
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
@@ -388,6 +389,7 @@
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||
|
||||
@@ -62,76 +62,6 @@ final class ViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ValidationCategory: String, CaseIterable {
|
||||
case textNovel = "TXT/小说"
|
||||
case textRich = "复杂图文"
|
||||
case webFixed = "Fixed/互动"
|
||||
case plainText = "TXT"
|
||||
|
||||
var sortOrder: Int {
|
||||
switch self {
|
||||
case .textNovel: return 0
|
||||
case .textRich: return 1
|
||||
case .webFixed: return 2
|
||||
case .plainText: return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookValidationReport {
|
||||
let title: String
|
||||
let category: ValidationCategory
|
||||
let profile: String
|
||||
let passed: Bool
|
||||
let notes: [String]
|
||||
}
|
||||
|
||||
private struct ResourceValidationSummary {
|
||||
let checkedCount: Int
|
||||
let passedCount: Int
|
||||
let failedBooks: [String]
|
||||
let matrixLines: [String]
|
||||
let diagnosticLines: [String]
|
||||
let rerunHint: String
|
||||
|
||||
var statusText: String {
|
||||
var lines: [String] = []
|
||||
if checkedCount == 0 {
|
||||
lines.append("样本验证:未发现可验证样本")
|
||||
} else if failedBooks.isEmpty {
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过")
|
||||
} else {
|
||||
let titles = failedBooks.prefix(2).joined(separator: "、")
|
||||
lines.append("样本验证:\(passedCount)/\(checkedCount) 通过,失败样本:\(titles)")
|
||||
}
|
||||
lines.append(contentsOf: matrixLines.prefix(3))
|
||||
lines.append(contentsOf: prioritizedDiagnosticLines())
|
||||
if !rerunHint.isEmpty {
|
||||
lines.append(rerunHint)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func prioritizedDiagnosticLines() -> [String] {
|
||||
var selected: [String] = []
|
||||
if let semanticLine = diagnosticLines.first(where: { $0.contains("属性闭环诊断") }) {
|
||||
selected.append(semanticLine)
|
||||
}
|
||||
if let paginationLine = diagnosticLines.first(where: { $0.contains("分页诊断") && !selected.contains($0) }) {
|
||||
selected.append(paginationLine)
|
||||
}
|
||||
if selected.count < 2 {
|
||||
for line in diagnosticLines where !selected.contains(line) {
|
||||
selected.append(line)
|
||||
if selected.count == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
@@ -163,15 +93,9 @@ final class ViewController: UIViewController {
|
||||
}()
|
||||
|
||||
private var books: [DemoBook] = []
|
||||
private var validationSummary: ResourceValidationSummary?
|
||||
private var validationTask: Task<Void, Never>?
|
||||
private let launchAutomationPlan = LaunchAutomationPlan.parse(arguments: ProcessInfo.processInfo.arguments)
|
||||
private var didRunLaunchAutomation = false
|
||||
|
||||
deinit {
|
||||
validationTask?.cancel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
@@ -212,7 +136,6 @@ final class ViewController: UIViewController {
|
||||
books = discoverBooks()
|
||||
tableView.reloadData()
|
||||
updateChrome()
|
||||
validateSampleBooks()
|
||||
}
|
||||
|
||||
private func updateChrome() {
|
||||
@@ -220,14 +143,7 @@ final class ViewController: UIViewController {
|
||||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
|
||||
tableView.backgroundView = emptyStateLabel
|
||||
} else {
|
||||
var lines = [
|
||||
"ReadViewSDK Demo",
|
||||
"已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
]
|
||||
if let validationSummary {
|
||||
lines.append(validationSummary.statusText)
|
||||
}
|
||||
statusLabel.text = lines.joined(separator: "\n")
|
||||
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
tableView.backgroundView = nil
|
||||
}
|
||||
}
|
||||
@@ -271,379 +187,6 @@ final class ViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSampleBooks() {
|
||||
validationTask?.cancel()
|
||||
guard !books.isEmpty else {
|
||||
validationSummary = nil
|
||||
updateChrome()
|
||||
return
|
||||
}
|
||||
|
||||
let pageSize = currentReaderPageSize()
|
||||
let style = currentReaderTextStyle()
|
||||
validationTask = Task.detached(priority: .utility) { [books] in
|
||||
let summary = Self.validateBooks(books, pageSize: pageSize, style: style)
|
||||
await MainActor.run {
|
||||
print("[ReadViewDemo] \(summary.statusText)")
|
||||
self.validationSummary = summary
|
||||
self.updateChrome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateBooks(
|
||||
_ books: [DemoBook],
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> ResourceValidationSummary {
|
||||
var checkedCount = 0
|
||||
var passedCount = 0
|
||||
var failedBooks: [String] = []
|
||||
var reports: [BookValidationReport] = []
|
||||
var diagnosticLines: [String] = []
|
||||
|
||||
for book in books {
|
||||
let result = validateBook(book, pageSize: pageSize, style: style)
|
||||
checkedCount += 1
|
||||
if result.report.passed {
|
||||
passedCount += 1
|
||||
} else {
|
||||
failedBooks.append(book.title)
|
||||
}
|
||||
reports.append(result.report)
|
||||
diagnosticLines.append(contentsOf: result.diagnostics)
|
||||
}
|
||||
|
||||
return ResourceValidationSummary(
|
||||
checkedCount: checkedCount,
|
||||
passedCount: passedCount,
|
||||
failedBooks: failedBooks,
|
||||
matrixLines: makeMatrixLines(from: reports),
|
||||
diagnosticLines: diagnosticLines,
|
||||
rerunHint: "复现入口:启动 Demo 查看摘要,打开样本书后再验证搜索 / 高亮 / 主题字号切换"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func validateBook(
|
||||
_ book: DemoBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let fileExtension = book.fileURL.pathExtension.lowercased()
|
||||
if fileExtension == "txt" {
|
||||
let builder = RDPlainTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(textFileURL: book.fileURL, pageSize: pageSize, style: style)
|
||||
let passed = !textBook.pages.isEmpty && !textBook.chapters.isEmpty
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: passed,
|
||||
notes: [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
)
|
||||
let diagnostics = [
|
||||
"TXT 验证:\(book.title) · chapters \(textBook.chapters.count) · pages \(textBook.pages.count)"
|
||||
]
|
||||
return (report, diagnostics)
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .plainText,
|
||||
profile: "txt",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["TXT 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
let parser = RDEPUBParser()
|
||||
do {
|
||||
try parser.parse(epubURL: book.fileURL)
|
||||
let publication = parser.makePublication()
|
||||
switch publication.readingProfile {
|
||||
case .textReflowable:
|
||||
return validateReflowableBook(
|
||||
book,
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
case .webFixedLayout, .webInteractive:
|
||||
return validateWebBook(
|
||||
book,
|
||||
publication: publication,
|
||||
viewportSize: pageSize
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: .textRich,
|
||||
profile: "parse-failed",
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateReflowableBook(
|
||||
_ book: DemoBook,
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
do {
|
||||
let textBook = try builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: style
|
||||
)
|
||||
let missingResources = builder.lastBuildResourceDiagnostics.filter { !$0.existsOnDisk }
|
||||
let diagnostics = builder.lastBuildPaginationDiagnostics
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let category: ValidationCategory = attachmentPages > 0 ? .textRich : .textNovel
|
||||
let passed = !textBook.pages.isEmpty && missingResources.isEmpty
|
||||
|
||||
var reportNotes = [
|
||||
"章节 \(textBook.chapters.count)",
|
||||
"页数 \(textBook.pages.count)"
|
||||
]
|
||||
if attachmentPages > 0 {
|
||||
reportNotes.append("attachment 页 \(attachmentPages)")
|
||||
}
|
||||
|
||||
var summaryLines: [String] = []
|
||||
if let paginationSummary = makePaginationSummary(diagnostics, title: book.title) {
|
||||
summaryLines.append("分页诊断:\(paginationSummary)")
|
||||
}
|
||||
if let semanticSummary = builder.phase7SemanticSummary(title: book.title) {
|
||||
summaryLines.append("属性闭环诊断:\(semanticSummary)")
|
||||
}
|
||||
if let restoreSummary = makeRestoreSummary(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
textBook: textBook,
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
title: book.title
|
||||
) {
|
||||
summaryLines.append("恢复诊断:\(restoreSummary)")
|
||||
}
|
||||
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: reportNotes
|
||||
)
|
||||
return (report, summaryLines)
|
||||
} catch {
|
||||
let category: ValidationCategory = book.title.contains("凡人") ? .textNovel : .textRich
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: false,
|
||||
notes: [error.localizedDescription]
|
||||
)
|
||||
return (report, ["EPUB 验证失败:\(book.title) · \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func validateWebBook(
|
||||
_ book: DemoBook,
|
||||
publication: RDEPUBPublication,
|
||||
viewportSize: CGSize
|
||||
) -> (report: BookValidationReport, diagnostics: [String]) {
|
||||
let linearItems = publication.spine.filter(\.linear)
|
||||
let missingFiles = linearItems.filter {
|
||||
guard let normalizedHref = publication.resourceResolver.normalizedHref($0.href) else {
|
||||
return true
|
||||
}
|
||||
return publication.resourceResolver.fileURL(forRelativePath: normalizedHref) == nil
|
||||
}
|
||||
let defaultConfiguration = RDEPUBReaderConfiguration.default
|
||||
let preferences = RDEPUBPreferences(
|
||||
fontSize: defaultConfiguration.fontSize,
|
||||
lineHeightMultiple: defaultConfiguration.lineHeightMultiple,
|
||||
reflowableContentInsets: defaultConfiguration.reflowableContentInsets,
|
||||
fixedContentInset: defaultConfiguration.fixedContentInset,
|
||||
fixedLayoutFit: defaultConfiguration.fixedLayoutFit,
|
||||
fixedLayoutSpreadMode: defaultConfiguration.fixedLayoutSpreadMode
|
||||
)
|
||||
let spreadCount = publication.layout == .fixed
|
||||
? publication.makeFixedSpreads(
|
||||
preferences: preferences,
|
||||
viewportSize: viewportSize
|
||||
).count
|
||||
: 0
|
||||
let passed = !linearItems.isEmpty && missingFiles.isEmpty && (publication.layout != .fixed || spreadCount > 0)
|
||||
let category: ValidationCategory = .webFixed
|
||||
let report = BookValidationReport(
|
||||
title: book.title,
|
||||
category: category,
|
||||
profile: publication.readingProfile.rawValue,
|
||||
passed: passed,
|
||||
notes: [
|
||||
"spine \(linearItems.count)",
|
||||
publication.layout == .fixed ? "spread \(spreadCount)" : "interactive"
|
||||
]
|
||||
)
|
||||
let diagnostic = "Web 路径验证:\(book.title) · profile \(publication.readingProfile.rawValue) · spine \(linearItems.count) · missing \(missingFiles.count)"
|
||||
return (report, [diagnostic])
|
||||
}
|
||||
|
||||
nonisolated private static func makeMatrixLines(from reports: [BookValidationReport]) -> [String] {
|
||||
let grouped = Dictionary(grouping: reports, by: \.category)
|
||||
return ValidationCategory.allCases.compactMap { category in
|
||||
guard let items = grouped[category], !items.isEmpty else { return nil }
|
||||
let passed = items.filter(\.passed).count
|
||||
let notes = items.prefix(2).map { "\($0.title)(\($0.profile))" }.joined(separator: "、")
|
||||
return "矩阵[\(category.rawValue)] \(passed)/\(items.count) · \(notes)"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func makePaginationSummary(
|
||||
_ diagnostics: [RDEPUBTextChapterPaginationDiagnostic],
|
||||
title: String
|
||||
) -> String? {
|
||||
guard !diagnostics.isEmpty else { return nil }
|
||||
let attachmentPages = diagnostics.reduce(0) { $0 + $1.attachmentPageCount }
|
||||
let semanticBreakPages = diagnostics.reduce(0) { $0 + $1.blockAdjustedPageCount }
|
||||
let blockKinds = uniqueValues(diagnostics.flatMap(\.blockKinds))
|
||||
let semanticHints = uniqueValues(diagnostics.flatMap(\.semanticHints))
|
||||
let attachmentPlacements = uniqueValues(diagnostics.flatMap(\.attachmentPlacements))
|
||||
let breakReasonCounts = diagnostics
|
||||
.flatMap(\.breakReasons)
|
||||
.reduce(into: [RDEPUBTextPageBreakReason: Int]()) { counts, reason in
|
||||
counts[reason, default: 0] += 1
|
||||
}
|
||||
let orderedReasons = breakReasonCounts
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.value == rhs.value {
|
||||
return lhs.key.rawValue < rhs.key.rawValue
|
||||
}
|
||||
return lhs.value > rhs.value
|
||||
}
|
||||
.map { "\($0.key.rawValue):\($0.value)" }
|
||||
.joined(separator: ", ")
|
||||
let note = diagnostics
|
||||
.flatMap(\.sampleNotes)
|
||||
.first(where: { $0.contains("attachment") || $0.contains("block") || $0.contains("page break") })
|
||||
var parts = [
|
||||
"\(title)",
|
||||
"章节 \(diagnostics.count)",
|
||||
"attachment 页 \(attachmentPages)",
|
||||
"semantic break 页 \(semanticBreakPages)",
|
||||
blockKinds.isEmpty ? nil : "block kinds [\(blockKinds.map(\.rawValue).joined(separator: ","))]",
|
||||
semanticHints.isEmpty ? nil : "hints [\(semanticHints.map(\.rawValue).joined(separator: ","))]",
|
||||
attachmentPlacements.isEmpty ? nil : "placements [\(attachmentPlacements.map(\.rawValue).joined(separator: ","))]",
|
||||
orderedReasons.isEmpty ? nil : "reasons [\(orderedReasons)]"
|
||||
].compactMap { $0 }
|
||||
if let note {
|
||||
parts.append(note)
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func makeRestoreSummary(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
textBook: RDEPUBTextBook,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
title: String
|
||||
) -> String? {
|
||||
guard textBook.pages.count > 1 else { return nil }
|
||||
let targetPageNumber = min(max(textBook.pages.count / 2, 1), textBook.pages.count)
|
||||
guard let restoreLocation = textBook.location(forPageNumber: targetPageNumber, bookIdentifier: publication.metadata.identifier),
|
||||
let baseResolvedPage = textBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let alternateFont = UIFont.systemFont(ofSize: style.font.pointSize + 2)
|
||||
let alternateLineSpacing = style.lineSpacing + max(alternateFont.lineHeight * 0.15, 2)
|
||||
let alternateStyle = RDEPUBTextRenderStyle(
|
||||
font: alternateFont,
|
||||
lineSpacing: alternateLineSpacing,
|
||||
textColor: style.textColor,
|
||||
backgroundColor: style.backgroundColor
|
||||
)
|
||||
let themeStyle = RDEPUBTextRenderStyle(
|
||||
font: style.font,
|
||||
lineSpacing: style.lineSpacing,
|
||||
textColor: .white,
|
||||
backgroundColor: .black
|
||||
)
|
||||
|
||||
let builder = RDEPUBTextBookBuilder()
|
||||
guard let alternateBook = try? builder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: alternateStyle
|
||||
), let alternatePageNumber = alternateBook.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
), let alternateResolvedLocation = alternateBook.location(
|
||||
forPageNumber: alternatePageNumber,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let themeBuilder = RDEPUBTextBookBuilder()
|
||||
let themeBook = try? themeBuilder.build(
|
||||
parser: parser,
|
||||
publication: publication,
|
||||
pageSize: pageSize,
|
||||
style: themeStyle
|
||||
)
|
||||
let themePageNumber = themeBook?.pageNumber(
|
||||
for: restoreLocation,
|
||||
resolver: publication.resourceResolver,
|
||||
bookIdentifier: publication.metadata.identifier
|
||||
)
|
||||
|
||||
let hrefStable = (publication.resourceResolver.normalizedHref(alternateResolvedLocation.href) ?? alternateResolvedLocation.href) ==
|
||||
(publication.resourceResolver.normalizedHref(restoreLocation.href) ?? restoreLocation.href)
|
||||
let progressionDelta = abs(alternateResolvedLocation.navigationProgression - restoreLocation.navigationProgression)
|
||||
let themeStable = themePageNumber == baseResolvedPage
|
||||
|
||||
return [
|
||||
title,
|
||||
"base \(baseResolvedPage)",
|
||||
"font-shift \(alternatePageNumber)",
|
||||
"theme-stable \(themeStable ? "yes" : "no")",
|
||||
"href-stable \(hrefStable ? "yes" : "no")",
|
||||
String(format: "progression-delta %.3f", progressionDelta)
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
|
||||
nonisolated private static func uniqueValues<T: Equatable>(_ values: [T]) -> [T] {
|
||||
values.reduce(into: [T]()) { result, value in
|
||||
if !result.contains(value) {
|
||||
result.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func openBook(
|
||||
_ book: DemoBook,
|
||||
@@ -683,27 +226,6 @@ final class ViewController: UIViewController {
|
||||
return controller
|
||||
}
|
||||
|
||||
private func currentReaderPageSize() -> CGSize {
|
||||
let viewportSize = UIScreen.main.bounds.size
|
||||
let insets = RDEPUBReaderConfiguration.default.reflowableContentInsets
|
||||
return CGSize(
|
||||
width: max(viewportSize.width - insets.left - insets.right, 1),
|
||||
height: max(viewportSize.height - insets.top - insets.bottom, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func currentReaderTextStyle() -> RDEPUBTextRenderStyle {
|
||||
let configuration = RDEPUBReaderConfiguration.default
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
return RDEPUBTextRenderStyle(
|
||||
font: font,
|
||||
lineSpacing: lineSpacing,
|
||||
textColor: configuration.theme.contentTextColor,
|
||||
backgroundColor: configuration.theme.contentBackgroundColor
|
||||
)
|
||||
}
|
||||
|
||||
private func runLaunchAutomationIfNeeded() {
|
||||
guard !didRunLaunchAutomation,
|
||||
let launchAutomationPlan,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import XCTest
|
||||
|
||||
struct DemoReaderState {
|
||||
let rawValue: String
|
||||
let fields: [String: String]
|
||||
|
||||
init(rawValue: String) {
|
||||
self.rawValue = rawValue
|
||||
var parsed: [String: String] = [:]
|
||||
let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ";"))
|
||||
for token in rawValue.components(separatedBy: separators) where !token.isEmpty {
|
||||
guard let delimiterIndex = token.firstIndex(of: "=") else { continue }
|
||||
let key = String(token[..<delimiterIndex])
|
||||
let value = String(token[token.index(after: delimiterIndex)...])
|
||||
parsed[key] = value
|
||||
}
|
||||
fields = parsed
|
||||
}
|
||||
|
||||
subscript(key: String) -> String? {
|
||||
fields[key]
|
||||
}
|
||||
|
||||
var isOpened: Bool { fields["reader"] == "opened" }
|
||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||
var display: String? { fields["display"] }
|
||||
var toolbar: String? { fields["toolbar"] }
|
||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||
var selection: Int? { fields["selection"].flatMap(Int.init) }
|
||||
var href: String? { fields["href"] }
|
||||
var progression: Double? { fields["progression"].flatMap(Double.init) }
|
||||
var mode: String? { fields["mode"] }
|
||||
var pagination: String? { fields["pagination"] }
|
||||
var knownPages: Int? { fields["knownPages"].flatMap(Int.init) }
|
||||
var knownChapters: Int? { fields["knownChapters"].flatMap(Int.init) }
|
||||
var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) }
|
||||
var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) }
|
||||
var avoidOrphans: Int? { fields["avoidOrphans"].flatMap(Int.init) }
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
func currentDemoReaderState() -> DemoReaderState? {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
guard state.exists else { return nil }
|
||||
return DemoReaderState(rawValue: state.label)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func waitForDemoReaderState(
|
||||
timeout: TimeInterval = 8,
|
||||
description: String,
|
||||
where predicate: (DemoReaderState) -> Bool
|
||||
) -> DemoReaderState {
|
||||
let stateElement = staticTexts[IDs.demoReaderState]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastState = DemoReaderState(rawValue: "<missing>")
|
||||
|
||||
while Date() < deadline {
|
||||
if stateElement.exists {
|
||||
lastState = DemoReaderState(rawValue: stateElement.label)
|
||||
if predicate(lastState) {
|
||||
return lastState
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
|
||||
XCTFail("阅读器状态未满足条件: \(description),当前:\(lastState.rawValue)")
|
||||
return lastState
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,15 @@ extension XCUIApplication {
|
||||
func waitForReader(timeout: TimeInterval = 12) -> XCUIElement {
|
||||
let state = staticTexts[IDs.demoReaderState]
|
||||
XCTAssertTrue(state.waitForExistence(timeout: timeout), "阅读器状态标签未出现")
|
||||
XCTAssertTrue(state.label.contains("reader=opened"), "阅读器未进入 opened 状态:\(state.label)")
|
||||
let parsed = DemoReaderState(rawValue: state.label)
|
||||
XCTAssertTrue(parsed.isOpened, "阅读器未进入 opened 状态:\(state.label)")
|
||||
return state
|
||||
}
|
||||
|
||||
func waitForReaderPage(_ pageNumber: Int, timeout: TimeInterval = 12) {
|
||||
waitForReaderState(containing: "page=\(pageNumber)", timeout: timeout)
|
||||
_ = waitForDemoReaderState(timeout: timeout, description: "page=\(pageNumber)") { state in
|
||||
state.page == pageNumber
|
||||
}
|
||||
}
|
||||
|
||||
func showReaderChromeIfNeeded() {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
final class BookmarkManagementTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testBookmarksPanelShowsBookmarkAfterAdding() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||
bookmarkButton.tap()
|
||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
||||
|
||||
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), "书签表格未出现")
|
||||
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5), "添加书签后面板应有至少 1 行数据")
|
||||
}
|
||||
|
||||
func testBookmarksPanelEmptyState() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarksButton = app.buttons[IDs.readerBookmarks]
|
||||
XCTAssertTrue(bookmarksButton.waitForExistence(timeout: 3), "书签列表按钮不存在")
|
||||
bookmarksButton.tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerBookmarksTable].waitForExistence(timeout: 5), "书签面板未出现")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].label.contains("暂无"), "空状态应显示暂无书签")
|
||||
}
|
||||
|
||||
func testDeleteBookmarkFromPanel() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerBookmark].tap()
|
||||
app.waitForReaderState(containing: "bookmarks=1", timeout: 5)
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerBookmarks].tap()
|
||||
|
||||
let bookmarksTable = app.tables[IDs.readerBookmarksTable]
|
||||
XCTAssertTrue(bookmarksTable.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(waitForCellCount(in: bookmarksTable, minimum: 1, timeout: 5))
|
||||
|
||||
bookmarksTable.cells.element(boundBy: 0).tap()
|
||||
|
||||
let deleteButton = app.buttons["删除书签"]
|
||||
if deleteButton.waitForExistence(timeout: 3) { deleteButton.tap() }
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerBookmarksEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
func testBookmarkIconStateToggle() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let bookmarkButton = app.buttons[IDs.readerBookmark]
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "书签按钮不存在")
|
||||
|
||||
bookmarkButton.tap()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "添加书签后按钮应仍然存在")
|
||||
|
||||
bookmarkButton.tap()
|
||||
XCTAssertTrue(bookmarkButton.waitForExistence(timeout: 3), "移除书签后按钮应仍然存在")
|
||||
}
|
||||
|
||||
private func waitForCellCount(
|
||||
in table: XCUIElement,
|
||||
minimum: Int,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if table.cells.count >= minimum {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return table.cells.count >= minimum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
|
||||
final class ErrorAndEdgeCaseTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testVerticalScrollSwipeNavigation() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "verticalscroll")
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeUp() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
|
||||
func testOpenAndCloseMultipleTimes() throws {
|
||||
for iteration in 1...3 {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
XCTAssertTrue(backButton.waitForExistence(timeout: 3), "返回按钮不存在")
|
||||
backButton.tap()
|
||||
|
||||
let booksTable = app.tables[IDs.demoBooksTable]
|
||||
XCTAssertTrue(booksTable.waitForExistence(timeout: 5), "书列表未出现")
|
||||
}
|
||||
}
|
||||
|
||||
func testRapidPageNavigation() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderPage(1, timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
guard paging.waitForExistence(timeout: 5) else { throw XCTSkip("分页视图不存在") }
|
||||
|
||||
for _ in 1...5 { paging.swipeLeft() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testSettingsPanelScrollReachesAllControls() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
let settingsScroll = app.scrollViews[IDs.settingsScroll]
|
||||
XCTAssertTrue(settingsScroll.waitForExistence(timeout: 5), "设置面板未出现")
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(app.buttons[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||
|
||||
settingsScroll.swipeUp()
|
||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 5))
|
||||
|
||||
settingsScroll.swipeUp()
|
||||
XCTAssertTrue(app.buttons[IDs.settingsTheme(5)].waitForExistence(timeout: 5))
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import XCTest
|
||||
|
||||
final class HighlightsManagementTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testHighlightsPanelOpens() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else {
|
||||
throw XCTSkip("文本选区元素不存在")
|
||||
}
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
||||
|
||||
app.waitForReaderState(containing: "highlights=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerHighlights].waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
|
||||
}
|
||||
|
||||
func testHighlightsPanelShowsCreatedHighlight() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5), "高亮表格未出现")
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "创建高亮后面板应至少有 1 行数据")
|
||||
}
|
||||
|
||||
func testHighlightsPanelFilterAll() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let filterControl = app.segmentedControls[IDs.readerHighlightsFilter]
|
||||
XCTAssertTrue(filterControl.waitForExistence(timeout: 5), "筛选控件未出现")
|
||||
filterControl.buttons.element(boundBy: 0).tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 3))
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5), "筛选全部后应有高亮数据")
|
||||
}
|
||||
|
||||
func testHighlightsPanelEmptyState() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
let highlightsButton = app.buttons[IDs.readerHighlights]
|
||||
XCTAssertTrue(highlightsButton.waitForExistence(timeout: 3), "高亮列表按钮不存在")
|
||||
highlightsButton.tap()
|
||||
|
||||
XCTAssertTrue(app.tables[IDs.readerHighlightsTable].waitForExistence(timeout: 5), "高亮管理面板未出现")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "空状态 label 应存在")
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].label.contains("暂无"), "空状态应显示暂无标注")
|
||||
}
|
||||
|
||||
func testDeleteHighlightFromPanel() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerHighlights].tap()
|
||||
|
||||
let highlightsTable = app.tables[IDs.readerHighlightsTable]
|
||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
||||
|
||||
highlightsTable.cells.element(boundBy: 0).tap()
|
||||
|
||||
let deleteButton = app.buttons["删除标注"].firstMatch
|
||||
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch
|
||||
if deleteButton.waitForExistence(timeout: 3) {
|
||||
deleteButton.tap()
|
||||
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
|
||||
deleteHighlightButton.tap()
|
||||
}
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
private func waitForCellCount(
|
||||
in table: XCUIElement,
|
||||
minimum: Int,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if table.cells.count >= minimum {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return table.cells.count >= minimum
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import XCTest
|
||||
|
||||
final class LargeBookOnDemandTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testLargeBookInitialOpenIsReadableWhilePaginationIsPartial() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, 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
|
||||
}
|
||||
|
||||
XCTAssertEqual(partialState.mode, "bookPageMap")
|
||||
XCTAssertEqual(partialState.pagination, "partial")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "首开局部分页阶段应已可阅读")
|
||||
}
|
||||
|
||||
func testLargeBookBackgroundPaginationProgresses() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始分页进度") { state in
|
||||
state.mode == "bookPageMap" && state.knownChapters != nil && state.buildableChapters != nil
|
||||
}
|
||||
let initialKnownChapters = initialState.knownChapters ?? 0
|
||||
let initialKnownPages = initialState.knownPages ?? 0
|
||||
|
||||
let progressedState = app.waitForDemoReaderState(timeout: 45, description: "后台分页推进") { state in
|
||||
guard state.mode == "bookPageMap" else { return false }
|
||||
if state.pagination == "full" {
|
||||
return true
|
||||
}
|
||||
return (state.knownChapters ?? 0) > initialKnownChapters || (state.knownPages ?? 0) > initialKnownPages
|
||||
}
|
||||
|
||||
XCTAssertEqual(progressedState.mode, "bookPageMap")
|
||||
XCTAssertTrue(
|
||||
(progressedState.knownChapters ?? 0) >= initialKnownChapters ||
|
||||
(progressedState.knownPages ?? 0) >= initialKnownPages
|
||||
)
|
||||
}
|
||||
|
||||
func testLargeBookSecondOpenRestoresFromCache() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 60, description: "首次打开完成全书缓存") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "full" && (state.buildableChapters ?? 0) > 0
|
||||
}
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5), "返回按钮不存在")
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 5), "书架列表未出现")
|
||||
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: false)
|
||||
app.waitForReader(timeout: 15)
|
||||
|
||||
let reopenedState = app.waitForDemoReaderState(timeout: 10, description: "二开直接恢复完整缓存") { state in
|
||||
state.mode == "bookPageMap" && state.pagination == "full" && (state.knownChapters ?? 0) == (state.buildableChapters ?? -1)
|
||||
}
|
||||
|
||||
XCTAssertEqual(reopenedState.pagination, "full")
|
||||
XCTAssertEqual(reopenedState.knownChapters, reopenedState.buildableChapters)
|
||||
}
|
||||
|
||||
func testLargeBookContinuousPagingExtendsKnownPageMap() throws {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, displayType: "scroll", resetsReaderState: true)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 8)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 15, description: "初始局部分页") { state in
|
||||
state.mode == "bookPageMap" && state.knownPages != nil && state.page != nil
|
||||
}
|
||||
let initialKnownPages = initialState.knownPages ?? 0
|
||||
let initialPage = initialState.page ?? 0
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在")
|
||||
|
||||
for _ in 0..<4 {
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let extendedState = app.waitForDemoReaderState(timeout: 20, description: "连续翻页触发扩窗") { state in
|
||||
guard state.mode == "bookPageMap" else { return false }
|
||||
let pageAdvanced = (state.page ?? 0) > initialPage
|
||||
let pageMapExtended = (state.knownPages ?? 0) > initialKnownPages || state.pagination == "full"
|
||||
return pageAdvanced && pageMapExtended
|
||||
}
|
||||
|
||||
XCTAssertTrue((extendedState.page ?? 0) > initialPage, "连续翻页后当前页应向后推进")
|
||||
XCTAssertTrue(
|
||||
(extendedState.knownPages ?? 0) > initialKnownPages || extendedState.pagination == "full",
|
||||
"连续翻页后已知页图应扩窗或直接完成全量分页"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import XCTest
|
||||
|
||||
final class LocationPersistenceTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testReadingPositionRestoredOnReopen() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
_ = app.waitForDemoReaderState(timeout: 5, description: "存在页码") { $0.page != nil }
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) {
|
||||
paging.swipeLeft()
|
||||
}
|
||||
|
||||
let stateAfterSwipe = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
||||
(state.page ?? 0) > 1
|
||||
}
|
||||
let pageAfterSwipe = stateAfterSwipe.page ?? 0
|
||||
|
||||
guard pageAfterSwipe > 1 else {
|
||||
throw XCTSkip("当前翻页模式下未能翻到第 2 页")
|
||||
}
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let restoredState = app.waitForDemoReaderState(timeout: 8, description: "恢复阅读位置") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let restoredPage = restoredState.page ?? 0
|
||||
XCTAssertEqual(restoredPage, pageAfterSwipe, "重新打开后应恢复到上次阅读页码")
|
||||
}
|
||||
|
||||
func testResetStateLaunchesAtFirstPage() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 5, description: "首开第 1 页") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let page = state.page ?? 0
|
||||
XCTAssertEqual(page, 1, "重置状态后应从第 1 页开始")
|
||||
}
|
||||
|
||||
func testPositionSurvivesFontChange() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
if app.buttons[IDs.readerSettings].waitForExistence(timeout: 3) { app.buttons[IDs.readerSettings].tap() }
|
||||
if app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5) { app.buttons[IDs.settingsFontIncrease].tap() }
|
||||
if app.buttons[IDs.settingsDone].waitForExistence(timeout: 3) { app.buttons[IDs.settingsDone].tap() }
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
if backButton.waitForExistence(timeout: 3) { backButton.tap() }
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,14 @@ final class PageNavigationTests: XCTestCase {
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5))
|
||||
|
||||
let state = app.staticTexts[IDs.demoReaderState]
|
||||
let beforeLabel = state.label
|
||||
let beforeState = app.waitForDemoReaderState(timeout: 5, description: "滑动前页码") { $0.page != nil }
|
||||
let beforePage = beforeState.page ?? 0
|
||||
|
||||
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))
|
||||
let afterState = app.waitForDemoReaderState(timeout: 8, description: "水平滑动后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != beforePage
|
||||
}
|
||||
XCTAssertNotEqual(beforeLabel, state.label, "水平滑动后页码未变化")
|
||||
XCTAssertNotEqual(beforePage, afterState.page, "水平滑动后页码未变化")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import XCTest
|
||||
|
||||
final class SelectionMenuTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsCopyOption() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||
}
|
||||
|
||||
func testSelectionMenuShowsAnnotateOption() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||
}
|
||||
|
||||
func testTapBlankAreaClearsSelection() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现")
|
||||
|
||||
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
||||
if contentArea.waitForExistence(timeout: 3) {
|
||||
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
||||
}
|
||||
|
||||
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2)
|
||||
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
||||
}
|
||||
|
||||
func testCopySelectedText() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let selectionText = app.otherElements[IDs.readerSelectionText].firstMatch
|
||||
guard selectionText.waitForExistence(timeout: 5) else { throw XCTSkip("文本选区元素不存在") }
|
||||
|
||||
let start = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.5))
|
||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||
start.press(forDuration: 0.8, thenDragTo: end)
|
||||
|
||||
let copyMenuItem = app.menuItems["拷贝"]
|
||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import XCTest
|
||||
|
||||
final class SettingsEffectTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testFontSizeChangeUpdatesContent() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "page=", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 3), "设置按钮不存在")
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testThemeSwitchChangesBackground() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
|
||||
app.scrollViews[IDs.settingsScroll].swipeUp()
|
||||
|
||||
let darkThemeButton = app.buttons[IDs.settingsTheme(5)]
|
||||
if darkThemeButton.waitForExistence(timeout: 3) { darkThemeButton.tap() }
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
let contentView = app.otherElements[IDs.readerContentView]
|
||||
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
||||
}
|
||||
|
||||
func testColumnCountChangeUpdatesLayout() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
app.scrollViews[IDs.settingsScroll].swipeUp()
|
||||
|
||||
let columnsControl = app.segmentedControls[IDs.settingsColumns]
|
||||
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
|
||||
columnsControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testLineHeightChangeUpdatesContent() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
|
||||
let lineHeightControl = app.segmentedControls[IDs.settingsLineHeight]
|
||||
if lineHeightControl.waitForExistence(timeout: 3) && lineHeightControl.buttons.count > 1 {
|
||||
lineHeightControl.buttons.element(boundBy: 1).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
|
||||
func testDisplayTypeChangeRePaginates() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "horizontalscroll")
|
||||
app.waitForReader(timeout: 12)
|
||||
app.waitForReaderState(containing: "display=horizontalScroll", timeout: 5)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
||||
|
||||
let displayTypeControl = app.segmentedControls[IDs.settingsDisplayType]
|
||||
if displayTypeControl.waitForExistence(timeout: 3) && displayTypeControl.buttons.count > 0 {
|
||||
displayTypeControl.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.waitForReaderState(containing: "display=", timeout: 8)
|
||||
}
|
||||
|
||||
func testDefaultBodyPaginationDoesNotEnableWidowOrphanCompaction() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: "默认正文分页策略") { state in
|
||||
state.avoidWidows != nil && state.avoidOrphans != nil
|
||||
}
|
||||
|
||||
XCTAssertEqual(state.avoidWidows, 0, "默认正文分页不应启用 widow compaction,避免页尾明显留白")
|
||||
XCTAssertEqual(state.avoidOrphans, 0, "默认正文分页不应启用 orphan compaction,避免提前换页")
|
||||
}
|
||||
|
||||
func testSettingsPersistAfterReopen() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 5))
|
||||
let fontValueLabel = app.staticTexts[IDs.settingsFontValue]
|
||||
XCTAssertTrue(fontValueLabel.waitForExistence(timeout: 3))
|
||||
let originalFontValue = fontValueLabel.label
|
||||
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
let newFontValue = fontValueLabel.label
|
||||
XCTAssertNotEqual(newFontValue, originalFontValue, "点击增大后字号 label 应变化")
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
app.buttons[IDs.readerBack].tap()
|
||||
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
|
||||
let persistedFontValue = app.staticTexts[IDs.settingsFontValue]
|
||||
if persistedFontValue.waitForExistence(timeout: 5) {
|
||||
XCTAssertEqual(persistedFontValue.label, newFontValue, "重新打开后字号应持久化为之前的值")
|
||||
}
|
||||
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
|
||||
final class TOCInteractionTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTOCPanelShowsChapterList() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
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: 5), "目录表格未出现")
|
||||
XCTAssertTrue(tocTable.cells.count > 0, "目录应至少有 1 个章节")
|
||||
}
|
||||
|
||||
func testTOCNavigatesToChapter() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 5, description: "初始页码") { state in
|
||||
state.page != nil
|
||||
}
|
||||
let initialPage = initialState.page ?? 0
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerToc].tap()
|
||||
|
||||
let tocTable = app.tables[IDs.readerTocTable]
|
||||
XCTAssertTrue(tocTable.waitForExistence(timeout: 5), "目录表格未出现")
|
||||
|
||||
if tocTable.cells.count > 1 {
|
||||
tocTable.cells.element(boundBy: 1).tap()
|
||||
|
||||
let updatedState = app.waitForDemoReaderState(timeout: 8, description: "目录跳转后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != initialPage
|
||||
}
|
||||
let updatedPage = updatedState.page ?? 0
|
||||
XCTAssertNotEqual(updatedPage, initialPage, "目录跳转后页码应变化")
|
||||
} else {
|
||||
tocTable.cells.element(boundBy: 0).tap()
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 8)
|
||||
}
|
||||
}
|
||||
|
||||
func testTOCEmptyHandling() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
app.buttons[IDs.readerToc].tap()
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerTocPanel].waitForExistence(timeout: 5), "目录面板应可打开")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import XCTest
|
||||
|
||||
final class ToolbarStateTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTapContentHidesToolbar() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertTrue(app.otherElements[IDs.readerTopToolbar].waitForExistence(timeout: 3), "顶部工具栏应可见")
|
||||
XCTAssertTrue(app.otherElements[IDs.readerBottomToolbar].waitForExistence(timeout: 3), "底部工具栏应可见")
|
||||
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
let backButton = app.buttons[IDs.readerBack]
|
||||
let hidden = !backButton.waitForExistence(timeout: 2)
|
||||
XCTAssertTrue(hidden, "点击内容区后工具栏应隐藏")
|
||||
}
|
||||
|
||||
func testToolbarShowHideToggle() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
||||
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 3))
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
|
||||
func testAddHighlightButtonDisabledWithoutSelection() throws {
|
||||
app.launchAndOpenSampleBook()
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let addHighlightButton = app.buttons[IDs.readerAddHighlight]
|
||||
XCTAssertTrue(addHighlightButton.waitForExistence(timeout: 3), "标注按钮应存在")
|
||||
|
||||
let beforeState = app.waitForDemoReaderState(timeout: 3, description: "初始高亮数") { $0.highlights != nil }
|
||||
let highlightsBefore = beforeState.highlights ?? 0
|
||||
|
||||
addHighlightButton.tap()
|
||||
|
||||
let afterState = app.waitForDemoReaderState(timeout: 3, description: "点击后的高亮数") { $0.highlights != nil }
|
||||
let highlightsAfter = afterState.highlights ?? 0
|
||||
XCTAssertEqual(highlightsAfter, highlightsBefore, "无选区时点击标注按钮不应创建标注")
|
||||
}
|
||||
|
||||
func testPageCurlSwipeNavigation() throws {
|
||||
app.launchAndOpenSampleBook(displayType: "pagecurl")
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
app.waitForReaderPage(1, timeout: 5)
|
||||
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
if paging.waitForExistence(timeout: 5) { paging.swipeLeft() }
|
||||
|
||||
app.waitForReaderState(containing: "reader=opened", timeout: 5)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user