feat: complete large-book pagination runtime and UI coverage

This commit is contained in:
shenlei
2026-06-03 17:47:16 +08:00
parent 584976ac5f
commit feb05eaf87
53 changed files with 6307 additions and 5126 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
+9 -3
View File
@@ -27,9 +27,15 @@
"ZIPFoundation": [
"~> 0.9"
],
"DTCoreText": [],
"SnapKit": [],
"SSAlertSwift": []
"DTCoreText": [
],
"SnapKit": [
],
"SSAlertSwift": [
]
},
"requires_arc": true,
"swift_version": "5.10"
+1 -1
View File
@@ -43,7 +43,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDReaderView: 54205c82c62ea6c674b43c833642d2643c98c573
RDReaderView: 617ec758a5db3c10024acf83b110465ea8343b5b
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
SSAlertSwift: aad8dc0c20b36fcffe700b81d7be89d60c7ba4f1
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
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;
+1 -479
View File
@@ -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)
}
}