mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性
支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。 修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。 更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import ZIPFoundation
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct EPUBReadingRegressionTests {
|
||||
private func fixture(at root: URL) throws -> URL {
|
||||
let url = root.appendingPathComponent("示例.epub")
|
||||
let archive = try Archive(url: url, accessMode: .create)
|
||||
let files = [
|
||||
("mimetype", "application/epub+zip"),
|
||||
("META-INF/container.xml", #"<container><rootfiles><rootfile full-path="book.opf"/></rootfiles></container>"#),
|
||||
("book.opf", #"<package><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>回归书籍</dc:title><dc:creator>测试作者</dc:creator></metadata><manifest><item id="first" href="first.xhtml" media-type="application/xhtml+xml"/><item id="last" href="last.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="first"/><itemref idref="last"/></spine></package>"#),
|
||||
("first.xhtml", "<html><head><title>第一章</title></head><body><h1>第一章</h1><p>第一章测试正文</p></body></html>"),
|
||||
("last.xhtml", "<html><head><title>第二章</title></head><body><h1>第二章</h1><p>第二章测试正文</p></body></html>")
|
||||
]
|
||||
for (path, value) in files {
|
||||
let data = Data(value.utf8)
|
||||
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: .deflate) { position, size in
|
||||
data.subdata(in: Int(position)..<Int(position) + size)
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func importedEPUBReadsNavigatesAndResumesAfterRenamingAndReloadingShelf() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let url = try fixture(at: root)
|
||||
let repository = InMemoryBookRepository()
|
||||
let managed = root.appendingPathComponent("managed", isDirectory: true)
|
||||
let library = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
|
||||
await library.load()
|
||||
await library.importLocalFiles([url])
|
||||
#expect(library.errorMessage == nil)
|
||||
let book = try #require(library.books.first)
|
||||
let shelfReader = BookshelfReadViewModel()
|
||||
await shelfReader.load(book: book, source: nil)
|
||||
#expect(shelfReader.errorMessage == nil && shelfReader.chapters.count == 2)
|
||||
let reader = ReaderViewModel(book: book, chapters: shelfReader.chapters, initialIndex: 0, cache: .init(directory: root.appendingPathComponent("cache")))
|
||||
#expect(!reader.canEditContent && !reader.usesReplaceRules)
|
||||
await reader.load(rules: [])
|
||||
#expect(reader.content == "第一章测试正文")
|
||||
await reader.prepareContinuousReading(rules: [])
|
||||
#expect(reader.scrollChapters.map(\.content) == ["第一章测试正文", "第二章测试正文"])
|
||||
reader.goNext()
|
||||
await reader.load(rules: [])
|
||||
#expect(reader.content == "第二章测试正文")
|
||||
await library.updateProgress(for: book, chapter: try #require(reader.currentChapter), position: 4)
|
||||
try await library.renameLocalFile(at: URL(string: book.bookUrl)!, to: "改名")
|
||||
let reloaded = BookLibraryModel(repository: repository, localDirectory: managed, chapterRepository: InMemoryBookChapterRepository())
|
||||
await reloaded.load()
|
||||
let saved = try #require(reloaded.books.first)
|
||||
#expect(saved.name == "回归书籍" && saved.originName == "改名.epub")
|
||||
#expect(saved.durChapterIndex == 1 && saved.durChapterPos == 4)
|
||||
let chapters = try LocalBookService().loadChapters(book: saved).chapters
|
||||
let resumed = ReaderViewModel(book: saved, chapters: chapters, initialIndex: saved.durChapterIndex)
|
||||
await resumed.load(rules: [])
|
||||
#expect(resumed.currentIndex == 1 && resumed.requestedPosition == 4)
|
||||
#expect(resumed.content == "第二章测试正文")
|
||||
#expect(try await reloaded.localFiles.list().count == 1)
|
||||
}
|
||||
|
||||
@Test func corruptEPUBImportRollsBackFileAndContinuesOtherImports() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let good = try fixture(at: root)
|
||||
let bad = root.appendingPathComponent("损坏.epub")
|
||||
try Data("bad epub".utf8).write(to: bad)
|
||||
let library = BookLibraryModel(repository: InMemoryBookRepository(), localDirectory: root.appendingPathComponent("managed", isDirectory: true), chapterRepository: InMemoryBookChapterRepository())
|
||||
await library.load()
|
||||
await library.importLocalFiles([bad, good])
|
||||
#expect(library.books.count == 1)
|
||||
#expect(library.errorMessage?.contains("损坏.epub") == true)
|
||||
#expect(try await library.localFiles.list().map { $0.url.lastPathComponent } == ["示例.epub"])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,26 @@ import LegadoCore
|
||||
|
||||
@MainActor
|
||||
struct SearchRegressionTests {
|
||||
@Test func arithmeticPageExpressionCanLoadTheSecondPage() async throws {
|
||||
let transport = GatedModelSearchTransport()
|
||||
await transport.release()
|
||||
let model = model(transport: transport, repository: InMemorySearchBookRepository())
|
||||
defer { model.cancel(reset: true) }
|
||||
var source = source("formula")
|
||||
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
|
||||
model.search(keyword: "书", sources: [source])
|
||||
try await waitUntil { model.state == .finished }
|
||||
#expect(model.hasMore)
|
||||
model.loadMore()
|
||||
try await waitUntil { !model.isLoadingMore }
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.count == 2)
|
||||
#expect(requests.compactMap(\.httpBody).map { String(decoding: $0, as: UTF8.self) } == [
|
||||
"keyword=%E4%B9%A6&page=0", "keyword=%E4%B9%A6&page=1"
|
||||
])
|
||||
#expect(!model.hasMore)
|
||||
}
|
||||
|
||||
@Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
|
||||
let source = source("slow")
|
||||
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
// Opt-in only: point LEGADO_SOURCE_PROBE_FILE at a downloaded JSON source pack.
|
||||
// No remote source or response is included in the application or normal test run.
|
||||
struct BookSourceRemoteProbe {
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"] != nil))
|
||||
func sampleReadingPipelines() async throws {
|
||||
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_FILE"])
|
||||
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
var reports: [BookSourceDebugReport] = []
|
||||
let transport = ProbeTransport()
|
||||
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
|
||||
for source in sources {
|
||||
var report = await BookSourceDebugger(transport: transport).run(source: source, keyword: keyword)
|
||||
report.contentPreview = nil
|
||||
reports.append(report)
|
||||
print("SOURCE_CHAIN \(source.bookSourceName) " + String(decoding: try JSONEncoder().encode(report), as: UTF8.self))
|
||||
}
|
||||
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_CHAIN_OUTPUT"] {
|
||||
try JSONEncoder().encode(reports).write(to: URL(fileURLWithPath: output))
|
||||
try JSONEncoder().encode(await transport.records()).write(to: URL(fileURLWithPath: output + ".requests.json"))
|
||||
}
|
||||
#expect(reports.count == sources.count)
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] != nil))
|
||||
func sampleSearches() async throws {
|
||||
guard let path = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_FILE"] else { return }
|
||||
let sources = try BookSourceImporter().decode(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let transport = ProbeTransport()
|
||||
let service = BookSearchService(transport: transport, networkPolicy: .init(), history: .init())
|
||||
let keyword = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_KEYWORD"] ?? "斗罗大陆"
|
||||
let page = Int(ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_PAGE"] ?? "1") ?? 1
|
||||
var rows: [[String: JSONValue]] = []
|
||||
for await outcome in service.searchOutcomes(keyword: keyword, page: page, sources: sources, maximumConcurrentSources: 3, includeDisabled: true) {
|
||||
let source = try #require(sources.first { $0.id == outcome.sourceID })
|
||||
let row: [String: JSONValue] = [
|
||||
"name": .string(source.bookSourceName), "source": .string(source.id),
|
||||
"count": .number(Double(outcome.books.count)),
|
||||
"books": .array(outcome.books.prefix(3).map { .object([
|
||||
"name": .string($0.name), "url": .string($0.bookURL), "author": .string($0.author),
|
||||
"cover": .string($0.coverURL ?? ""), "wordCount": .string($0.wordCount ?? "")
|
||||
]) }),
|
||||
"diagnostics": .array(outcome.diagnostics.map { .object(["code": .string($0.code), "message": .string($0.message), "rule": .string($0.rule)]) })
|
||||
]
|
||||
rows.append(row)
|
||||
print("SOURCE_PROBE " + JSONValue.object(row).stringValue)
|
||||
}
|
||||
let report = JSONValue.object(["keyword": .string(keyword), "page": .number(Double(page)), "outcomes": .array(rows.map(JSONValue.object)), "requests": .array(await transport.records())])
|
||||
if let output = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_OUTPUT"] {
|
||||
try JSONEncoder().encode(report).write(to: URL(fileURLWithPath: output))
|
||||
}
|
||||
#expect(rows.count == Set(sources.map(\.id)).count)
|
||||
}
|
||||
}
|
||||
|
||||
private actor ProbeTransport: HTTPTransport {
|
||||
private var values: [JSONValue] = []
|
||||
private let transport = URLSessionHTTPTransport()
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
do {
|
||||
let response = try await transport.data(for: request)
|
||||
var record: [String: JSONValue] = [
|
||||
"url": .string(request.url?.absoluteString ?? ""),
|
||||
"finalURL": .string(response.finalURL.absoluteString),
|
||||
"status": .number(Double(response.statusCode)),
|
||||
"bytes": .number(Double(response.data.count)),
|
||||
"preview": .string(String(response.decodedText.prefix(240)))
|
||||
]
|
||||
if let directory = ProcessInfo.processInfo.environment["LEGADO_SOURCE_PROBE_SNAPSHOT_DIR"] {
|
||||
let folder = URL(fileURLWithPath: directory, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let file = folder.appendingPathComponent(UUID().uuidString + ".response")
|
||||
try response.data.write(to: file)
|
||||
record["snapshot"] = .string(file.path)
|
||||
}
|
||||
values.append(.object(record))
|
||||
return response
|
||||
} catch {
|
||||
values.append(.object(["url": .string(request.url?.absoluteString ?? ""), "error": .string(error.localizedDescription)]))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func records() -> [JSONValue] { values }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import ZIPFoundation
|
||||
@testable import LegadoCore
|
||||
|
||||
struct EPUBBookTests {
|
||||
private let service = LocalBookService()
|
||||
private let png = Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABgCAIAAAAip+O/AAABxklEQVR4nNXCA3YjAABF0becsW12bNdIGptt7Nq2bXeMjc06/j2XI8UG6RwtMUrnWGmtdI6XmaRzotwsnZMVFumcqrRK53SVTTpnqu3SOVvjkM45g1M6540u6VyodUvnoskjnUtmr3QuW3zSuWL1S+eqLSCda/agdK47QtK54QxL56arTjq33PXSue2JSOeONyqdu76YdO7549K5H0hI50EwKZ2HoZR0isJp6Tyqy0jncX1WOk8iOek8jeal8yxWkM7zeIN0XiQapfMy2SSdV6lm6bxOt0jnTaZVOm+zbdJ5l2uXzvt8h3Q+FDql87GhSzqfGrul87mpRzpfmnulU9zSJ52S1n7plLYNSKesfVA65R1D0qnoHJZOZdeIdKq6R6VT3TMmnZrecekY+iakY+yflE7twJR0TIPT0jEPzUjHMjwrHevInHRso/PSsY8tSMcxvigd58SSdFyTy9JxT61IxzO9Kh3vzJp0fLPr0vHPbUgnML8pneDClnRCi9vSCS/tSKdueVc69St70oms7ksnunYgndj6oXTiG1+lk9j8Jp3k1nfppLZ/SCe981M6md1f0snu/ZZObv+PdPIHf6VTOPwn/T/zbe3/96qaXAAAAABJRU5ErkJggg==")!
|
||||
|
||||
private func archive(_ files: [(String, Data)]) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent("示例.EPUB")
|
||||
let archive = try Archive(url: url, accessMode: .create)
|
||||
for (path, data) in files {
|
||||
try archive.addEntry(with: path, type: .file, uncompressedSize: Int64(data.count), compressionMethod: path == "mimetype" ? .none : .deflate) { position, size in
|
||||
data.subdata(in: Int(position)..<Int(position) + size)
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func fixture(epub3: Bool = true, navigation: Bool = true, extras: [(String, Data)] = [], chapter: String? = nil) throws -> URL {
|
||||
let navItem = epub3
|
||||
? #"<item id="nav" href="目录/nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>"#
|
||||
: #"<item id="nav" href="目录/toc.ncx" media-type="application/x-dtbncx+xml"/>"#
|
||||
let package = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="\(epub3 ? "3.0" : "2.0")">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>测试书籍</dc:title><dc:creator>作者甲</dc:creator><dc:creator>作者乙</dc:creator>
|
||||
<dc:description><p>测试简介 &amp; 内容</p></dc:description>
|
||||
<meta name="cover" content="cover"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="last" href="Text/end.xhtml" media-type="application/xhtml+xml"/>
|
||||
\(navigation ? navItem : "")
|
||||
<item id="main" href="Text/中文%20章节.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="intro" href="Text/intro.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="cover" href="Images/封面.png" media-type="image/png" properties="cover-image"/>
|
||||
<item id="extra" href="Text/extra.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="nav"><itemref idref="intro"/><itemref idref="main"/><itemref idref="extra"/><itemref idref="last"/></spine>
|
||||
</package>
|
||||
"""
|
||||
let body = chapter ?? """
|
||||
<html><head><title>正文</title><style>不要显示样式</style></head><body>
|
||||
<p>锚点前的引言</p><section><h1 id="one">第一章</h1><p>甲 & 乙<em>相遇</em>。</p>
|
||||
<script>不要显示脚本</script><p style="display: none">隐藏内容</p>
|
||||
<p><ruby>字<rt>zi</rt><rp>(zi)</rp></ruby>形</p><img src="../Images/封面.png" alt="风景"/></section>
|
||||
<section><div><h1 id="two">第二章</h1></div><p>第二章正文</p></section>
|
||||
</body></html>
|
||||
"""
|
||||
let nav = """
|
||||
<html xmlns:epub="http://www.idpf.org/2007/ops"><body>
|
||||
<nav epub:type="landmarks"><a href="../Text/end.xhtml">错误地标</a></nav>
|
||||
<nav epub:type="toc"><ol><li><span>第一卷</span><ol>
|
||||
<li><a href="../Text/中文%20章节.xhtml#one">第一章</a></li>
|
||||
<li><a href="../Text/中文%20章节.xhtml#two">第二章</a></li></ol></li>
|
||||
<li><a href="../Text/end.xhtml">尾声</a></li></ol></nav></body></html>
|
||||
"""
|
||||
let ncx = """
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/"><navMap>
|
||||
<navPoint><navLabel><text>第一卷</text></navLabel>
|
||||
<navPoint><navLabel><text>第一章</text></navLabel><content src="../Text/中文%20章节.xhtml#one"/></navPoint>
|
||||
<navPoint><navLabel><text>第二章</text></navLabel><content src="../Text/中文%20章节.xhtml#two"/></navPoint>
|
||||
</navPoint><navPoint><navLabel><text>尾声</text></navLabel><content src="../Text/end.xhtml"/></navPoint>
|
||||
</navMap></ncx>
|
||||
"""
|
||||
var files: [(String, Data)] = [
|
||||
("mimetype", Data("application/epub+zip".utf8)),
|
||||
("META-INF/container.xml", Data(#"<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OPS/book.opf" media-type="application/oebps-package+xml"/></rootfiles></container>"#.utf8)),
|
||||
("OPS/book.opf", Data(package.utf8)),
|
||||
("OPS/Text/intro.xhtml", Data("<html><head><title>序言</title></head><body><p>开篇内容</p></body></html>".utf8)),
|
||||
("OPS/Text/中文 章节.xhtml", Data(body.utf8)),
|
||||
("OPS/Text/extra.xhtml", Data("<html><body><h1>未列入目录</h1><p>不应遗漏正文</p></body></html>".utf8)),
|
||||
("OPS/Text/end.xhtml", Data("<html><body><p>完结正文</p></body></html>".utf8)),
|
||||
("OPS/Images/封面.png", png)
|
||||
]
|
||||
if navigation { files.append((epub3 ? "OPS/目录/nav.xhtml" : "OPS/目录/toc.ncx", Data((epub3 ? nav : ncx).utf8))) }
|
||||
return try archive(files + extras)
|
||||
}
|
||||
|
||||
@Test(arguments: [true, false]) func readsEPUB2And3MetadataSpineAnchorsAndIllustrations(epub3: Bool) throws {
|
||||
let url = try fixture(epub3: epub3)
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let report = try service.openFile(at: url)
|
||||
#expect(report.book.name == "测试书籍")
|
||||
#expect(report.book.author == "作者甲、作者乙")
|
||||
#expect(report.book.intro == "测试简介 & 内容")
|
||||
#expect(report.book.coverUrl?.hasPrefix("data:image/png;base64,") == true)
|
||||
#expect(report.book.readConfig?.useReplaceRule == false)
|
||||
#expect(!report.book.canUpdate)
|
||||
#expect(report.chapters.map(\.title) == ["序言", "正文", "第一章", "第二章", "未列入目录", "尾声"])
|
||||
#expect(report.chapters.map(\.index) == Array(0..<6))
|
||||
#expect(Set(report.chapters.map(\.id)).count == 6)
|
||||
#expect(try service.loadChapters(book: report.book).chapters == report.chapters)
|
||||
let contents = try report.chapters.map { try service.loadContent(book: report.book, chapter: $0).content }
|
||||
#expect(contents[0] == "开篇内容")
|
||||
#expect(contents[1] == "锚点前的引言")
|
||||
#expect(contents[2] == "第一章\n甲 & 乙相遇。\n字形\n【插图 1:风景】")
|
||||
#expect(contents[3] == "第二章\n第二章正文")
|
||||
#expect(contents[4].contains("不应遗漏正文"))
|
||||
#expect(contents[5] == "完结正文")
|
||||
let images = try service.loadIllustrations(book: report.book, chapter: report.chapters[2])
|
||||
#expect(images.count == 1 && images.first?.data == png && images.first?.caption == "风景")
|
||||
#expect(try service.loadIllustrations(book: report.book, chapter: report.chapters[3]).isEmpty)
|
||||
}
|
||||
|
||||
@Test func fallsBackToSpineWithoutNavigationAndDoesNotUseManifestOrder() throws {
|
||||
let url = try fixture(navigation: false)
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let report = try service.openFile(at: url)
|
||||
#expect(report.chapters.count == 4)
|
||||
#expect(report.chapters.first?.title == "序言")
|
||||
let body = try service.loadContent(book: report.book, chapter: report.chapters[1]).content
|
||||
#expect(body.contains("锚点前的引言") && body.contains("第一章") && body.contains("第二章正文"))
|
||||
}
|
||||
|
||||
@Test func importsCopyPreservingMetadataAndOriginalFile() throws {
|
||||
let url = try fixture()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let report = try service.importFile(at: url, into: url.deletingLastPathComponent().appendingPathComponent("managed"))
|
||||
let destination = try #require(URL(string: report.book.bookUrl))
|
||||
#expect(destination != url && destination.pathExtension == "epub")
|
||||
#expect(report.book.originName == "示例.EPUB")
|
||||
#expect(try Data(contentsOf: destination) == Data(contentsOf: url))
|
||||
#expect(try service.loadContent(book: report.book, chapter: report.chapters[2]).content.contains("甲 & 乙"))
|
||||
}
|
||||
|
||||
@Test func fileManagementPreservesEPUBOnRenameExportAndRemoval() async throws {
|
||||
let url = try fixture()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let root = url.deletingLastPathComponent()
|
||||
let files = LocalFileService(directory: root.appendingPathComponent("managed"))
|
||||
let first = try await files.importFile(from: url)
|
||||
let second = try await files.importFile(from: url)
|
||||
#expect(second.lastPathComponent == "示例 (2).EPUB")
|
||||
let renamed = try await files.rename(first, to: "新名字")
|
||||
#expect(renamed.pathExtension == "EPUB")
|
||||
#expect(try await files.list().count == 2)
|
||||
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
|
||||
let export = try await exporter.originalFile(renamed, filename: "测试书籍.epub")
|
||||
#expect(export.url.lastPathComponent == "测试书籍.epub")
|
||||
#expect(try Data(contentsOf: export.url) == Data(contentsOf: url))
|
||||
let staged = try #require(try await files.stageRemoval(renamed))
|
||||
try await files.restore(staged)
|
||||
#expect(try service.openFile(at: renamed).book.name == "测试书籍")
|
||||
let removed = try #require(try await files.stageRemoval(renamed))
|
||||
try await files.finishRemoval(removed)
|
||||
#expect(try await files.list().count == 1)
|
||||
}
|
||||
|
||||
@Test func rejectsCorruptionTraversalEncryptionAndOversizeWithoutLeavingImportedFiles() throws {
|
||||
let good = try fixture()
|
||||
defer { try? FileManager.default.removeItem(at: good.deletingLastPathComponent()) }
|
||||
#expect(throws: LocalBookError.self) { try LocalBookService(maximumFileSize: 1).openFile(at: good) }
|
||||
let broken = good.deletingLastPathComponent().appendingPathComponent("损坏.epub")
|
||||
try Data("不是压缩包".utf8).write(to: broken)
|
||||
let managed = good.deletingLastPathComponent().appendingPathComponent("managed")
|
||||
#expect(throws: EPUBError.self) { try service.importFile(at: broken, into: managed) }
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: managed.path).isEmpty)
|
||||
let malicious = try fixture(extras: [("../escape", Data("outside".utf8))])
|
||||
defer { try? FileManager.default.removeItem(at: malicious.deletingLastPathComponent()) }
|
||||
#expect(throws: EPUBError.self) { try service.openFile(at: malicious) }
|
||||
let encrypted = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Text/中文%20章节.xhtml"/></CipherData></EncryptedData></encryption>"#.utf8))])
|
||||
defer { try? FileManager.default.removeItem(at: encrypted.deletingLastPathComponent()) }
|
||||
#expect(throws: EPUBError.self) { try service.openFile(at: encrypted) }
|
||||
let fontOnly = try fixture(extras: [("META-INF/encryption.xml", Data(#"<encryption><EncryptedData><CipherData><CipherReference URI="OPS/Fonts/font.otf"/></CipherData></EncryptedData></encryption>"#.utf8))])
|
||||
defer { try? FileManager.default.removeItem(at: fontOnly.deletingLastPathComponent()) }
|
||||
#expect(try service.openFile(at: fontOnly).chapters.count == 6)
|
||||
}
|
||||
|
||||
@Test func rejectsForgedChapterResourceAndMissingAnchors() throws {
|
||||
let url = try fixture()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let report = try service.openFile(at: url)
|
||||
var chapter = report.chapters[2]
|
||||
chapter.resourceUrl = "../../outside"
|
||||
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
|
||||
chapter = report.chapters[2]
|
||||
chapter.startFragmentId = "missing"
|
||||
#expect(throws: EPUBError.self) { try service.loadContent(book: report.book, chapter: chapter) }
|
||||
}
|
||||
|
||||
@Test func ignoresBadNavigationAnchorsAndOrdersValidAnchorsByDocumentPosition() throws {
|
||||
let url = try fixture(chapter: "<html><body><h1 id='two'>第二章</h1><p>先读</p><h1 id='one'>第一章</h1><p>后读</p></body></html>")
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
let report = try service.openFile(at: url)
|
||||
#expect(report.chapters.map(\.title) == ["序言", "第二章", "第一章", "未列入目录", "尾声"])
|
||||
#expect(try service.loadContent(book: report.book, chapter: report.chapters[1]).content == "第二章\n先读")
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,20 @@ struct LegadoDatabaseTests {
|
||||
#expect(report.result.nextURL == "https://rss.example/list?page=1")
|
||||
}
|
||||
|
||||
@Test func rssJSONListWithoutWildcardExtractsIndividualArticles() async throws {
|
||||
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "JSON 订阅")
|
||||
source.ruleArticles = "$.items"
|
||||
source.ruleTitle = "$.title"
|
||||
source.ruleLink = "$.link"
|
||||
let report = try await RSSRuleParser().parse(
|
||||
sortName: "全部", sortURL: "https://rss.example/feed", redirectURL: "https://rss.example/feed",
|
||||
body: #"{"items":[{"title":"第一篇","link":"/one"},{"title":"第二篇","link":"/two"}]}"#,
|
||||
source: source
|
||||
)
|
||||
#expect(report.result.articles.map(\.title) == ["第一篇", "第二篇"])
|
||||
#expect(report.result.articles.map(\.link) == ["https://rss.example/one", "https://rss.example/two"])
|
||||
}
|
||||
|
||||
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
|
||||
let importer = RSSSourceImporter()
|
||||
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ReplaceRuleExecutionTests {
|
||||
private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport {
|
||||
let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test")
|
||||
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11)
|
||||
return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules,
|
||||
removeDuplicateTitle: false, resegment: false)
|
||||
}
|
||||
|
||||
@Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() {
|
||||
let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]",
|
||||
replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)")
|
||||
var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#,
|
||||
replacement: #"@js:if (result === '') { ':' } else { '…' }"#)
|
||||
punctuation.sortOrder = 1
|
||||
let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width])
|
||||
#expect(result.content == "ABC123\n他说道:“等待…”")
|
||||
#expect(result.effectiveRuleIDs == [1, 2])
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() {
|
||||
let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """
|
||||
@js:n = typeof n === 'undefined' ? 1 : n + 1;
|
||||
java.put('count', String(Number(java.get('count') || 0) + 1));
|
||||
[book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':')
|
||||
""")
|
||||
let report = process("a b", rules: [rule])
|
||||
#expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
// A new application of the same rule must not inherit another chapter's variables.
|
||||
#expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1")
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(#"'$1'"#, "x"),
|
||||
(#"String.fromCharCode(92) + 'n'"#, #"\n"#),
|
||||
("null", "null"),
|
||||
("undefined", "null"),
|
||||
("['a', 'b']", "a,b"),
|
||||
("123", "123.0"),
|
||||
("false", "false")
|
||||
])
|
||||
func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) {
|
||||
let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0)
|
||||
let report = process("x", rules: [rule])
|
||||
#expect(report.content == sample.1)
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() {
|
||||
let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]",
|
||||
replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'")
|
||||
var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功")
|
||||
following.sortOrder = 1
|
||||
let report = process("ab tail", rules: [failing, following])
|
||||
#expect(report.content == "ab 后续成功")
|
||||
#expect(report.effectiveRuleIDs == [2])
|
||||
#expect(report.diagnostics.count == 1)
|
||||
#expect(report.diagnostics.first?.code == "rule.malformed")
|
||||
}
|
||||
|
||||
@Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() {
|
||||
var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')")
|
||||
excluded.excludeScope = "测试书"
|
||||
var disabled = excluded
|
||||
disabled.id = 2
|
||||
disabled.excludeScope = nil
|
||||
disabled.isEnabled = false
|
||||
let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')")
|
||||
var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'")
|
||||
literal.isRegex = false
|
||||
let report = process("x", rules: [excluded, disabled, unmatched, literal])
|
||||
#expect(report.content == "@js:'literal'")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func preservesLastNonblankTitleButAllowsDeletingBody() {
|
||||
var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12")
|
||||
rename.scopeTitle = true
|
||||
rename.scopeContent = false
|
||||
var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:' \\n'")
|
||||
erase.scopeTitle = true
|
||||
erase.sortOrder = 1
|
||||
let report = process("正文", rules: [erase, rename])
|
||||
#expect(report.title == "第12章")
|
||||
#expect(report.content == " \n")
|
||||
#expect(report.effectiveRuleIDs == [1, 2])
|
||||
erase.replacement = ""
|
||||
erase.scopeContent = false
|
||||
let titleOnly = process("正文", rules: [erase])
|
||||
#expect(titleOnly.title == "第十二章")
|
||||
#expect(titleOnly.effectiveRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"),
|
||||
(#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"),
|
||||
(#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"),
|
||||
(#"(?:[+*]|\\)+"#, #"+*\"#, "")
|
||||
])
|
||||
func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws {
|
||||
let replacement = sample.0.hasPrefix(""") ? "“$1”" : ""
|
||||
#expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2)
|
||||
}
|
||||
|
||||
@Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"])
|
||||
func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) {
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws {
|
||||
let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2))
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" })
|
||||
}
|
||||
#expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb")
|
||||
// Include untouched tail in the output limit, not only replaced spans.
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" })
|
||||
}
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
return "b"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() {
|
||||
let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}")
|
||||
let report = process("x", rules: [rule])
|
||||
#expect(report.content == "x")
|
||||
#expect(report.diagnostics.first?.code == "rule.resource_limit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
// The payloads and host below are synthetic and never use network access.
|
||||
struct SampledRequestRegressionTests {
|
||||
@Test func equalsSeparatedSourceHeaderReachesSearchTransport() async throws {
|
||||
var source = source()
|
||||
source.header = #"{"type"="app"}"#
|
||||
source.searchUrl = #"/search,{"method":"POST","body":"keyword={{key}}&page={{page-1}}"}"#
|
||||
let transport = SampledRequestTransport()
|
||||
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.books.count == 1)
|
||||
let request = try #require(await transport.requests().first)
|
||||
#expect(request.value(forHTTPHeaderField: "type") == "app")
|
||||
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "keyword=%E6%96%97%E7%BD%97%E5%A4%A7%E9%99%86&page=0")
|
||||
}
|
||||
|
||||
@Test func stringEncodedLenientHeadersReachSearchTransport() async throws {
|
||||
var source = source()
|
||||
source.searchUrl = #"/search?word={{key}},{"headers":"{os:'pc'}"}"#
|
||||
let transport = SampledRequestTransport()
|
||||
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.books.count == 1)
|
||||
let request = try #require(await transport.requests().first)
|
||||
#expect(request.value(forHTTPHeaderField: "os") == "pc")
|
||||
}
|
||||
|
||||
@Test func unquotedKeywordInObjectBodyPreservesJSONValueTypes() async throws {
|
||||
var source = source()
|
||||
source.searchUrl = #"/search,{"method":"POST","body":{"keyword":{{key}},"page_index":{{page}},"page_size":20,"enabled":true,"cursor":null}}"#
|
||||
let transport = SampledRequestTransport()
|
||||
let result = await BookSearchService(transport: transport).search(keyword: "斗罗大陆", page: 1, source: source)
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.books.count == 1)
|
||||
let request = try #require(await transport.requests().first)
|
||||
let body = try JSONValue.decode(data: #require(request.httpBody))
|
||||
#expect(body["keyword"] == .string("斗罗大陆"))
|
||||
#expect(body["page_index"] == .number(1))
|
||||
#expect(body["page_size"] == .number(20))
|
||||
#expect(body["enabled"] == .bool(true))
|
||||
#expect(body["cursor"] == .null)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("application/json") == true)
|
||||
}
|
||||
|
||||
@Test func lenientSeparatorsDoNotRewriteQuotedCookieOrURLValues() throws {
|
||||
let value = try LegadoURLBuilder.decodeLenientJSON(#"{type=>app, 'Cookie':'token=a=b; mode=pc', referer:'https://fixture.invalid/?a=b', retry:2, enabled:false, empty:null}"#)
|
||||
#expect(value["type"] == .string("app"))
|
||||
#expect(value["Cookie"] == .string("token=a=b; mode=pc"))
|
||||
#expect(value["referer"] == .string("https://fixture.invalid/?a=b"))
|
||||
#expect(value["retry"] == .number(2))
|
||||
#expect(value["enabled"] == .bool(false))
|
||||
#expect(value["empty"] == .null)
|
||||
}
|
||||
|
||||
@Test func unquotedWebViewFlagUsedByAudioSourceParses() throws {
|
||||
let request = try LegadoURLBuilder().prepare("https://request.fixture.invalid/audio,{webView:“true”}")
|
||||
#expect(request.useWebView)
|
||||
}
|
||||
|
||||
@Test func lenientValuesKeepCommentsAndPutRuleExpressionsWorking() throws {
|
||||
let options = try LegadoURLBuilder.decodeLenientJSON("{retry: 2 /* comment */, enabled: FALSE, word: 小说}")
|
||||
#expect(options["retry"] == .number(2))
|
||||
#expect(options["enabled"] == .bool(false))
|
||||
#expect(options["word"] == .string("小说"))
|
||||
let put = try LegadoURLBuilder.decodeLenientJSON("{id:$.books[0].id, name:name}", quoteUnquotedValues: true)
|
||||
#expect(put["id"] == .string("$.books[0].id"))
|
||||
#expect(put["name"] == .string("name"))
|
||||
#expect(throws: (any Error).self) { try LegadoURLBuilder.decodeLenientJSON("{broken:}") }
|
||||
}
|
||||
|
||||
private func source() -> BookSource {
|
||||
var source = BookSource(bookSourceUrl: "https://request.fixture.invalid", bookSourceName: "请求兼容测试")
|
||||
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
private actor SampledRequestTransport: HTTPTransport {
|
||||
private var values: [URLRequest] = []
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
values.append(request)
|
||||
return .init(data: Data(#"{"books":[{"name":"测试书","url":"/book/42"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: try #require(request.url))
|
||||
}
|
||||
func requests() -> [URLRequest] { values }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
// All responses and hosts are synthetic; these tests never contact source websites.
|
||||
struct SampledSourceRegressionTests {
|
||||
@Test func JavaScriptNumbersZeroAndOneRemainNumeric() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let value = try evaluator.evaluate(script: "({zero: 0, one: 1, yes: true, no: false, list: [0, 1, true]})", result: .null, executionContext: .init()).value
|
||||
#expect(value["zero"] == .number(0))
|
||||
#expect(value["one"] == .number(1))
|
||||
#expect(value["yes"] == .bool(true))
|
||||
#expect(value["no"] == .bool(false))
|
||||
#expect(value["list"] == .array([.number(0), .number(1), .bool(true)]))
|
||||
}
|
||||
|
||||
@Test func JavaScriptGetStringUsesTheSameJSONTemplateAndReplacementSemantics() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试小说","alternate":"备用小说"}"#.utf8))
|
||||
let value = await RuleEngine().evaluate("@js:java.getString('/book/{$.id}') + ':' + java.getString('$.name||$.alternate##小说')", in: input)
|
||||
#expect(value.diagnostics.isEmpty)
|
||||
#expect(value.strings == ["/book/42:测试"])
|
||||
}
|
||||
|
||||
@Test func legacyJSONTemplatesKeepEachSearchBookURLDistinct() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 模板")
|
||||
source.searchUrl = "/search?word={{key}}"
|
||||
source.ruleSearch = .init(bookList: "data.bookList", name: "name", bookUrl: "/pc/book/{$.id}/catalog")
|
||||
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
|
||||
#"{"data":{"bookList":[{"id":42,"name":"测试甲"},{"id":43,"name":"测试乙"}]}}"#
|
||||
)).search(keyword: "测试", page: 1, source: source)
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/pc/book/42/catalog", "https://fixture.invalid/pc/book/43/catalog"])
|
||||
}
|
||||
|
||||
@Test func legacyJSONRootWildcardAndRecursiveURLTemplate() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.invalid", bookSourceName: "旧式 JSON 列表")
|
||||
source.searchUrl = "/search?word={{key}}"
|
||||
source.ruleSearch = .init(bookList: "$.[*]", name: "$..name", bookUrl: "https://fixture.invalid{$..url}")
|
||||
let outcome = await BookSearchService(transport: SampledResponseTransport(body:
|
||||
#"[{"name":"测试甲","url":"/book/42"},{"name":"测试乙","url":"/book/43"}]"#
|
||||
)).search(keyword: "测试", page: 1, source: source)
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
#expect(outcome.books.map(\.bookURL) == ["https://fixture.invalid/book/42", "https://fixture.invalid/book/43"])
|
||||
}
|
||||
|
||||
@Test func legacyJSONTemplatesRespectQuotedBracesAndReplacement() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"id":42,"words":1200,"meta":{"label}":"玄幻"},"tags":["甲","乙"]}"#.utf8))
|
||||
let value = await RuleEngine().evaluate(#"{$.meta['label}']}/{$.words}字/{$.tags[*]}##1200##1.2千"#, in: input)
|
||||
#expect(value.diagnostics.isEmpty)
|
||||
#expect(value.strings == ["玄幻/1.2千字/甲\n乙"])
|
||||
let fromText = await RuleEngine().evaluate("/book/{$.id}", inText: input.stringValue)
|
||||
#expect(fromText.diagnostics.isEmpty)
|
||||
#expect(fromText.strings == ["/book/42"])
|
||||
}
|
||||
|
||||
@Test func JSONFallbackSkipsEmptyArraysAndEmptyNames() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"empty":[],"books":[{"name":"备用书"}],"name":"","alternate":"备用书","zero":0}"#.utf8))
|
||||
for useText in [false, true] {
|
||||
let list = if useText {
|
||||
await RuleEngine().evaluate("$.empty||$.books", inText: input.stringValue, listMode: true)
|
||||
} else {
|
||||
await RuleEngine().evaluate("$.empty||$.books", in: input, listMode: true)
|
||||
}
|
||||
#expect(list.diagnostics.isEmpty)
|
||||
#expect(list.listItems(rule: "$.empty||$.books").count == 1)
|
||||
let name = await RuleEngine().evaluate("$.name||$.alternate", in: input)
|
||||
#expect(name.strings == ["备用书"])
|
||||
let zero = await RuleEngine().evaluate("$.zero||$.alternate", in: input)
|
||||
#expect(zero.strings == ["0"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func replacementAppliesToTheCombinedSelectorResult() async throws {
|
||||
// Search names in the sampled fallback sources and multi-part metadata
|
||||
// use one trailing ## expression for the entire selector combination.
|
||||
let input = try JSONValue.decode(data: Data(#"{"name":"测试小说","fallback":"备用小说","a":"A1","b":"B2"}"#.utf8))
|
||||
let fallback = await RuleEngine().evaluate("$.name||$.fallback##小说", in: input)
|
||||
#expect(fallback.strings == ["测试"])
|
||||
let combined = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input)
|
||||
#expect(combined.strings.joined(separator: "\n") == "A\nB")
|
||||
let anchored = await RuleEngine().evaluate(#"$.a&&$.b##^A1\nB2$##整段"#, in: input)
|
||||
#expect(anchored.strings == ["整段"])
|
||||
let list = await RuleEngine().evaluate("$.a&&$.b##[0-9]", in: input, listMode: true)
|
||||
#expect(list.strings == ["A", "B"])
|
||||
let html = await RuleEngine().evaluate("h1@text||h2@text##小说", inText: "<h1>测试小说</h1>")
|
||||
#expect(html.strings == ["测试"])
|
||||
}
|
||||
|
||||
@Test func replacementTextIsNotParsedAsSelectorSyntax() async {
|
||||
let result = await RuleEngine().evaluate(#"h1@text##["']|[()]"#, inText: "<h1>书名(测试)'</h1>")
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.strings == ["书名测试"])
|
||||
let replacement = await RuleEngine().evaluate("h1@text##书##A&&B", inText: "<h1>书</h1>")
|
||||
#expect(replacement.diagnostics.isEmpty)
|
||||
#expect(replacement.strings == ["A&&B"])
|
||||
}
|
||||
}
|
||||
|
||||
private struct SampledResponseTransport: HTTPTransport {
|
||||
let body: String
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
.init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: try #require(request.url))
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase {
|
||||
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
|
||||
}
|
||||
|
||||
func testRepeatedShortChapterPrependingDoesNotAccumulateOffsetDrift() async throws {
|
||||
func short(_ index: Int) -> ReaderScrollChapter {
|
||||
.init(id: index, title: "第\(index + 1)章 · 连续阅读", content: "短章正文,继续向下可以自然读到下一章。")
|
||||
}
|
||||
let fixture = host(chapters: (20...22).map(short), active: 20)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
for first in stride(from: 19, through: 0, by: -1) {
|
||||
fixture.coordinator.parent = surface(chapters: (first...22).map(short), active: 20)
|
||||
fixture.coordinator.update(fixture.view)
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
|
||||
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY,
|
||||
accuracy: 1 / fixture.window.screen.scale)
|
||||
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
|
||||
fixture.view.accessibilityValue ?? "Missing viewport report")
|
||||
}
|
||||
|
||||
func testFractionalPixelBoundariesBelongToTheFollowingRow() {
|
||||
let layout = ReaderContinuousLayout()
|
||||
let frames = (0..<100).map { CGRect(x: 0, y: CGFloat($0 * 5) / 3, width: 390, height: 5.0 / 3) }
|
||||
layout.install(frames: frames, size: CGSize(width: 390, height: 500.0 / 3), correction: 0)
|
||||
for index in frames.indices {
|
||||
XCTAssertEqual(layout.row(at: frames[index].minY), index)
|
||||
}
|
||||
}
|
||||
|
||||
func testPrependingPreservesTheChapterBelowTopPadding() async throws {
|
||||
func short(_ index: Int) -> ReaderScrollChapter {
|
||||
.init(id: index, title: "短章\(index)", content: "短章正文")
|
||||
}
|
||||
let fixture = host(chapters: [short(20)], active: 20)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
// A short initial chapter can settle at offset zero with its heading
|
||||
// still below the reading area's top padding.
|
||||
fixture.view.setContentOffset(.zero, animated: false)
|
||||
fixture.coordinator.parent = surface(chapters: (19...21).map(short), active: 20)
|
||||
fixture.coordinator.update(fixture.view)
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
|
||||
XCTAssertEqual(layout.frames[row].minY - fixture.view.contentOffset.y, 16,
|
||||
accuracy: 1 / fixture.window.screen.scale)
|
||||
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
|
||||
fixture.view.accessibilityValue ?? "Missing viewport report")
|
||||
}
|
||||
|
||||
private func chapter(_ index: Int) -> ReaderScrollChapter {
|
||||
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨👩👧👦,用于验证连续阅读的排版和位置。\n", count: 100))
|
||||
}
|
||||
|
||||
@@ -88,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase {
|
||||
XCTAssertEqual(result as? String, "已保存|规则正文")
|
||||
}
|
||||
}
|
||||
|
||||
private actor RSSPaginationTransport: HTTPTransport {
|
||||
private var requests: [URL] = []
|
||||
private var shouldFailSecondPage = true
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try XCTUnwrap(request.url)
|
||||
requests.append(url)
|
||||
if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) }
|
||||
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
|
||||
.first { $0.name == "page" }?.value ?? "1"
|
||||
if page == "2", shouldFailSecondPage {
|
||||
shouldFailSecondPage = false
|
||||
throw URLError(.networkConnectionLost)
|
||||
}
|
||||
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
|
||||
func requestedPages() -> [String] {
|
||||
requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value }
|
||||
}
|
||||
|
||||
func requestCount() -> Int { requests.count }
|
||||
}
|
||||
|
||||
private actor RSSDelayedTransport: HTTPTransport {
|
||||
let pausedHost: String
|
||||
let pausedPage: String
|
||||
private var requestTotal = 0
|
||||
private var pending: CheckedContinuation<Void, Never>?
|
||||
|
||||
init(host: String, page: String) { pausedHost = host; pausedPage = page }
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try XCTUnwrap(request.url)
|
||||
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
|
||||
.first { $0.name == "page" }?.value ?? "1"
|
||||
requestTotal += 1
|
||||
if url.host == pausedHost, page == pausedPage {
|
||||
await withCheckedContinuation { continuation in
|
||||
pending = continuation
|
||||
}
|
||||
}
|
||||
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
|
||||
func waitUntilPaused() async throws {
|
||||
for _ in 0..<500 {
|
||||
if pending != nil { return }
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
|
||||
func resume() { pending?.resume(); pending = nil }
|
||||
func requestCount() -> Int { requestTotal }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class RSSLibraryRegressionTests: XCTestCase {
|
||||
func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws {
|
||||
let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try Data("existing file".utf8).write(to: blocked)
|
||||
defer { try? FileManager.default.removeItem(at: blocked) }
|
||||
let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3"))
|
||||
XCTAssertNotNil(model.errorMessage)
|
||||
await model.load()
|
||||
XCTAssertNotNil(model.errorMessage)
|
||||
let saved = await model.save(source(), replacing: nil)
|
||||
XCTAssertFalse(saved)
|
||||
await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#)
|
||||
XCTAssertTrue(model.sources.isEmpty)
|
||||
XCTAssertFalse(model.isWorking)
|
||||
XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8))
|
||||
}
|
||||
|
||||
private func source(host: String = "feed.test") -> RssSource {
|
||||
var source = RssSource(sourceUrl: "https://\(host)", sourceName: host)
|
||||
source.sortUrl = "https://\(host)/list?page={{page}}"
|
||||
source.ruleArticles = "$.items"
|
||||
source.ruleTitle = "$.title"
|
||||
source.ruleLink = "$.link"
|
||||
source.ruleNextPage = "PAGE"
|
||||
return source
|
||||
}
|
||||
|
||||
func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
|
||||
let transport = RSSPaginationTransport()
|
||||
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
|
||||
let feed = source()
|
||||
await model.refresh(feed)
|
||||
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
|
||||
await model.loadMore(feed)
|
||||
XCTAssertNotNil(model.errorMessage)
|
||||
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
|
||||
await model.loadMore(feed)
|
||||
let pages = await transport.requestedPages()
|
||||
XCTAssertEqual(pages, ["1", "2", "2"])
|
||||
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
|
||||
}
|
||||
|
||||
func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
|
||||
let transport = RSSPaginationTransport()
|
||||
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
|
||||
await model.refresh(source())
|
||||
let unavailable = source(host: "unavailable.test")
|
||||
await model.refresh(unavailable)
|
||||
XCTAssertTrue(model.articles.isEmpty)
|
||||
let count = await transport.requestCount()
|
||||
await model.loadMore(unavailable)
|
||||
let afterLoadMore = await transport.requestCount()
|
||||
XCTAssertEqual(afterLoadMore, count)
|
||||
}
|
||||
|
||||
func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
|
||||
let transport = RSSDelayedTransport(host: "slow.test", page: "1")
|
||||
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
|
||||
let slow = Task { await model.refresh(source(host: "slow.test")) }
|
||||
try await transport.waitUntilPaused()
|
||||
await model.refresh(source())
|
||||
XCTAssertTrue(model.isWorking)
|
||||
await transport.resume()
|
||||
await slow.value
|
||||
XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"])
|
||||
XCTAssertFalse(model.isWorking)
|
||||
}
|
||||
|
||||
func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
|
||||
let transport = RSSDelayedTransport(host: "feed.test", page: "2")
|
||||
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
|
||||
let feed = source()
|
||||
await model.refresh(feed)
|
||||
let pending = Task { await model.loadMore(feed) }
|
||||
try await transport.waitUntilPaused()
|
||||
await model.loadMore(feed)
|
||||
let count = await transport.requestCount()
|
||||
XCTAssertEqual(count, 2)
|
||||
await transport.resume()
|
||||
await pending.value
|
||||
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,11 +80,14 @@ final class DarkModeUITests: XCTestCase {
|
||||
let keyboard = try luminances(app.keyboards.firstMatch, in: app)
|
||||
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
|
||||
}
|
||||
let accessory = app.buttons["完成"]
|
||||
let accessoryFrame = accessory.frame.intersection(app.frame)
|
||||
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 {
|
||||
try assertReadableDarkFill(accessory, in: app)
|
||||
}
|
||||
let accessory = app.buttons["syntaxCodeEditor.done"]
|
||||
XCTAssertTrue(accessory.exists && accessory.isHittable)
|
||||
try assertReadableDarkFill(accessory, in: app)
|
||||
app.buttons["@"].tap()
|
||||
XCTAssertTrue((app.textViews["syntaxCodeEditor"].value as? String)?.contains("@") == true)
|
||||
accessory.tap()
|
||||
let dismissed = expectation(for: NSPredicate { _, _ in !accessory.exists || !accessory.isHittable }, evaluatedWith: nil)
|
||||
wait(for: [dismissed], timeout: 5)
|
||||
}
|
||||
|
||||
func testDarkSettingsRulesAndManagementPages() throws {
|
||||
@@ -146,8 +149,8 @@ final class DarkModeUITests: XCTestCase {
|
||||
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
|
||||
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
|
||||
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
|
||||
XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10))
|
||||
app.buttons["搜索选项"].tap()
|
||||
XCTAssertTrue(app.buttons["search.options"].waitForExistence(timeout: 10))
|
||||
app.buttons["search.options"].tap()
|
||||
app.buttons["分组或书源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Search group filter")
|
||||
|
||||
@@ -108,7 +108,10 @@ final class LiveReadingFlowUITests: XCTestCase {
|
||||
showReaderMenu()
|
||||
app.buttons["换源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10))
|
||||
let replacement = app.buttons["sourceSwitch.candidate." + replacementID]
|
||||
let replacement = app.buttons.matching(NSPredicate(
|
||||
format: "identifier BEGINSWITH %@ AND label CONTAINS %@",
|
||||
"sourceSwitch.candidate." + replacementID + "|", "滚开"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(replacement.waitForExistence(timeout: 240))
|
||||
attach("07-source-candidates")
|
||||
replacement.tap()
|
||||
|
||||
@@ -354,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase {
|
||||
app.launch()
|
||||
let reader = app.collectionViews["reader.continuous"]
|
||||
XCTAssertTrue(reader.waitForExistence(timeout: 20))
|
||||
var lastInitialState = "<not reported>"
|
||||
let ready = expectation(for: NSPredicate { _, _ in
|
||||
let text = reader.value as? String ?? ""
|
||||
let text = reader.value as? String ?? "<missing>"
|
||||
if text != lastInitialState {
|
||||
print("[ContinuousReaderInitial] \(text)")
|
||||
lastInitialState = text
|
||||
}
|
||||
return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
|
||||
}, evaluatedWith: nil)
|
||||
wait(for: [ready], timeout: 20)
|
||||
|
||||
@@ -2,34 +2,89 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class RuleEditorUITests: XCTestCase {
|
||||
func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||
try exerciseLocalTXTImport(fromFileManagement: false)
|
||||
}
|
||||
|
||||
func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws {
|
||||
try exerciseLocalTXTImport(fromFileManagement: true)
|
||||
}
|
||||
|
||||
private func exerciseLocalTXTImport(fromFileManagement: Bool) throws {
|
||||
func testRSSSearchSubmissionPaginationAndFavorites() throws {
|
||||
guard let fixture = ProcessInfo.processInfo.environment["LEGADO_RSS_UI_FIXTURE_URL"] else {
|
||||
throw XCTSkip("Set LEGADO_RSS_UI_FIXTURE_URL to the local RSS fixture source URL")
|
||||
}
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"]
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss", "-themeMode", "1"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 20))
|
||||
app.buttons["RSS操作"].tap()
|
||||
app.buttons["URL或粘贴JSON"].tap()
|
||||
let editor = app.textViews.firstMatch
|
||||
XCTAssertTrue(editor.waitForExistence(timeout: 5))
|
||||
editor.tap()
|
||||
editor.typeText(fixture)
|
||||
app.navigationBars["导入RSS源"].buttons["导入"].tap()
|
||||
let source = app.staticTexts["RSS 全流程测试"]
|
||||
XCTAssertTrue(source.waitForExistence(timeout: 10))
|
||||
source.tap()
|
||||
XCTAssertTrue(app.staticTexts["首页文章"].waitForExistence(timeout: 10))
|
||||
let search = app.searchFields["源内搜索"]
|
||||
if !search.exists { app.swipeDown() }
|
||||
XCTAssertTrue(search.waitForExistence(timeout: 5))
|
||||
search.tap()
|
||||
search.typeText("needle\n")
|
||||
let result = app.staticTexts["搜索结果 needle 第1页"]
|
||||
XCTAssertTrue(result.waitForExistence(timeout: 10))
|
||||
let nextPage = app.buttons["加载下一页"]
|
||||
let ready = expectation(for: NSPredicate { _, _ in nextPage.exists && nextPage.isEnabled }, evaluatedWith: nil)
|
||||
wait(for: [ready], timeout: 10)
|
||||
nextPage.tap()
|
||||
XCTAssertTrue(app.staticTexts["搜索结果 needle 第2页"].waitForExistence(timeout: 10))
|
||||
result.tap()
|
||||
XCTAssertTrue(app.navigationBars["文章"].waitForExistence(timeout: 5))
|
||||
if app.buttons["取消收藏"].exists { app.buttons["取消收藏"].tap() }
|
||||
XCTAssertTrue(app.buttons["收藏"].waitForExistence(timeout: 5))
|
||||
app.buttons["收藏"].tap()
|
||||
XCTAssertTrue(app.buttons["取消收藏"].waitForExistence(timeout: 5))
|
||||
app.navigationBars["文章"].buttons.element(boundBy: 0).tap()
|
||||
let cancelSearch = app.navigationBars["RSS 全流程测试"].buttons.matching(NSPredicate(
|
||||
format: "label IN %@", ["Cancel", "取消", "Close", "关闭"]
|
||||
)).firstMatch
|
||||
if cancelSearch.exists { cancelSearch.tap() }
|
||||
app.navigationBars["RSS 全流程测试"].buttons["RSS"].tap()
|
||||
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 5))
|
||||
app.segmentedControls.buttons["收藏"].tap()
|
||||
XCTAssertTrue(result.waitForExistence(timeout: 5))
|
||||
attach(app, name: "RSS search article saved to favorites")
|
||||
app.terminate()
|
||||
app.launch()
|
||||
XCTAssertTrue(app.staticTexts["RSS 全流程测试"].waitForExistence(timeout: 10))
|
||||
app.segmentedControls.buttons["收藏"].tap()
|
||||
XCTAssertTrue(result.waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
|
||||
try exerciseLocalBookImport(fromFileManagement: false)
|
||||
}
|
||||
|
||||
func testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
|
||||
try exerciseLocalBookImport(fromFileManagement: true)
|
||||
}
|
||||
|
||||
private func exerciseLocalBookImport(fromFileManagement: Bool) throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3", "-pageAnim", "0"]
|
||||
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
|
||||
app.launch()
|
||||
let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书"
|
||||
let importTitle = fromFileManagement ? "导入 TXT / EPUB" : "本地书"
|
||||
let more = fromFileManagement
|
||||
? app.navigationBars["文件管理"].buttons["更多"].firstMatch
|
||||
: app.buttons["更多"].firstMatch
|
||||
XCTAssertTrue(more.waitForExistence(timeout: 20))
|
||||
more.tap()
|
||||
app.buttons[importTitle].tap()
|
||||
app.buttons[importTitle].firstMatch.tap()
|
||||
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||
cancel.tap()
|
||||
let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
|
||||
wait(for: [closed], timeout: 10)
|
||||
more.tap()
|
||||
app.buttons[importTitle].tap()
|
||||
app.buttons[importTitle].firstMatch.tap()
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||
|
||||
let pickerFiles = app.collectionViews["File View"].cells
|
||||
@@ -66,6 +121,10 @@ final class RuleEditorUITests: XCTestCase {
|
||||
let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch
|
||||
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
|
||||
uppercase.tap()
|
||||
let epub = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "EPUB-Book")).firstMatch
|
||||
XCTAssertTrue(epub.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(epub.isEnabled)
|
||||
epub.tap()
|
||||
XCTAssertTrue(open.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(open.isEnabled)
|
||||
attach(app, name: "TXT selectable and JSON excluded")
|
||||
@@ -78,14 +137,34 @@ final class RuleEditorUITests: XCTestCase {
|
||||
XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15))
|
||||
XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists)
|
||||
XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].waitForExistence(timeout: 10), app.debugDescription)
|
||||
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].waitForExistence(timeout: 10), app.debugDescription)
|
||||
app.buttons["导入"].tap()
|
||||
}
|
||||
// Repeated runs preserve managed files and correctly add a numeric suffix.
|
||||
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15))
|
||||
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15))
|
||||
attach(app, name: "Local TXT books imported onto shelf")
|
||||
let epubBook = app.staticTexts["EPUB 导入测试"].firstMatch
|
||||
XCTAssertTrue(epubBook.waitForExistence(timeout: 15))
|
||||
attach(app, name: "Local TXT and EPUB books imported onto shelf")
|
||||
epubBook.tap()
|
||||
let reader = app.textViews["reader.text"]
|
||||
XCTAssertTrue(reader.waitForExistence(timeout: 15))
|
||||
XCTAssertTrue((reader.value as? String)?.contains("这是 EPUB 导入后的第一章正文") == true)
|
||||
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
app.buttons["更多"].firstMatch.tap()
|
||||
app.buttons["本章插图"].tap()
|
||||
XCTAssertTrue(app.navigationBars["本章插图"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.images["书籍插图"].waitForExistence(timeout: 10))
|
||||
app.buttons["完成"].tap()
|
||||
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
app.buttons["目录"].tap()
|
||||
app.staticTexts["第二章 EPUB"].firstMatch.tap()
|
||||
let loaded = expectation(for: NSPredicate { _, _ in
|
||||
(reader.value as? String)?.contains("目录锚点跳转成功") == true
|
||||
}, evaluatedWith: nil)
|
||||
wait(for: [loaded], timeout: 10)
|
||||
attach(app, name: "EPUB second chapter after TOC navigation")
|
||||
}
|
||||
|
||||
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
|
||||
@@ -146,7 +225,7 @@ final class RuleEditorUITests: XCTestCase {
|
||||
attach(app, name: "JSON import review after file picker dismissal")
|
||||
app.buttons["导入"].tap()
|
||||
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
|
||||
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription)
|
||||
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user