mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct BookDetailRegressionTests {
|
||||
@Test func restoringShelfSourceWithSameURLKeepsSourceAndMetadataConsistent() async {
|
||||
let oldSource = BookSource(bookSourceUrl: "https://old-detail.test", bookSourceName: "旧源")
|
||||
var newSource = BookSource(bookSourceUrl: "https://new-detail.test", bookSourceName: "新源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
newSource.ruleToc = toc
|
||||
let search = SearchBook(bookURL: "https://shared.test/book", origin: oldSource.id, originName: oldSource.bookSourceName,
|
||||
type: 0, name: "书")
|
||||
let model = BookDetailViewModel(searchBook: search, source: oldSource,
|
||||
service: NetworkBookService(transport: DetailTransport()),
|
||||
chapterRepository: InMemoryBookChapterRepository())
|
||||
var stored = Book(bookUrl: search.bookURL, name: "书架标题", origin: newSource.id)
|
||||
stored.originName = oldSource.bookSourceName
|
||||
stored.tocUrl = newSource.id + "/toc"
|
||||
stored.durChapterIndex = 1
|
||||
stored.durChapterPos = 120
|
||||
model.restoreLibraryBook(from: stored, sources: [oldSource, newSource])
|
||||
await model.load()
|
||||
#expect(model.state == .ready)
|
||||
#expect(model.source.id == newSource.id)
|
||||
#expect(model.book.origin == newSource.id)
|
||||
#expect(model.book.originName == newSource.bookSourceName)
|
||||
#expect(model.book.name == stored.name)
|
||||
#expect(model.book.durChapterIndex == 1)
|
||||
#expect(model.book.durChapterPos == 120)
|
||||
#expect(model.chapters.count == 2)
|
||||
}
|
||||
|
||||
@Test func refreshAfterSourceSwitchUsesNewBookURLInsteadOfOriginalSearchResult() async {
|
||||
var oldSource = BookSource(bookSourceUrl: "https://old-detail.test", bookSourceName: "旧源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
oldSource.ruleToc = toc
|
||||
var newSource = oldSource
|
||||
newSource.bookSourceUrl = "https://new-detail.test"
|
||||
let originalSearch = SearchBook(bookURL: oldSource.id + "/book", origin: oldSource.id, originName: "旧源", type: 0, name: "书")
|
||||
let model = BookDetailViewModel(searchBook: originalSearch, source: oldSource,
|
||||
service: NetworkBookService(transport: DetailTransport()),
|
||||
chapterRepository: InMemoryBookChapterRepository())
|
||||
var replacement = Book(bookUrl: newSource.id + "/book", name: "书", origin: newSource.id)
|
||||
replacement.tocUrl = newSource.id + "/toc"
|
||||
let chapters = [BookChapter(url: newSource.id + "/1", title: "第一章", bookUrl: replacement.bookUrl, index: 0)]
|
||||
model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true), source: newSource)
|
||||
await model.refresh()
|
||||
#expect(model.state == .ready)
|
||||
#expect(model.book.bookUrl == replacement.bookUrl)
|
||||
#expect(model.book.origin == newSource.id)
|
||||
#expect(model.chapters.allSatisfy { $0.bookUrl == replacement.bookUrl })
|
||||
}
|
||||
|
||||
@Test func returningFromReaderAdoptsSwitchedShelfSourceBeforeSavingDetail() {
|
||||
let old = Book(bookUrl: "old-book", name: "书", author: "作者", origin: "old-source")
|
||||
let oldSource = BookSource(bookSourceUrl: "old-source", bookSourceName: "旧源")
|
||||
let newSource = BookSource(bookSourceUrl: "new-source", bookSourceName: "新源")
|
||||
let chapters = [BookChapter(url: "old-chapter", title: "旧章节", bookUrl: old.bookUrl, index: 0)]
|
||||
let model = BookDetailViewModel(demoBook: old, source: oldSource, chapters: chapters)
|
||||
var replacement = old
|
||||
replacement.bookUrl = "new-book"
|
||||
replacement.origin = newSource.id
|
||||
replacement.durChapterPos = 42
|
||||
model.restoreSourceAfterReading(from: [replacement], sources: [oldSource, newSource])
|
||||
#expect(model.book == replacement)
|
||||
#expect(model.source == newSource)
|
||||
#expect(model.state == .idle)
|
||||
#expect(model.chapters.isEmpty)
|
||||
}
|
||||
|
||||
@Test func refreshingSearchDetailPreservesShelfSettingsAndProgress() async {
|
||||
var source = BookSource(bookSourceUrl: "https://detail-regression.test", bookSourceName: "测试源")
|
||||
var info = BookInfoRule()
|
||||
info.name = "@CSS:h1@text"
|
||||
info.intro = "@CSS:p@text"
|
||||
info.tocUrl = "@CSS:a.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
source.ruleToc = toc
|
||||
let search = SearchBook(
|
||||
bookURL: source.bookSourceUrl + "/book", origin: source.bookSourceUrl,
|
||||
originName: source.bookSourceName, type: 0, name: "搜索结果"
|
||||
)
|
||||
var stored = Book(bookUrl: search.bookURL, name: "旧标题", origin: source.bookSourceUrl)
|
||||
stored.group = 8
|
||||
stored.order = 7
|
||||
stored.canUpdate = false
|
||||
stored.customTag = "自定义标签"
|
||||
stored.customCoverUrl = "https://custom.test/cover"
|
||||
stored.customIntro = "自定义简介"
|
||||
var config = BookReadConfig()
|
||||
config.pageAnim = 3
|
||||
config.ttsEngine = "custom-tts"
|
||||
stored.readConfig = config
|
||||
stored.syncTime = 900
|
||||
stored.durChapterIndex = 1
|
||||
stored.durChapterTitle = "第二章"
|
||||
stored.durChapterPos = 123
|
||||
stored.durChapterTime = 500
|
||||
stored.totalChapterNum = 2
|
||||
stored.lastCheckCount = 1
|
||||
let model = BookDetailViewModel(
|
||||
searchBook: search, source: source,
|
||||
service: NetworkBookService(transport: DetailTransport()),
|
||||
chapterRepository: InMemoryBookChapterRepository()
|
||||
)
|
||||
model.restoreLibraryBook(from: stored)
|
||||
|
||||
await model.load()
|
||||
|
||||
#expect(model.state == .ready)
|
||||
#expect(model.book.name == "最新标题")
|
||||
#expect(model.book.intro == "最新简介")
|
||||
#expect(model.book.group == stored.group)
|
||||
#expect(model.book.order == stored.order)
|
||||
#expect(model.book.canUpdate == false)
|
||||
#expect(model.book.customTag == stored.customTag)
|
||||
#expect(model.book.customCoverUrl == stored.customCoverUrl)
|
||||
#expect(model.book.customIntro == stored.customIntro)
|
||||
#expect(model.book.readConfig == stored.readConfig)
|
||||
#expect(model.book.syncTime == 900)
|
||||
#expect(model.book.durChapterIndex == 1)
|
||||
#expect(model.book.durChapterPos == 123)
|
||||
#expect(model.book.durChapterTime == 500)
|
||||
#expect(model.book.lastCheckCount == 1)
|
||||
#expect(model.book.totalChapterNum == 2)
|
||||
|
||||
stored.group = 16
|
||||
stored.canUpdate = true
|
||||
stored.durChapterPos = 321
|
||||
stored.durChapterTime = 600
|
||||
model.restoreUserSettings(from: stored)
|
||||
model.restoreReadingProgress(from: stored)
|
||||
#expect(model.book.group == 16)
|
||||
#expect(model.book.canUpdate)
|
||||
#expect(model.book.durChapterPos == 321)
|
||||
#expect(model.book.totalChapterNum == 2)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let html = url.path == "/book"
|
||||
? "<h1>最新标题</h1><p>最新简介</p><a class='toc' href='/toc'>目录</a>"
|
||||
: "<ul><li><a href='/1'>第一章</a></li><li><a href='/2'>第二章</a></li></ul>"
|
||||
return .init(data: Data(html.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct BookLibraryRegressionTests {
|
||||
private let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "测试源")
|
||||
|
||||
private func fixture() -> (Book, [BookChapter]) {
|
||||
var book = Book(bookUrl: "https://source.test/book", name: "书", origin: source.bookSourceUrl)
|
||||
book.totalChapterNum = 2
|
||||
book.durChapterTitle = "第一章"
|
||||
let chapters = (0..<3).map {
|
||||
BookChapter(url: "https://source.test/\($0)", title: ["第一章", "第二章", "第三章"][$0], bookUrl: book.bookUrl, index: $0)
|
||||
}
|
||||
return (book, chapters)
|
||||
}
|
||||
|
||||
@Test func directoryRefreshPreservesConcurrentReadingAndGroupChanges() async throws {
|
||||
let (book, chapters) = fixture()
|
||||
let repository = InMemoryBookRepository(books: [book])
|
||||
let loader = DelayedChapterLoader(chapters: chapters)
|
||||
let model = BookLibraryModel(
|
||||
repository: repository,
|
||||
updateCoordinator: .init(repository: InMemoryBookChapterRepository(), loader: loader)
|
||||
)
|
||||
await model.load()
|
||||
let update = Task { await model.checkForUpdates(sources: [source]) }
|
||||
await loader.waitForRequest()
|
||||
await model.updateProgress(for: book, chapter: chapters[1], position: 123)
|
||||
await model.setBook(book, groupIDs: [8])
|
||||
await loader.finish()
|
||||
await update.value
|
||||
|
||||
let stored = try #require(model.books.first)
|
||||
#expect(stored.durChapterIndex == 1)
|
||||
#expect(stored.durChapterPos == 123)
|
||||
#expect(stored.group == 8)
|
||||
#expect(stored.totalChapterNum == 3)
|
||||
#expect(stored.latestChapterTitle == "第三章")
|
||||
#expect(try await repository.load() == model.books)
|
||||
}
|
||||
|
||||
@Test func directoryRefreshDoesNotResurrectRemovedBooksOrDropNewBooks() async throws {
|
||||
let (book, chapters) = fixture()
|
||||
let repository = InMemoryBookRepository(books: [book])
|
||||
let loader = DelayedChapterLoader(chapters: chapters)
|
||||
let model = BookLibraryModel(
|
||||
repository: repository,
|
||||
updateCoordinator: .init(repository: InMemoryBookChapterRepository(), loader: loader),
|
||||
chapterRepository: InMemoryBookChapterRepository()
|
||||
)
|
||||
await model.load()
|
||||
let update = Task { await model.checkForUpdates(sources: [source]) }
|
||||
await loader.waitForRequest()
|
||||
await model.remove(bookURL: book.bookUrl)
|
||||
let added = Book(bookUrl: "local://new", name: "新增书")
|
||||
await model.addOrUpdate(added)
|
||||
await loader.finish()
|
||||
await update.value
|
||||
|
||||
#expect(model.books == [added])
|
||||
#expect(try await repository.load() == [added])
|
||||
}
|
||||
|
||||
@Test func overlappingShelfWritesKeepProgressAndGroupChanges() async throws {
|
||||
let (book, chapters) = fixture()
|
||||
let repository = SuspendedBookRepository(books: [book])
|
||||
let model = BookLibraryModel(repository: repository)
|
||||
await model.load()
|
||||
let progress = Task { await model.updateProgress(for: book, chapter: chapters[1], position: 42) }
|
||||
await repository.waitForSave()
|
||||
var groupStarted = false
|
||||
let group = Task {
|
||||
groupStarted = true
|
||||
await model.setBook(book, groupIDs: [4])
|
||||
}
|
||||
while !groupStarted { await Task.yield() }
|
||||
#expect(model.books.first?.group == 4)
|
||||
await repository.finishSave()
|
||||
await progress.value
|
||||
await group.value
|
||||
|
||||
#expect(model.books.first?.durChapterPos == 42)
|
||||
#expect(model.books.first?.group == 4)
|
||||
#expect(try await repository.load() == model.books)
|
||||
}
|
||||
|
||||
@Test func failedSaveRestoresLastPersistedShelf() async throws {
|
||||
let (book, _) = fixture()
|
||||
let repository = FailingBookRepository(books: [book])
|
||||
let model = BookLibraryModel(repository: repository)
|
||||
await model.load()
|
||||
await model.setBook(book, groupIDs: [4])
|
||||
#expect(model.books == [book])
|
||||
#expect(model.errorMessage != nil)
|
||||
#expect(try await repository.load() == [book])
|
||||
}
|
||||
}
|
||||
|
||||
private actor DelayedChapterLoader: ChapterListLoading {
|
||||
let chapters: [BookChapter]
|
||||
private var requested = false
|
||||
private var continuation: CheckedContinuation<Void, Never>?
|
||||
init(chapters: [BookChapter]) { self.chapters = chapters }
|
||||
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport {
|
||||
requested = true
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
return .init(chapters: chapters)
|
||||
}
|
||||
func waitForRequest() async { while !requested { await Task.yield() } }
|
||||
func finish() { continuation?.resume(); continuation = nil }
|
||||
}
|
||||
|
||||
private actor SuspendedBookRepository: BookRepository {
|
||||
private var books: [Book]
|
||||
private var started = false
|
||||
private var continuation: CheckedContinuation<Void, Never>?
|
||||
init(books: [Book]) { self.books = books }
|
||||
func load() -> [Book] { books }
|
||||
func save(_ books: [Book]) async {
|
||||
if !started {
|
||||
started = true
|
||||
await withCheckedContinuation { continuation = $0 }
|
||||
}
|
||||
self.books = books
|
||||
}
|
||||
func waitForSave() async { while !started { await Task.yield() } }
|
||||
func finishSave() { continuation?.resume(); continuation = nil }
|
||||
}
|
||||
|
||||
private actor FailingBookRepository: BookRepository {
|
||||
let books: [Book]
|
||||
init(books: [Book]) { self.books = books }
|
||||
func load() -> [Book] { books }
|
||||
func save(_ books: [Book]) throws { throw CocoaError(.fileWriteOutOfSpace) }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct LocalFileManagementTests {
|
||||
private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let file = root.appendingPathComponent("原文件.txt")
|
||||
try "第一章 开始\n正文".write(to: file, atomically: true, encoding: .utf8)
|
||||
var book = Book(bookUrl: file.absoluteString, name: "原书名", origin: "local")
|
||||
book.originName = file.lastPathComponent
|
||||
book.durChapterIndex = 2
|
||||
book.durChapterTitle = "第三章"
|
||||
book.durChapterPos = 42
|
||||
book.group = 8
|
||||
let repository: any BookRepository = fails ? FileOperationFailingRepository(books: [book]) : InMemoryBookRepository(books: [book])
|
||||
let model = BookLibraryModel(repository: repository, localDirectory: root, chapterRepository: InMemoryBookChapterRepository())
|
||||
await model.load()
|
||||
return (root, book, model)
|
||||
}
|
||||
|
||||
@Test func renamingFileRetargetsShelfAndPreservesTitleAndReadingState() async throws {
|
||||
let (root, original, model) = try await fixture()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try await model.renameLocalFile(at: URL(string: original.bookUrl)!, to: "新文件")
|
||||
let book = try #require(model.books.first)
|
||||
#expect(book.bookUrl == root.appendingPathComponent("新文件.txt").absoluteString)
|
||||
#expect(book.originName == "新文件.txt")
|
||||
#expect(book.name == original.name)
|
||||
#expect(book.durChapterIndex == 2 && book.durChapterPos == 42 && book.group == 8)
|
||||
#expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: URL(string: original.bookUrl)!.path))
|
||||
}
|
||||
|
||||
@Test func failedShelfSaveRestoresRenamedFileAndShelf() async throws {
|
||||
let (root, book, model) = try await fixture(fails: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
await #expect(throws: (any Error).self) { try await model.renameLocalFile(at: URL(string: book.bookUrl)!, to: "新文件") }
|
||||
#expect(model.books == [book])
|
||||
#expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("新文件.txt").path))
|
||||
}
|
||||
|
||||
@Test func failedShelfSaveRestoresDeletedFileBytes() async throws {
|
||||
let (root, book, model) = try await fixture(fails: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let file = URL(string: book.bookUrl)!
|
||||
let data = try Data(contentsOf: file)
|
||||
await #expect(throws: (any Error).self) { try await model.deleteLocalFile(at: file) }
|
||||
#expect(model.books == [book])
|
||||
#expect(try Data(contentsOf: file) == data)
|
||||
}
|
||||
|
||||
@Test func deletingFileAlsoRemovesShelfReference() async throws {
|
||||
let (root, book, model) = try await fixture()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let file = URL(string: book.bookUrl)!
|
||||
try await model.deleteLocalFile(at: file)
|
||||
#expect(model.books.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: file.path))
|
||||
#expect(try await model.localFiles.list().isEmpty)
|
||||
}
|
||||
|
||||
@Test func mixedImportKeepsSuccessfulFilesAndReportsUnsupportedOnes() async throws {
|
||||
let (root, original, model) = try await fixture()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("原文件.txt")
|
||||
let unsupported = root.appendingPathComponent("unsupported.pdf")
|
||||
try Data("pdf".utf8).write(to: unsupported)
|
||||
await model.importLocalFiles([source, unsupported, source])
|
||||
#expect(model.books.count == 3)
|
||||
#expect(model.books.first == original)
|
||||
#expect(model.errorMessage?.contains("unsupported.pdf") == true)
|
||||
#expect(try await model.localFiles.list().count == 3)
|
||||
}
|
||||
|
||||
@Test func missingFileFromOldContainerCanBeRemovedFromShelf() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let missing = root.appendingPathComponent("old-container/missing.txt")
|
||||
let book = Book(bookUrl: missing.absoluteString, name: "丢失的书", origin: "local")
|
||||
let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("current-container"))
|
||||
await model.load()
|
||||
try await model.deleteLocalFile(at: missing)
|
||||
#expect(model.books.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: root.path))
|
||||
}
|
||||
|
||||
@Test func existingFileOutsideLibraryCannotBeDeletedThroughShelf() async throws {
|
||||
let (root, _, _) = try await fixture()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let external = root.appendingPathComponent("原文件.txt")
|
||||
let bytes = try Data(contentsOf: external)
|
||||
let book = Book(bookUrl: external.absoluteString, name: "外部文件", origin: "local")
|
||||
let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("managed"))
|
||||
await model.load()
|
||||
await #expect(throws: LocalFileError.outsideLibrary) { try await model.deleteLocalFile(at: external) }
|
||||
#expect(model.books == [book])
|
||||
#expect(try Data(contentsOf: external) == bytes)
|
||||
}
|
||||
}
|
||||
|
||||
private actor FileOperationFailingRepository: BookRepository {
|
||||
let books: [Book]
|
||||
init(books: [Book]) { self.books = books }
|
||||
func load() -> [Book] { books }
|
||||
func save(_ books: [Book]) throws { throw CocoaError(.fileWriteOutOfSpace) }
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct ReaderRegressionTests {
|
||||
@Test func failedContinuousChapterPreservesReasonAndRetryFetchesFreshBody() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
let loader = RecoveringReaderLoader()
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let chapters = (0..<3).map { BookChapter(url: "chapter\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0,
|
||||
source: .init(bookSourceUrl: book.origin, bookSourceName: "源"), service: loader, cache: cache)
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
#expect(model.currentChapterError == "HTTP 403:正文访问失败")
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.allSatisfy { $0.error == "HTTP 403:正文访问失败" })
|
||||
model.updateContinuousPosition(chapter: 1)
|
||||
#expect(model.currentChapterError == "HTTP 403:正文访问失败")
|
||||
let revision = model.scrollRevision
|
||||
try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[1].url, content: "旧缓存正文", sourceID: book.origin))
|
||||
await loader.recover()
|
||||
await model.retryContinuousChapter(index: 1, rules: [])
|
||||
#expect(model.currentChapterError == nil)
|
||||
#expect(model.content == "恢复正文1")
|
||||
#expect(model.scrollChapters.first(where: { $0.id == 1 })?.error == nil)
|
||||
#expect(model.currentIndex == 1)
|
||||
#expect(model.scrollRevision == revision)
|
||||
#expect(model.scrollRetries.isEmpty)
|
||||
}
|
||||
|
||||
@Test func switchedReaderUsesVerifiedBodyWithoutRepeatingTheRequestAndCachesIt() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
let loader = RecoveringReaderLoader()
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "old")
|
||||
let chapters = [BookChapter(url: "chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)]
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0,
|
||||
source: .init(bookSourceUrl: "old", bookSourceName: "旧源"), service: loader, cache: cache)
|
||||
var replacement = book
|
||||
replacement.origin = "new"
|
||||
replacement.durChapterPos = 2
|
||||
model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true,
|
||||
verifiedContent: .init(content: "已验证的正文")),
|
||||
source: .init(bookSourceUrl: "new", bookSourceName: "新源"))
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
#expect(model.content == "已验证的正文")
|
||||
#expect(model.currentChapterError == nil)
|
||||
#expect(model.requestedPosition == 2)
|
||||
#expect(await loader.calls == 0)
|
||||
let cached = try #require(try await cache.load(bookURL: book.bookUrl, chapterURL: chapters[0].url))
|
||||
#expect(cached.content == "已验证的正文")
|
||||
#expect(cached.sourceID == "new")
|
||||
}
|
||||
|
||||
@Test func sameURLSourceSwitchFetchesReplacementInsteadOfOldCachedBody() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
let book = Book(bookUrl: "shared-book", name: "书", origin: "old-source")
|
||||
let chapters = (0..<2).map { BookChapter(url: "shared-\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0,
|
||||
source: .init(bookSourceUrl: "old-source", bookSourceName: "旧源"),
|
||||
service: SourceAwareReaderLoader(), cache: cache)
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.content == "old-source正文0")
|
||||
var replacement = book
|
||||
replacement.origin = "new-source"
|
||||
model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true),
|
||||
source: .init(bookSourceUrl: "new-source", bookSourceName: "新源"))
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
#expect(model.content == "new-source正文0")
|
||||
#expect(!model.loadedFromCache)
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.allSatisfy { $0.content.contains("new-source") })
|
||||
try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[1].url,
|
||||
content: "迟到的旧源缓存", sourceID: "old-source"))
|
||||
model.select(index: 1)
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
#expect(model.content == "new-source正文1")
|
||||
#expect(!model.loadedFromCache)
|
||||
}
|
||||
|
||||
@Test func sourceSwitchReplacesDirectoryBodyAndScrollWindowAtMappedPosition() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let oldBook = Book(bookUrl: "old-book", name: "书", origin: "old-source")
|
||||
let oldChapters = (0..<3).map { BookChapter(url: "old-\($0)", title: "旧\($0)", bookUrl: oldBook.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(book: oldBook, chapters: oldChapters, initialIndex: 0,
|
||||
source: .init(bookSourceUrl: "old-source", bookSourceName: "旧源"),
|
||||
service: SourceAwareReaderLoader(), cache: .init(directory: directory))
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
model.updateContinuousPosition(chapter: 1)
|
||||
let current = model.bookForSourceSwitch(position: 4)
|
||||
#expect(current.durChapterIndex == 1)
|
||||
#expect(current.durChapterTitle == "旧1")
|
||||
#expect(current.durChapterPos == 4)
|
||||
let generation = model.chapterLoadGeneration
|
||||
var newBook = Book(bookUrl: "new-book", name: "书", origin: "new-source")
|
||||
newBook.durChapterIndex = 2
|
||||
newBook.durChapterPos = 4
|
||||
let newChapters = (0..<4).map { BookChapter(url: "new-\($0)", title: "新\($0)", bookUrl: newBook.bookUrl, index: $0) }
|
||||
model.applySourceSwitch(.init(book: newBook, chapters: newChapters, exactChapterMatch: true),
|
||||
source: .init(bookSourceUrl: "new-source", bookSourceName: "新源"))
|
||||
#expect(model.book.origin == "new-source")
|
||||
#expect(model.chapters == newChapters)
|
||||
#expect(model.currentIndex == 2)
|
||||
#expect(model.requestedPosition == 4)
|
||||
#expect(model.content.isEmpty)
|
||||
#expect(model.scrollChapters.isEmpty)
|
||||
#expect(model.chapterLoadGeneration > generation)
|
||||
await model.load(rules: [], prefetchNextChapter: false)
|
||||
#expect(model.content == "new-source正文2")
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.allSatisfy { $0.content.contains("new-source") })
|
||||
}
|
||||
|
||||
@Test func localBookshelfLoadProvidesBookAndDirectory() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let file = directory.appending(path: "本地书.txt")
|
||||
try "第一章 开始\n正文\n第二章 继续\n后续正文".write(to: file, atomically: true, encoding: .utf8)
|
||||
let imported = try LocalBookService().importTextFile(at: file, into: directory.appending(path: "books"))
|
||||
let model = BookshelfReadViewModel(updateCoordinator: .init(repository: InMemoryBookChapterRepository()))
|
||||
|
||||
await model.load(book: imported.book, source: nil)
|
||||
|
||||
#expect(!model.chapters.isEmpty)
|
||||
#expect(model.resolvedBook == imported.book)
|
||||
#expect(!model.isLoading)
|
||||
#expect(model.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func selectingSameChapterRestartsLoadAtRequestedPosition() async {
|
||||
let book = Book(bookUrl: "demo://book", name: "书")
|
||||
let chapters = [BookChapter(url: "demo://1", title: "第一章", bookUrl: book.bookUrl, index: 0)]
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文第一段\n正文第二段")
|
||||
let generation = model.chapterLoadGeneration
|
||||
|
||||
model.select(index: 0, position: 6)
|
||||
#expect(model.currentIndex == 0)
|
||||
#expect(model.chapterLoadGeneration != generation)
|
||||
await model.load(rules: [])
|
||||
|
||||
#expect(model.hasLoadedCurrentChapter)
|
||||
#expect(!model.content.isEmpty)
|
||||
#expect(model.requestedPosition == 6)
|
||||
#expect(!model.isLoading)
|
||||
}
|
||||
|
||||
@Test func previousChapterEndPositionCannotOverflowRenderedOffset() async {
|
||||
let book = Book(bookUrl: "demo://book", name: "书")
|
||||
let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "正文内容")
|
||||
model.goPrevious(position: Int.max)
|
||||
await model.load(rules: [])
|
||||
let prefix = model.title.utf16.count + 1
|
||||
let renderedLength = prefix + model.content.utf16.count
|
||||
|
||||
#expect(ReaderPositionResolver.renderedLocation(
|
||||
position: model.requestedPosition, prefixLength: prefix, renderedLength: renderedLength
|
||||
) == renderedLength - 1)
|
||||
#expect(ReaderPositionResolver.renderedLocation(position: 2, prefixLength: 4, renderedLength: 20) == 6)
|
||||
#expect(ReaderPositionResolver.renderedLocation(position: Int.max, prefixLength: Int.max, renderedLength: 0) == 0)
|
||||
}
|
||||
|
||||
@Test func enteringScrollModePreparesContinuationWithoutChangingCurrentChapter() async {
|
||||
let book = Book(bookUrl: "demo://scroll", name: "书")
|
||||
let chapters = (0..<3).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "章节正文")
|
||||
await model.load(rules: [])
|
||||
#expect(!model.hasContinuationWindow)
|
||||
|
||||
await model.loadAdjacentChapters(rules: [])
|
||||
|
||||
#expect(model.currentIndex == 1)
|
||||
#expect(model.isPreviousWindowReady)
|
||||
#expect(model.isContinuationWindowReady)
|
||||
let generation = model.chapterLoadGeneration
|
||||
model.promoteContinuation(position: 2)
|
||||
#expect(model.currentIndex == 2)
|
||||
#expect(model.chapterLoadGeneration != generation)
|
||||
#expect(model.requestedPosition == 2)
|
||||
#expect(!model.content.isEmpty)
|
||||
}
|
||||
|
||||
@Test func bookmarkPrefersChapterTitleAfterDirectoryReordering() {
|
||||
let chapters = [
|
||||
BookChapter(url: "notice", title: "前言", bookUrl: "book", index: 0),
|
||||
BookChapter(url: "first", title: "第一章", bookUrl: "book", index: 1),
|
||||
BookChapter(url: "second", title: "第二章", bookUrl: "book", index: 2)
|
||||
]
|
||||
var bookmark = Bookmark(time: 1, bookName: "书")
|
||||
bookmark.chapterIndex = 1
|
||||
bookmark.chapterName = "第二章"
|
||||
#expect(bookmark.resolvedChapterIndex(in: chapters) == 2)
|
||||
bookmark.chapterName = "旧标题"
|
||||
#expect(bookmark.resolvedChapterIndex(in: chapters) == 1)
|
||||
bookmark.chapterIndex = 99
|
||||
#expect(bookmark.resolvedChapterIndex(in: chapters) == nil)
|
||||
}
|
||||
|
||||
@Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async {
|
||||
let book = Book(bookUrl: "demo://continuous", name: "书")
|
||||
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 5, content: "很短的正文👨👩👧👦")
|
||||
let generation = model.chapterLoadGeneration
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
for _ in 0..<4 { await model.extendContinuousReading(direction: 1, rules: []) }
|
||||
|
||||
#expect(model.scrollChapters.map(\.id) == Array(3...15))
|
||||
#expect(model.currentIndex == 5)
|
||||
#expect(model.chapterLoadGeneration == generation)
|
||||
let revision = model.scrollRevision
|
||||
#expect(model.updateContinuousPosition(chapter: 10))
|
||||
#expect(model.currentIndex == 10)
|
||||
#expect(model.title == chapters[10].title)
|
||||
#expect(model.chapterLoadGeneration == generation)
|
||||
#expect(model.scrollRevision == revision)
|
||||
#expect(model.scrollChapters.count == 13)
|
||||
#expect(model.updateContinuousPosition(chapter: 4))
|
||||
#expect(model.currentIndex == 4)
|
||||
}
|
||||
|
||||
@Test func continuousReadingSkipsVolumesAndReloadsRecycledChaptersInOrder() async {
|
||||
let book = Book(bookUrl: "demo://volumes", name: "书")
|
||||
var chapters = (0..<16).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
chapters[3].isVolume = true
|
||||
chapters[7].isVolume = true
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 5, content: "正文")
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.map(\.id) == [2, 4, 5, 6, 8])
|
||||
model.retainContinuousChapters([5, 6, 8])
|
||||
await model.extendContinuousReading(direction: -1, rules: [])
|
||||
#expect(model.scrollChapters.map(\.id) == [2, 4, 5, 6, 8])
|
||||
#expect(Set(model.scrollChapters.map(\.id)).count == model.scrollChapters.count)
|
||||
}
|
||||
|
||||
@Test func explicitNavigationResetsContinuousSessionAndKeepsRequestedOffset() async {
|
||||
let book = Book(bookUrl: "demo://jump", name: "书")
|
||||
let chapters = (0..<10).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "第一段\n第二段")
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
let revision = model.scrollRevision
|
||||
model.select(index: 7, position: 4)
|
||||
#expect(model.scrollChapters.isEmpty)
|
||||
#expect(model.scrollRevision != revision)
|
||||
await model.load(rules: [])
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.currentIndex == 7)
|
||||
#expect(model.requestedPosition == 4)
|
||||
#expect(model.scrollChapters.map(\.id) == Array(5...9))
|
||||
}
|
||||
|
||||
@Test func failedContinuousChapterCanBeRetriedWithoutMovingTheViewport() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let file = directory.appending(path: "retry.txt")
|
||||
try "原始正文".write(to: file, atomically: true, encoding: .utf8)
|
||||
var book = Book(bookUrl: file.absoluteString, name: "书")
|
||||
book.charset = "UTF-8"
|
||||
let chapters = (0..<2).map { BookChapter(url: "local:\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0)
|
||||
await model.load(rules: [])
|
||||
try FileManager.default.removeItem(at: file)
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.last?.error != nil)
|
||||
let revision = model.scrollRevision
|
||||
try "恢复后的正文".write(to: file, atomically: true, encoding: .utf8)
|
||||
await model.retryContinuousChapter(index: 1, rules: [])
|
||||
#expect(model.scrollChapters.last?.error == nil)
|
||||
#expect(model.scrollChapters.last?.content.contains("恢复后的正文") == true)
|
||||
#expect(model.currentIndex == 0)
|
||||
#expect(model.scrollRevision == revision)
|
||||
}
|
||||
|
||||
@Test func genuinelyEmptyChaptersDoNotBecomeFailuresOrArtificialBlankScreens() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let file = directory.appending(path: "empty.txt")
|
||||
try "正文".write(to: file, atomically: true, encoding: .utf8)
|
||||
var book = Book(bookUrl: file.absoluteString, name: "书")
|
||||
book.charset = "UTF-8"
|
||||
var chapters = (0..<2).map { BookChapter(url: "local:\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
chapters[1].start = 0
|
||||
chapters[1].end = 0
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0)
|
||||
await model.load(rules: [])
|
||||
await model.prepareContinuousReading(rules: [])
|
||||
#expect(model.scrollChapters.last?.content == "")
|
||||
#expect(model.scrollChapters.last?.error == nil)
|
||||
}
|
||||
|
||||
@Test func aLatePrefetchCannotRestoreAnOldScrollSessionAfterDirectoryNavigation() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
let book = Book(bookUrl: "https://test.invalid/book", name: "书")
|
||||
let chapters = (0..<6).map { BookChapter(url: "https://test.invalid/\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
|
||||
try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[0].url, content: "缓存正文"))
|
||||
let loader = SuspendedReaderChapterLoader()
|
||||
let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0,
|
||||
source: .init(bookSourceUrl: "https://test.invalid", bookSourceName: "测试源"),
|
||||
service: loader, cache: cache)
|
||||
await model.load(rules: [])
|
||||
let prefetch = Task { await model.prepareContinuousReading(rules: []) }
|
||||
await loader.waitUntilSuspended()
|
||||
model.select(index: 4, position: 2)
|
||||
await model.load(rules: [])
|
||||
await loader.release()
|
||||
await prefetch.value
|
||||
#expect(model.currentIndex == 4)
|
||||
#expect(model.requestedPosition == 2)
|
||||
#expect(model.content == "正文4")
|
||||
#expect(model.scrollChapters.isEmpty)
|
||||
#expect(!model.isLoadingScrollAhead)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecoveringReaderLoader: ChapterContentLoading {
|
||||
var calls = 0
|
||||
private var recovered = false
|
||||
func recover() { recovered = true }
|
||||
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
|
||||
calls += 1
|
||||
if recovered { return .init(content: "恢复正文\(chapter.index)") }
|
||||
return .init(diagnostics: [.init(severity: .error, code: "content.request", message: "HTTP 403:正文访问失败", rule: chapter.url)])
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceAwareReaderLoader: ChapterContentLoading {
|
||||
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
|
||||
.init(title: chapter.title, content: source.id + "正文\(chapter.index)")
|
||||
}
|
||||
}
|
||||
|
||||
private actor SuspendedReaderChapterLoader: ChapterContentLoading {
|
||||
private var pending: CheckedContinuation<ChapterContentReport, Never>?
|
||||
private var started: CheckedContinuation<Void, Never>?
|
||||
|
||||
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
|
||||
guard chapter.index == 1 else { return .init(title: chapter.title, content: "正文\(chapter.index)") }
|
||||
return await withCheckedContinuation { continuation in
|
||||
pending = continuation
|
||||
started?.resume()
|
||||
started = nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilSuspended() async {
|
||||
if pending != nil { return }
|
||||
await withCheckedContinuation { started = $0 }
|
||||
}
|
||||
|
||||
func release() {
|
||||
pending?.resume(returning: .init(title: "旧章节", content: "迟到的正文"))
|
||||
pending = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
private actor FailingSourceRepository: BookSourceRepository {
|
||||
func load() -> [BookSource] { [] }
|
||||
func save(_ sources: [BookSource]) throws { throw CocoaError(.fileWriteNoPermission) }
|
||||
}
|
||||
private actor FailingReplaceRepository: ReplaceRuleRepository {
|
||||
func load() -> [ReplaceRule] { [] }
|
||||
func save(_ sources: [ReplaceRule]) throws { throw CocoaError(.fileWriteNoPermission) }
|
||||
}
|
||||
|
||||
private actor ImportFixtureTransport: HTTPTransport {
|
||||
let documents: [String: String]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
init(documents: [String: String]) { self.documents = documents }
|
||||
func data(for request: URLRequest) throws -> HTTPResponse {
|
||||
requests.append(request)
|
||||
guard let url = request.url, let document = documents[url.absoluteString] else { throw URLError(.fileDoesNotExist) }
|
||||
return .init(data: Data(document.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RuleLibraryRegressionTests {
|
||||
@Test func largeImportKeepsTheMainActorResponsive() async throws {
|
||||
let comment = String(repeating: "规则说明", count: 1_024)
|
||||
let entries = (0..<1_000).map { index in
|
||||
["bookSourceUrl": "https://large-\(index).test", "bookSourceName": "书源\(index)", "bookSourceComment": comment]
|
||||
}
|
||||
let input = String(decoding: try JSONSerialization.data(withJSONObject: entries), as: UTF8.self)
|
||||
let model = SourceLibraryModel.demo(sources: [])
|
||||
var servicedUIWork = false
|
||||
let heartbeat = Task { @MainActor in servicedUIWork = true }
|
||||
defer { heartbeat.cancel() }
|
||||
|
||||
await model.previewText(input)
|
||||
|
||||
#expect(servicedUIWork, "Parsing a large catalog must yield the main actor to UI work")
|
||||
#expect(model.pendingImport?.validCount == entries.count)
|
||||
#expect(model.sources.isEmpty)
|
||||
#expect(!model.isWorking)
|
||||
}
|
||||
|
||||
@Test func failedSourceSaveAndImportKeepDraftAndReview() async throws {
|
||||
let model = SourceLibraryModel(repository: FailingSourceRepository())
|
||||
let source = BookSource(bookSourceUrl: "https://test", bookSourceName: "测试")
|
||||
#expect(await model.saveSource(source) == false)
|
||||
#expect(model.sources.isEmpty)
|
||||
await model.previewText(String(decoding: try JSONEncoder().encode(source), as: UTF8.self))
|
||||
#expect(model.pendingImport?.validCount == 1)
|
||||
await model.commitPendingImport(selectedIDs: [source.id], keepOriginalName: false, keepOriginalGroup: false, keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true)
|
||||
#expect(model.pendingImport != nil)
|
||||
#expect(model.sources.isEmpty)
|
||||
}
|
||||
|
||||
@Test func replacingSourcePreservesOrderAndBothEnabledFlags() async throws {
|
||||
var old = BookSource(bookSourceUrl: "https://test", bookSourceName: "旧名称")
|
||||
old.customOrder = 42; old.enabled = false; old.enabledExplore = false
|
||||
old.bookSourceGroup = "本地分组"
|
||||
var new = BookSource(bookSourceUrl: old.id, bookSourceName: "新名称")
|
||||
new.lastUpdateTime = 10
|
||||
let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: [old]))
|
||||
await model.load()
|
||||
await model.previewText(String(decoding: try JSONEncoder().encode(new), as: UTF8.self))
|
||||
await model.commitPendingImport(selectedIDs: [old.id], keepOriginalName: true, keepOriginalGroup: true, keepOriginalEnabled: true, customGroup: nil, addCustomGroup: true)
|
||||
let saved = try #require(model.sources.first)
|
||||
#expect(saved.customOrder == 42)
|
||||
#expect(!saved.enabled && !saved.enabledExplore)
|
||||
#expect(saved.bookSourceName == "旧名称")
|
||||
#expect(saved.bookSourceGroup == "本地分组")
|
||||
}
|
||||
|
||||
@Test func importReviewDeduplicatesIDsAndKeepsValidEntries() async {
|
||||
let model = SourceLibraryModel.demo(sources: [])
|
||||
await model.previewText(#"[{"bookSourceUrl":"https://test","bookSourceName":"旧"},{"bookSourceUrl":"https://test","bookSourceName":"新"},{"bookSourceName":"缺地址"},{"bookSourceUrl":"https://bad","bookSourceName":"坏类型","ruleContent":99}]"#)
|
||||
#expect(model.pendingImport?.validCount == 1)
|
||||
#expect(model.pendingImport?.invalidCount == 2)
|
||||
#expect(model.pendingImport?.items.first?.source.bookSourceName == "新")
|
||||
}
|
||||
|
||||
@Test func changingSourceURLCannotSilentlyDeleteAnotherSource() async {
|
||||
let first = BookSource(bookSourceUrl: "https://first", bookSourceName: "一")
|
||||
let second = BookSource(bookSourceUrl: "https://second", bookSourceName: "二")
|
||||
let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: [first, second]))
|
||||
await model.load()
|
||||
var edited = first
|
||||
edited.bookSourceUrl = second.id
|
||||
#expect(await model.saveSource(edited, replacing: first.id) == false)
|
||||
#expect(model.sources == [first, second])
|
||||
}
|
||||
|
||||
@Test func remoteManifestFeedsPreviewAndHonorsWithoutUserAgent() async throws {
|
||||
let transport = ImportFixtureTransport(documents: [
|
||||
"https://import.test/manifest": #"{"sourceUrls":["https://import.test/a#requestWithoutUA","https://import.test/b"]}"#,
|
||||
"https://import.test/a": #"{"bookSourceUrl":"https://a.test","bookSourceName":"A"}"#,
|
||||
"https://import.test/b": #"[{"bookSourceUrl":"https://b.test","bookSourceName":"B"},{"bookSourceName":"损坏"}]"#
|
||||
])
|
||||
let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport)
|
||||
await model.previewRemoteURL("https://import.test/manifest")
|
||||
#expect(model.pendingImport?.validCount == 2)
|
||||
#expect(model.pendingImport?.invalidCount == 1)
|
||||
let requests = await transport.requests
|
||||
#expect(requests.count == 3)
|
||||
#expect(requests[1].value(forHTTPHeaderField: "User-Agent") == "null")
|
||||
#expect(model.sources.isEmpty)
|
||||
}
|
||||
|
||||
@Test func cyclicImportManifestReportsErrorWithoutMutation() async {
|
||||
let transport = ImportFixtureTransport(documents: ["https://import.test/cycle": #"{"sourceUrls":["https://import.test/cycle"]}"#])
|
||||
let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport)
|
||||
await model.previewRemoteURL("https://import.test/cycle")
|
||||
#expect(model.pendingImport == nil)
|
||||
#expect(model.sources.isEmpty)
|
||||
#expect(model.notice?.detail.contains("循环") == true)
|
||||
#expect(await transport.requests.count == 1)
|
||||
}
|
||||
|
||||
@Test func directSubscriptionImportUsesSameManifestResolution() async {
|
||||
let transport = ImportFixtureTransport(documents: [
|
||||
"https://import.test/list": #"{"sourceUrls":["https://import.test/one"]}"#,
|
||||
"https://import.test/one": #"{"bookSourceUrl":"https://one.test","bookSourceName":"订阅"}"#
|
||||
])
|
||||
let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport)
|
||||
await model.importRemoteURL("https://import.test/list")
|
||||
#expect(model.sources.first?.bookSourceName == "订阅")
|
||||
#expect(model.pendingImport == nil)
|
||||
}
|
||||
|
||||
@Test func purificationSaveValidatesAndFailureLeavesLibraryUntouched() async {
|
||||
let model = ReplaceRuleLibraryModel(repository: FailingReplaceRepository())
|
||||
var rule = model.newRule()
|
||||
#expect(await model.save(rule) == false)
|
||||
#expect(model.rules.isEmpty)
|
||||
rule.pattern = "广告"
|
||||
#expect(await model.save(rule) == false)
|
||||
#expect(model.rules.isEmpty)
|
||||
}
|
||||
|
||||
@Test func purificationCopyGetsDistinctIDAndMovePersistsExecutionOrder() async {
|
||||
let repository = InMemoryReplaceRuleRepository()
|
||||
let model = ReplaceRuleLibraryModel(repository: repository)
|
||||
var rule = model.newRule(); rule.name = "清理"; rule.pattern = "广告"
|
||||
#expect(await model.save(rule))
|
||||
let copy = model.newRule(copying: rule)
|
||||
#expect(copy.id != rule.id)
|
||||
#expect(copy.name == "清理 副本")
|
||||
#expect(await model.save(copy))
|
||||
await model.move(from: [1], to: 0)
|
||||
#expect(model.rules.map(\.id) == [copy.id, rule.id])
|
||||
#expect(model.rules.map(\.sortOrder) == [0, 1])
|
||||
#expect(await repository.load() == model.rules)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import LegadoCore
|
||||
@testable import LegadoAppModels
|
||||
|
||||
@MainActor
|
||||
struct SearchRegressionTests {
|
||||
@Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
|
||||
let source = source("slow")
|
||||
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者")
|
||||
let transport = GatedModelSearchTransport()
|
||||
let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [cached]))
|
||||
defer { model.cancel(reset: true) }
|
||||
model.search(keyword: "书", sources: [source])
|
||||
try await waitUntil { model.isShowingCachedResults }
|
||||
#expect(model.books.map(\.bookURL) == [cached.bookURL])
|
||||
#expect(model.state == .searching)
|
||||
#expect(model.completedSourceCount == 0)
|
||||
await transport.release()
|
||||
try await waitUntil { model.state == .finished }
|
||||
#expect(model.books.first?.bookURL == source.id + "/fresh")
|
||||
#expect(!model.isShowingCachedResults)
|
||||
}
|
||||
|
||||
@Test func pausedSearchPersistsEarlyResultsAndResumesOnlyUnfinishedSourcesAtSamePage() async throws {
|
||||
let transport = GatedModelSearchTransport(fastHost: "fast.example")
|
||||
let repository = InMemorySearchBookRepository()
|
||||
let model = model(transport: transport, repository: repository)
|
||||
defer { model.cancel(reset: true) }
|
||||
let fast = source("fast"), slow = source("slow")
|
||||
model.search(keyword: "书", sources: [fast, slow])
|
||||
try await waitUntil("first source result") { !model.books.isEmpty }
|
||||
model.cancel()
|
||||
try await waitUntil("paused result persistence") { try await repository.cachedMatches(keyword: "书").count == 1 }
|
||||
#expect(model.isPaused)
|
||||
#expect(model.completedSourceCount == 1)
|
||||
model.resume()
|
||||
await transport.release()
|
||||
try await waitUntil("resume completion") { model.state == .finished && !model.isPaused }
|
||||
let requests = await transport.requests()
|
||||
#expect(requests.filter { $0.url?.host == "fast.example" }.count == 1)
|
||||
#expect(requests.allSatisfy { URLComponents(url: $0.url!, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "page" })?.value == "1" })
|
||||
#expect(model.completedSourceCount == 2)
|
||||
#expect(model.books.first?.origins == [fast.id, slow.id])
|
||||
model.loadMore()
|
||||
try await waitUntil("second page completion") { !model.isLoadingMore }
|
||||
#expect(!model.hasMore)
|
||||
}
|
||||
|
||||
@Test func disabledOutOfScopeExpiredAndEditedSourceCachesAreNotDisplayed() async throws {
|
||||
var active = source("slow")
|
||||
let other = source("other")
|
||||
var expired = SearchBook(bookURL: active.id + "/expired", origin: active.id, originName: "源", type: 0, name: "书")
|
||||
expired.time = 0
|
||||
let outOfScope = SearchBook(bookURL: other.id + "/cached", origin: other.id, originName: "源", type: 0, name: "书")
|
||||
let edited = SearchBook(bookURL: active.id + "/edited", origin: active.id, originName: "源", type: 0, name: "书")
|
||||
active.lastUpdateTime = edited.time + 1
|
||||
let transport = GatedModelSearchTransport()
|
||||
let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [expired, outOfScope, edited]))
|
||||
defer { model.cancel(reset: true) }
|
||||
var started = transport.started.makeAsyncIterator()
|
||||
model.search(keyword: "书", sources: [active])
|
||||
_ = await started.next()
|
||||
#expect(model.books.isEmpty)
|
||||
#expect(!model.isShowingCachedResults)
|
||||
active.enabled = false
|
||||
model.search(keyword: "书", sources: [active])
|
||||
#expect(model.state == .unavailable)
|
||||
#expect(model.books.isEmpty)
|
||||
}
|
||||
|
||||
private func source(_ name: String) -> BookSource {
|
||||
var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name)
|
||||
value.searchUrl = "/search?q={{key}}&page={{page}}"
|
||||
value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
|
||||
return value
|
||||
}
|
||||
|
||||
private func model(transport: GatedModelSearchTransport, repository: any SearchBookRepository) -> SearchViewModel {
|
||||
.init(service: BookSearchService(transport: transport, history: .init()),
|
||||
keywordRepository: InMemorySearchKeywordRepository(), searchBookRepository: repository)
|
||||
}
|
||||
|
||||
private func waitUntil(_ reason: String = "Search state did not settle", _ condition: @MainActor () async throws -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now + .seconds(3)
|
||||
while try await !condition() {
|
||||
try #require(ContinuousClock.now < deadline, Comment(rawValue: reason))
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor GatedModelSearchTransport: HTTPTransport {
|
||||
nonisolated let started: AsyncStream<Void>
|
||||
private let continuation: AsyncStream<Void>.Continuation
|
||||
private let fastHost: String?
|
||||
private var released = false
|
||||
private var pending: [UUID: (URLRequest, CheckedContinuation<HTTPResponse, Error>)] = [:]
|
||||
private var recorded: [URLRequest] = []
|
||||
init(fastHost: String? = nil) {
|
||||
self.fastHost = fastHost
|
||||
(started, continuation) = AsyncStream.makeStream(of: Void.self)
|
||||
}
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
recorded.append(request)
|
||||
if released || request.url?.host == fastHost { return response(request) }
|
||||
let id = UUID()
|
||||
return try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { value in
|
||||
pending[id] = (request, value)
|
||||
continuation.yield(())
|
||||
}
|
||||
} onCancel: { Task { await self.cancel(id) } }
|
||||
}
|
||||
private func cancel(_ id: UUID) {
|
||||
pending.removeValue(forKey: id)?.1.resume(throwing: CancellationError())
|
||||
}
|
||||
func release() {
|
||||
released = true
|
||||
let values = pending.values
|
||||
pending = [:]
|
||||
for (request, continuation) in values { continuation.resume(returning: response(request)) }
|
||||
}
|
||||
func requests() -> [URLRequest] { recorded }
|
||||
private func response(_ request: URLRequest) -> HTTPResponse {
|
||||
.init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/fresh"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
@Suite struct AddBookURLServiceTests {
|
||||
@Test func explicitOriginHasPriority() {
|
||||
var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源")
|
||||
exact.customOrder = 0
|
||||
var explicit = BookSource(bookSourceUrl: "https://chosen.example", bookSourceName: "指定源")
|
||||
explicit.customOrder = 9
|
||||
let matched = AddBookURLService.matchingSource(
|
||||
bookURL: "https://site.example/book/1,{\"origin\":\"https://chosen.example\"}",
|
||||
sources: [exact, explicit]
|
||||
)
|
||||
#expect(matched?.bookSourceUrl == explicit.bookSourceUrl)
|
||||
}
|
||||
|
||||
@Test func exactBasePrecedesPattern() {
|
||||
var pattern = BookSource(bookSourceUrl: "https://fallback.example", bookSourceName: "正则源")
|
||||
pattern.bookUrlPattern = #"https://site\.example/book/.*"#
|
||||
pattern.customOrder = 0
|
||||
var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源")
|
||||
exact.customOrder = 10
|
||||
let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/1", sources: [pattern, exact])
|
||||
#expect(matched?.bookSourceUrl == exact.bookSourceUrl)
|
||||
}
|
||||
|
||||
@Test func patternRequiresWholeURLMatchAndEnabledSource() {
|
||||
var disabled = BookSource(bookSourceUrl: "https://disabled.example", bookSourceName: "停用")
|
||||
disabled.bookUrlPattern = #"https://site\.example/book/.*"#; disabled.enabled = false
|
||||
var enabled = BookSource(bookSourceUrl: "https://enabled.example", bookSourceName: "启用")
|
||||
enabled.bookUrlPattern = #"https://site\.example/book/\d+"#
|
||||
let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/12", sources: [disabled, enabled])
|
||||
#expect(matched?.bookSourceUrl == enabled.bookSourceUrl)
|
||||
#expect(AddBookURLService.matchingSource(bookURL: "https://site.example/book/12/extra", sources: [enabled]) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ApplicationSettingsTests {
|
||||
@Test func validatesAndPersistsWithoutOverwritingOnFailure() throws {
|
||||
let suite = "legado.settings.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
#expect(store.settings == ApplicationSettings())
|
||||
var value = ApplicationSettings()
|
||||
value.userAgent = " Test Browser "
|
||||
value.timeoutSeconds = 60
|
||||
value.concurrentSources = 3
|
||||
value.hosts = "127.0.0.1 books.example.com"
|
||||
try store.save(value)
|
||||
#expect(store.settings.userAgent == "Test Browser")
|
||||
#expect(store.settings.timeoutSeconds == 60)
|
||||
value.userAgent = "invalid\r\nHeader: injected"
|
||||
#expect(throws: SettingsError.self) { try store.save(value) }
|
||||
#expect(store.settings.userAgent == "Test Browser")
|
||||
}
|
||||
|
||||
@Test func parsesIPv4IPv6CommentsAndMultipleDomains() throws {
|
||||
let hosts = try ApplicationSettings.parseHosts("""
|
||||
# local mirror
|
||||
127.0.0.1 Books.Example.com mirror.example.com # comment
|
||||
::1 ipv6.example.com
|
||||
192.168.0.2 books.example.com
|
||||
""")
|
||||
#expect(hosts["books.example.com"] == "192.168.0.2")
|
||||
#expect(hosts["mirror.example.com"] == "127.0.0.1")
|
||||
#expect(hosts["ipv6.example.com"] == "::1")
|
||||
for invalid in ["no-ip example.com", "256.0.0.1 example.com", "127.0.0.1 https://example.com", "127.0.0.1 bad..example", "127.0.0.1 -bad.com"] {
|
||||
#expect(throws: SettingsError.self) { _ = try ApplicationSettings.parseHosts(invalid) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func proxyAndRequestPrecedence() throws {
|
||||
var settings = ApplicationSettings()
|
||||
settings.proxy = "socks5://127.0.0.1:1089"
|
||||
settings.userAgent = "Global UA"
|
||||
settings.timeoutSeconds = 45
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
try settings.configure(config)
|
||||
#expect(config.connectionProxyDictionary?["SOCKSPort"] as? Int == 1089)
|
||||
try settings.configure(config, proxyOverride: "http://localhost:8000")
|
||||
#expect(config.connectionProxyDictionary?["HTTPPort"] as? Int == 8000)
|
||||
var request = URLRequest(url: URL(string: "https://example.com")!, timeoutInterval: 5)
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Global UA")
|
||||
request.setValue("Source UA", forHTTPHeaderField: "user-agent")
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Source UA")
|
||||
#expect(settings.configure(request).timeoutInterval == 45)
|
||||
request.setValue("", forHTTPHeaderField: "User-Agent")
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "")
|
||||
for invalid in ["ftp://localhost:3", "http://localhost:0", "http://localhost:65536", "http://localhost/path", "http://user:pass@localhost:3"] {
|
||||
#expect(throws: SettingsError.self) { _ = try ApplicationSettings.proxyDictionary(invalid) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func existingTransportUsesSavedSettingsOnNextRequest() async throws {
|
||||
let suite = "legado.transport.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [SettingsEchoURLProtocol.self]
|
||||
let transport = URLSessionHTTPTransport(configuration: config, settingsStore: store)
|
||||
let request = URLRequest(url: URL(string: "https://settings.example/test")!)
|
||||
var settings = ApplicationSettings()
|
||||
settings.userAgent = "First UA"
|
||||
try store.save(settings)
|
||||
#expect(try await transport.data(for: request).decodedText == "First UA")
|
||||
settings.userAgent = "Second UA"
|
||||
settings.timeoutSeconds = 15
|
||||
try store.save(settings)
|
||||
#expect(try await transport.data(for: request).decodedText == "Second UA")
|
||||
var explicit = request
|
||||
explicit.setValue("Source UA", forHTTPHeaderField: "User-Agent")
|
||||
#expect(try await transport.data(for: explicit).decodedText == "Source UA")
|
||||
}
|
||||
|
||||
@Test func suppressedSourceUAStaysSuppressedInURLRequest() throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://example.com,{headers:{'User-Agent':'null'}}"#)
|
||||
#expect(prepared.headers["User-Agent"] == nil)
|
||||
#expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
var settings = ApplicationSettings()
|
||||
settings.userAgent = "Global UA"
|
||||
#expect(settings.configure(prepared.urlRequest()).value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SettingsEchoURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data((request.value(forHTTPHeaderField: "User-Agent") ?? "").utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookChapterRepositoryTests {
|
||||
@Test func normalizesAndPersistsChaptersPerBook() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookChapterRepository(directory: directory)
|
||||
var chapter = BookChapter(url: "1", title: "第一章", bookUrl: "wrong", index: 9)
|
||||
chapter.wordCount = "1000"
|
||||
|
||||
try await repository.save([chapter], bookURL: "book-a")
|
||||
let restored = try await repository.load(bookURL: "book-a")
|
||||
|
||||
#expect(restored.first?.bookUrl == "book-a")
|
||||
#expect(restored.first?.index == 0)
|
||||
#expect(restored.first?.wordCount == "1000")
|
||||
#expect(try await repository.load(bookURL: "book-b").isEmpty)
|
||||
}
|
||||
|
||||
@Test func removesOnlyRequestedBook() async throws {
|
||||
let repository = InMemoryBookChapterRepository()
|
||||
let a = BookChapter(url: "1", title: "A", bookUrl: "a", index: 0)
|
||||
let b = BookChapter(url: "1", title: "B", bookUrl: "b", index: 0)
|
||||
await repository.save([a], bookURL: "a")
|
||||
await repository.save([b], bookURL: "b")
|
||||
|
||||
await repository.remove(bookURL: "a")
|
||||
|
||||
#expect(await repository.load(bookURL: "a").isEmpty)
|
||||
#expect(await repository.load(bookURL: "b") == [b])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct ExploreMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString])
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookExploreServiceTests {
|
||||
@Test func parsesLegacyAndJavaScriptExploreKinds() async {
|
||||
var legacy = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "传统发现")
|
||||
legacy.exploreUrl = "热门::/hot?page={{page}}&&完结::/finished?page={{page}}\n新书::/new"
|
||||
let service = BookExploreService()
|
||||
|
||||
let legacyReport = await service.kinds(for: legacy)
|
||||
|
||||
#expect(legacyReport.kinds.map(\.title) == ["热门", "完结", "新书"])
|
||||
#expect(legacyReport.kinds[0].url == "/hot?page={{page}}")
|
||||
#expect(legacyReport.diagnostics.isEmpty)
|
||||
|
||||
var scripted = legacy
|
||||
scripted.exploreUrl = "@js:JSON.stringify([{title:'科幻',url:'/sci?page={{page}}'},{title:'文学',url:'/lit'}])"
|
||||
let scriptedReport = await service.kinds(for: scripted)
|
||||
|
||||
#expect(scriptedReport.kinds.map(\.title) == ["科幻", "文学"])
|
||||
#expect(scriptedReport.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func distinguishesExploreDestinationsFromDynamicControlsAndPlaceholders() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "动态发现")
|
||||
source.exploreUrl = #"[{"title":"关键词","type":"text"},{"title":"刷新","type":"button","action":"refresh()"},{"title":"占位"},{"title":"热门","url":" /hot?page={{page}} "}]"#
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.count == 4)
|
||||
#expect(report.kinds[0].navigableURL == nil)
|
||||
#expect(report.kinds[1].navigableURL == nil)
|
||||
#expect(report.kinds[2].navigableURL == nil)
|
||||
#expect(report.kinds[3].navigableURL == "/hot?page={{page}}")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func toleratesGsonStyleScalarValuesInExploreJSON() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "宽松分类")
|
||||
source.exploreUrl = #"[{"title":2026,"url":123,"type":true,"action":7,"chars":["全部",2,false,null],"default":0}]"#
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.count == 1)
|
||||
#expect(report.kinds[0].title == "2026")
|
||||
#expect(report.kinds[0].url == "123")
|
||||
#expect(report.kinds[0].type == "true")
|
||||
#expect(report.kinds[0].action == "7")
|
||||
#expect(report.kinds[0].chars == ["全部", "2", "false", nil])
|
||||
#expect(report.kinds[0].defaultValue == "0")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func toleratesLegacyJSON5ExploreSyntaxAcceptedByGson() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "旧式分类")
|
||||
source.exploreUrl = "[{title:'热门', url:'/hot',}, // old source comment\n]"
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.map(\.title) == ["热门"])
|
||||
#expect(report.kinds.first?.url == "/hot")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func loadsPaginatedJSONExploreBooksAndFallsBackToSearchRules() async throws {
|
||||
let transport = ExploreMockTransport(pages: [
|
||||
"https://example.test/hot?page=2": #"{"data":[{"id":2,"name":"第二页新书","author":"作者乙","cover":"/2.jpg"}]}"#
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "发现源")
|
||||
source.exploreUrl = "热门::/hot?page={{page}}"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "$.data",
|
||||
name: "$.name",
|
||||
author: "$.author",
|
||||
bookUrl: "$.id@js:'/book/' + result",
|
||||
coverUrl: "$.cover"
|
||||
)
|
||||
let kind = ExploreKind(title: "热门", url: "/hot?page={{page}}")
|
||||
|
||||
let outcome = await BookExploreService(transport: transport).explore(source: source, kind: kind, page: 2)
|
||||
|
||||
let book = try #require(outcome.books.first)
|
||||
#expect(book.name == "第二页新书")
|
||||
#expect(book.bookURL == "https://example.test/book/2")
|
||||
#expect(book.coverURL == "https://example.test/2.jpg")
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookRepositoryTests {
|
||||
@Test func persistsBooksAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
let fileURL = directory.appending(path: "books.json")
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookRepository(fileURL: fileURL)
|
||||
var book = Book(bookUrl: "https://example.test/book/1", name: "测试书", author: "作者", origin: "https://example.test")
|
||||
book.durChapterIndex = 3
|
||||
|
||||
try await repository.save([book])
|
||||
let restored = try await repository.load()
|
||||
|
||||
#expect(restored == [book])
|
||||
#expect(restored.first?.durChapterIndex == 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct SearchMockTransport: HTTPTransport {
|
||||
let response: HTTPResponse
|
||||
var expectedQueryFragment: String?
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
if let expectedQueryFragment {
|
||||
#expect(request.url?.absoluteString.contains(expectedQueryFragment) == true)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private struct DelayedSearchTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let isSlow = request.url?.host == "slow.example.test"
|
||||
try await Task.sleep(for: isSlow ? .milliseconds(200) : .milliseconds(10))
|
||||
let name = isSlow ? "慢源结果" : "快源结果"
|
||||
let data = Data(#"{"books":[{"name":"\#(name)","author":"作者","url":"/book/1"}]}"#.utf8)
|
||||
return .init(data: data, statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookSearchServiceTests {
|
||||
@Test func streamsFastSourceWithoutWaitingForSlowSource() async throws {
|
||||
func makeSource(host: String, name: String) -> BookSource {
|
||||
var source = BookSource(bookSourceUrl: "https://\(host)", bookSourceName: name)
|
||||
source.searchUrl = "https://\(host)/search?q={{key}}"
|
||||
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url")
|
||||
return source
|
||||
}
|
||||
let slow = makeSource(host: "slow.example.test", name: "慢源")
|
||||
let fast = makeSource(host: "fast.example.test", name: "快源")
|
||||
let stream = BookSearchService(transport: DelayedSearchTransport())
|
||||
.searchOutcomes(keyword: "结果", sources: [slow, fast])
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
let first = try #require(await iterator.next())
|
||||
|
||||
#expect(first.sourceID == fast.id)
|
||||
#expect(first.books.first?.name == "快源结果")
|
||||
}
|
||||
|
||||
@Test func ordersMergedResultsBySearchRelevance() async throws {
|
||||
let finalURL = try #require(URL(string: "https://api.example.test/search"))
|
||||
let data = Data(#"{"books":[{"name":"其他","author":"甲","kind":"普通","url":"/4"},{"name":"关键词扩展","author":"乙","kind":"普通","url":"/3"},{"name":"分类书","author":"丙","kind":"关键词","url":"/2"},{"name":"关键词","author":"丁","kind":"普通","url":"/1"}]}"#.utf8)
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
||||
expectedQueryFragment: nil
|
||||
)
|
||||
var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "排序源")
|
||||
source.searchUrl = "https://api.example.test/search?q={{key}}"
|
||||
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", kind: "kind", bookUrl: "url")
|
||||
|
||||
let report = await BookSearchService(transport: transport).search(keyword: "关键词", sources: [source])
|
||||
|
||||
#expect(report.books.map(\.name) == ["关键词", "分类书", "关键词扩展", "其他"])
|
||||
}
|
||||
|
||||
@Test func searchesJSONSourceAndResolvesRelativeURLs() async throws {
|
||||
let finalURL = try #require(URL(string: "https://api.example.test/search?q=swift"))
|
||||
let data = Data(
|
||||
"""
|
||||
{"data":{"books":[
|
||||
{"name":"Swift 入门","author":"A","url":"/book/1","cover":"/cover/1.jpg","tags":["编程","Swift"]},
|
||||
{"name":"Swift 进阶","author":"B","url":"/book/2","cover":"/cover/2.jpg","tags":["编程"]}
|
||||
]}}
|
||||
""".utf8
|
||||
)
|
||||
let transport = SearchMockTransport(response: .init(
|
||||
data: data,
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: finalURL
|
||||
), expectedQueryFragment: "q=swift")
|
||||
var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "JSON 源")
|
||||
source.searchUrl = "/search?q={{key}}&page=<1,2,3>"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "$.data.books[*]",
|
||||
name: "name",
|
||||
author: "author",
|
||||
kind: "tags[*]",
|
||||
bookUrl: "url",
|
||||
coverUrl: "cover"
|
||||
)
|
||||
let service = BookSearchService(transport: transport)
|
||||
|
||||
let report = await service.search(keyword: "swift", sources: [source])
|
||||
|
||||
#expect(report.books.count == 2)
|
||||
#expect(report.books.map(\.name).sorted() == ["Swift 入门", "Swift 进阶"])
|
||||
#expect(report.books.allSatisfy { $0.bookURL.hasPrefix("https://api.example.test/book/") })
|
||||
#expect(report.outcomes[0].diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func mergesSameBookAcrossSources() async throws {
|
||||
let finalURL = try #require(URL(string: "https://api.example.test/search"))
|
||||
let data = Data(#"{"books":[{"name":"同一本书","author":"作者","url":"/book/1"}]}"#.utf8)
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
||||
expectedQueryFragment: nil
|
||||
)
|
||||
func makeSource(_ id: String) -> BookSource {
|
||||
var source = BookSource(bookSourceUrl: id, bookSourceName: id)
|
||||
source.searchUrl = "https://api.example.test/search?q={{key}}"
|
||||
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url")
|
||||
return source
|
||||
}
|
||||
let service = BookSearchService(transport: transport)
|
||||
|
||||
let report = await service.search(keyword: "同一本书", sources: [makeSource("https://one.test"), makeSource("https://two.test")])
|
||||
|
||||
#expect(report.books.count == 1)
|
||||
#expect(report.books[0].origins.count == 2)
|
||||
}
|
||||
|
||||
@Test func mergesDecoratedAuthorAndPunctuatedTitleAcrossSources() {
|
||||
let plain = SearchBook(bookURL: "one", origin: "one", originName: "一源", type: 0, name: "长夜里的灯", author: "林舟")
|
||||
let decorated = SearchBook(bookURL: "two", origin: "two", originName: "二源", type: 0, name: "长夜里的灯!", author: "作者:林舟 著")
|
||||
let outcomes = [
|
||||
SourceSearchOutcome(sourceID: "one", books: [plain], diagnostics: []),
|
||||
SourceSearchOutcome(sourceID: "two", books: [decorated], diagnostics: [])
|
||||
]
|
||||
|
||||
let merged = BookSearchService().merge(outcomes: outcomes, keyword: "长夜里的灯")
|
||||
|
||||
#expect(merged.count == 1)
|
||||
#expect(merged[0].origins == ["one", "two"])
|
||||
}
|
||||
|
||||
@Test func searchesHTMLSourceWithCSSRules() async throws {
|
||||
let finalURL = try #require(URL(string: "https://html.example.test/search?q=ios"))
|
||||
let data = Data(
|
||||
"""
|
||||
<html><body><section class="results">
|
||||
<article class="book"><a class="name" href="/book/ios">iOS 阅读</a><span class="author">作者甲</span><img src="/ios.jpg"></article>
|
||||
<article class="book"><a class="name" href="/book/swift">Swift 阅读</a><span class="author">作者乙</span><img src="/swift.jpg"></article>
|
||||
</section></body></html>
|
||||
""".utf8
|
||||
)
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
||||
expectedQueryFragment: "q=ios"
|
||||
)
|
||||
var source = BookSource(bookSourceUrl: "https://html.example.test", bookSourceName: "HTML 源")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "@CSS:article.book",
|
||||
name: "@CSS:a.name@text",
|
||||
author: "@CSS:.author@text",
|
||||
bookUrl: "@CSS:a.name@href",
|
||||
coverUrl: "@CSS:img@src"
|
||||
)
|
||||
let service = BookSearchService(transport: transport)
|
||||
|
||||
let report = await service.search(keyword: "ios", sources: [source])
|
||||
|
||||
#expect(report.books.count == 2)
|
||||
#expect(report.books.first(where: { $0.name == "iOS 阅读" })?.bookURL == "https://html.example.test/book/ios")
|
||||
#expect(report.outcomes[0].diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func searchesLegadoStyleSourceWithJavaScriptPipelineAndVariables() async throws {
|
||||
let finalURL = try #require(URL(string: "https://audio.example.test/search"))
|
||||
let data = Data(
|
||||
#"{"content":{"content":[{"id":1001,"title":"听书测试","author":"播音员","cover":{"path":"cover/a.jpg"}}]}}"#.utf8
|
||||
)
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
||||
expectedQueryFragment: nil
|
||||
)
|
||||
var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "JS 源")
|
||||
source.searchUrl = "https://audio.example.test/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "$.content.content",
|
||||
name: "$.title",
|
||||
author: "$.author",
|
||||
bookUrl: "$.id@js:java.put('bookId', result); '/toc?bookId=' + result",
|
||||
coverUrl: "$.cover@js:var cover = JSON.parse(result); '/file/' + cover.path"
|
||||
)
|
||||
|
||||
let report = await BookSearchService(transport: transport).search(keyword: "听书", sources: [source])
|
||||
|
||||
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
||||
let book = try #require(report.books.first)
|
||||
#expect(book.bookURL == "https://audio.example.test/toc?bookId=1001")
|
||||
#expect(book.coverURL == "https://audio.example.test/file/cover/a.jpg")
|
||||
#expect(book.variables["bookId"] == "1001")
|
||||
#expect(report.outcomes[0].diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func searchesLegacyEncodedHTMLUsingURLCharsetOption() async throws {
|
||||
let finalURL = try #require(URL(string: "https://legacy.example.test/search"))
|
||||
let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030"))
|
||||
let html = #"<article class="book"><a class="name" href="/book/1">旧城故事</a><span class="author">林舟</span></article>"#
|
||||
let data = try #require(html.data(using: encoding))
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
||||
expectedQueryFragment: nil
|
||||
)
|
||||
var source = BookSource(bookSourceUrl: "https://legacy.example.test", bookSourceName: "旧编码源")
|
||||
source.searchUrl = #"/search?q={{key}}, {"charset":"gb18030"}"#
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "@CSS:article.book",
|
||||
name: "@CSS:a.name@text",
|
||||
author: "@CSS:.author@text",
|
||||
bookUrl: "@CSS:a.name@href"
|
||||
)
|
||||
|
||||
let report = await BookSearchService(transport: transport).search(keyword: "旧城", sources: [source])
|
||||
|
||||
#expect(report.books.first?.name == "旧城故事")
|
||||
#expect(report.books.first?.author == "林舟")
|
||||
}
|
||||
|
||||
@Test func evaluatesDynamicRequestJavaScriptAndPassesVariablesToRules() async throws {
|
||||
let finalURL = try #require(URL(string: "https://dynamic.example.test/search?q=DUNE"))
|
||||
let transport = SearchMockTransport(
|
||||
response: .init(
|
||||
data: Data(#"{"books":[{"name":"沙丘","author":"Frank","id":"42"}]}"#.utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: finalURL
|
||||
),
|
||||
expectedQueryFragment: "q=DUNE"
|
||||
)
|
||||
var source = BookSource(bookSourceUrl: "https://dynamic.example.test", bookSourceName: "动态源")
|
||||
source.searchUrl = "@js:java.put('requestToken', key.toUpperCase()); '/search?q=' + java.get('requestToken')"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "$.books[*]",
|
||||
name: "name",
|
||||
author: "author",
|
||||
bookUrl: "$.id@js:'/book/' + result + '?token=' + java.get('requestToken')"
|
||||
)
|
||||
|
||||
let report = await BookSearchService(transport: transport).search(keyword: "dune", sources: [source])
|
||||
|
||||
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
||||
#expect(report.books.first?.bookURL == "https://dynamic.example.test/book/42?token=DUNE")
|
||||
#expect(report.books.first?.variables["requestToken"] == "DUNE")
|
||||
}
|
||||
|
||||
@Test func searchesRenderedWebViewHTMLThroughInjectedExecutor() async throws {
|
||||
let url = try #require(URL(string: "https://web-search.example/results"))
|
||||
let executor = await MainActor.run { SearchWebViewExecutor(response: .init(
|
||||
data: Data(#"<article class="book"><a href="/book/9">渲染后的书</a><span>网页作者</span></article>"#.utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
)) }
|
||||
var source = BookSource(bookSourceUrl: "https://web-search.example", bookSourceName: "WebView 源")
|
||||
source.searchUrl = #"/search?q={{key}}, {"webView":true,"webJs":"document.body.dataset.loaded='yes'"}"#
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "@CSS:article.book",
|
||||
name: "@CSS:a@text",
|
||||
author: "@CSS:span@text",
|
||||
bookUrl: "@CSS:a@href"
|
||||
)
|
||||
|
||||
let report = await BookSearchService(
|
||||
transport: SearchMockTransport(response: .init(data: Data(), statusCode: 500, headers: [:], finalURL: url)),
|
||||
webViewExecutor: executor
|
||||
).search(keyword: "网页", sources: [source])
|
||||
|
||||
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
||||
#expect(report.books.first?.name == "渲染后的书")
|
||||
#expect(report.books.first?.bookURL == "https://web-search.example/book/9")
|
||||
#expect(await executor.callCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SearchWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable {
|
||||
let response: HTTPResponse
|
||||
private(set) var callCount = 0
|
||||
|
||||
init(response: HTTPResponse) { self.response = response }
|
||||
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
callCount += 1
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct DebuggerMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString], "缺少调试页面:\(url.absoluteString)")
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookSourceDebuggerTests {
|
||||
@Test func runsSearchDetailTocAndContentInOrder() async throws {
|
||||
let pages = [
|
||||
"https://debug.example.test/search?q=demo": #"{"books":[{"name":"调试之书","url":"/book/1"}]}"#,
|
||||
"https://debug.example.test/book/1": #"<h1>调试之书</h1><a class="toc" href="/toc/1">目录</a>"#,
|
||||
"https://debug.example.test/toc/1": #"<li class="chapter"><a href="/chapter/1">第一章</a></li>"#,
|
||||
"https://debug.example.test/chapter/1": "<article>调试正文成功</article>"
|
||||
]
|
||||
var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "调试源")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
|
||||
var info = BookInfoRule()
|
||||
info.name = "@CSS:h1@text"
|
||||
info.tocUrl = "@CSS:a.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article@text"
|
||||
source.ruleContent = content
|
||||
|
||||
let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages))
|
||||
.run(source: source, keyword: "demo")
|
||||
|
||||
#expect(report.steps.map(\.stage) == SourceDebugStage.allCases)
|
||||
#expect(report.steps.allSatisfy { $0.status == .passed })
|
||||
#expect(report.sampleBookName == "调试之书")
|
||||
#expect(report.sampleChapterTitle == "第一章")
|
||||
#expect(report.contentPreview == "调试正文成功")
|
||||
#expect(report.passed)
|
||||
}
|
||||
|
||||
@Test func reportsConfigurationAndSkipsAfterEmptySearch() async {
|
||||
let pages = ["https://debug.example.test/search?q=none": #"{"books":[]}"#]
|
||||
var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "空源")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
|
||||
|
||||
let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages))
|
||||
.run(source: source, keyword: "none")
|
||||
|
||||
#expect(report.steps.first?.status == .failed)
|
||||
#expect(report.steps.first(where: { $0.stage == .search })?.status == .failed)
|
||||
#expect(report.steps.first(where: { $0.stage == .detail })?.status == .skipped)
|
||||
#expect(!report.passed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Compression
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookSourceImporterTests {
|
||||
private let importer = BookSourceImporter()
|
||||
|
||||
@Test func decodesArrayAndObjectRules() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
[{
|
||||
"bookSourceUrl": "https://example.test",
|
||||
"bookSourceName": "示例书源",
|
||||
"enabled": true,
|
||||
"bookSourceType": 0,
|
||||
"searchUrl": "https://example.test/search?q={{key}}",
|
||||
"ruleSearch": {
|
||||
"bookList": "@css:.book",
|
||||
"name": "@css:h2@text",
|
||||
"bookUrl": "@css:a@href"
|
||||
},
|
||||
"ruleContent": "{\\\"content\\\":\\\"@css:article@html\\\"}"
|
||||
}]
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let sources = try importer.decode(data)
|
||||
|
||||
#expect(sources.count == 1)
|
||||
#expect(sources[0].bookSourceName == "示例书源")
|
||||
#expect(sources[0].ruleSearch?.bookList == "@css:.book")
|
||||
#expect(sources[0].ruleContent?.content == "@css:article@html")
|
||||
}
|
||||
|
||||
@Test func acceptsUTF8BOMAndLossyPrimitiveTypes() throws {
|
||||
var data = Data([0xEF, 0xBB, 0xBF])
|
||||
data.append(
|
||||
contentsOf: """
|
||||
{
|
||||
"bookSourceUrl": "https://example.test",
|
||||
"bookSourceName": "兼容书源",
|
||||
"enabled": "1",
|
||||
"enabledExplore": 0,
|
||||
"bookSourceType": "2",
|
||||
"respondTime": "1200"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
|
||||
#expect(source.enabled)
|
||||
#expect(!source.enabledExplore)
|
||||
#expect(source.bookSourceType == 2)
|
||||
#expect(source.respondTime == 1_200)
|
||||
}
|
||||
|
||||
@Test func preservesEnabledCookieJarDefaultAndExplicitNull() throws {
|
||||
let sources = try importer.decode(Data(
|
||||
#"[{"bookSourceUrl":"https://default-cookie.test","bookSourceName":"默认"},{"bookSourceUrl":"https://null-cookie.test","bookSourceName":"空值","enabledCookieJar":null}]"#.utf8
|
||||
))
|
||||
|
||||
#expect(sources[0].enabledCookieJar == true)
|
||||
#expect(sources[1].enabledCookieJar == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsWholeBatchBeforeMutation() throws {
|
||||
let existing = [BookSource(bookSourceUrl: "https://kept.test", bookSourceName: "保留")]
|
||||
let data = Data(
|
||||
"""
|
||||
[
|
||||
{"bookSourceUrl":"https://valid.test","bookSourceName":"有效"},
|
||||
{"bookSourceUrl":"","bookSourceName":"无效"}
|
||||
]
|
||||
""".utf8
|
||||
)
|
||||
|
||||
#expect(throws: BookSourceImportError.self) {
|
||||
try importer.prepare(data: data, existing: existing)
|
||||
}
|
||||
#expect(existing.count == 1)
|
||||
}
|
||||
|
||||
@Test func handlesDuplicatePolicies() throws {
|
||||
var old = BookSource(bookSourceUrl: "https://same.test", bookSourceName: "旧名称")
|
||||
old.enabled = false
|
||||
let data = Data(
|
||||
"""
|
||||
{"bookSourceUrl":"https://same.test","bookSourceName":"新名称"}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let replaced = try importer.prepare(data: data, existing: [old], duplicatePolicy: .replace)
|
||||
#expect(replaced.replaced == 1)
|
||||
#expect(replaced.sources[0].bookSourceName == "新名称")
|
||||
|
||||
let skipped = try importer.prepare(data: data, existing: [old], duplicatePolicy: .skip)
|
||||
#expect(skipped.skipped == 1)
|
||||
#expect(skipped.sources[0].bookSourceName == "旧名称")
|
||||
|
||||
#expect(throws: BookSourceImportError.self) {
|
||||
try importer.prepare(data: data, existing: [old], duplicatePolicy: .fail)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func roundTripsWithoutLosingCompatibilityFields() throws {
|
||||
let originalData = Data(
|
||||
"""
|
||||
{
|
||||
"bookSourceUrl":"https://roundtrip.test",
|
||||
"bookSourceName":"往返测试",
|
||||
"bookSourceGroup":"测试,文本",
|
||||
"header":"{\\"User-Agent\\":\\"Legado\\"}",
|
||||
"eventListener":true,
|
||||
"customButton":true,
|
||||
"ruleToc":{"chapterList":"//li","chapterName":"a/text()"}
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
let source = try #require(importer.decode(originalData).first)
|
||||
|
||||
let encoded = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(BookSource.self, from: encoded)
|
||||
|
||||
#expect(decoded == source)
|
||||
}
|
||||
|
||||
@Test func importsDesensitizedJavaScriptFixture() throws {
|
||||
let url = try #require(Bundle.module.url(
|
||||
forResource: "legado-js-audio-source",
|
||||
withExtension: "json",
|
||||
subdirectory: "Fixtures"
|
||||
))
|
||||
let source = try #require(importer.decode(Data(contentsOf: url)).first)
|
||||
|
||||
#expect(source.bookSourceType == 1)
|
||||
#expect(source.searchUrl?.contains("searchKey") == true)
|
||||
#expect(source.ruleSearch?.bookUrl?.contains("@js:") == true)
|
||||
#expect(source.ruleToc?.chapterUrl?.contains("java.get('bookId')") == true)
|
||||
#expect(source.ruleReview?.reviewUrl == "/review/{{chapterId}}")
|
||||
|
||||
let roundTrip = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(source))
|
||||
#expect(roundTrip == source)
|
||||
}
|
||||
|
||||
@Test func importsSourceDocumentsLargerThanTwentyMegabytes() throws {
|
||||
var data = Data(#"[{"bookSourceUrl":"https://large.test","bookSourceName":"大书源"}]"#.utf8)
|
||||
data.append(Data(repeating: 0x20, count: 21 * 1_024 * 1_024))
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
#expect(source.bookSourceName == "大书源")
|
||||
}
|
||||
|
||||
@Test func decodesGBKEncodedSourceDocuments() throws {
|
||||
let json = #"[{"bookSourceUrl":"https://gbk.test","bookSourceName":"编码书源","searchUrl":"/search?q={{key}}"}]"#
|
||||
let gbk = String.Encoding(
|
||||
rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
|
||||
)
|
||||
let data = try #require(json.data(using: gbk))
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
|
||||
#expect(source.bookSourceName == "编码书源")
|
||||
#expect(source.searchUrl == "/search?q={{key}}")
|
||||
}
|
||||
|
||||
@Test func decodesGzipCompressedSourceDocuments() throws {
|
||||
let json = Data(#"[{"bookSourceUrl":"https://gzip.test","bookSourceName":"压缩书源"}]"#.utf8)
|
||||
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: json.count + 4_096)
|
||||
defer { destination.deallocate() }
|
||||
let produced = json.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
|
||||
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||
return compression_encode_buffer(
|
||||
destination, json.count + 4_096,
|
||||
base, json.count,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
#expect(produced > 0)
|
||||
var gzipped = Data([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF])
|
||||
gzipped.append(destination, count: produced)
|
||||
gzipped.append(Data(repeating: 0, count: 8))
|
||||
|
||||
let source = try #require(importer.decode(gzipped).first)
|
||||
|
||||
#expect(source.bookSourceName == "压缩书源")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookSourceRepositoryTests {
|
||||
@Test func fileRepositoryPersistsAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookSourceRepository(fileURL: directory.appending(path: "sources.json"))
|
||||
let expected = [BookSource(bookSourceUrl: "https://saved.test", bookSourceName: "已保存")]
|
||||
|
||||
try await repository.save(expected)
|
||||
let loaded = try await repository.load()
|
||||
|
||||
#expect(loaded == expected)
|
||||
}
|
||||
|
||||
@Test func groupOperationsSplitSeparatorsAndDeduplicate() {
|
||||
var source = BookSource(bookSourceUrl: "https://groups.test", bookSourceName: "分组源")
|
||||
source.bookSourceGroup = "文学, 科幻;文学;听书"
|
||||
|
||||
#expect(source.groups == ["文学", "科幻", "听书"])
|
||||
#expect(source.hasGroup("科幻"))
|
||||
|
||||
source.addGroups("悬疑,科幻")
|
||||
#expect(source.groups == ["文学", "科幻", "听书", "悬疑"])
|
||||
|
||||
source.removeGroups("文学;听书")
|
||||
#expect(source.bookSourceGroup == "科幻,悬疑")
|
||||
}
|
||||
|
||||
@Test func renamesAndDeletesGroupsWithoutLeavingEmptyText() {
|
||||
var source = BookSource(bookSourceUrl: "https://rename.test", bookSourceName: "重命名源")
|
||||
source.bookSourceGroup = "旧组,保留"
|
||||
|
||||
source.replaceGroup("旧组", with: "新组")
|
||||
#expect(source.groups == ["保留", "新组"])
|
||||
source.replaceGroup("保留", with: nil)
|
||||
source.removeGroups("新组")
|
||||
|
||||
#expect(source.groups.isEmpty)
|
||||
#expect(source.bookSourceGroup == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookSourceSwitchServiceTests {
|
||||
@Test(.timeLimit(.minutes(1))) func automaticRecoveryUsesEarlyValidSourceWithoutWaitingForSlowSources() async throws {
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self)
|
||||
defer { continuation.finish() }
|
||||
let loader = CandidateAwareSwitchLoader()
|
||||
let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream),
|
||||
detailLoader: loader, chapterLoader: loader,
|
||||
contentLoader: SwitchContentLoader(),
|
||||
chapterRepository: InMemoryBookChapterRepository())
|
||||
var source = BookSource(bookSourceUrl: "valid", bookSourceName: "好源")
|
||||
source.searchUrl = "/search"
|
||||
let candidate = SearchBook(bookURL: "valid-book", origin: "valid", originName: "好源", type: 0, name: "书")
|
||||
continuation.yield(.init(sourceID: "valid", books: [candidate], diagnostics: []))
|
||||
let result = await service.firstAvailableSource(for: Book(bookUrl: "old-book", name: "书", origin: "old"), sources: [source])
|
||||
#expect(result?.source.id == "valid")
|
||||
#expect(result?.report.chapters.first?.title == "有效目录")
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func streamsCandidatesBeforeAllSourcesFinishAndKeepsDistinctOrigins() async throws {
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self)
|
||||
defer { continuation.finish() }
|
||||
let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream))
|
||||
let book = Book(bookUrl: "old-book", name: "书", author: "作者", origin: "old")
|
||||
let sources = ["a", "b"].map { id in
|
||||
var source = BookSource(bookSourceUrl: id, bookSourceName: id)
|
||||
source.searchUrl = "/search"
|
||||
return source
|
||||
}
|
||||
var iterator = await service.candidateUpdates(for: book, sources: sources).makeAsyncIterator()
|
||||
let first = SearchBook(bookURL: "shared-url", origin: "a", originName: "A", type: 0, name: "书", author: "作者")
|
||||
continuation.yield(.init(sourceID: "a", books: [first], diagnostics: []))
|
||||
let early = try #require(await iterator.next())
|
||||
#expect(early.map(\.book.origin) == ["a"])
|
||||
var second = first
|
||||
second.origin = "b"
|
||||
continuation.yield(.init(sourceID: "b", books: [second, second], diagnostics: []))
|
||||
let updated = try #require(await iterator.next())
|
||||
#expect(Set(updated.map(\.book.origin)) == ["a", "b"])
|
||||
#expect(Set(updated.map(\.id)).count == 2)
|
||||
continuation.finish()
|
||||
#expect(await iterator.next() == nil)
|
||||
}
|
||||
|
||||
@Test func ranksExactAuthorAndExcludesCurrentSource() async {
|
||||
var current = Book(bookUrl: "old", name: "长夜里的灯", author: "林舟", origin: "old-source")
|
||||
current.originName = "旧源"
|
||||
let exact = SearchBook(bookURL: "new-a", origin: "source-a", originName: "甲源", type: 0, name: "长夜里的灯", author: "作者:林舟")
|
||||
let otherAuthor = SearchBook(bookURL: "new-b", origin: "source-b", originName: "乙源", type: 0, name: "长夜里的灯", author: "另一位")
|
||||
let wrongTitle = SearchBook(bookURL: "new-c", origin: "source-c", originName: "丙源", type: 0, name: "长夜的灯", author: "林舟")
|
||||
let report = BookSearchReport(books: [], outcomes: [
|
||||
.init(sourceID: "source-a", books: [exact], diagnostics: []),
|
||||
.init(sourceID: "source-b", books: [otherAuthor], diagnostics: []),
|
||||
.init(sourceID: "source-c", books: [wrongTitle], diagnostics: [])
|
||||
])
|
||||
let service = BookSourceSwitchService(searcher: StubSwitchSearch(report: report))
|
||||
let sources = ["old-source", "source-a", "source-b", "source-c"].map {
|
||||
var source = BookSource(bookSourceUrl: $0, bookSourceName: $0)
|
||||
source.searchUrl = "/search"
|
||||
return source
|
||||
}
|
||||
|
||||
let candidates = await service.candidates(for: current, sources: sources)
|
||||
|
||||
#expect(candidates.map(\.book.bookURL) == ["new-a", "new-b"])
|
||||
#expect(candidates[0].score > candidates[1].score)
|
||||
}
|
||||
|
||||
@Test func preservesCharacterPositionWhenChapterTitleMatches() {
|
||||
var current = Book(bookUrl: "old", name: "书", origin: "old")
|
||||
current.customTag = "收藏"
|
||||
current.group = 8
|
||||
current.totalChapterNum = 10
|
||||
current.durChapterIndex = 4
|
||||
current.durChapterTitle = "第五章 · 重逢"
|
||||
current.durChapterPos = 388
|
||||
current.durChapterTime = 77
|
||||
var readConfig = BookReadConfig()
|
||||
readConfig.pageAnim = 3
|
||||
readConfig.useReplaceRule = false
|
||||
current.readConfig = readConfig
|
||||
let replacement = Book(bookUrl: "new", name: "书", origin: "new")
|
||||
let chapters = (0..<8).map {
|
||||
BookChapter(url: "n\($0)", title: $0 == 6 ? "第五章 重逢" : "第\($0)章", bookUrl: "new", index: $0)
|
||||
}
|
||||
|
||||
let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100)
|
||||
|
||||
#expect(report.exactChapterMatch)
|
||||
#expect(report.book.durChapterIndex == 6)
|
||||
#expect(report.book.durChapterPos == 388)
|
||||
#expect(report.book.durChapterTime == 77)
|
||||
#expect(report.book.customTag == "收藏")
|
||||
#expect(report.book.group == 8)
|
||||
#expect(report.book.readConfig == current.readConfig)
|
||||
}
|
||||
|
||||
@Test func fallsBackToRelativeProgressAndResetsCharacterOffset() {
|
||||
var current = Book(bookUrl: "old", name: "书", origin: "old")
|
||||
current.totalChapterNum = 11
|
||||
current.durChapterIndex = 5
|
||||
current.durChapterTitle = "旧目录章节"
|
||||
current.durChapterPos = 999
|
||||
let replacement = Book(bookUrl: "new", name: "书", origin: "new")
|
||||
let chapters = (0..<21).map { BookChapter(url: "n\($0)", title: "新\($0)", bookUrl: "new", index: $0) }
|
||||
|
||||
let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100)
|
||||
|
||||
#expect(!report.exactChapterMatch)
|
||||
#expect(report.book.durChapterIndex == 10)
|
||||
#expect(report.book.durChapterPos == 0)
|
||||
}
|
||||
|
||||
@Test func refusesSwitchWhenCandidateDirectoryCannotBeLoaded() async {
|
||||
let repository = InMemoryBookChapterRepository()
|
||||
let service = BookSourceSwitchService(
|
||||
searcher: StubSwitchSearch(report: .init(books: [], outcomes: [])),
|
||||
detailLoader: StubSwitchLoader(chapters: []),
|
||||
chapterLoader: StubSwitchLoader(chapters: []),
|
||||
chapterRepository: repository
|
||||
)
|
||||
let current = Book(bookUrl: "old", name: "书", origin: "old")
|
||||
let candidate = SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书")
|
||||
let source = BookSource(bookSourceUrl: "new", bookSourceName: "新源")
|
||||
|
||||
#expect(await service.prepareSwitch(current: current, candidate: candidate, source: source) == nil)
|
||||
#expect(await repository.load(bookURL: "new").isEmpty)
|
||||
}
|
||||
|
||||
@Test func automaticallySkipsBrokenCandidateAndUsesFirstValidDirectory() async {
|
||||
let broken = SearchBook(bookURL: "broken-book", origin: "broken", originName: "A 坏源", type: 0, name: "书")
|
||||
let valid = SearchBook(bookURL: "valid-book", origin: "valid", originName: "B 好源", type: 0, name: "书")
|
||||
let search = StubSwitchSearch(report: .init(books: [], outcomes: [
|
||||
.init(sourceID: "broken", books: [broken], diagnostics: []),
|
||||
.init(sourceID: "valid", books: [valid], diagnostics: [])
|
||||
]))
|
||||
let loader = CandidateAwareSwitchLoader()
|
||||
let service = BookSourceSwitchService(
|
||||
searcher: search,
|
||||
detailLoader: loader,
|
||||
chapterLoader: loader,
|
||||
contentLoader: SwitchContentLoader(),
|
||||
chapterRepository: InMemoryBookChapterRepository()
|
||||
)
|
||||
let current = Book(bookUrl: "old-book", name: "书", origin: "old")
|
||||
let sources = ["old", "broken", "valid"].map { id in
|
||||
var source = BookSource(bookSourceUrl: id, bookSourceName: id)
|
||||
source.searchUrl = "/search"
|
||||
return source
|
||||
}
|
||||
|
||||
let available = await service.firstAvailableSource(for: current, sources: sources, now: 100)
|
||||
|
||||
#expect(available?.source.bookSourceUrl == "valid")
|
||||
#expect(available?.report.chapters.map(\.title) == ["有效目录"])
|
||||
}
|
||||
|
||||
@Test func refusesCandidateWithDirectoryButNoBodyWithoutSavingItsDirectory() async {
|
||||
let repository = InMemoryBookChapterRepository()
|
||||
let chapter = BookChapter(url: "new/1", title: "第一章", bookUrl: "new", index: 0)
|
||||
let loader = StubSwitchLoader(chapters: [chapter])
|
||||
for body in ["", " \n\t"] {
|
||||
let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader,
|
||||
contentLoader: SwitchContentLoader(body: body), chapterRepository: repository)
|
||||
let report = await service.prepareSwitch(
|
||||
current: Book(bookUrl: "old", name: "书", origin: "old"),
|
||||
candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"),
|
||||
source: BookSource(bookSourceUrl: "new", bookSourceName: "新源")
|
||||
)
|
||||
#expect(report == nil)
|
||||
#expect(await repository.load(bookURL: "new").isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func automaticRecoverySkipsSearchableSourcesWithDirectoriesButBrokenBodies() async throws {
|
||||
let candidates = ["broken-body", "valid"].map {
|
||||
SearchBook(bookURL: $0 + "-book", origin: $0, originName: $0, type: 0, name: "书")
|
||||
}
|
||||
let sources = candidates.map { candidate in
|
||||
var source = BookSource(bookSourceUrl: candidate.origin, bookSourceName: candidate.originName)
|
||||
source.searchUrl = "/search"
|
||||
return source
|
||||
}
|
||||
let loader = StubSwitchLoader(chapters: [BookChapter(url: "chapter", title: "第一章", bookUrl: "new", index: 0)])
|
||||
let service = BookSourceSwitchService(
|
||||
searcher: StubSwitchSearch(report: .init(books: [], outcomes: candidates.map {
|
||||
.init(sourceID: $0.origin, books: [$0], diagnostics: [])
|
||||
})), detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(),
|
||||
chapterRepository: InMemoryBookChapterRepository()
|
||||
)
|
||||
let available = try #require(await service.firstAvailableSource(for: Book(bookUrl: "old", name: "书", origin: "old"), sources: sources))
|
||||
#expect(available.source.id == "valid")
|
||||
#expect(available.report.verifiedContent?.content == "验证后的正文")
|
||||
}
|
||||
|
||||
@Test func validatesMappedChapterWithNextReadableURLAndKeepsReadingPosition() async throws {
|
||||
var current = Book(bookUrl: "old", name: "书", origin: "old")
|
||||
current.durChapterTitle = "第二章"
|
||||
current.durChapterPos = 88
|
||||
var volume = BookChapter(url: "volume", title: "卷二", bookUrl: "new", index: 2)
|
||||
volume.isVolume = true
|
||||
let chapters = [BookChapter(url: "first", title: "第一章", bookUrl: "new", index: 0),
|
||||
BookChapter(url: "second", title: "第二章", bookUrl: "new", index: 1), volume,
|
||||
BookChapter(url: "third", title: "第三章", bookUrl: "new", index: 3)]
|
||||
let loader = StubSwitchLoader(chapters: chapters)
|
||||
let contentLoader = RecordingSwitchContentLoader()
|
||||
let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader, contentLoader: contentLoader,
|
||||
chapterRepository: InMemoryBookChapterRepository())
|
||||
let report = try #require(await service.prepareSwitch(current: current,
|
||||
candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"),
|
||||
source: BookSource(bookSourceUrl: "new", bookSourceName: "新源")))
|
||||
#expect(await contentLoader.chapterURL == "second")
|
||||
#expect(await contentLoader.nextURL == "third")
|
||||
#expect(report.book.durChapterPos == 88)
|
||||
#expect(report.verifiedContent?.content == "验证后的正文")
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwitchContentLoader: ChapterContentLoading {
|
||||
var body = "验证后的正文"
|
||||
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
|
||||
.init(content: source.id == "broken-body" ? "" : body)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingSwitchContentLoader: ChapterContentLoading {
|
||||
var chapterURL: String?
|
||||
var nextURL: String?
|
||||
func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport {
|
||||
chapterURL = chapter.url
|
||||
nextURL = nextChapterURL
|
||||
return .init(content: "验证后的正文")
|
||||
}
|
||||
}
|
||||
|
||||
private struct StreamingSwitchSearch: BookSourceSearching {
|
||||
let stream: AsyncStream<SourceSearchOutcome>
|
||||
func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport {
|
||||
.init(books: [], outcomes: [])
|
||||
}
|
||||
func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> { stream }
|
||||
}
|
||||
|
||||
private struct StubSwitchSearch: BookSourceSearching {
|
||||
let report: BookSearchReport
|
||||
func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport { report }
|
||||
}
|
||||
|
||||
private struct StubSwitchLoader: BookDetailLoading, ChapterListLoading {
|
||||
let chapters: [BookChapter]
|
||||
func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport {
|
||||
.init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, author: searchBook.author, origin: source.bookSourceUrl))
|
||||
}
|
||||
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport {
|
||||
.init(chapters: chapters)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CandidateAwareSwitchLoader: BookDetailLoading, ChapterListLoading {
|
||||
func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport {
|
||||
.init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, origin: source.bookSourceUrl))
|
||||
}
|
||||
|
||||
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport {
|
||||
guard source.bookSourceUrl == "valid" else { return .init(chapters: []) }
|
||||
return .init(chapters: [BookChapter(url: "chapter", title: "有效目录", bookUrl: book.bookUrl, index: 0)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookUpdateCoordinatorTests {
|
||||
@Test func countsOnlyGrowthAndPreservesChapterMetadata() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.totalChapterNum = 2
|
||||
book.durChapterIndex = 1
|
||||
book.durChapterTitle = "第二章 旧雨"
|
||||
var oldFirst = BookChapter(url: "old-1", title: "第一章", bookUrl: "book", index: 0)
|
||||
oldFirst.wordCount = "一千字"
|
||||
oldFirst.variable = #"{"token":"keep"}"#
|
||||
let oldSecond = BookChapter(url: "old-2", title: "第二章 旧雨", bookUrl: "book", index: 1)
|
||||
let incoming = [
|
||||
BookChapter(url: "new-1", title: "第一章", bookUrl: "book", index: 8),
|
||||
BookChapter(url: "new-2", title: "第二章 旧雨", bookUrl: "book", index: 9),
|
||||
BookChapter(url: "new-3", title: "第三章 天晴", bookUrl: "book", index: 10)
|
||||
]
|
||||
|
||||
let result = BookUpdateMerger().merge(
|
||||
book: book,
|
||||
oldChapters: [oldFirst, oldSecond],
|
||||
newChapters: incoming,
|
||||
now: 99
|
||||
)
|
||||
|
||||
#expect(result.addedChapterCount == 1)
|
||||
#expect(result.book.lastCheckCount == 1)
|
||||
#expect(result.book.totalChapterNum == 3)
|
||||
#expect(result.book.latestChapterTitle == "第三章 天晴")
|
||||
#expect(result.book.durChapterIndex == 1)
|
||||
#expect(result.chapters[0].wordCount == "一千字")
|
||||
#expect(result.chapters[0].variable == oldFirst.variable)
|
||||
#expect(result.chapters.map(\.index) == [0, 1, 2])
|
||||
}
|
||||
|
||||
@Test func retainsUnreadCountWhenDirectoryDoesNotGrow() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.totalChapterNum = 2
|
||||
book.lastCheckCount = 4
|
||||
let chapters = [
|
||||
BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0),
|
||||
BookChapter(url: "2", title: "第二章", bookUrl: "book", index: 1)
|
||||
]
|
||||
|
||||
let result = BookUpdateMerger().merge(book: book, oldChapters: chapters, newChapters: chapters, now: 10)
|
||||
|
||||
#expect(result.addedChapterCount == 0)
|
||||
#expect(result.book.lastCheckCount == 4)
|
||||
#expect(result.book.latestChapterTime == 0)
|
||||
#expect(result.book.lastCheckTime == 10)
|
||||
}
|
||||
|
||||
@Test func remapsProgressByTitleAndResetsPositionWhenChapterIsRemoved() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.durChapterIndex = 2
|
||||
book.durChapterTitle = "第二章"
|
||||
book.durChapterPos = 130
|
||||
let old = [
|
||||
BookChapter(url: "notice", title: "后记", bookUrl: "book", index: 0),
|
||||
BookChapter(url: "first", title: "第一章", bookUrl: "book", index: 1),
|
||||
BookChapter(url: "second", title: "第二章", bookUrl: "book", index: 2)
|
||||
]
|
||||
let refreshed = Array(old.dropFirst())
|
||||
|
||||
let remapped = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10)
|
||||
#expect(remapped.book.durChapterIndex == 1)
|
||||
#expect(remapped.book.durChapterPos == 130)
|
||||
|
||||
book.durChapterIndex = 0
|
||||
book.durChapterTitle = "后记"
|
||||
book.durChapterPos = 130
|
||||
let removed = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10)
|
||||
#expect(removed.book.durChapterIndex == 0)
|
||||
#expect(removed.book.durChapterTitle == "第一章")
|
||||
#expect(removed.book.durChapterPos == 0)
|
||||
}
|
||||
|
||||
@Test func coordinatorFallsBackToStoredDirectoryWhenRefreshFails() async throws {
|
||||
let old = [BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0)]
|
||||
let repository = InMemoryBookChapterRepository(values: ["book": old])
|
||||
let loader = StubChapterListLoader(report: .init(
|
||||
chapters: [],
|
||||
diagnostics: [.init(severity: .error, code: "toc.failed", message: "失败", rule: "toc")]
|
||||
))
|
||||
let coordinator = BookUpdateCoordinator(repository: repository, loader: loader)
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let source = BookSource(bookSourceUrl: "source", bookSourceName: "源")
|
||||
|
||||
let result = await coordinator.refresh(book: book, source: source, now: 20)
|
||||
|
||||
#expect(!result.refreshed)
|
||||
#expect(result.chapters == old)
|
||||
#expect(result.diagnostics.first?.code == "toc.failed")
|
||||
}
|
||||
}
|
||||
|
||||
private struct StubChapterListLoader: ChapterListLoading {
|
||||
let report: ChapterListReport
|
||||
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { report }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ChapterContentCacheTests {
|
||||
@Test func savesLoadsAndSeparatesBooks() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
let first = CachedChapterContent(bookURL: "book-a", chapterURL: "same", title: "一", content: "正文 A")
|
||||
let second = CachedChapterContent(bookURL: "book-b", chapterURL: "same", title: "二", content: "正文 B")
|
||||
|
||||
try await cache.save(first)
|
||||
try await cache.save(second)
|
||||
|
||||
#expect(try await cache.load(bookURL: "book-a", chapterURL: "same") == first)
|
||||
#expect(try await cache.load(bookURL: "book-b", chapterURL: "same") == second)
|
||||
let statistics = try await cache.statistics()
|
||||
#expect(statistics.entries == 2)
|
||||
}
|
||||
|
||||
@Test func removesOnlyRequestedBookAndRejectsEmptyEntries() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
try await cache.save(.init(bookURL: "a", chapterURL: "1", content: "A"))
|
||||
try await cache.save(.init(bookURL: "b", chapterURL: "1", content: "B"))
|
||||
|
||||
try await cache.remove(bookURL: "a")
|
||||
|
||||
#expect(try await cache.load(bookURL: "a", chapterURL: "1") == nil)
|
||||
#expect(try await cache.load(bookURL: "b", chapterURL: "1")?.content == "B")
|
||||
await #expect(throws: ChapterContentCacheError.invalidEntry) {
|
||||
try await cache.save(.init(bookURL: "a", chapterURL: "2", content: ""))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func coordinatorUsesDiskCacheBeforeNetwork() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let loader = StubChapterLoader(content: "网络正文")
|
||||
let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory))
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let chapter = BookChapter(url: "chapter", title: "章", bookUrl: book.bookUrl, index: 0)
|
||||
let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源")
|
||||
|
||||
let first = await coordinator.load(book: book, chapter: chapter, source: source)
|
||||
let second = await coordinator.load(book: book, chapter: chapter, source: source)
|
||||
|
||||
#expect(!first.loadedFromCache)
|
||||
#expect(second.loadedFromCache)
|
||||
#expect(second.report.content == "网络正文")
|
||||
#expect(await loader.calls() == 1)
|
||||
}
|
||||
|
||||
@Test func removesOneChapterAndForceRefreshesIt() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ChapterContentCache(directory: directory)
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "one", content: "旧正文"))
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "two", content: "保留正文"))
|
||||
try await cache.remove(bookURL: "book", chapterURL: "one")
|
||||
#expect(try await cache.load(bookURL: "book", chapterURL: "one") == nil)
|
||||
#expect(try await cache.load(bookURL: "book", chapterURL: "two")?.content == "保留正文")
|
||||
|
||||
let loader = StubChapterLoader(content: "刷新正文")
|
||||
let coordinator = ChapterContentCoordinator(loader: loader, cache: cache)
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let chapter = BookChapter(url: "two", title: "章", bookUrl: book.bookUrl, index: 0)
|
||||
let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源")
|
||||
let refreshed = await coordinator.load(book: book, chapter: chapter, source: source, forceRefresh: true)
|
||||
|
||||
#expect(!refreshed.loadedFromCache)
|
||||
#expect(refreshed.report.content == "刷新正文")
|
||||
#expect(await loader.calls() == 1)
|
||||
}
|
||||
|
||||
@Test func coordinatorPrefetchesOnce() async {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let loader = StubChapterLoader(content: "下一章")
|
||||
let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory))
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let chapter = BookChapter(url: "next", title: "下一章", bookUrl: book.bookUrl, index: 1)
|
||||
let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源")
|
||||
|
||||
#expect(await coordinator.prefetch(book: book, chapter: chapter, source: source))
|
||||
#expect(!(await coordinator.prefetch(book: book, chapter: chapter, source: source)))
|
||||
#expect(await loader.calls() == 1)
|
||||
}
|
||||
|
||||
@Test func cachePlannerSelectsRangeAndSkipsVolumeRows() {
|
||||
var chapters = (0..<5).map { BookChapter(url: "c\($0)", title: "第 \($0) 章", bookUrl: "book", index: $0) }
|
||||
chapters[2].isVolume = true
|
||||
|
||||
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: 1, limit: 2)
|
||||
|
||||
#expect(jobs.map(\.chapter.index) == [1, 3])
|
||||
#expect(jobs.map(\.nextChapterURL) == ["c3", "c4"])
|
||||
}
|
||||
|
||||
@Test func cachePlannerClampsStartAndRejectsEmptyLimit() {
|
||||
let chapters = (0..<2).map { BookChapter(url: "c\($0)", title: "章", bookUrl: "book", index: $0) }
|
||||
|
||||
#expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 99).map(\.chapter.index) == [1])
|
||||
#expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 0, limit: 0).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private actor StubChapterLoader: ChapterContentLoading {
|
||||
private let content: String
|
||||
private var callCount = 0
|
||||
|
||||
init(content: String) { self.content = content }
|
||||
|
||||
func loadContent(
|
||||
book: Book,
|
||||
chapter: BookChapter,
|
||||
source: BookSource,
|
||||
nextChapterURL: String?,
|
||||
maxPages: Int
|
||||
) async -> ChapterContentReport {
|
||||
callCount += 1
|
||||
return .init(title: chapter.title, content: content)
|
||||
}
|
||||
|
||||
func calls() -> Int { callCount }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ContentSanitizerTests {
|
||||
@Test func metadataCleanerRemovesHTMLDecodesEntitiesAndKeepsParagraphs() {
|
||||
let input = "<p>第一段 文字</p><div>第二段<br>换行</div><script>bad()</script>"
|
||||
|
||||
let value = MetadataTextSanitizer().clean(input)
|
||||
|
||||
#expect(value == "第一段 文字\n第二段\n换行")
|
||||
#expect(MetadataTextSanitizer().clean(" 纯文本简介 ") == "纯文本简介")
|
||||
#expect(MetadataTextSanitizer().clean("<br><p></p>") == nil)
|
||||
}
|
||||
|
||||
@Test func appliesOrderedScopedRulesAndRemovesDuplicateTitle() {
|
||||
let book = Book(bookUrl: "https://source.test/book/1", name: "测试书", origin: "https://source.test")
|
||||
let chapter = BookChapter(url: "chapter/1", title: "第一章 开始", bookUrl: book.bookUrl, index: 0)
|
||||
var titleRule = ReplaceRule(id: 1, name: "标题", pattern: "开始", replacement: "启程")
|
||||
titleRule.scopeTitle = true
|
||||
titleRule.scopeContent = false
|
||||
titleRule.scope = "测试书"
|
||||
var adRule = ReplaceRule(id: 2, name: "广告", pattern: "广告[^\n]*", replacement: "")
|
||||
adRule.scopeContent = true
|
||||
adRule.sortOrder = 1
|
||||
var excluded = ReplaceRule(id: 3, name: "排除", pattern: "正文", replacement: "错误")
|
||||
excluded.excludeScope = "https://source.test"
|
||||
|
||||
let report = ContentSanitizer().process(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
content: "第一章 开始\n 正文第一段 \n广告请访问 example.com\n正文第二段",
|
||||
rules: [excluded, adRule, titleRule]
|
||||
)
|
||||
|
||||
#expect(report.title == "第一章 启程")
|
||||
#expect(report.content == "正文第一段\n\n正文第二段")
|
||||
#expect(report.removedDuplicateTitle)
|
||||
#expect(report.effectiveRuleIDs == [1, 2])
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func rejectsHighRiskRegexWithoutChangingContent() {
|
||||
let book = Book(bookUrl: "local", name: "书", origin: "local")
|
||||
let chapter = BookChapter(url: "1", title: "章节", bookUrl: book.bookUrl, index: 0)
|
||||
let risky = ReplaceRule(id: 7, name: "危险", pattern: "(a+)+$", replacement: "")
|
||||
|
||||
let report = ContentSanitizer().process(book: book, chapter: chapter, content: "aaaa!", rules: [risky])
|
||||
|
||||
#expect(report.content == "aaaa!")
|
||||
#expect(report.diagnostics.contains { $0.code == "rule.resource_limit" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct DiagnosticLogTests {
|
||||
@Test func boundedJournalSurvivesRelaunchAndCanBeCleared() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let log = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true })
|
||||
log.record(category: "test", message: "first")
|
||||
log.record(.warning, category: "test", message: "second")
|
||||
log.record(.error, category: "test", message: "third")
|
||||
let restored = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true })
|
||||
#expect(restored.snapshot().map(\.message) == ["third", "second"])
|
||||
#expect(restored.export().contains("[ERROR] [test] third"))
|
||||
#expect(!restored.export().contains("first"))
|
||||
try restored.clear()
|
||||
#expect(restored.snapshot().isEmpty)
|
||||
#expect(DiagnosticLog(directory: directory).snapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func disabledLoggingAndSensitiveRequestFields() {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let disabled = DiagnosticLog(directory: directory, isEnabled: { false })
|
||||
disabled.record(category: "JS", message: "secret")
|
||||
#expect(disabled.snapshot().isEmpty)
|
||||
let log = DiagnosticLog(directory: directory, isEnabled: { true })
|
||||
var request = URLRequest(url: URL(string: "https://user:pass@example.com/chapter?token=TOP_SECRET#private")!)
|
||||
request.setValue("Bearer PRIVATE_HEADER", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = Data("PRIVATE_BODY".utf8)
|
||||
log.network(request, status: 200, startedAt: Date())
|
||||
log.record(category: "JS", message: "token=PRIVATE_TOKEN password=PRIVATE_PASS https://example.com/?key=PRIVATE_QUERY")
|
||||
log.record(category: "JS", message: "Authorization: Bearer PRIVATE_AUTH")
|
||||
log.record(category: "JS", message: #"{"password": "PRIVATE_JSON"}"#)
|
||||
let exported = log.export()
|
||||
for secret in ["TOP_SECRET", "private", "user:pass", "PRIVATE_HEADER", "PRIVATE_BODY", "PRIVATE_TOKEN", "PRIVATE_PASS", "PRIVATE_QUERY", "PRIVATE_AUTH", "PRIVATE_JSON"] {
|
||||
#expect(!exported.contains(secret))
|
||||
}
|
||||
#expect(exported.contains("https://example.com/chapter"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# 规则测试样本
|
||||
|
||||
这些文件是自动化测试输入,不是 App 的内置书源库。测试包通过 SwiftPM 单独复制该目录,App 目标不会打包它。
|
||||
|
||||
- `synthetic-html-rules.json`:最小 HTML 规则,使用保留域名 `fixture.invalid`,验证倒序目录、裸字段提取和带章节变量的内容净化。网络响应在测试中自造,不访问真实网站。
|
||||
- `legado-js-audio-source.json`:脱敏 JS 听书规则结构样本。地址使用 `fixture.invalid`,用于测试导入字段及脚本保留,不在常规导入测试中请求网络或执行脚本。
|
||||
|
||||
新增样本优先自造数据;第三方材料须保留版权与许可。不要把账号凭据或真实付费内容放入 fixture。
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"bookSourceUrl": "https://fixture.invalid",
|
||||
"bookSourceName": "脱敏 JS 听书源",
|
||||
"bookSourceGroup": "兼容夹具",
|
||||
"bookSourceType": 1,
|
||||
"enabled": true,
|
||||
"enabledExplore": true,
|
||||
"searchUrl": "https://fixture.invalid/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}",
|
||||
"ruleSearch": {
|
||||
"bookList": "$.content.content",
|
||||
"name": "$.title",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.id@js:java.put('bookId', result);'/toc?bookId='+result",
|
||||
"coverUrl": "$.cover@js:var cover=JSON.parse(result);'/file/'+cover.path"
|
||||
},
|
||||
"ruleBookInfo": {},
|
||||
"ruleToc": {
|
||||
"chapterList": "$.content.content",
|
||||
"chapterName": "$.chapterTitle",
|
||||
"chapterUrl": "$.id@js:java.put('chapterId', result);'/audio?bookId='+java.get('bookId')+'&chapterId='+result"
|
||||
},
|
||||
"ruleContent": {
|
||||
"content": "@js:result"
|
||||
},
|
||||
"ruleReview": {
|
||||
"reviewUrl": "/review/{{chapterId}}",
|
||||
"contentRule": "$.content",
|
||||
"postTimeRule": "$.time"
|
||||
},
|
||||
"eventListener": false,
|
||||
"customButton": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"bookSourceName": "合成 HTML 规则",
|
||||
"bookSourceUrl": "https://fixture.invalid",
|
||||
"ruleToc": {
|
||||
"chapterList": "id.chapter-list.0@tag.a[-1:0]",
|
||||
"chapterName": "text",
|
||||
"chapterUrl": "href"
|
||||
},
|
||||
"ruleContent": {
|
||||
"content": "class.chapter-body@html",
|
||||
"replaceRegex": "##(?m)^(?:第一章|信息|{{book.durChapterTitle}}|\\(本章完\\))\\s*$"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct HTMLRuleEvaluatorTests {
|
||||
private let evaluator = HTMLRuleEvaluator()
|
||||
private let html = """
|
||||
<!doctype html><html><body>
|
||||
<ul id="books">
|
||||
<li class="book"><a href="/a"><span class="title">A & One</span></a><p>Intro <b>A</b></p></li>
|
||||
<li class="book"><a href="/b"><span class="title">B</span></a><p>Intro B</p></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@Test func legacyListChainsPreserveTableRowsAndCurrentElementOutputs() throws {
|
||||
let page = "<table class='grid'><tr><th>书名</th></tr><tr data-id='42'><td><a href='/42'>测试书</a></td><td>作者</td></tr></table>"
|
||||
let rows = try evaluator.selectCSSFragments("class.grid@tag.tr!0", html: page)
|
||||
#expect(rows.count == 1)
|
||||
let row = try #require(rows.first)
|
||||
#expect(try evaluator.extractCSS("tag.td.0@text", html: row, isFragment: true) == ["测试书"])
|
||||
#expect(try evaluator.extractCSS("data-id", html: row, isFragment: true) == ["42"])
|
||||
let links = try evaluator.selectCSSFragments("tag.a", html: row, isFragment: true)
|
||||
let link = try #require(links.first)
|
||||
#expect(try evaluator.extractCSS("text", html: link, isFragment: true) == ["测试书"])
|
||||
#expect(try evaluator.extractCSS("href", html: link, isFragment: true) == ["/42"])
|
||||
#expect(try evaluator.extractXPath("./@href", html: link, isFragment: true) == ["/42"])
|
||||
#expect(try evaluator.extractXPath("./text()", html: link, isFragment: true) == ["测试书"])
|
||||
}
|
||||
|
||||
@Test func selectsCSSFragmentsAndExtractsOutputs() throws {
|
||||
let fragments = try evaluator.selectCSSFragments("li.book", html: html)
|
||||
|
||||
#expect(fragments.count == 2)
|
||||
#expect(try evaluator.extractCSS(".title@text", html: fragments[0]) == ["A & One"])
|
||||
#expect(try evaluator.extractCSS("a@href", html: fragments[0]) == ["/a"])
|
||||
#expect(try evaluator.extractCSS("p@html", html: fragments[0]) == ["<p>Intro <b>A</b></p>"])
|
||||
#expect(try evaluator.extractCSS("a@all", html: fragments[0]).first?.contains("href=\"/a\"") == true)
|
||||
}
|
||||
|
||||
@Test func selectsXPathFragmentsAndScalarValues() throws {
|
||||
let fragments = try evaluator.selectXPathFragments("//li[@class='book']", html: html)
|
||||
|
||||
#expect(fragments.count == 2)
|
||||
#expect(try evaluator.extractXPath("//span[@class='title']/text()", html: fragments[1]) == ["B"])
|
||||
#expect(try evaluator.extractXPath("count(//li[@class='book'])", html: html) == ["2"])
|
||||
}
|
||||
|
||||
@Test func ruleEngineAppliesHTMLCombination() async {
|
||||
let engine = RuleEngine()
|
||||
|
||||
let result = await engine.evaluate("@CSS:.missing@text||@CSS:.title@text", inText: html)
|
||||
|
||||
#expect(result.strings == ["A & One", "B"])
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func supportsLegadoChainAndIndexedSelectors() throws {
|
||||
#expect(try evaluator.extractCSS("id.books@tag.li[1]@class.title@text", html: html) == ["B"])
|
||||
#expect(try evaluator.extractCSS("li.book[-1:0]@class.title@text", html: html) == ["B", "A & One"])
|
||||
#expect(try evaluator.extractCSS("li.book[!0]@class.title@text", html: html) == ["B"])
|
||||
}
|
||||
|
||||
@Test func supportsLegacyOutputOnlyRulesOnCurrentDocument() throws {
|
||||
#expect(try evaluator.extractCSS("@text", html: "<main><b>正文</b></main>") == ["正文"])
|
||||
#expect(try evaluator.extractCSS("@html", html: "<main><b>正文</b></main>").first?.contains("<main>") == true)
|
||||
#expect(try evaluator.extractCSS("meta@content", html: "<!doctype html><html><head><meta content='书名'></head><body>正文</body></html>", isFragment: true) == ["书名"])
|
||||
}
|
||||
|
||||
@Test func reportsSwiftSoupSelectorMessageInsteadOfOpaqueNSError() throws {
|
||||
do {
|
||||
_ = try evaluator.extractCSS("div[", html: html)
|
||||
Issue.record("应拒绝未闭合的 CSS selector")
|
||||
} catch let error as RuleEngineError {
|
||||
#expect(!error.localizedDescription.contains("SwiftSoup.Exception error 0"))
|
||||
#expect(error.localizedDescription.contains("CSS 规则执行失败"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func supportsLegacyDiscreteNegativeIndexes() throws {
|
||||
#expect(try evaluator.extractCSS("tag.li.-1:0@class.title@text", html: html) == ["B", "A & One"])
|
||||
#expect(try evaluator.selectCSSFragments("li.book[-1]", html: html).count == 1)
|
||||
}
|
||||
|
||||
@Test func htmlAndAllCombineOuterHTMLAndDeduplicateAttributes() throws {
|
||||
let value = """
|
||||
<div class="item"><script>bad()</script><span>A</span></div>
|
||||
<div class="item"><style>.bad{}</style><span>B</span></div>
|
||||
<a class="same" href="/same">one</a><a class="same" href="/same">two</a>
|
||||
"""
|
||||
|
||||
let sanitized = try evaluator.extractCSS(".item@html", html: value)
|
||||
let all = try evaluator.extractCSS(".item@all", html: value)
|
||||
let links = try evaluator.extractCSS("a.same@href", html: value)
|
||||
|
||||
#expect(sanitized.count == 1)
|
||||
#expect(sanitized[0].contains("<div class=\"item\">") == true)
|
||||
#expect(sanitized[0].contains("script") == false)
|
||||
#expect(sanitized[0].contains("style") == false)
|
||||
#expect(all.count == 1)
|
||||
#expect(links == ["/same"])
|
||||
}
|
||||
|
||||
@Test func negativeRangeStepUsesListLengthAdjustment() throws {
|
||||
let value = "<p>0</p><p>1</p><p>2</p><p>3</p><p>4</p>"
|
||||
|
||||
#expect(try evaluator.extractCSS("p[0:4:-2]@text", html: value) == ["0", "3"])
|
||||
}
|
||||
|
||||
@Test func rangeWhollyOutsideOneSideIsIgnoredInsteadOfClamped() throws {
|
||||
let value = "<p>0</p><p>1</p><p>2</p>"
|
||||
|
||||
#expect(try evaluator.extractCSS("p[8:9]@text", html: value).isEmpty)
|
||||
#expect(try evaluator.extractCSS("p[-9:-8]@text", html: value).isEmpty)
|
||||
#expect(try evaluator.extractCSS("p[-9:1]@text", html: value) == ["0", "1"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct HostsHTTPTransportTests {
|
||||
let url = URL(string: "https://books.example.com/chapter")!
|
||||
|
||||
@Test func handlesPartialContentLengthAndHeadResponses() throws {
|
||||
let partial = Data("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel".utf8)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(partial, url: url) == nil)
|
||||
var complete = partial
|
||||
complete.append(Data("lo".utf8))
|
||||
let response = try HostsHTTPResponseDecoder.decode(complete, url: url)
|
||||
#expect(response?.decodedText == "hello")
|
||||
#expect(response?.finalURL == url)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(partial, url: url, method: "HEAD")?.data.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test func handlesChunkExtensionsTrailersAndInterimResponses() throws {
|
||||
let raw = "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3;ext=a\r\nhel\r\n2\r\nlo\r\n0\r\nX-Trailer: yes\r\n\r\n"
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url)?.decodedText == "hello")
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.dropLast(2).utf8), url: url) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsMalformedFramesAndHandlesConnectionClose() throws {
|
||||
let raw = "HTTP/1.1 200 OK\r\n\r\nbody"
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url) == nil)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url, complete: true)?.decodedText == "body")
|
||||
for malformed in ["Content-Length: -1\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n-1\r\n", "Transfer-Encoding: chunked\r\n\r\n1\r\nxXX"] {
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try HostsHTTPResponseDecoder.decode(Data(("HTTP/1.1 200 OK\r\n" + malformed).utf8), url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func hostsOverrideConnectsToIPAndRetainsOriginHost() async throws {
|
||||
let server = try HostsLoopbackServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
let origin = URL(string: "http://books.invalid:\(port)/chapter?lang=zh")!
|
||||
let response = try await HostsHTTPTransport.data(for: URLRequest(url: origin, timeoutInterval: 3), address: "127.0.0.1", ignoreCertificateErrors: false)
|
||||
#expect(response.finalURL == origin)
|
||||
#expect(response.decodedText.contains("GET /chapter?lang=zh HTTP/1.1"))
|
||||
#expect(response.decodedText.contains("Host: books.invalid:\(port)"))
|
||||
}
|
||||
|
||||
@Test func redirectIntoHostsUsesOverrideAndDropsCrossOriginCredentials() async throws {
|
||||
let server = try HostsLoopbackServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
let suite = "legado.hosts.redirect.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
var settings = ApplicationSettings()
|
||||
settings.hosts = "127.0.0.1 books.invalid"
|
||||
try store.save(settings)
|
||||
let transport = URLSessionHTTPTransport(settingsStore: store)
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/redirect")!, timeoutInterval: 3)
|
||||
request.setValue("Bearer PRIVATE", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("origin=PRIVATE", forHTTPHeaderField: "Cookie")
|
||||
let response = try await transport.data(for: request)
|
||||
#expect(response.finalURL.host == "books.invalid")
|
||||
#expect(response.finalURL.path == "/chapter")
|
||||
#expect(response.decodedText.contains("Host: books.invalid:\(port)"))
|
||||
#expect(!response.decodedText.contains("PRIVATE"))
|
||||
}
|
||||
|
||||
@Test func databaseCompactionPreservesUserData() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "test.sqlite3"))
|
||||
let sources = [BookSource(bookSourceUrl: "https://example.com", bookSourceName: "test")]
|
||||
try await database.replaceBookSources(sources)
|
||||
try await database.compact()
|
||||
#expect(try await database.loadBookSources() == sources)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HostsLoopbackServer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "legado.hosts.loopback.test")
|
||||
private let listener: NWListener
|
||||
private var continuation: CheckedContinuation<UInt16, Error>?
|
||||
|
||||
init() throws {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
||||
listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
func start() async throws -> UInt16 {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
self.continuation = continuation
|
||||
self.listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self, let continuation = self.continuation else { return }
|
||||
if case .ready = state, let port = self.listener.port {
|
||||
self.continuation = nil
|
||||
continuation.resume(returning: port.rawValue)
|
||||
} else if case .failed(let error) = state {
|
||||
self.continuation = nil
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
self.listener.newConnectionHandler = { connection in
|
||||
connection.start(queue: self.queue)
|
||||
Self.receive(connection, buffer: Data())
|
||||
}
|
||||
self.listener.start(queue: self.queue)
|
||||
self.queue.asyncAfter(deadline: .now() + 3) {
|
||||
guard let continuation = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
self.listener.cancel()
|
||||
continuation.resume(throwing: URLError(.timedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() { listener.cancel() }
|
||||
|
||||
private static func receive(_ connection: NWConnection, buffer: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, complete, error in
|
||||
var buffer = buffer
|
||||
if let data { buffer.append(data) }
|
||||
if buffer.range(of: Data("\r\n\r\n".utf8)) != nil {
|
||||
let text = String(decoding: buffer, as: UTF8.self)
|
||||
var response: Data
|
||||
if text.hasPrefix("GET /redirect "), let port = connection.currentPath?.localEndpoint,
|
||||
case .hostPort(_, let localPort) = port {
|
||||
response = Data("HTTP/1.1 302 Found\r\nLocation: http://books.invalid:\(localPort.rawValue)/chapter\r\nContent-Length: 0\r\n\r\n".utf8)
|
||||
} else {
|
||||
response = Data("HTTP/1.1 200 OK\r\nContent-Length: \(buffer.count)\r\n\r\n".utf8)
|
||||
response.append(buffer)
|
||||
}
|
||||
connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })
|
||||
} else if complete || error != nil || buffer.count > 65536 { connection.cancel() }
|
||||
else { receive(connection, buffer: buffer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct ImportedSourceTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let text: String
|
||||
switch url.path {
|
||||
case "/search":
|
||||
text = #"{"books":[{"name":"第一本","url":"/book/1"},{"name":"第二本","url":"/book/2"}]}"#
|
||||
case "/book/2":
|
||||
text = #"<h1>第二本</h1><a class="toc" href="/toc/2">目录</a>"#
|
||||
case "/toc/2":
|
||||
text = #"<li><a href="/chapter/2">第一章</a></li>"#
|
||||
case "/chapter/2": text = "<article>导入后正文解析成功</article>"
|
||||
default: throw URLError(.fileDoesNotExist)
|
||||
}
|
||||
return .init(data: Data(text.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportedSourcePipelineTests {
|
||||
@Test func importedAndEditedSourceRunsSearchDetailTocAndContentInOrder() async throws {
|
||||
let json = #"{"bookSourceUrl":"https://pipeline.test","bookSourceName":"导入源","searchUrl":"/search?q={{key}}","ruleSearch":"{\"bookList\":\"-$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\"}","ruleBookInfo":{"name":"@CSS:h1@text","tocUrl":"@CSS:a.toc@href"},"ruleToc":{"chapterList":"@CSS:li","chapterName":"@CSS:a@text","chapterUrl":"@CSS:a@href"},"ruleContent":{"content":"@CSS:missing@text","future":true}}"#
|
||||
let imported = try #require(BookSourceImporter().decode(Data(json.utf8)).first)
|
||||
var draft = try BookSourceEditorDocument(source: imported)
|
||||
draft.set(.string("@CSS:article@text"), at: "ruleContent.content")
|
||||
let saved = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(draft.source()))
|
||||
let report = await BookSourceDebugger(transport: ImportedSourceTransport()).run(source: saved, keyword: "任意")
|
||||
#expect(report.passed)
|
||||
#expect(report.sampleBookName == "第二本")
|
||||
#expect(report.sampleChapterTitle == "第一章")
|
||||
#expect(report.contentPreview == "导入后正文解析成功")
|
||||
#expect(saved.additionalFields["ruleContent"]?["future"] == .bool(true))
|
||||
}
|
||||
|
||||
@Test func plusListPrefixKeepsSearchOrder() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://pipeline.test", bookSourceName: "正序")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "+$.books", name: "$.name", bookUrl: "$.url")
|
||||
var iterator = BookSearchService(transport: ImportedSourceTransport()).searchOutcomes(keyword: "任意", sources: [source]).makeAsyncIterator()
|
||||
let outcome = try #require(await iterator.next())
|
||||
#expect(outcome.books.map(\.name) == ["第一本", "第二本"])
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct JSONPathEvaluatorTests {
|
||||
private let evaluator = JSONPathEvaluator()
|
||||
|
||||
private var fixture: JSONValue {
|
||||
get throws {
|
||||
try JSONValue.decode(data: Data(
|
||||
"""
|
||||
{
|
||||
"store": {
|
||||
"books": [
|
||||
{"title":"A","price":10,"tags":["x","y"]},
|
||||
{"title":"B","price":20,"tags":["z"]},
|
||||
{"title":"C","price":30,"tags":[]}
|
||||
]
|
||||
},
|
||||
"title":"Root"
|
||||
}
|
||||
""".utf8
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func evaluatesChildrenWildcardsAndNegativeIndex() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[*].title", in: fixture).map(\.stringValue) == ["A", "B", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[-1].title", in: fixture).map(\.stringValue) == ["C"])
|
||||
}
|
||||
|
||||
@Test func evaluatesUnionSliceAndRecursiveDescent() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[0,2].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[0:3:2].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$..title", in: fixture).map(\.stringValue) == ["Root", "A", "B", "C"])
|
||||
}
|
||||
|
||||
@Test func filtersByComparisonExistenceAndBooleanLogic() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.price > 10)].title", in: fixture).map(\.stringValue) == ["B", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.price >= 20 && @.price < 30)].title", in: fixture).map(\.stringValue) == ["B"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.tags[0])].title", in: fixture).map(\.stringValue) == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func filtersByRegexRootReferenceAndNestedBrackets() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.title =~ /a|b/i)].title", in: fixture).map(\.stringValue) == ["A", "B"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.title != $.title && (@.price == 10 || @.price == 30))].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct JavaScriptNetworkMock: JavaScriptNetworkLoading {
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
#expect(request.url?.absoluteString == "https://js-network.example.test/api?page=2")
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(String(data: request.httpBody ?? Data(), encoding: .utf8) == #"{"id":7}"#)
|
||||
#expect(request.value(forHTTPHeaderField: "X-Source") == "source")
|
||||
#expect(request.value(forHTTPHeaderField: "X-Extra") == "extra")
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data(#"{"ok":true}"#.utf8),
|
||||
statusCode: 201,
|
||||
headers: ["X-Reply": "yes"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RetryingLegacyJavaScriptNetworkMock: JavaScriptNetworkLoading, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
lock.lock()
|
||||
count += 1
|
||||
let current = count
|
||||
lock.unlock()
|
||||
if current == 1 {
|
||||
return .init(
|
||||
data: Data("temporary".utf8),
|
||||
statusCode: 503,
|
||||
headers: [:],
|
||||
finalURL: try #require(request.url)
|
||||
)
|
||||
}
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie")?.contains("explicit=yes") == true)
|
||||
let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030"))
|
||||
return .init(
|
||||
data: try #require("旧编码辅助请求".data(using: encoding)),
|
||||
statusCode: 200,
|
||||
headers: ["Set-Cookie": "session=js-ok; Path=/"],
|
||||
finalURL: try #require(request.url)
|
||||
)
|
||||
}
|
||||
|
||||
func calls() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private struct JavaScriptAjaxAllMock: JavaScriptNetworkLoading {
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data("body:\(url.lastPathComponent)".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class JavaScriptDirectRequestMock: JavaScriptRedirectNetworkLoading, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var recorded: [(URLRequest, Bool)] = []
|
||||
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
try load(request, followRedirects: true)
|
||||
}
|
||||
|
||||
func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse {
|
||||
lock.lock()
|
||||
recorded.append((request, followRedirects))
|
||||
lock.unlock()
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data("reply-\(request.httpMethod ?? "")".utf8),
|
||||
statusCode: request.httpMethod == "GET" ? 302 : 200,
|
||||
headers: ["X-Reply": "direct"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
|
||||
func calls() -> [(URLRequest, Bool)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
}
|
||||
|
||||
struct JavaScriptRuleEvaluatorTests {
|
||||
@Test func decryptsSourceContentUsingRhinoPackageImportsAndAESByteArrays() throws {
|
||||
// OpenSSL AES-128-CBC/PKCS7 fixture, with a 16-byte IV prepended.
|
||||
let script = #"""
|
||||
var javaImport = new JavaImporter();
|
||||
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
|
||||
Packages.javax.crypto, Packages.java.util);
|
||||
with (javaImport) {
|
||||
var encrypted = Base64.getDecoder().decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
|
||||
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
|
||||
var iv = IvParameterSpec(Arrays.copyOfRange(encrypted, 0, 16));
|
||||
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, iv);
|
||||
var decoded = String(cipher.doFinal(Arrays.copyOfRange(encrypted, 16, encrypted.length)));
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
|
||||
var encoded = Base64.getEncoder().encodeToString(cipher.doFinal(decoded.getBytes()));
|
||||
decoded + '|' + encoded;
|
||||
}
|
||||
"""#
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
#expect(value.value.stringValue == "第一段正文\n第二段正文。|uB3e+ZcJb8o8pStt5rXNGONNFrAK5INFVB+WH+YdxgC/+CbXYZApQvQ2GK2xxkx3")
|
||||
}
|
||||
|
||||
@Test func javaCipherRejectsInvalidInputsAndUnsupportedPackageImports() throws {
|
||||
for script in [
|
||||
"var i = new JavaImporter(); i.importPackage(Packages.java.io);",
|
||||
"Packages.javax.crypto.Cipher.getInstance('DES/ECB/NoPadding');",
|
||||
"var i = new JavaImporter(Packages.java.lang, Packages.javax.crypto.spec, Packages.javax.crypto); with(i) { var c = Cipher.getInstance('AES/CBC/PKCS5Padding'); c.init(2, SecretKeySpec(String('short').getBytes(), 'AES'), IvParameterSpec(String('0123456789abcdef').getBytes())); c.doFinal([1, 2, 3]); }"
|
||||
] {
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func importedAESHelperDeclaredInsideWithRetainsJavaStringByteDecoding() throws {
|
||||
let script = #"""
|
||||
var javaImport = new JavaImporter();
|
||||
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
|
||||
Packages.javax.crypto, Packages.java.util);
|
||||
with (javaImport) {
|
||||
function decode(content) {
|
||||
var bytes = Base64.getDecoder().decode(String(content));
|
||||
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
|
||||
var iv = IvParameterSpec(Arrays.copyOfRange(bytes, 0, 16));
|
||||
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
|
||||
cipher.init(2, key, iv);
|
||||
return String(cipher.doFinal(Arrays.copyOfRange(bytes, 16, bytes.length)));
|
||||
}
|
||||
}
|
||||
decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
|
||||
"""#
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
#expect(value.value.stringValue == "第一段正文\n第二段正文。")
|
||||
}
|
||||
|
||||
@Test func supportsBundledHttpTTSJavaImporterHmacSHA1Script() throws {
|
||||
let script = #"""
|
||||
var aly = new JavaImporter(
|
||||
Packages.javax.crypto.Mac,
|
||||
Packages.javax.crypto.spec.SecretKeySpec,
|
||||
Packages.javax.xml.bind.DatatypeConverter,
|
||||
Packages.java.net.URLEncoder,
|
||||
Packages.java.lang.String,
|
||||
Packages.android.util.Base64
|
||||
);
|
||||
with (aly) {
|
||||
var mac = Mac.getInstance('HmacSHA1');
|
||||
mac.init(new SecretKeySpec(String('key').getBytes('UTF-8'), 'HmacSHA1'));
|
||||
var signature = Base64.encodeToString(
|
||||
mac.doFinal(String('The quick brown fox jumps over the lazy dog').getBytes('UTF-8')),
|
||||
Base64.NO_WRAP
|
||||
);
|
||||
signature + ':' + URLEncoder.encode('a b*~中', 'UTF-8');
|
||||
}
|
||||
"""#
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "3nybhbi3iqa8ino29wqQcBydtNk=:a+b*%7E%E4%B8%AD")
|
||||
}
|
||||
|
||||
@Test func javaImporterDoesNotExposeArbitraryNativeClasses() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "typeof Packages.java.io + ':' + typeof Packages.java.lang.Runtime + ':' + Object.isFrozen(Packages)",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "undefined:undefined:true")
|
||||
}
|
||||
|
||||
@Test func exposesDollarAsCurrentJSONForLegadoInlineExpressions() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "$.book_id",
|
||||
result: .object(["book_id": .string("42")]),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "42")
|
||||
}
|
||||
|
||||
@Test func exposesJavaGetStringRuleBridge() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let html = #"<article><a href="/chapter/1">第一章</a></article>"#
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.getString('a@text', result) + ':' + java.getString('a@href', result, true)",
|
||||
result: .string(html),
|
||||
executionContext: .init(baseURL: "https://reader.example/book/")
|
||||
)
|
||||
let directJSON = try evaluator.evaluate(
|
||||
script: "java.getString('name', result) + ':' + java.getString('$.id', result)",
|
||||
result: .object(["name": .string("书名"), "id": .number(42)]),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "第一章:https://reader.example/chapter/1")
|
||||
#expect(directJSON.value.stringValue == "书名:42")
|
||||
}
|
||||
|
||||
@Test func jsonParseKeepsPlainTextResponsesButStillRejectsBrokenJSON() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let plain = try evaluator.evaluate(
|
||||
script: "JSON.parse(result)",
|
||||
result: .string("The server is temporarily unavailable"),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(plain.value.stringValue == "The server is temporarily unavailable")
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
_ = try evaluator.evaluate(
|
||||
script: "JSON.parse(result)",
|
||||
result: .string(#"{"broken":}"#),
|
||||
executionContext: .init()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func directJavaGetResolvesRelativeURLAndAppliesSourceHeaders() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://direct.example.test/base/", bookSourceName: "相对请求")
|
||||
source.header = #"{"X-Source":"yes"}"#
|
||||
let loader = JavaScriptDirectRequestMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
|
||||
_ = try evaluator.evaluate(
|
||||
script: "java.get('/chapter/1').body()",
|
||||
result: .null,
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
|
||||
let call = try #require(loader.calls().first)
|
||||
#expect(call.0.url?.absoluteString == "https://direct.example.test/chapter/1")
|
||||
#expect(call.0.value(forHTTPHeaderField: "X-Source") == "yes")
|
||||
#expect(call.0.value(forHTTPHeaderField: "User-Agent") != nil)
|
||||
}
|
||||
|
||||
@Test func loadsSharedSourceJavaScriptLibrary() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://js-lib.example.test", bookSourceName: "共享脚本源")
|
||||
source.jsLib = "function decorate(value) { return 'lib:' + value; }"
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "decorate(result)",
|
||||
result: .string("ok"),
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "lib:ok")
|
||||
}
|
||||
@Test func providesAjaxAndConnectWithLegadoURLOptions() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://js-network.example.test", bookSourceName: "JS 网络源")
|
||||
source.header = #"{"X-Source":"source"}"#
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptNetworkMock())
|
||||
let url = #"/api?page=2,{"method":"POST","body":{"id":7},"headers":{"X-Extra":"extra"}}"#
|
||||
|
||||
let ajax = try evaluator.evaluate(
|
||||
script: "java.ajax(result)",
|
||||
result: .string(url),
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
let connect = try evaluator.evaluate(
|
||||
script: "var r=java.connect(result); r.body + ':' + r.code + ':' + r.headers['X-Reply']",
|
||||
result: .string(url),
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
|
||||
#expect(ajax.value.stringValue == #"{"ok":true}"#)
|
||||
#expect(connect.value.stringValue == #"{"ok":true}:201:yes"#)
|
||||
}
|
||||
@Test func ajaxUsesCharsetHTTPRetryCookieMergeAndBodyJavaScript() throws {
|
||||
let host = "https://legacy-js.example.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
LegadoCookieStore.shared.setCookie("stored=one; Path=/", for: host)
|
||||
var source = BookSource(bookSourceUrl: host, bookSourceName: "旧编码 JS 源")
|
||||
source.enabledCookieJar = true
|
||||
let loader = RetryingLegacyJavaScriptNetworkMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
let rule = #"/api, {"charset":"gb18030","retry":1,"headers":{"Cookie":"explicit=yes"},"bodyJs":"result.replace('辅助', '内部')"}"#
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.ajax(result) + ':' + java.getCookie('\(host)', 'session')",
|
||||
result: .string(rule),
|
||||
executionContext: .init(baseURL: host, source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "旧编码内部请求:js-ok")
|
||||
#expect(loader.calls() == 2)
|
||||
}
|
||||
@Test func readsAndWritesSharedCookies() throws {
|
||||
let host = "https://cookie-js-bridge.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.setCookie('\(host)', 'token=abc; Path=/'); java.getCookie('\(host)', 'token') + ':' + java.getCookie('\(host)').indexOf('token=abc')",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:0")
|
||||
#expect(LegadoCookieStore.shared.cookieHeader(for: host).contains("token=abc"))
|
||||
}
|
||||
@Test func exposesGlobalCookieStore() throws {
|
||||
// Use a separate registrable domain because Swift Testing runs these
|
||||
// cookie-store tests concurrently.
|
||||
let host = "https://global-cookie.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "cookie.setCookie('\(host)', 'token=abc; Path=/'); var map=cookie.cookieToMap(cookie.getCookie('\(host)')); var header=cookie.mapToCookie(map); cookie.getKey('\(host)', 'token') + ':' + header",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:token=abc")
|
||||
|
||||
let removed = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "cookie.removeCookie('\(host)'); cookie.getCookie('\(host)')",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
#expect(removed.value.stringValue.isEmpty)
|
||||
}
|
||||
@Test func ajaxAllReturnsResponseObjectsInInputOrder() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptAjaxAllMock())
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "var rows=java.ajaxAll(['https://ajax-all.test/a','https://ajax-all.test/b'], false); rows[0].body + '|' + rows[1].url",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "body:a|https://ajax-all.test/b")
|
||||
}
|
||||
@Test func getHeadPostExposeJsoupStyleResponseAndDisableRedirects() throws {
|
||||
let loader = JavaScriptDirectRequestMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "var g=java.get('https://direct.test/get', {'X-Test':'yes'}, 5000); var h=java.head('https://direct.test/head', {}); var p=java.post('https://direct.test/post', 'raw=正文', {'Content-Type':'text/plain'}); g.statusCode() + ':' + g.header('x-reply') + ':' + h.body() + ':' + p.body()",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
let calls = loader.calls()
|
||||
#expect(value.value.stringValue == "302:direct:reply-HEAD:reply-POST")
|
||||
#expect(calls.count == 3)
|
||||
#expect(calls.allSatisfy { !$0.1 })
|
||||
#expect(calls[0].0.value(forHTTPHeaderField: "X-Test") == "yes")
|
||||
#expect(calls[1].0.httpBody == nil)
|
||||
#expect(String(decoding: calls[2].0.httpBody ?? Data(), as: UTF8.self) == "raw=正文")
|
||||
}
|
||||
@Test func executesPureJavaScriptAndJSONPathPipeline() async throws {
|
||||
let engine = RuleEngine()
|
||||
let root = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试"}"#.utf8))
|
||||
|
||||
let pure = await engine.evaluate("@js:result.name + '-JS'", in: root)
|
||||
#expect(pure.firstString == "测试-JS")
|
||||
#expect(pure.diagnostics.isEmpty)
|
||||
|
||||
let pipeline = await engine.evaluate(
|
||||
"$.id@js:java.put('bookId', result); '/book/' + result",
|
||||
in: root
|
||||
)
|
||||
#expect(pipeline.firstString == "/book/42")
|
||||
#expect(pipeline.variables["bookId"] == "42")
|
||||
#expect(pipeline.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func carriesVariablesAndProvidesLegadoHelpers() async {
|
||||
let engine = RuleEngine()
|
||||
let context = RuleExecutionContext(variables: ["bookId": "1001"])
|
||||
|
||||
let result = await engine.evaluate(
|
||||
"@js:java.md5Encode(java.get('bookId')).length + ':' + java.get('bookId')",
|
||||
in: .null,
|
||||
context: context
|
||||
)
|
||||
|
||||
#expect(result.firstString == "32:1001")
|
||||
#expect(result.variables["bookId"] == "1001")
|
||||
}
|
||||
|
||||
@Test func providesByteHexTimeAndUUIDHelpers() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "java.bytesToStr(java.strToBytes('正文')) + ':' + java.hexDecodeToString(java.hexEncodeToString('A')) + ':' + java.timeFormatUTC(0, 'yyyy-MM-dd HH:mm', 0) + ':' + java.randomUUID().length",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "正文:A:1970-01-01 00:00:36")
|
||||
}
|
||||
|
||||
@Test func exposesSourceTagBase64BytesAndStableInstallationIdentifier() throws {
|
||||
let source = BookSource(bookSourceUrl: "https://bridge-source.test", bookSourceName: "桥接源")
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.getSource().bookSourceName + ':' + java.getTag() + ':' + java.bytesToStr(java.base64DecodeToByteArray('b2s=')) + ':' + (java.androidId() === java.androidId())",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "桥接源:https://bridge-source.test:ok:true")
|
||||
}
|
||||
|
||||
@Test func exposesEntityPropertiesAndFormStyleEncodeURI() throws {
|
||||
var book = Book(bookUrl: "https://book.test/1", name: "书名", author: "作者")
|
||||
book.kind = "玄幻"
|
||||
let chapter = BookChapter(url: "/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 3)
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "book.name + ':' + book.kind + ':' + chapter.title + ':' + chapter.index + ':' + java.encodeURI('a b&中')",
|
||||
result: .null,
|
||||
executionContext: .init(book: book, chapter: chapter)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "书名:玄幻:第一章:3:a+b%26%E4%B8%AD")
|
||||
}
|
||||
|
||||
@Test func keepsJavaSpecialVariablesAndSourceVariablesInSeparateScopes() throws {
|
||||
let source = BookSource(bookSourceUrl: "https://variable-source.test", bookSourceName: "变量源")
|
||||
let book = Book(bookUrl: "https://book.test/variable", name: "变量书")
|
||||
let chapter = BookChapter(url: "/one", title: "变量章", bookUrl: book.bookUrl, index: 0)
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let first = try evaluator.evaluate(
|
||||
script: "source.put('token','persisted'); java.get('bookName') + ':' + java.get('title')",
|
||||
result: .null,
|
||||
executionContext: .init(source: source, book: book, chapter: chapter)
|
||||
)
|
||||
let second = try evaluator.evaluate(
|
||||
script: "source.get('token')",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(first.value.stringValue == "变量书:变量章")
|
||||
#expect(second.value.stringValue == "persisted")
|
||||
}
|
||||
|
||||
@Test func resolvesDynamicLenientAndLoginHeadersByPriority() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://header-source.test", bookSourceName: "Header源")
|
||||
source.header = #"@js:"{'X-Static':'base','X-Dynamic':'js'}""#
|
||||
BookSourceRuntimeStore.shared.put(
|
||||
sourceID: source.id,
|
||||
key: "loginHeader",
|
||||
value: #"{"X-Static":"login","Cookie":"token=ok"}"#
|
||||
)
|
||||
|
||||
let headers = try BookSourceHeaderResolver().resolve(source)
|
||||
|
||||
#expect(headers["X-Dynamic"] == "js")
|
||||
#expect(headers["X-Static"] == "login")
|
||||
#expect(headers["Cookie"] == "token=ok")
|
||||
}
|
||||
|
||||
@Test func exposesSourceLoginInfoMapAndHeaderMap() throws {
|
||||
var source = BookSource(bookSourceUrl: "httpTts:99", bookSourceName: "在线朗读")
|
||||
source.header = #"{"X-Base":"one"}"#
|
||||
BookSourceRuntimeStore.shared.put(sourceID: source.id, key: "loginHeader", value: #"{"X-Login":"two"}"#)
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "source.putLoginInfo(JSON.stringify({token:'abc'})); source.getLoginInfoMap().get('token') + ':' + source.getHeaderMap(true)['X-Login'] + ':' + source.getKey()",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:two:httpTts:99")
|
||||
}
|
||||
|
||||
@Test func rejectsObviousUnboundedScripts() async {
|
||||
let result = await RuleEngine().evaluate("@js:while(true){}", in: .null)
|
||||
|
||||
#expect(result.values.isEmpty)
|
||||
#expect(result.diagnostics.contains { $0.code == "rule.resource_limit" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LegadoDatabaseTests {
|
||||
@Test func decodesBookAndChapterFieldsWithoutBreakingLegacyPayloads() throws {
|
||||
let payload = Data(#"""
|
||||
{
|
||||
"bookUrl":"book","name":"书","durVolumeIndex":2,"chapterInVolumeIndex":4,
|
||||
"readConfig":{"reverseToc":true,"dailyChapters":5,"playSpeed":1.25}
|
||||
}
|
||||
"""#.utf8)
|
||||
let book = try JSONDecoder().decode(Book.self, from: payload)
|
||||
#expect(book.durVolumeIndex == 2)
|
||||
#expect(book.chapterInVolumeIndex == 4)
|
||||
#expect(book.readConfig?.reverseToc == true)
|
||||
#expect(book.readConfig?.dailyChapters == 5)
|
||||
|
||||
let chapter = try JSONDecoder().decode(
|
||||
BookChapter.self,
|
||||
from: Data(#"{"url":"/1","bookUrl":"book","startFragmentId":"a","endFragmentId":"b","imgUrl":"cover"}"#.utf8)
|
||||
)
|
||||
#expect(chapter.startFragmentId == "a")
|
||||
#expect(chapter.endFragmentId == "b")
|
||||
#expect(chapter.imageURL == "cover")
|
||||
let encoded = String(decoding: try JSONEncoder().encode(chapter), as: UTF8.self)
|
||||
#expect(encoded.contains("\"imgUrl\":\"cover\""))
|
||||
}
|
||||
|
||||
@Test func storesSourcesBooksAndChapterKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let sources = SQLiteBookSourceRepository(database: database)
|
||||
let books = SQLiteBookRepository(database: database)
|
||||
let chapters = SQLiteBookChapterRepository(database: database)
|
||||
|
||||
var source = BookSource(bookSourceUrl: "https://source.example", bookSourceName: "示例")
|
||||
source.customOrder = 7
|
||||
try await sources.save([source])
|
||||
|
||||
var book = Book(bookUrl: "https://source.example/book/1", name: "书", author: "作者", origin: source.bookSourceUrl)
|
||||
book.durChapterPos = 42
|
||||
try await books.save([book])
|
||||
|
||||
var first = BookChapter(url: "/1", title: "第一章", bookUrl: book.bookUrl, index: 99)
|
||||
first.variable = #"{"token":"x"}"#
|
||||
let second = BookChapter(url: "/2", title: "第二章", bookUrl: book.bookUrl, index: 100)
|
||||
try await chapters.save([first, second], bookURL: book.bookUrl)
|
||||
|
||||
#expect(try await sources.load() == [source])
|
||||
#expect(try await books.load() == [book])
|
||||
let stored = try await chapters.load(bookURL: book.bookUrl)
|
||||
#expect(stored.map(\.index) == [0, 1])
|
||||
#expect(stored.first?.variable == first.variable)
|
||||
|
||||
try await books.save([])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
|
||||
}
|
||||
|
||||
@Test func importsLegacyJSONOnlyIntoEmptyTables() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let sourceURL = directory.appending(path: "book-sources.json")
|
||||
let booksURL = directory.appending(path: "books.json")
|
||||
let source = BookSource(bookSourceUrl: "https://legacy.example", bookSourceName: "旧源")
|
||||
let book = Book(bookUrl: "https://legacy.example/1", name: "旧书", origin: source.bookSourceUrl)
|
||||
try JSONEncoder().encode([source]).write(to: sourceURL)
|
||||
try JSONEncoder().encode([book]).write(to: booksURL)
|
||||
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
|
||||
#expect(try await database.loadBookSources() == [source])
|
||||
#expect(try await database.loadBooks() == [book])
|
||||
|
||||
try JSONEncoder().encode([BookSource(bookSourceUrl: "https://ignored", bookSourceName: "忽略")])
|
||||
.write(to: sourceURL)
|
||||
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
|
||||
#expect(try await database.loadBookSources() == [source])
|
||||
}
|
||||
|
||||
@Test func doesNotReimportLegacyChaptersAfterRemovingBook() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let legacyDirectory = directory.appending(path: "Chapters", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true)
|
||||
let book = Book(bookUrl: "https://source.example/book", name: "书", origin: "https://source.example")
|
||||
let stale = BookChapter(url: "https://source.example/old", title: "后记", bookUrl: book.bookUrl, index: 0)
|
||||
try JSONEncoder().encode([stale]).write(to: legacyDirectory.appending(path: "old.json"))
|
||||
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let books = SQLiteBookRepository(database: database)
|
||||
let chapters = SQLiteBookChapterRepository(database: database, legacyDirectory: legacyDirectory)
|
||||
try await books.save([book])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).map(\.title) == ["后记"])
|
||||
|
||||
try await books.save([])
|
||||
try await books.save([book])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
|
||||
}
|
||||
|
||||
@Test func storesBookmarksAndCompositeReadRecords() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteReadingActivityRepository(database: database)
|
||||
let bookmark = Bookmark(time: 123, bookName: "书", bookAuthor: "作者")
|
||||
let records = [
|
||||
ReadRecord(deviceId: "A", bookName: "书", readTime: 10, lastRead: 100),
|
||||
ReadRecord(deviceId: "B", bookName: "书", readTime: 20, lastRead: 200)
|
||||
]
|
||||
try await repository.saveBookmarks([bookmark])
|
||||
try await repository.saveReadRecords(records)
|
||||
#expect(try await repository.loadBookmarks() == [bookmark])
|
||||
#expect(try await repository.loadReadRecords() == Array(records.reversed()))
|
||||
}
|
||||
|
||||
@Test func storesBookGroupsInExplicitOrder() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteBookGroupRepository(database: database)
|
||||
let groups = [
|
||||
BookGroup(groupId: 2, groupName: "二", order: 9),
|
||||
BookGroup(groupId: 1, groupName: "一", order: 1)
|
||||
]
|
||||
try await repository.save(groups)
|
||||
#expect(try await repository.load() == groups)
|
||||
}
|
||||
|
||||
@Test func searchKeywordUsageOrderingUsesCountAndTimestamp() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteSearchKeywordRepository(database: database, legacyWords: ["旧词"])
|
||||
|
||||
#expect(try await repository.load(order: .lastUseTime).map(\.word) == ["旧词"])
|
||||
_ = try await repository.record("甲", at: 100)
|
||||
_ = try await repository.record("乙", at: 200)
|
||||
let updated = try await repository.record("甲", at: 300)
|
||||
#expect(updated.usage == 2)
|
||||
#expect(try await repository.load(order: .usage).map(\.word).first == "甲")
|
||||
#expect(try await repository.load(order: .lastUseTime).map(\.word).first == "甲")
|
||||
try await repository.delete("甲")
|
||||
#expect(try await repository.load(order: .usage).allSatisfy { $0.word != "甲" })
|
||||
}
|
||||
|
||||
@Test func preservesCacheDeadlinesSourceCleanupAndCookieKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let caches = SQLiteLegadoCacheRepository(database: database)
|
||||
let cookies = SQLiteLegadoCookieRepository(database: database)
|
||||
|
||||
try await caches.put(.init(key: "alive", value: "A", deadline: 200))
|
||||
try await caches.put(.init(key: "expired", value: "E", deadline: 50))
|
||||
try await caches.put(.init(key: "loginHeader_source", value: "H"))
|
||||
#expect(try await caches.value(for: "alive", now: 100) == "A")
|
||||
#expect(try await caches.value(for: "expired", now: 100) == nil)
|
||||
try await caches.clearExpired(now: 100)
|
||||
try await caches.deleteSourceVariables("source")
|
||||
#expect(try await caches.value(for: "loginHeader_source", now: 100) == nil)
|
||||
|
||||
try await cookies.put(.init(url: "host", cookie: "a=1"))
|
||||
try await cookies.put(.init(url: "source|example.com|/", cookie: "b=2"))
|
||||
#expect(try await cookies.get("host")?.cookie == "a=1")
|
||||
#expect(try await cookies.getOkHTTP().count == 1)
|
||||
try await cookies.deleteOkHTTP()
|
||||
#expect(try await cookies.getOkHTTP().isEmpty)
|
||||
}
|
||||
|
||||
@Test func searchBookStoreUsesSourceEnableGroupAndOrderForChangeSource() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let sourceRepository = SQLiteBookSourceRepository(database: database)
|
||||
let repository = SQLiteSearchBookRepository(database: database)
|
||||
var firstSource = BookSource(bookSourceUrl: "https://one", bookSourceName: "一号")
|
||||
firstSource.bookSourceGroup = "文字"
|
||||
firstSource.customOrder = 20
|
||||
var secondSource = BookSource(bookSourceUrl: "https://two", bookSourceName: "二号")
|
||||
secondSource.bookSourceGroup = "文字,精选"
|
||||
secondSource.customOrder = 10
|
||||
try await sourceRepository.save([firstSource, secondSource])
|
||||
var one = SearchBook(bookURL: "https://one/book", origin: firstSource.bookSourceUrl, originName: "一号", type: 0, name: "书", author: "作者")
|
||||
one.latestChapterTitle = "第一章"
|
||||
one.originOrder = 20
|
||||
var two = SearchBook(bookURL: "https://two/book", origin: secondSource.bookSourceUrl, originName: "二号", type: 0, name: "书", author: "作者")
|
||||
two.coverURL = "cover"
|
||||
two.originOrder = 10
|
||||
try await repository.put([one, two])
|
||||
|
||||
#expect(try await repository.first(name: "书", author: "作者")?.bookURL == two.bookURL)
|
||||
let changed = try await repository.changeSources(name: "书", author: "作", sourceGroup: "文字", key: "二号")
|
||||
#expect(changed.map(\.bookURL) == [two.bookURL])
|
||||
#expect(changed.first?.originOrder == 10)
|
||||
#expect(try await repository.enabledWithCover(name: "书", author: "作者").map(\.bookURL) == [two.bookURL])
|
||||
|
||||
secondSource.enabled = false
|
||||
try await sourceRepository.save([firstSource, secondSource])
|
||||
#expect(try await repository.enabledWithCover(name: "书", author: "作者").isEmpty)
|
||||
#expect(try await repository.get(bookURL: two.bookURL) != nil)
|
||||
}
|
||||
|
||||
@Test func storesConfigurationEntitiesWithOrderingAndKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteConfigurationRepository(database: database)
|
||||
|
||||
let toc = [
|
||||
TxtTocRule(id: 2, name: "后", serialNumber: 2),
|
||||
TxtTocRule(id: 1, name: "前", serialNumber: 1)
|
||||
]
|
||||
try await repository.saveTxtTocRules(toc)
|
||||
#expect(try await repository.loadTxtTocRules().map(\.id) == [1, 2])
|
||||
|
||||
let engines = [HttpTTS(id: 2, name: "B"), HttpTTS(id: 1, name: "A")]
|
||||
try await repository.saveHttpTTS(engines)
|
||||
#expect(try await repository.loadHttpTTS().map(\.name) == ["A", "B"])
|
||||
|
||||
let subscriptions = [
|
||||
RuleSub(id: 2, name: "后", url: "2"),
|
||||
RuleSub(id: 1, name: "前", url: "1")
|
||||
]
|
||||
var orderedSubscriptions = subscriptions
|
||||
orderedSubscriptions[0].customOrder = 2
|
||||
orderedSubscriptions[1].customOrder = 1
|
||||
try await repository.saveRuleSubs(orderedSubscriptions)
|
||||
#expect(try await repository.loadRuleSubs().map(\.id) == [1, 2])
|
||||
|
||||
let keys = [
|
||||
KeyboardAssist(type: 1, key: "b", value: "B", serialNo: 2),
|
||||
KeyboardAssist(type: 0, key: "a", value: "A", serialNo: 1)
|
||||
]
|
||||
try await repository.saveKeyboardAssists(keys)
|
||||
#expect(try await repository.loadKeyboardAssists().map(\.key) == ["a", "b"])
|
||||
|
||||
var serverB = Server(id: 2, name: "B")
|
||||
serverB.sortNumber = 2
|
||||
var serverA = Server(id: 1, name: "A")
|
||||
serverA.sortNumber = 1
|
||||
try await repository.saveServers([serverB, serverA])
|
||||
#expect(try await repository.loadServers().map(\.id) == [1, 2])
|
||||
}
|
||||
|
||||
@Test func decodesRssModelsAndPreservesDefaults() throws {
|
||||
let source = try JSONDecoder().decode(
|
||||
RssSource.self,
|
||||
from: Data(#"{"sourceUrl":"https://rss","sourceName":"订阅","enabled":"false","articleStyle":"2","loginUi":{"type":"input"}}"#.utf8)
|
||||
)
|
||||
#expect(source.enabled == false)
|
||||
#expect(source.articleStyle == 2)
|
||||
#expect(source.loginUi?.contains("input") == true)
|
||||
#expect(source.enableJs == true)
|
||||
|
||||
let article = try JSONDecoder().decode(
|
||||
RssArticle.self,
|
||||
from: Data(#"{"origin":"https://rss","sort":"all","title":"文章","link":"/1","order":"42","read":1}"#.utf8)
|
||||
)
|
||||
#expect(article.order == 42)
|
||||
#expect(article.read)
|
||||
#expect(article.record(at: 100).record == "/1")
|
||||
#expect(article.star(at: 200).starTime == 200)
|
||||
}
|
||||
|
||||
@Test func rssSourceRepositorySupportsGroupsSearchAndOrdering() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteRssSourceRepository(database: database)
|
||||
var later = RssSource(sourceUrl: "https://b", sourceName: "科技日报")
|
||||
later.sourceGroup = "资讯,科技"; later.customOrder = 20
|
||||
var earlier = RssSource(sourceUrl: "https://a", sourceName: "新闻")
|
||||
earlier.sourceGroup = "资讯"; earlier.sourceComment = "每日"; earlier.customOrder = 10
|
||||
try await repository.save([later, earlier])
|
||||
|
||||
#expect(try await repository.all().map(\.sourceUrl) == ["https://a", "https://b"])
|
||||
#expect(Set(try await repository.groups()) == Set(["资讯", "科技"]))
|
||||
#expect(try await repository.inGroup("科技").map(\.sourceUrl) == ["https://b"])
|
||||
#expect(try await repository.search("每日").map(\.sourceUrl) == ["https://a"])
|
||||
try await repository.setEnabled(false, sourceURL: "https://a")
|
||||
#expect(try await repository.disabled().map(\.sourceUrl) == ["https://a"])
|
||||
}
|
||||
|
||||
@Test func rssArticlesUseCompositeKeyIgnoreAppendAndGlobalReadLink() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let articles = SQLiteRssArticleRepository(database: database)
|
||||
let records = SQLiteRssReadRecordRepository(database: database)
|
||||
var old = RssArticle(origin: "one", sort: "all", title: "旧标题", link: "/same")
|
||||
old.order = 1
|
||||
try await articles.append([old])
|
||||
var duplicate = old; duplicate.title = "不应覆盖"; duplicate.order = 99
|
||||
var secondSort = old; secondSort.sort = "other"; secondSort.title = "另一分类"
|
||||
try await articles.append([duplicate, secondSort])
|
||||
|
||||
#expect(try await articles.articles(origin: "one", sort: "all").first?.title == "旧标题")
|
||||
#expect(try await articles.articles(origin: "one", sort: "other").count == 1)
|
||||
try await records.insertIgnoringExisting(old.record(at: 100))
|
||||
var sameLinkOtherOrigin = RssArticle(origin: "two", sort: "all", title: "同链接", link: "/same")
|
||||
sameLinkOtherOrigin.order = 2
|
||||
try await articles.append([sameLinkOtherOrigin])
|
||||
#expect(try await articles.articles(origin: "two", sort: "all").first?.read == true)
|
||||
try await articles.clearOld(origin: "one", sort: "all", before: 2)
|
||||
#expect(try await articles.articles(origin: "one", sort: "all").isEmpty)
|
||||
try await articles.updateOrigin(from: "one", to: "renamed")
|
||||
#expect(try await articles.article(origin: "renamed", link: "/same", sort: "other")?.origin == "renamed")
|
||||
}
|
||||
|
||||
@Test func rssStarsReplaceCompositeKeyAndSortNewestFirst() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let stars = SQLiteRssStarRepository(database: database)
|
||||
var firstArticle = RssArticle(origin: "one", sort: "all", title: "甲", link: "/1")
|
||||
firstArticle.group = "技术"
|
||||
var secondArticle = RssArticle(origin: "one", sort: "all", title: "乙", link: "/2")
|
||||
secondArticle.group = "生活"
|
||||
try await stars.save(firstArticle.star(at: 100))
|
||||
try await stars.save(secondArticle.star(at: 200))
|
||||
var replacement = firstArticle.star(at: 300); replacement.title = "甲更新"
|
||||
try await stars.save(replacement)
|
||||
|
||||
#expect(try await stars.all().map(\.title) == ["甲更新", "乙"])
|
||||
#expect(Set(try await stars.groups()) == Set(["技术", "生活"]))
|
||||
try await stars.updateOrigin(from: "one", to: "renamed")
|
||||
#expect(try await stars.star(origin: "renamed", link: "/1")?.origin == "renamed")
|
||||
try await stars.remove(origin: "renamed", link: "/1")
|
||||
#expect(try await stars.all().map(\.link) == ["/2"])
|
||||
}
|
||||
|
||||
@Test func defaultRssParserExtractsItemFieldsAndPrioritizesImages() throws {
|
||||
let xml = #"""
|
||||
<rss xmlns:media="urn:media" xmlns:content="urn:content"><channel>
|
||||
<item><title> 第一篇 </title><link> /one </link>
|
||||
<description><![CDATA[<p>x</p><img src="description.jpg">]]></description>
|
||||
<content:encoded><![CDATA[<img src="content.jpg">正文]]></content:encoded>
|
||||
<pubDate> today </pubDate>
|
||||
</item>
|
||||
<item><title>第二篇</title><link>/two</link>
|
||||
<media:thumbnail url="thumb.jpg" />
|
||||
<enclosure type="image/jpeg" url="enclosure.jpg" />
|
||||
<time>later</time>
|
||||
</item>
|
||||
</channel></rss>
|
||||
"""#
|
||||
let result = try RSSDefaultParser.parse(sortName: "全部", xml: xml, sourceURL: "https://rss")
|
||||
#expect(result.nextURL == nil)
|
||||
#expect(result.articles.count == 2)
|
||||
#expect(result.articles[0].title == "第一篇")
|
||||
#expect(result.articles[0].image == "description.jpg")
|
||||
#expect(result.articles[0].content?.contains("正文") == true)
|
||||
#expect(result.articles[0].origin == "https://rss")
|
||||
#expect(result.articles[0].sort == "全部")
|
||||
#expect(result.articles[1].image == "enclosure.jpg")
|
||||
#expect(result.articles[1].pubDate == "later")
|
||||
}
|
||||
|
||||
@Test func rssRuleParserExtractsFieldsReversesAndSupportsPageSentinel() async throws {
|
||||
var source = RssSource(sourceUrl: "https://rss.example/root/", sourceName: "规则源")
|
||||
source.ruleArticles = "-@CSS:.item"
|
||||
source.ruleTitle = "@CSS:h2@text"
|
||||
source.rulePubDate = "@CSS:time@text"
|
||||
source.ruleDescription = "@CSS:p@text"
|
||||
source.ruleImage = "@CSS:img@src"
|
||||
source.ruleLink = "@CSS:a@href"
|
||||
source.ruleNextPage = "PAGE"
|
||||
source.type = 2
|
||||
let body = #"""
|
||||
<div class="item"><h2>甲</h2><time>1</time><p>A</p><img src="a.jpg"><a href="/a">go</a></div>
|
||||
<div class="item"><h2>乙</h2><time>2</time><p>B</p><img src="b.jpg"><a href="/b">go</a></div>
|
||||
"""#
|
||||
let report = try await RSSRuleParser().parse(
|
||||
sortName: "分类", sortURL: "https://rss.example/list?page=1",
|
||||
redirectURL: "https://rss.example/list?page=1", body: body, source: source
|
||||
)
|
||||
#expect(report.result.articles.map(\.title) == ["乙", "甲"])
|
||||
#expect(report.result.articles.first?.link == "https://rss.example/b")
|
||||
#expect(report.result.articles.first?.image == "https://rss.example/root/b.jpg")
|
||||
#expect(report.result.articles.first?.type == 2)
|
||||
#expect(report.result.nextURL == "https://rss.example/list?page=1")
|
||||
}
|
||||
|
||||
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
|
||||
let importer = RSSSourceImporter()
|
||||
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
|
||||
#expect(object.sources.map(\.sourceUrl) == ["https://one"])
|
||||
let array = try importer.decode(Data(#"[{"sourceUrl":"https://two","sourceName":"二"}]"#.utf8))
|
||||
#expect(array.sources.map(\.sourceUrl) == ["https://two"])
|
||||
let manifest = try importer.decode(Data(#"{"sourceUrls":["https://list/one.json","https://list/two.json"]}"#.utf8))
|
||||
#expect(manifest.subscriptionURLs.count == 2)
|
||||
|
||||
var old = RssSource(sourceUrl: "https://one", sourceName: "旧"); old.lastUpdateTime = 100
|
||||
var newer = RssSource(sourceUrl: "https://one", sourceName: "新"); newer.lastUpdateTime = 200
|
||||
#expect(importer.defaultSelection(incoming: [newer], existing: [old]) == [true])
|
||||
let merged = try importer.merge([newer], into: [old], addGroup: "导入")
|
||||
#expect(merged.first?.sourceName == "新")
|
||||
#expect(merged.first?.groups.contains("导入") == true)
|
||||
#expect(try importer.decode(importer.encode(merged)).sources.first?.sourceUrl == "https://one")
|
||||
}
|
||||
|
||||
@Test func rssSortResolverParsesSeparatorsAndFallback() async {
|
||||
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
|
||||
source.sortUrl = "新闻::/news&&科技::/tech\n生活::/life"
|
||||
let sorts = await RSSSortResolver().resolve(source)
|
||||
#expect(sorts.map(\.name) == ["新闻", "科技", "生活"])
|
||||
#expect(sorts.map(\.url) == ["/news", "/tech", "/life"])
|
||||
source.sortUrl = "invalid"
|
||||
#expect(await RSSSortResolver().resolve(source) == [.init(name: "", url: "https://rss")])
|
||||
}
|
||||
|
||||
@Test func rssReadingPolicyHandlesDescriptionRuleAndMediaBranches() {
|
||||
let policy = RSSReadingPolicy()
|
||||
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源")
|
||||
var article = RssArticle(origin: source.sourceUrl, sort: "", title: "文章", link: "/post/1")
|
||||
article.description = "<p>摘要正文</p>"
|
||||
#expect(policy.mode(article: article, source: source) == .html(content: "<p>摘要正文</p>", baseURL: "https://rss.example/post/1"))
|
||||
article.description = nil
|
||||
#expect(policy.mode(article: article, source: source) == .web(url: "https://rss.example/post/1"))
|
||||
source.ruleContent = "@CSS:article@html"
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "<article>正文</article>") == .html(content: "<article>正文</article>", baseURL: "https://rss.example/post/1"))
|
||||
article.type = 1
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "../cover.jpg") == .image(url: "https://rss.example/cover.jpg"))
|
||||
article.type = 2
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "video.mp4") == .video(url: "https://rss.example/post/video.mp4"))
|
||||
}
|
||||
|
||||
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
|
||||
let processor = RSSHTMLProcessor()
|
||||
#expect(processor.contentHTML("<head></head><body>x</body>", style: "body{color:red}").contains("<style>body{color:red}</style></head>"))
|
||||
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.defaultStyle)</style>"))
|
||||
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
|
||||
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
|
||||
let start = processor.startHTML("<body><p>x</p></body>", source: source)
|
||||
#expect(start.contains("<script>window.ready=true</script></body>"))
|
||||
#expect(start.contains("<style>p{font-size:20px}</style>"))
|
||||
|
||||
let blacklistWins = RSSResourcePolicy(blacklist: "https://ads,.*\\.track\\.js", whitelist: "https://ads/ok")
|
||||
#expect(!blacklistWins.allows("https://ads/ok"))
|
||||
#expect(!blacklistWins.allows("https://cdn.track.js"))
|
||||
#expect(blacklistWins.allows("https://cdn.example/a.js"))
|
||||
let whitelist = RSSResourcePolicy(blacklist: nil, whitelist: "https://safe,https://cdn\\.example/.*")
|
||||
#expect(whitelist.allows("https://safe/image"))
|
||||
#expect(!whitelist.allows("https://other/image"))
|
||||
#expect(blacklistWins.contentBlockerJSON()?.contains("\"type\":\"block\"") == true)
|
||||
#expect(whitelist.contentBlockerJSON()?.contains("ignore-previous-rules") == true)
|
||||
}
|
||||
|
||||
@Test func rssStartPageUsesDedicatedStartJSAndStyle() throws {
|
||||
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源")
|
||||
source.startHtml = "<body><p>入口</p></body>"
|
||||
source.startJs = "window.started=true"
|
||||
source.startStyle = "p{color:green}"
|
||||
#expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "<body><p>入口</p></body>", baseURL: "https://rss.example"))
|
||||
source.startHtml = "@js:'<body>脚本入口</body>'"
|
||||
#expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "<body>脚本入口</body>", baseURL: "https://rss.example"))
|
||||
source.startHtml = nil
|
||||
#expect(try RSSStartPageResolver().resolve(source) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LegadoURLBuilderTests {
|
||||
@Test func canDisableCookieHandlingPerSourceRequest() throws {
|
||||
let prepared = try LegadoURLBuilder().prepare("https://example.test/books")
|
||||
#expect(prepared.urlRequest(handleCookies: false).httpShouldHandleCookies == false)
|
||||
#expect(prepared.urlRequest().httpShouldHandleCookies)
|
||||
}
|
||||
private let builder = LegadoURLBuilder()
|
||||
|
||||
@Test func relativeRequestOptionsAndHeaderCasingSurviveResolution() throws {
|
||||
let base = try #require(URL(string: "https://example.test/book/1"))
|
||||
let resolved = LegadoURLBuilder.resolve(#"../chapter,{"method":"POST","headers":{"referer":"custom"},"body":"id=1"}"#, against: base)
|
||||
let request = try builder.prepare(resolved, defaultHeaders: ["Referer": "old"])
|
||||
#expect(request.url.absoluteString == "https://example.test/chapter")
|
||||
#expect(request.method == .post)
|
||||
#expect(request.headers.filter { $0.key.lowercased() == "referer" }.count == 1)
|
||||
#expect(request.headers["referer"] == "custom")
|
||||
#expect(String(decoding: try #require(request.body), as: UTF8.self) == "id=1")
|
||||
}
|
||||
|
||||
@Test func rendersKeywordPageAndRequestOptions() throws {
|
||||
let template = """
|
||||
/search?q={{key}}&page=<0,1,2>, {"method":"POST","headers":{"X-Test":"yes"},"body":"token={{token}}","retry":2}
|
||||
"""
|
||||
let prepared = try builder.prepare(
|
||||
template,
|
||||
context: .init(key: "三体", page: 2, variables: ["token": "abc"]),
|
||||
baseURL: URL(string: "https://example.test/base/"),
|
||||
defaultHeaders: ["User-Agent": "Legado-iOS"]
|
||||
)
|
||||
|
||||
#expect(prepared.url.absoluteString.contains("q=%E4%B8%89%E4%BD%93"))
|
||||
#expect(prepared.url.absoluteString.contains("page=1"))
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.headers["X-Test"] == "yes")
|
||||
#expect(prepared.headers["User-Agent"] == "Legado-iOS")
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "token=abc")
|
||||
#expect(prepared.retry == 2)
|
||||
}
|
||||
|
||||
@Test func evaluatesInlineJavaScriptWithSearchBindings() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/{{key.toUpperCase()}}?page={{page + 1}}",
|
||||
context: .init(key: "swift", page: 2)
|
||||
)
|
||||
|
||||
#expect(prepared.url.absoluteString == "https://example.test/SWIFT?page=3")
|
||||
}
|
||||
|
||||
@Test func evaluatesWholeURLAndOptionJavaScriptWhileCarryingVariables() throws {
|
||||
let wholeScript = try builder.prepare(
|
||||
"@js:'/search?q=' + key",
|
||||
context: .init(key: "book", page: 4),
|
||||
baseURL: URL(string: "https://example.test")
|
||||
)
|
||||
let prepared = try builder.prepare(
|
||||
#"/search?q=book, {"js":"java.put('signature', 's-' + page); result + '&sig=' + java.get('signature')"}"#,
|
||||
context: .init(key: "book", page: 4),
|
||||
baseURL: URL(string: "https://example.test")
|
||||
)
|
||||
|
||||
#expect(wholeScript.url.absoluteString == "https://example.test/search?q=book")
|
||||
#expect(prepared.url.absoluteString == "https://example.test/search?q=book&sig=s-4")
|
||||
#expect(prepared.variables["signature"] == "s-4")
|
||||
}
|
||||
|
||||
@Test func supportsLegacySearchTokensAndJSONObjectBody() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/search, {\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}",
|
||||
context: .init(key: "三体", page: 3)
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
let body = try #require(prepared.body)
|
||||
let json = try JSONValue.decode(data: body)
|
||||
#expect(json["title"]?.stringValue == "三体")
|
||||
#expect(json["page"]?.stringValue == "3")
|
||||
}
|
||||
|
||||
@Test func encodesLegacyCharsetQueryAndFormBody() throws {
|
||||
let query = try builder.prepare(
|
||||
#"https://example.test/search?q={{key}}, {"charset":"gb18030"}"#,
|
||||
context: .init(key: "旧城")
|
||||
)
|
||||
let form = try builder.prepare(
|
||||
#"https://example.test/search, {"method":"POST","charset":"gb18030","body":"q={{key}}&tag=中文 空格"}"#,
|
||||
context: .init(key: "旧城")
|
||||
)
|
||||
|
||||
#expect(query.url.absoluteString.contains("q=%BE%C9%B3%C7"))
|
||||
#expect(String(decoding: try #require(form.body), as: UTF8.self) == "q=%BE%C9%B3%C7&tag=%D6%D0%CE%C4+%BF%D5%B8%F1")
|
||||
#expect(form.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=gb18030")
|
||||
}
|
||||
|
||||
@Test func parsesGsonLenientStyleOptions() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/search?q={{key}}, {'method':'POST', body:'key={{key}}', charset:'utf-8',}"#,
|
||||
context: .init(key: "三体")
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.charset == "utf-8")
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E4%B8%89%E4%BD%93")
|
||||
}
|
||||
|
||||
@Test func keepsJsonInQueryWhenLaterSplitParses() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/search?data={"q":"{{key}}"}, {"method":"POST","body":"key={{key}}"}"#,
|
||||
context: .init(key: "剑来")
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.url.absoluteString.contains("data="))
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E5%89%91%E6%9D%A5")
|
||||
}
|
||||
|
||||
@Test func appliesDefaultBrowserUserAgentWhenMissing() throws {
|
||||
let prepared = try builder.prepare("https://example.test/books")
|
||||
#expect(prepared.headers["User-Agent"] == LegadoURLBuilder.defaultUserAgent)
|
||||
#expect(prepared.headers["User-Agent"]?.contains("Chrome/128.0.0.0") == true)
|
||||
let explicit = try builder.prepare(
|
||||
"https://example.test/books",
|
||||
defaultHeaders: ["User-Agent": "Custom/1.0"]
|
||||
)
|
||||
#expect(explicit.headers["User-Agent"] == "Custom/1.0")
|
||||
}
|
||||
|
||||
@Test func nullUserAgentSuppressesDefaultHeader() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/books",
|
||||
defaultHeaders: ["user-agent": "null"]
|
||||
)
|
||||
|
||||
#expect(prepared.headers.keys.allSatisfy { $0.caseInsensitiveCompare("User-Agent") != .orderedSame })
|
||||
#expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
}
|
||||
|
||||
@Test func preservesTypeDnsServerAndProxyOptions() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/file, {"type":"image","dnsIp":"1.1.1.1,8.8.8.8","serverID":12}"#,
|
||||
defaultHeaders: ["proxy": "http://127.0.0.1:7890", "X-Test": "yes"]
|
||||
)
|
||||
|
||||
#expect(prepared.responseType == "image")
|
||||
#expect(prepared.dnsIP == "1.1.1.1,8.8.8.8")
|
||||
#expect(prepared.serverID == 12)
|
||||
#expect(prepared.proxy == "http://127.0.0.1:7890")
|
||||
#expect(prepared.headers["proxy"] == nil)
|
||||
#expect(prepared.headers["X-Test"] == "yes")
|
||||
}
|
||||
|
||||
@Test func rendersPostFormAndIgnoresBodyForGetAndHead() throws {
|
||||
let post = try builder.prepare(
|
||||
#"https://example.test/search, {"method":"POST","body":"q={{key}}&space=空 格"}"#,
|
||||
context: .init(key: "三体")
|
||||
)
|
||||
let emptyPost = try builder.prepare(#"https://example.test/search, {"method":"POST"}"#)
|
||||
let get = try builder.prepare(#"https://example.test/search, {"body":"ignored=1"}"#)
|
||||
let head = try builder.prepare(#"https://example.test/search, {"method":"HEAD","body":"ignored=1"}"#)
|
||||
|
||||
#expect(String(decoding: try #require(post.body), as: UTF8.self) == "q=%E4%B8%89%E4%BD%93&space=%E7%A9%BA+%E6%A0%BC")
|
||||
#expect(emptyPost.body == Data())
|
||||
#expect(post.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=utf-8")
|
||||
#expect(get.urlRequest().httpBody == nil)
|
||||
#expect(head.urlRequest().httpBody == nil)
|
||||
}
|
||||
|
||||
@Test func explicitContentTypeKeepsRawPostBody() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/raw, {"method":"POST","headers":{"content-type":"text/plain; charset=utf-8"},"body":"原始 空格"}"#
|
||||
)
|
||||
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "原始 空格")
|
||||
#expect(prepared.headers["content-type"] == "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LocalBookServiceTests {
|
||||
@Test func decodesDefaultTocRuleWithMissingReplacement() throws {
|
||||
let data = Data(#"{"id":-1,"name":"目录","rule":"^第.+章$","serialNumber":0,"enable":true}"#.utf8)
|
||||
let value = try JSONDecoder().decode(TxtTocRule.self, from: data)
|
||||
#expect(value.replacement == "")
|
||||
#expect(value.example == nil)
|
||||
#expect(value.enable)
|
||||
}
|
||||
|
||||
@Test func importsUTF8TextSplitsChaptersAndReadsContent() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "测试书.txt")
|
||||
let sourceData = Data("序言\n第一章 相遇\n这是第一章正文\n第二章 远行\n这是第二章正文".utf8)
|
||||
try sourceData.write(to: source)
|
||||
|
||||
let service = LocalBookService()
|
||||
let report = try service.importTextFile(at: source, into: root.appending(path: "library"))
|
||||
|
||||
#expect(report.book.origin == "local")
|
||||
#expect(report.book.charset == "UTF-8")
|
||||
#expect(report.chapters.map(\.title) == ["前言", "第一章 相遇", "第二章 远行"])
|
||||
#expect(report.chapters[1].url == "82e56047094237f3")
|
||||
let firstTitle = try #require(sourceData.range(of: Data("第一章 相遇".utf8)))
|
||||
let secondTitle = try #require(sourceData.range(of: Data("第二章 远行".utf8)))
|
||||
#expect(report.chapters[1].start == Int64(firstTitle.upperBound))
|
||||
#expect(report.chapters[1].end == Int64(secondTitle.lowerBound))
|
||||
#expect(report.chapters.map(\.wordCount) == ["3字", "9字", "8字"])
|
||||
#expect(report.book.wordCount == "20字")
|
||||
#expect(report.book.intro == "序言\n")
|
||||
let content = try service.loadContent(book: report.book, chapter: report.chapters[1])
|
||||
#expect(content.content == " 这是第一章正文\n")
|
||||
#expect(FileManager.default.fileExists(atPath: try #require(URL(string: report.book.bookUrl)).path))
|
||||
}
|
||||
|
||||
@Test func createsSingleChapterWhenNoHeadingExists() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "短篇.txt")
|
||||
try Data("这是一个没有章节标题的短篇。".utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService().importTextFile(at: source, into: root.appending(path: "library"))
|
||||
|
||||
#expect(report.chapters.count == 1)
|
||||
#expect(report.chapters[0].title == "短篇")
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[0]).content == "这是一个没有章节标题的短篇。")
|
||||
}
|
||||
|
||||
@Test func splitsLongHeadinglessTextAtTenKiBThreshold() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "无目录长文.txt")
|
||||
let paragraph = String(repeating: "a", count: 10_500)
|
||||
try Data("\(paragraph)\n\(paragraph)\n尾声".utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService(tocRules: []).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.count == 2)
|
||||
#expect(report.chapters.map(\.title) == ["第1章(1)", "第1章(2)"])
|
||||
#expect(report.chapters[0].end == report.chapters[1].start)
|
||||
#expect(report.book.wordCount == "2.1万字")
|
||||
#expect(report.book.intro == nil)
|
||||
}
|
||||
|
||||
@Test func numbersHeadinglessChaptersBy512KBBlock() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "多块无目录.txt")
|
||||
let paragraph = String(repeating: "a", count: 10_500) + "\n"
|
||||
try Data(String(repeating: paragraph, count: 55).utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService(tocRules: []).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.first?.title == "第1章(1)")
|
||||
#expect(report.chapters.contains { $0.title.hasPrefix("第2章(") })
|
||||
for index in 1..<report.chapters.count {
|
||||
#expect(report.chapters[index - 1].end == report.chapters[index].start)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func selectsCustomTocRuleAndRunsTitleScript() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "自定义目录.txt")
|
||||
let body = String(repeating: "正文", count: 650)
|
||||
try Data("卷1 开始\n\(body)\n卷2 继续\n\(body)\n卷3 结束\n\(body)".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "卷标题",
|
||||
rule: #"(?m)^卷\d+[^\r\n]*$"#,
|
||||
replacement: "result.replace('卷', '第')",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.book.tocUrl == rule.rule + TxtTocRuleSelector.separator + rule.replacement)
|
||||
#expect(report.chapters.map(\.title) == ["第1 开始", "第2 继续", "第3 结束"])
|
||||
}
|
||||
|
||||
@Test func tocRuleSelectorKeepsEarlierRuleUnlessLaterWinsByMoreThanTwo() {
|
||||
let body = String(repeating: "字", count: 1_100)
|
||||
let content = "A1\n\(body)\nA2\n\(body)\nA3\n\(body)\nB1\n\(body)\nB2\n\(body)\nB3\n\(body)\nB4"
|
||||
let first = TxtTocRule(id: 1, name: "A", rule: #"(?m)^A\d$"#, serialNumber: 1)
|
||||
let second = TxtTocRule(id: 2, name: "B", rule: #"(?m)^B\d$"#, serialNumber: 2)
|
||||
|
||||
#expect(TxtTocRuleSelector.select(content: content, rules: [first, second])?.id == first.id)
|
||||
}
|
||||
|
||||
@Test func titleScriptPutVolumeInsertsVolumeChapter() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "分卷.txt")
|
||||
let body = String(repeating: "正文", count: 650)
|
||||
try Data("章1\n\(body)\n章2\n\(body)\n章3\n\(body)".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "脚本分卷",
|
||||
rule: #"(?m)^章\d$"#,
|
||||
replacement: "if (index === 2) java.putVolume('第二卷'); result;",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.map(\.title) == ["章1", "第二卷", "章2", "章3"])
|
||||
#expect(report.chapters.map(\.isVolume) == [false, true, false, false])
|
||||
#expect(report.chapters[1].start == report.chapters[1].end)
|
||||
}
|
||||
|
||||
@Test func scansHeadingsAcross512KBBlocksAndReadsByByteRange() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "跨块.txt")
|
||||
let longBody = String(repeating: "段落内容\n", count: 50_000)
|
||||
let sourceData = Data("第一章 开始\n\(longBody)第二章 结束\n结尾".utf8)
|
||||
try sourceData.write(to: source)
|
||||
|
||||
let report = try LocalBookService().importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
let secondTitle = try #require(sourceData.range(of: Data("第二章 结束".utf8)))
|
||||
|
||||
#expect(report.chapters.map(\.title) == ["第一章 开始", "第二章 结束"])
|
||||
#expect(report.chapters[0].end == Int64(secondTitle.lowerBound))
|
||||
#expect(report.chapters[1].start == Int64(secondTitle.upperBound))
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[1]).content == " 结尾")
|
||||
}
|
||||
|
||||
@Test func preservesUTF16EndianBoundariesAcross512KBBlocks() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let text = "第一章 开始\n\(String(repeating: "段落内容\n", count: 70_000))第二章 结束\n结尾"
|
||||
let fixtures: [(String, String.Encoding, [UInt8], String)] = [
|
||||
("LE", .utf16LittleEndian, [0xFF, 0xFE], "UTF-16LE"),
|
||||
("BE", .utf16BigEndian, [0xFE, 0xFF], "UTF-16BE")
|
||||
]
|
||||
|
||||
for (suffix, encoding, bom, charset) in fixtures {
|
||||
let source = root.appending(path: "UTF16\(suffix).txt")
|
||||
var sourceData = Data(bom)
|
||||
sourceData.append(try #require(text.data(using: encoding)))
|
||||
try sourceData.write(to: source)
|
||||
let report = try LocalBookService().importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library-\(suffix)")
|
||||
)
|
||||
let titleData = try #require("第二章 结束".data(using: encoding))
|
||||
let titleRange = try #require(sourceData.range(of: titleData))
|
||||
let second = try #require(report.chapters.first(where: { $0.title == "第二章 结束" }))
|
||||
|
||||
#expect(report.book.charset == charset)
|
||||
#expect(second.start == Int64(titleRange.upperBound))
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: second).content == " 结尾")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func splitsOversizedTocChapterIntoVolumeAndUpdatesFollowingScriptState() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "超长章节.txt")
|
||||
let longBody = String(repeating: "正文内容\n", count: 12_000)
|
||||
try Data("第一章\n\(longBody)第二章\n结尾".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "长章脚本",
|
||||
rule: #"(?m)^第[一二]章$"#,
|
||||
replacement: "result === '第二章' ? lastVolumeTitle + '|' + index + '|' + result : result",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.first?.title == "第一章")
|
||||
#expect(report.chapters.first?.isVolume == true)
|
||||
#expect(report.chapters.dropFirst().contains { $0.title == "第一章(1)" })
|
||||
#expect(report.chapters.last?.title.hasPrefix("第一章|") == true)
|
||||
#expect(report.chapters.last?.title.hasSuffix("|第二章") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LocalFileServiceTests {
|
||||
private func root() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func importsSameNamedFilesWithoutOverwritingAndIgnoresSymlinks() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
try "第一章\n正文".write(to: source, atomically: true, encoding: .utf8)
|
||||
let directory = root.appendingPathComponent("books", isDirectory: true)
|
||||
let service = LocalFileService(directory: directory)
|
||||
let first = try await service.importFile(from: source)
|
||||
let second = try await service.importFile(from: source)
|
||||
try FileManager.default.createSymbolicLink(at: directory.appendingPathComponent("link.txt"), withDestinationURL: source)
|
||||
let files = try await service.list()
|
||||
#expect(files.count == 2)
|
||||
#expect(Set(files.map { $0.url.lastPathComponent }) == ["书.txt", "书 (2).txt"])
|
||||
#expect(try Data(contentsOf: first) == Data(contentsOf: second))
|
||||
}
|
||||
|
||||
@Test func rejectsEscapesSymlinksAndOverwriteDuringRename() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let directory = root.appendingPathComponent("books", isDirectory: true)
|
||||
let service = LocalFileService(directory: directory)
|
||||
let source = root.appendingPathComponent("source.txt")
|
||||
try "正文".write(to: source, atomically: true, encoding: .utf8)
|
||||
let file = try await service.importFile(from: source)
|
||||
let second = try await service.importFile(from: source)
|
||||
await #expect(throws: LocalFileError.invalidName) { try await service.rename(file, to: "../escape") }
|
||||
await #expect(throws: LocalFileError.alreadyExists) { try await service.rename(file, to: second.lastPathComponent) }
|
||||
await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(source) }
|
||||
let link = directory.appendingPathComponent("link.txt")
|
||||
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: source)
|
||||
await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(link) }
|
||||
#expect(FileManager.default.fileExists(atPath: source.path))
|
||||
#expect(FileManager.default.fileExists(atPath: file.path))
|
||||
}
|
||||
|
||||
@Test func stagedDeletionCanRestoreOriginalBytes() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
let data = Data([0xff, 0xfe, 0x61, 0x00])
|
||||
try data.write(to: source)
|
||||
let service = LocalFileService(directory: root)
|
||||
let staged = try #require(try await service.stageRemoval(source))
|
||||
#expect(!FileManager.default.fileExists(atPath: source.path))
|
||||
#expect(try await service.list().isEmpty)
|
||||
try await service.restore(staged)
|
||||
#expect(try Data(contentsOf: source) == data)
|
||||
let final = try #require(try await service.stageRemoval(source))
|
||||
try await service.finishRemoval(final)
|
||||
#expect(!FileManager.default.fileExists(atPath: final.staged.path))
|
||||
}
|
||||
|
||||
@Test func originalExportPreservesEncodingAndNeverMovesSource() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
let data = Data([0xff, 0xfe, 0x61, 0x00])
|
||||
try data.write(to: source)
|
||||
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
|
||||
let result = try await exporter.originalFile(source, filename: "../书.txt")
|
||||
#expect(try Data(contentsOf: result.url) == data)
|
||||
#expect(try Data(contentsOf: source) == data)
|
||||
#expect(result.url.lastPathComponent == ".._书.txt")
|
||||
try await exporter.discard(result)
|
||||
#expect(!FileManager.default.fileExists(atPath: result.url.path))
|
||||
#expect(FileManager.default.fileExists(atPath: source.path))
|
||||
}
|
||||
|
||||
@Test func cachedExportUsesDirectoryOrderAndMarksMissingChapters() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let cache = ChapterContentCache(directory: root.appendingPathComponent("cache"))
|
||||
let book = Book(bookUrl: "book", name: "书", author: "作者")
|
||||
let chapters = (0..<3).map { BookChapter(url: "c\($0)", title: "第\($0 + 1)章", bookUrl: "book", index: $0) }
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "c2", content: "第三章正文", supplementalContent: "补充"))
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "c0", content: "第一章正文"))
|
||||
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"), cache: cache)
|
||||
let result = try await exporter.cachedBook(book, chapters: chapters.reversed())
|
||||
let text = try String(contentsOf: result.url, encoding: .utf8)
|
||||
#expect(result.cachedChapters == 2)
|
||||
#expect(result.totalChapters == 3)
|
||||
#expect(result.isPartial)
|
||||
#expect(text.contains("第2章\n\n【本章尚未缓存】"))
|
||||
#expect(text.contains("第三章正文\n补充"))
|
||||
#expect(try #require(text.range(of: "第一章正文")).lowerBound < #require(text.range(of: "第三章正文")).lowerBound)
|
||||
}
|
||||
|
||||
@Test func emptyCacheCannotProduceMisleadingEmptyBook() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let exports = root.appendingPathComponent("exports")
|
||||
let exporter = BookFileExporter(directory: exports, cache: ChapterContentCache(directory: root.appendingPathComponent("cache")))
|
||||
let book = Book(bookUrl: "book", name: "书")
|
||||
let chapter = BookChapter(url: "c", title: "第一章", bookUrl: "book", index: 0)
|
||||
await #expect(throws: LocalFileError.emptyCache) { try await exporter.cachedBook(book, chapters: [chapter]) }
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: exports.path).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct BookFlowMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString], "缺少 mock 页面:\(url.absoluteString)")
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkBookServiceTests {
|
||||
@Test func loadsDetailPaginatedTocAndPaginatedContent() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://reader.example.test/book/1": """
|
||||
<main class="book"><h1>迁移实战</h1><a class="author">作者甲</a>
|
||||
<img class="cover" src="/cover.jpg"><p class="intro">一本测试书</p>
|
||||
<a class="toc" href="/book/1/toc/1">目录</a></main>
|
||||
""",
|
||||
"https://reader.example.test/book/1/toc/1": """
|
||||
<ul><li class="chapter"><a href="/chapter/1">第一章</a></li></ul>
|
||||
<a class="next" href="/book/1/toc/2">下一页</a>
|
||||
""",
|
||||
"https://reader.example.test/book/1/toc/2": """
|
||||
<ul><li class="chapter"><a href="/chapter/2">第二章</a></li></ul>
|
||||
""",
|
||||
"https://reader.example.test/chapter/1": """
|
||||
<h1>第一章 新标题</h1><article><p>第一段</p></article>
|
||||
<a class="next" href="/chapter/1?page=2">下一页</a>
|
||||
""",
|
||||
"https://reader.example.test/chapter/1?page=2": """
|
||||
<article><p>第二段</p></article>
|
||||
"""
|
||||
])
|
||||
var source = BookSource(
|
||||
bookSourceUrl: "https://reader.example.test",
|
||||
bookSourceName: "完整链路源"
|
||||
)
|
||||
var info = BookInfoRule()
|
||||
info.`init` = "@CSS:main.book"
|
||||
info.name = "@CSS:h1@text"
|
||||
info.author = "@CSS:.author@text"
|
||||
info.intro = "@CSS:.intro@text"
|
||||
info.coverUrl = "@CSS:img.cover@src"
|
||||
info.tocUrl = "@CSS:a.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
toc.nextTocUrl = "@CSS:a.next@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article p@text"
|
||||
content.title = "@CSS:h1@text"
|
||||
content.nextContentUrl = "@CSS:a.next@href"
|
||||
source.ruleContent = content
|
||||
let searchBook = SearchBook(
|
||||
bookURL: "https://reader.example.test/book/1",
|
||||
origin: source.bookSourceUrl,
|
||||
originName: source.bookSourceName,
|
||||
type: 0,
|
||||
name: "搜索结果名"
|
||||
)
|
||||
let service = NetworkBookService(transport: transport)
|
||||
|
||||
let detail = await service.loadDetail(searchBook: searchBook, source: source)
|
||||
#expect(detail.book.name == "迁移实战")
|
||||
#expect(detail.book.author == "作者甲")
|
||||
#expect(detail.book.coverUrl == "https://reader.example.test/cover.jpg")
|
||||
#expect(detail.book.tocUrl == "https://reader.example.test/book/1/toc/1")
|
||||
#expect(detail.diagnostics.isEmpty)
|
||||
|
||||
let tocReport = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(tocReport.chapters.map(\.title) == ["第一章", "第二章"])
|
||||
#expect(tocReport.chapters.map(\.index) == [0, 1])
|
||||
#expect(tocReport.pagesVisited == 2)
|
||||
#expect(tocReport.diagnostics.isEmpty)
|
||||
|
||||
let chapter = try #require(tocReport.chapters.first)
|
||||
let contentReport = await service.loadContent(
|
||||
book: detail.book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: tocReport.chapters[1].url
|
||||
)
|
||||
#expect(contentReport.title == "第一章 新标题")
|
||||
#expect(contentReport.content == "第一段\n第二段")
|
||||
#expect(contentReport.pagesVisited == 2)
|
||||
#expect(contentReport.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func returnsDiagnosticsForEmptyContentAndStopsTocLoops() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://reader.example.test/toc": """
|
||||
<li class="chapter"><a href="/chapter/empty">空章</a></li>
|
||||
<a class="next" href="/toc">循环</a>
|
||||
""",
|
||||
"https://reader.example.test/chapter/empty": "<article></article>"
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://reader.example.test", bookSourceName: "诊断源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
toc.nextTocUrl = "@CSS:a.next@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article@text"
|
||||
source.ruleContent = content
|
||||
var book = Book(
|
||||
bookUrl: "https://reader.example.test/book/empty",
|
||||
name: "空书",
|
||||
origin: source.bookSourceUrl
|
||||
)
|
||||
book.tocUrl = "https://reader.example.test/toc"
|
||||
let service = NetworkBookService(transport: transport)
|
||||
|
||||
let tocReport = await service.loadChapters(book: book, source: source)
|
||||
#expect(tocReport.pagesVisited == 1)
|
||||
#expect(tocReport.chapters.count == 1)
|
||||
let chapter = try #require(tocReport.chapters.first)
|
||||
let contentReport = await service.loadContent(book: book, chapter: chapter, source: source)
|
||||
|
||||
#expect(contentReport.content.isEmpty)
|
||||
#expect(contentReport.diagnostics.contains { $0.code == "content.empty" })
|
||||
}
|
||||
|
||||
@Test func carriesBookVariablesIntoJavaScriptChapterRules() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://audio.example.test/toc": #"{"content":{"content":[{"id":2002,"chapterTitle":"第一集"}]}}"#
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "变量源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "$.content.content"
|
||||
toc.chapterName = "$.chapterTitle"
|
||||
toc.chapterUrl = "$.id@js:java.put('chapterId', result); '/audio?bookId=' + java.get('bookId') + '&chapterId=' + result"
|
||||
source.ruleToc = toc
|
||||
var book = Book(
|
||||
bookUrl: "https://audio.example.test/book/1001",
|
||||
name: "听书",
|
||||
origin: source.bookSourceUrl
|
||||
)
|
||||
book.tocUrl = "https://audio.example.test/toc"
|
||||
book.variable = #"{"bookId":"1001"}"#
|
||||
|
||||
let report = await NetworkBookService(transport: transport).loadChapters(book: book, source: source)
|
||||
|
||||
let chapter = try #require(report.chapters.first)
|
||||
#expect(chapter.url == "https://audio.example.test/audio?bookId=1001&chapterId=2002")
|
||||
let variables = try JSONDecoder().decode(
|
||||
[String: String].self,
|
||||
from: Data(try #require(chapter.variable).utf8)
|
||||
)
|
||||
#expect(variables["bookId"] == "1001")
|
||||
#expect(variables["chapterId"] == "2002")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
@testable import LegadoCore
|
||||
|
||||
struct QRCodeDecoderTests {
|
||||
@Test func decodesActualPNGQRCodeAndResolvesImportPayload() throws {
|
||||
let payload = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json"
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(payload.utf8)
|
||||
let output = try #require(filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)))
|
||||
let image = try #require(CIContext().createCGImage(output, from: output.extent))
|
||||
let data = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil))
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
#expect(CGImageDestinationFinalize(destination))
|
||||
let values = try QRCodeDecoder().detect(in: data as Data)
|
||||
#expect(values == [payload])
|
||||
#expect(try RuleImportInput.parse(values[0]) == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: false))
|
||||
}
|
||||
|
||||
@Test func invalidImageReportsDecodeFailure() {
|
||||
#expect(throws: (any Error).self) { try QRCodeDecoder().detect(in: Data("invalid image".utf8)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ReadingActivityRepositoryTests {
|
||||
@Test func decodesAndEncodesBookmarkPosition() throws {
|
||||
let data = Data(#"{"time":7,"bookName":"书","bookAuthor":"作者","chapterIndex":2,"chapterPos":88,"chapterName":"第二章","bookText":"片段","content":"备注"}"#.utf8)
|
||||
let bookmark = try JSONDecoder().decode(Bookmark.self, from: data)
|
||||
let encoded = try JSONEncoder().encode(bookmark)
|
||||
let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
|
||||
#expect(bookmark.chapterPosition == 88)
|
||||
#expect(object["chapterPos"] as? Int == 88)
|
||||
#expect(object["chapterPosition"] == nil)
|
||||
}
|
||||
|
||||
@Test func accumulatesPerDeviceAndSummarizesAcrossDevices() {
|
||||
let service = ReadingActivityService()
|
||||
let now: Int64 = 2_000
|
||||
var records = [ReadRecord(deviceId: "a", bookName: "书", readTime: 1_000, lastRead: 1_000)]
|
||||
records = service.addingReadTime(500, deviceID: "a", bookName: "书", now: now, to: records)
|
||||
records = service.addingReadTime(700, deviceID: "b", bookName: "书", now: now + 1, to: records)
|
||||
|
||||
let summary = service.summaries(records)
|
||||
#expect(records.count == 2)
|
||||
#expect(summary == [.init(bookName: "书", readTime: 2_200, lastRead: 2_001)])
|
||||
}
|
||||
|
||||
@Test func decodesLegacyReadRecordWithoutDailyStatistics() throws {
|
||||
let data = Data(#"{"deviceId":"ios","bookName":"旧书","readTime":120000,"lastRead":7}"#.utf8)
|
||||
let record = try JSONDecoder().decode(ReadRecord.self, from: data)
|
||||
|
||||
#expect(record.dailyReadTimes.isEmpty)
|
||||
#expect(record.readTime == 120_000)
|
||||
}
|
||||
|
||||
@Test func calculatesTodayStreakTotalsAndUniqueBooks() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 12)))
|
||||
let service = ReadingActivityService()
|
||||
let records = [
|
||||
ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "第一本",
|
||||
readTime: 9_000_000,
|
||||
lastRead: Int64(now.timeIntervalSince1970 * 1_000),
|
||||
dailyReadTimes: ["2026-08-18": 600_000, "2026-08-19": 1_200_000, "2026-08-20": 1_800_000]
|
||||
),
|
||||
ReadRecord(
|
||||
deviceId: "b",
|
||||
bookName: "第一本",
|
||||
readTime: 3_000_000,
|
||||
dailyReadTimes: ["2026-08-20": 300_000]
|
||||
),
|
||||
ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "第二本",
|
||||
readTime: 4_000_000,
|
||||
dailyReadTimes: ["2026-08-17": 60_000]
|
||||
)
|
||||
]
|
||||
|
||||
let statistics = service.statistics(records, now: now, calendar: calendar)
|
||||
|
||||
#expect(statistics.todayReadTime == 2_100_000)
|
||||
#expect(statistics.currentStreak == 4)
|
||||
#expect(statistics.totalReadTime == 16_000_000)
|
||||
#expect(statistics.bookCount == 2)
|
||||
#expect(statistics.recentDays.count == 7)
|
||||
}
|
||||
|
||||
@Test func keepsCurrentStreakUntilTheDayAfterLastReading() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 8)))
|
||||
let records = [ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "书",
|
||||
readTime: 120_000,
|
||||
dailyReadTimes: ["2026-08-18": 60_000, "2026-08-19": 60_000]
|
||||
)]
|
||||
|
||||
let statistics = ReadingActivityService().statistics(records, now: now, calendar: calendar)
|
||||
|
||||
#expect(statistics.todayReadTime == 0)
|
||||
#expect(statistics.currentStreak == 2)
|
||||
}
|
||||
|
||||
@Test func filtersAndSortsReadRecords() {
|
||||
let records = [
|
||||
ReadRecord(deviceId: "a", bookName: "Beta", readTime: 5_000, lastRead: 20),
|
||||
ReadRecord(deviceId: "a", bookName: "Alpha", readTime: 1_000, lastRead: 30),
|
||||
ReadRecord(deviceId: "b", bookName: "Beta", readTime: 2_000, lastRead: 40)
|
||||
]
|
||||
let service = ReadingActivityService()
|
||||
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 0).map(\.bookName) == ["Alpha", "Beta"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 1).map(\.bookName) == ["Beta", "Alpha"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 2).map(\.bookName) == ["Beta", "Alpha"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "alp", sortMode: 0).map(\.bookName) == ["Alpha"])
|
||||
}
|
||||
|
||||
@Test func sortsBookmarksByBookAndPosition() {
|
||||
var later = Bookmark(time: 1, bookName: "B", bookAuthor: "作者")
|
||||
later.chapterIndex = 1
|
||||
later.chapterPosition = 20
|
||||
var earlierPosition = Bookmark(time: 2, bookName: "A", bookAuthor: "作者")
|
||||
earlierPosition.chapterIndex = 2
|
||||
earlierPosition.chapterPosition = 30
|
||||
var first = Bookmark(time: 3, bookName: "A", bookAuthor: "作者")
|
||||
first.chapterIndex = 1
|
||||
first.chapterPosition = 40
|
||||
|
||||
let sorted = ReadingActivityService().sortedBookmarks([later, earlierPosition, first])
|
||||
|
||||
#expect(sorted.map(\.time) == [first.time, earlierPosition.time, later.time])
|
||||
}
|
||||
|
||||
@Test func persistsBookmarksAndRecordsAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileReadingActivityRepository(
|
||||
bookmarksURL: directory.appending(path: "bookmarks.json"),
|
||||
recordsURL: directory.appending(path: "records.json")
|
||||
)
|
||||
var bookmark = Bookmark(time: 4, bookName: "书")
|
||||
bookmark.chapterPosition = 19
|
||||
let record = ReadRecord(deviceId: "ios", bookName: "书", readTime: 3_000, lastRead: 5)
|
||||
|
||||
try await repository.saveBookmarks([bookmark])
|
||||
try await repository.saveReadRecords([record])
|
||||
|
||||
#expect(try await repository.loadBookmarks() == [bookmark])
|
||||
#expect(try await repository.loadReadRecords() == [record])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ReplaceRuleRepositoryTests {
|
||||
@Test func decodesOrderFieldAndDefaults() throws {
|
||||
let data = Data(#"{"id":9,"name":"广告","pattern":"ad","replacement":"","order":12}"#.utf8)
|
||||
let rule = try #require(ReplaceRuleImporter().decode(data).first)
|
||||
|
||||
#expect(rule.id == 9)
|
||||
#expect(rule.sortOrder == 12)
|
||||
#expect(rule.scopeContent)
|
||||
#expect(rule.isEnabled)
|
||||
#expect(rule.isRegex)
|
||||
#expect(rule.timeoutMillisecond == 3_000)
|
||||
}
|
||||
|
||||
@Test func mergesByIdentifierAndSorts() {
|
||||
var old = ReplaceRule(id: 1, name: "旧", pattern: "old")
|
||||
old.sortOrder = 10
|
||||
var replacement = ReplaceRule(id: 1, name: "新", pattern: "new")
|
||||
replacement.sortOrder = 3
|
||||
var first = ReplaceRule(id: 2, name: "先", pattern: "first")
|
||||
first.sortOrder = 1
|
||||
|
||||
let merged = ReplaceRuleImporter().merge([replacement, first], into: [old])
|
||||
|
||||
#expect(merged.map(\.id) == [2, 1])
|
||||
#expect(merged.last?.name == "新")
|
||||
}
|
||||
|
||||
@Test func persistsRulesAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileReplaceRuleRepository(fileURL: directory.appending(path: "rules.json"))
|
||||
let rule = ReplaceRule(id: 3, name: "空行", pattern: "\\n{3,}", replacement: "\n\n")
|
||||
|
||||
try await repository.save([rule])
|
||||
let restored = try await repository.load()
|
||||
|
||||
#expect(restored == [rule])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleEditingCompatibilityTests {
|
||||
@Test func unknownFieldsSurviveImportFieldEditingAndRepositoryRoundTrip() async throws {
|
||||
let input = #"{"bookSourceUrl":"https://roundtrip.test","bookSourceName":"扩展源","futureOption":{"token":"keep"},"ruleSearch":"{\"bookList\":\"$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\",\"futureRule\":{\"x\":true}}"}"#
|
||||
let original = try #require(BookSourceImporter().decode(Data(input.utf8)).first)
|
||||
var document = try BookSourceEditorDocument(source: original)
|
||||
document.set(.string("已编辑"), at: "bookSourceName")
|
||||
document.set(.string("$.title"), at: "ruleSearch.name")
|
||||
let edited = try document.source()
|
||||
let repository = InMemoryBookSourceRepository()
|
||||
try await repository.save([edited])
|
||||
let restored = try #require(await repository.load().first)
|
||||
let json = try JSONValue.decode(data: JSONEncoder().encode(restored))
|
||||
#expect(json["futureOption"]?["token"] == .string("keep"))
|
||||
#expect(json["ruleSearch"]?["futureRule"]?["x"] == .bool(true))
|
||||
#expect(restored.ruleSearch?.name == "$.title")
|
||||
#expect(restored.bookSourceName == "已编辑")
|
||||
#expect(json["additionalFields"] == nil)
|
||||
}
|
||||
|
||||
@Test func clearingKnownRuleDoesNotRestoreOldValueFromPreservedFields() throws {
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"书源","ruleContent":{"content":"article@text","future":42}}"#.utf8))
|
||||
var document = try BookSourceEditorDocument(source: source)
|
||||
document.set(nil, at: "ruleContent.content")
|
||||
let json = try JSONValue.decode(data: JSONEncoder().encode(document.source()))
|
||||
#expect(json["ruleContent"]?["content"] == nil)
|
||||
#expect(json["ruleContent"]?["future"] == .number(42))
|
||||
}
|
||||
|
||||
@Test func emptyRuleBlocksAndExplicitCookieNullRoundTrip() throws {
|
||||
let original = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"空规则","ruleExplore":{},"enabledCookieJar":null}"#.utf8))
|
||||
let restored = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(original))
|
||||
#expect(restored.ruleExplore != nil)
|
||||
#expect(restored.enabledCookieJar == nil)
|
||||
#expect(restored == original)
|
||||
}
|
||||
|
||||
@Test func partialInspectionReportsMalformedEntriesAndKeepsValidSiblings() throws {
|
||||
let input = #"[{"bookSourceUrl":"https://good","bookSourceName":"有效"},{"bookSourceUrl":"https://bad","bookSourceName":"错误","ruleSearch":{"name":12}},17,{"bookSourceName":"缺少地址"}]"#
|
||||
let inspected = try BookSourceImporter().inspect(Data(input.utf8))
|
||||
#expect(inspected.sources.map(\.bookSourceName) == ["有效"])
|
||||
#expect(inspected.rejected.count == 3)
|
||||
#expect(throws: BookSourceImportError.self) { try BookSourceImporter().decode(Data(input.utf8)) }
|
||||
}
|
||||
|
||||
@Test func editorRetainsRawInvalidJSONAndRejectsIncompleteSourceAtSave() throws {
|
||||
#expect(throws: (any Error).self) { try BookSourceEditorDocument(json: "{") }
|
||||
#expect(throws: BookSourceImportError.self) { try BookSourceEditorDocument(json: "[]") }
|
||||
let draft = try BookSourceEditorDocument(json: #"{"bookSourceName":"未完成"}"#)
|
||||
#expect(throws: BookSourceImportError.self) { try draft.source() }
|
||||
}
|
||||
|
||||
@Test func importInputAcceptsQRJSONDeepLinksAndWithoutUA() throws {
|
||||
let deep = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json%23requestWithoutUA"
|
||||
let parsed = try RuleImportInput.parse(deep)
|
||||
#expect(parsed == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: true))
|
||||
#expect(parsed.request()?.value(forHTTPHeaderField: "User-Agent") == "null")
|
||||
#expect(try RuleImportInput.parse("[合集](https://example.test/source.json)") == .remote(URL(string: "https://example.test/source.json")!, withoutUserAgent: false))
|
||||
#expect(try RuleImportInput.parse(#"{"bookSourceName":"测试"}"#) == .json(#"{"bookSourceName":"测试"}"#))
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("legado://import/replaceRule?src=https://example.test/rules") }
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("file:///etc/passwd") }
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("https://") }
|
||||
}
|
||||
|
||||
@Test func purificationTestMatchesReaderScopesAndCaptures() throws {
|
||||
var rule = ReplaceRule(id: 1, name: "替换", pattern: "第([0-9]+)章", replacement: "Chapter $1")
|
||||
rule.scopeTitle = true
|
||||
rule.scopeContent = true
|
||||
rule.scope = "示例书;https://test"
|
||||
let test = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test")
|
||||
#expect(test.report.title == "Chapter 12")
|
||||
#expect(test.report.content == "Chapter 34 正文")
|
||||
#expect(test.report.effectiveRuleIDs == [1])
|
||||
rule.excludeScope = "示例书"
|
||||
let excluded = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test")
|
||||
#expect(excluded.report.title == "第12章")
|
||||
#expect(excluded.report.effectiveRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test func purificationLiteralReplacementKeepsDollarSignsAndWhitespace() throws {
|
||||
var rule = ReplaceRule(id: 2, name: "字面", pattern: "[广告]", replacement: "$1")
|
||||
rule.isRegex = false
|
||||
let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: " 正文\n[广告]\n", bookName: "", sourceURL: "")
|
||||
#expect(result.report.content == " 正文\n$1\n")
|
||||
}
|
||||
|
||||
@Test func invalidPurificationRegexReportsErrorInsteadOfLiteralFallback() throws {
|
||||
let rule = ReplaceRule(id: 3, name: "损坏", pattern: "[")
|
||||
#expect(throws: ReplaceRuleImportError.self) { try ReplaceRuleValidator.validate(rule) }
|
||||
let book = Book(bookUrl: "test", name: "书", origin: "test")
|
||||
let chapter = BookChapter(url: "chapter", title: "标题", bookUrl: "test", index: 0)
|
||||
let report = ContentSanitizer().process(book: book, chapter: chapter, content: "[原文", rules: [rule])
|
||||
#expect(report.content == "[原文")
|
||||
#expect(report.diagnostics.first?.code == "rule.malformed")
|
||||
}
|
||||
|
||||
@Test func regexDeadlineInterruptsBacktrackingAndDoesNotReturnPartialReplacement() {
|
||||
let input = String(repeating: "a", count: 100) + "!"
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try RegexEvaluator().replacingStrict(pattern: "(a|aa)+$", in: input, with: "changed", timeoutMilliseconds: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func purificationResourceLimitsPreserveInputAndReportDiagnostic() throws {
|
||||
let rule = ReplaceRule(id: 3, name: "嵌套", pattern: "(a+)+$", replacement: "changed")
|
||||
let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: "aaaa!", bookName: "书", sourceURL: "test")
|
||||
#expect(result.report.content == "aaaa!")
|
||||
#expect(result.report.diagnostics.first?.code == "rule.resource_limit")
|
||||
let regex = RegexEvaluator(limits: .init(maximumMatches: 2))
|
||||
#expect(throws: RuleEngineError.self) { try regex.replacingStrict(pattern: "a", in: "aaa", with: "b") }
|
||||
#expect(try regex.replacingStrict(pattern: "a", in: "aa", with: "b") == "bb")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleEngineTests {
|
||||
@Test func scalarResultMatchesLegadoJoinAndLeadingURLSemantics() {
|
||||
let result = RuleEvaluation(values: [.string(""), .string("乙"), .string("丙")])
|
||||
#expect(result.joinedString == "\n乙\n丙")
|
||||
#expect(result.leadingString == nil)
|
||||
#expect(result.firstString == "乙")
|
||||
}
|
||||
|
||||
private let engine = RuleEngine()
|
||||
|
||||
@Test func javaScriptStagesContinueIntoSelectorsAndReplacementOnlyRules() async {
|
||||
let result = await engine.evaluate(
|
||||
"<js>result.replace('旧', '新')</js>class.body@text<js>result + ' 广告'</js>## 广告",
|
||||
inText: "<div class='body'>旧正文</div>"
|
||||
)
|
||||
#expect(result.strings == ["新正文"])
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
let replaced = await engine.evaluate("##广告##", inText: "正文广告")
|
||||
#expect(replaced.strings == ["正文"])
|
||||
#expect(replaced.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func listJavaScriptReceivesWholeListAndScalarJavaScriptReceivesJoinedText() async {
|
||||
let list = await engine.evaluate(
|
||||
"tag.a<js>result.reverse()</js>",
|
||||
inText: "<a>A</a><a>B</a>", listMode: true
|
||||
)
|
||||
#expect(list.diagnostics.isEmpty)
|
||||
#expect(list.values.first?[0]?.stringValue.contains(">B<") == true)
|
||||
let scalar = await engine.evaluate("tag.p@text@js:result.split('\\n').reverse().join('|')", inText: "<p>A</p><p>B</p>")
|
||||
#expect(scalar.strings == ["B|A"])
|
||||
}
|
||||
|
||||
@Test func javaScriptCanReadOriginalContentAfterExtractionAndReturnJSON() async {
|
||||
let result = await engine.evaluate(
|
||||
"tag.h1@text@js:result + ':' + java.getString('tag.p@text')",
|
||||
inText: "<h1>书名</h1><p>作者</p>"
|
||||
)
|
||||
#expect(result.strings == ["书名:作者"])
|
||||
let json = await engine.evaluate("<js>JSON.parse(result.slice(4))</js>$.data.name", inText: "data{\"data\":{\"name\":\"书名\"}}")
|
||||
#expect(json.strings == ["书名"])
|
||||
#expect(json.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func standaloneDynamicRulesReturnValuesAndJavaScriptIsNotParsedAsRuleSyntax() async {
|
||||
let variable = await engine.evaluate("@get:{token}", inText: "<p/>", context: .init(variables: ["token": "abc"]))
|
||||
#expect(variable.strings == ["abc"])
|
||||
let inline = await engine.evaluate("{{'https://example.test/' + 42}}", inText: "<p/>")
|
||||
#expect(inline.strings == ["https://example.test/42"])
|
||||
let script = await engine.evaluate("@js:result.replace('##', '')", inText: "正文##")
|
||||
#expect(script.strings == ["正文"])
|
||||
let nested = await engine.evaluate("{{@CSS:h1@text}}-{{//p/text()}}", inText: "<h1>书名</h1><p>作者</p>")
|
||||
#expect(nested.strings == ["书名-作者"])
|
||||
let jsonString = await engine.evaluate("<js>result</js>{{$.name}}", inText: #"{"name":"书名"}"#)
|
||||
#expect(jsonString.strings == ["书名"])
|
||||
let captures = await engine.evaluate("$1##(甲)##$1乙", in: .array([.string("甲"), .string("甲")]))
|
||||
#expect(captures.strings == ["甲乙"])
|
||||
}
|
||||
|
||||
@Test func importedQQBookURLsAndNumericCoverIDsUseStringPipelineSemantics() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"bid":1009704712}"#.utf8))
|
||||
let url = await engine.evaluate(
|
||||
"$.bid\n<js>1100000000+parseInt(result)</js>\nhttps://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid={{result}}",
|
||||
in: input
|
||||
)
|
||||
#expect(url.strings == ["https://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid=2109704712"])
|
||||
#expect(url.diagnostics.isEmpty)
|
||||
let cover = await engine.evaluate("$.bid@js:result.slice(-3)", in: input)
|
||||
#expect(cover.strings == ["712"])
|
||||
#expect(cover.diagnostics.isEmpty)
|
||||
let literal = await engine.evaluate("https://example.test/@get:{id}", in: input, context: .init(variables: ["id": "42"]))
|
||||
#expect(literal.strings == ["https://example.test/42"])
|
||||
}
|
||||
|
||||
@Test func combinesFallbackAndConcatenation() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"a":"A","b":"B"}"#.utf8))
|
||||
|
||||
let fallback = await engine.evaluate("$.missing||$.b", in: input)
|
||||
#expect(fallback.strings == ["B"])
|
||||
#expect(fallback.diagnostics.isEmpty)
|
||||
|
||||
let concatenated = await engine.evaluate("$.a&&$.b", in: input)
|
||||
#expect(concatenated.strings == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func appliesRegexReplacement() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"name":"A B"}"#.utf8))
|
||||
|
||||
let result = await engine.evaluate("$.name##\\s+## ", in: input)
|
||||
|
||||
#expect(result.firstString == "A B")
|
||||
}
|
||||
|
||||
@Test func regexCapturesAndRejectsHighRiskPattern() throws {
|
||||
let evaluator = RegexEvaluator()
|
||||
#expect(try evaluator.captureGroups(pattern: #"(\w+):(\d+)"#, input: "a:1 b:2") == [
|
||||
["a:1", "a", "1"], ["b:2", "b", "2"]
|
||||
])
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try evaluator.captureGroups(pattern: "(a+)+", input: String(repeating: "a", count: 100))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func interleaveStopsAtFirstBranchLength() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"a":["A"],"b":["B1","B2"]}"#.utf8))
|
||||
let result = await engine.evaluate("$.a[*]%%$.b[*]", in: input)
|
||||
#expect(result.strings == ["A", "B1"])
|
||||
}
|
||||
|
||||
@Test func replacementUsesFirstMatchAndInvalidRegexFallback() throws {
|
||||
let evaluator = RegexEvaluator()
|
||||
|
||||
#expect(try evaluator.replacing(pattern: #"\d+"#, in: "a12b34", with: "<$0>", firstOnly: true) == "<12>")
|
||||
#expect(try evaluator.replacing(pattern: #"\d+"#, in: "abc", with: "x", firstOnly: true) == "")
|
||||
#expect(try evaluator.replacing(pattern: "[", in: "a[b[", with: "x", firstOnly: false) == "axbx")
|
||||
#expect(try evaluator.replacing(pattern: "[", in: "unchanged", with: "fallback", firstOnly: true) == "fallback")
|
||||
}
|
||||
|
||||
@Test func supportsPutGetAndInlineJavaScriptInRules() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"selector":"field","field":"ok"}"#.utf8))
|
||||
|
||||
let putGet = await engine.evaluate(#"@put:{"selected":"$.selector"}$.@get:{selected}"#, in: input)
|
||||
let inline = await engine.evaluate(#"$.{{java.put('inlineUsed','yes'); 'field'}}"#, in: input)
|
||||
|
||||
#expect(putGet.firstString == "ok")
|
||||
#expect(putGet.variables["selected"] == "field")
|
||||
#expect(inline.firstString == "ok")
|
||||
#expect(inline.variables["inlineUsed"] == "yes")
|
||||
}
|
||||
|
||||
@Test func unquotedPutRulesPreserveBookIDsAndReturnTheSelectedTitle() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"id":42,"title":"书名","selector":"field","field":"正文"}"#.utf8))
|
||||
let result = await engine.evaluate("title@put:{bid:id, selected:$.selector}", in: input)
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.firstString == "书名")
|
||||
#expect(result.variables["bid"] == "42")
|
||||
#expect(result.variables["selected"] == "field")
|
||||
let body = await engine.evaluate("$.@get:{selected}", in: input, context: .init(variables: result.variables))
|
||||
#expect(body.firstString == "正文")
|
||||
let html = await engine.evaluate("h1@text@put:{chapter:a@href}", inText: "<h1>章节</h1><a href='/1'>目录</a>")
|
||||
#expect(html.diagnostics.isEmpty)
|
||||
#expect(html.firstString == "章节")
|
||||
#expect(html.variables["chapter"] == "/1")
|
||||
}
|
||||
|
||||
@Test func dynamicDoubleBracesTreatJSONPathAsNestedRuleInsteadOfJavaScript() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"book_id":"42","category":"科幻"}"#.utf8))
|
||||
|
||||
let value = await engine.evaluate(#"{{$.category}}-{{$.book_id}}"#, in: input)
|
||||
let piped = await engine.evaluate(#"{{$.category}}@js:result + '-已处理'"#, in: input)
|
||||
|
||||
#expect(value.firstString == "科幻-42")
|
||||
#expect(value.diagnostics.isEmpty)
|
||||
#expect(piped.firstString == "科幻-已处理")
|
||||
#expect(piped.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func putOnlyRuleKeepsCurrentResultWhileWritingVariable() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"name":"保留"}"#.utf8))
|
||||
|
||||
let result = await engine.evaluate(#"@put:{"saved":"$.name"}"#, in: input)
|
||||
|
||||
#expect(result.values == [input])
|
||||
#expect(result.variables["saved"] == "保留")
|
||||
}
|
||||
|
||||
@Test func allInOneRegexProducesCaptureRowsAndDollarFields() async throws {
|
||||
let rows = await engine.evaluate(
|
||||
#":name=([^;]+);url=([^\n]+)"#,
|
||||
inText: "name=甲;url=/a\nname=乙;url=/b",
|
||||
listMode: true
|
||||
)
|
||||
let first = try #require(rows.values.first)
|
||||
|
||||
let name = await engine.evaluate("$1", in: first)
|
||||
let url = await engine.evaluate("$2", in: first)
|
||||
|
||||
#expect(rows.values.count == 2)
|
||||
#expect(name.firstString == "甲")
|
||||
#expect(url.firstString == "/a")
|
||||
}
|
||||
|
||||
@Test func allInOneRegexSupportsSequentialAndChain() async {
|
||||
let rows = await engine.evaluate(
|
||||
#":<row>.*?</row>&&name=(\w+)"#,
|
||||
inText: "<row>name=first</row><skip>x</skip><row>name=second</row>",
|
||||
listMode: true
|
||||
)
|
||||
|
||||
#expect(rows.values.count == 2)
|
||||
#expect(rows.values[0][1]?.stringValue == "first")
|
||||
#expect(rows.values[1][1]?.stringValue == "second")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleExpressionParserTests {
|
||||
private let parser = RuleExpressionParser()
|
||||
|
||||
@Test func detectsModesAndReplacement() throws {
|
||||
let plan = try parser.parse("@Json:$.book.name##\\s+## ")
|
||||
|
||||
#expect(plan.combination == .single)
|
||||
#expect(plan.branches.count == 1)
|
||||
#expect(plan.branches[0].mode == .jsonPath)
|
||||
#expect(plan.branches[0].expression == "$.book.name")
|
||||
#expect(plan.branches[0].replacePattern == "\\s+")
|
||||
#expect(plan.branches[0].replacement == " ")
|
||||
}
|
||||
|
||||
@Test func doesNotSplitOperatorsInsideJSONPathFilter() throws {
|
||||
let plan = try parser.parse("$.books[?(@.enabled && @.score > 1)]||$.fallback")
|
||||
|
||||
#expect(plan.combination == .fallback)
|
||||
#expect(plan.branches.count == 2)
|
||||
#expect(plan.branches[0].expression.contains("&&"))
|
||||
}
|
||||
|
||||
@Test func rejectsUnbalancedRules() {
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try parser.parse("$.books[0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct SearchPerformanceTests {
|
||||
@Test @MainActor func complexSourcesCannotOccupyEverySearchSlot() async throws {
|
||||
let webView = DelayedPerformanceWebView()
|
||||
var sources = (0..<8).map { source("web\($0)") }
|
||||
for index in sources.indices {
|
||||
sources[index].searchUrl = #"/search,{"webView":true}"#
|
||||
}
|
||||
let fast = source("fast")
|
||||
sources.append(fast)
|
||||
var outcomes: [SourceSearchOutcome] = []
|
||||
let service = BookSearchService(transport: PerformanceSearchTransport(), webViewExecutor: webView, history: .init())
|
||||
for await value in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 8) {
|
||||
outcomes.append(value)
|
||||
}
|
||||
#expect(outcomes.first?.sourceID == fast.id)
|
||||
#expect(webView.maximumRunning == 4)
|
||||
#expect(outcomes.count == 9)
|
||||
}
|
||||
|
||||
@Test func preferredSourcesRunBeforeUnknownSourcesAndDuplicateIDsRunOnce() async {
|
||||
let transport = PerformanceSearchTransport()
|
||||
let service = BookSearchService(transport: transport, history: .init())
|
||||
let sources = [source("a"), source("b"), source("a")]
|
||||
var results: [String] = []
|
||||
for await outcome in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 1, preferredSourceIDs: [sources[1].id]) {
|
||||
results.append(outcome.sourceID)
|
||||
}
|
||||
#expect(results == [sources[1].id, sources[0].id])
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test func historySurvivesRelaunchAndFailuresRemainEligibleForRecovery() async {
|
||||
let suite = "search.history.\(UUID().uuidString)"
|
||||
defer { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) }
|
||||
let history = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!)
|
||||
let a = source("a"), b = source("b")
|
||||
let now = Date()
|
||||
let book = SearchBook(bookURL: "book", origin: a.id, originName: "A", type: 0, name: "书")
|
||||
await history.record(.init(sourceID: a.id, books: [book], diagnostics: []), now: now)
|
||||
await history.flush()
|
||||
let reloaded = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!)
|
||||
#expect(await reloaded.ordered([b, a], preferred: [], now: now).map(\.id) == [a.id, b.id])
|
||||
await reloaded.record(.init(sourceID: a.id, books: [], diagnostics: [
|
||||
.init(severity: .error, code: "search.request", message: "offline", rule: "")
|
||||
]), now: now)
|
||||
#expect(await reloaded.ordered([a, b], preferred: [], now: now).map(\.id) == [b.id, a.id])
|
||||
#expect(await reloaded.ordered([a, b], preferred: [], now: now.addingTimeInterval(301)).map(\.id) == [a.id, b.id])
|
||||
}
|
||||
|
||||
@Test func multiSourceRequestsHaveShorterTimeoutWithoutChangingSingleSourceAllowance() async {
|
||||
let transport = PerformanceSearchTransport()
|
||||
let service = BookSearchService(transport: transport, history: .init())
|
||||
let a = source("a"), b = source("b")
|
||||
_ = await service.search(keyword: "书", sources: [a, b])
|
||||
let multi = await transport.requests()
|
||||
#expect(multi.allSatisfy { $0.timeoutInterval <= 8 })
|
||||
_ = await service.search(keyword: "书", page: 1, source: a)
|
||||
#expect(await transport.requests().last?.timeoutInterval == 30)
|
||||
}
|
||||
|
||||
@Test func incrementalMergeKeepsRelevanceOriginsAndCanReplaceCachedMetadata() {
|
||||
var accumulator = BookSearchAccumulator()
|
||||
let cached = SearchBook(bookURL: "old", origin: "a", originName: "A", type: 0, name: "书", author: "作者")
|
||||
let fresh = SearchBook(bookURL: "fresh", origin: "b", originName: "B", type: 0, name: "书", author: "作者")
|
||||
accumulator.add([.init(bookURL: "other", origin: "a", originName: "A", type: 0, name: "其他"), cached])
|
||||
accumulator.add([fresh], replacingExisting: true)
|
||||
let results = accumulator.results(keyword: "书")
|
||||
#expect(results.map(\.bookURL) == ["fresh", "other"])
|
||||
#expect(results[0].origins == ["a", "b"])
|
||||
}
|
||||
|
||||
@Test func cacheQueryUsesLiteralKeywordAndDoesNotRequireDecodingSourceCatalog() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "cache.sqlite3"))
|
||||
try await database.replaceBookSources([.init(bookSourceUrl: "source", bookSourceName: "源")])
|
||||
let repository = SQLiteSearchBookRepository(database: database)
|
||||
let exact = SearchBook(bookURL: "exact", origin: "source", originName: "源", type: 0, name: "Swift")
|
||||
let literal = SearchBook(bookURL: "literal", origin: "source", originName: "源", type: 0, name: "100% 小说")
|
||||
try await repository.put([exact, literal])
|
||||
#expect(try await repository.cachedMatches(keyword: "%").map(\.bookURL) == ["literal"])
|
||||
#expect(try await repository.cachedMatches(keyword: "swift").map(\.bookURL) == ["exact"])
|
||||
#expect(try await repository.cachedMatches(keyword: "").isEmpty)
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func blockingBodyJavaScriptDoesNotBlockOtherSourcesNetworkPolicy() async throws {
|
||||
let gate = BlockingSearchJavaScriptLoader()
|
||||
defer { gate.release() }
|
||||
let policy = SourceNetworkPolicy(javaScript: .init(networkLoader: gate))
|
||||
var prepared = try LegadoURLBuilder().prepare("https://body.example/search")
|
||||
prepared.bodyJavaScript = "java.ajax('https://gate.example/body')"
|
||||
let blockingPrepared = prepared
|
||||
let transport = PerformanceSearchTransport()
|
||||
let slow = Task {
|
||||
try await policy.execute(blockingPrepared, source: source("body"), transport: transport, timeout: 1)
|
||||
}
|
||||
var started = gate.started.makeAsyncIterator()
|
||||
_ = await started.next()
|
||||
let plain = try await policy.execute(LegadoURLBuilder().prepare("https://plain.example/search"),
|
||||
source: source("plain"), transport: transport, timeout: 1)
|
||||
#expect(plain.statusCode == 200)
|
||||
#expect(!gate.didTimeOut)
|
||||
gate.release()
|
||||
#expect(try await slow.value.decodedText == "released")
|
||||
}
|
||||
|
||||
private func source(_ name: String) -> BookSource {
|
||||
var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name)
|
||||
value.searchUrl = "/search?q={{key}}"
|
||||
value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class DelayedPerformanceWebView: SourceWebViewRequestExecuting {
|
||||
private var running = 0
|
||||
private(set) var maximumRunning = 0
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
running += 1
|
||||
maximumRunning = max(maximumRunning, running)
|
||||
defer { running -= 1 }
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: prepared.url)
|
||||
}
|
||||
}
|
||||
|
||||
private actor PerformanceSearchTransport: HTTPTransport {
|
||||
private var recorded: [URLRequest] = []
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
recorded.append(request)
|
||||
return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
func requests() -> [URLRequest] { recorded }
|
||||
}
|
||||
|
||||
private final class BlockingSearchJavaScriptLoader: JavaScriptNetworkLoading, @unchecked Sendable {
|
||||
let started: AsyncStream<Void>
|
||||
private let continuation: AsyncStream<Void>.Continuation
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private let lock = NSLock()
|
||||
private var timedOut = false
|
||||
var didTimeOut: Bool { lock.withLock { timedOut } }
|
||||
init() {
|
||||
(started, continuation) = AsyncStream.makeStream(of: Void.self)
|
||||
}
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
continuation.yield(())
|
||||
if semaphore.wait(timeout: .now() + 2) == .timedOut {
|
||||
lock.withLock { timedOut = true }
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
return .init(data: Data("released".utf8), statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
func release() { semaphore.signal() }
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct CompatibilityTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let page = try #require(pages[url.absoluteString], "Unexpected URL: \(url)")
|
||||
if url.path == "/book/42" {
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "X-Token") == "token")
|
||||
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "id=42")
|
||||
}
|
||||
return .init(data: Data(page.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PostPaginationTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
#expect(request.httpMethod == "POST")
|
||||
let second = request.httpBody == Data("page=2".utf8)
|
||||
let page = second ? "2" : "1"
|
||||
let next = second ? "" : #"/pages,{"method":"POST","body":"page=2"}"#
|
||||
let data = try JSONSerialization.data(withJSONObject: [
|
||||
"chapters": [["name": "第\(page)章", "url": "/chapter/\(page)"]],
|
||||
"content": "第\(page)段", "next": next
|
||||
])
|
||||
return .init(data: data, statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceCompatibilityTests {
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_READABILITY_AUDIT"] != nil))
|
||||
func liveSearchResultReadability() async throws {
|
||||
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"])
|
||||
let imported = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let sources = Dictionary(imported.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }).values
|
||||
.filter { $0.enabled && $0.bookSourceType == 0 }
|
||||
let worker = Task { () -> [SearchBook] in
|
||||
var books: [SearchBook] = []
|
||||
for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: Array(sources)) {
|
||||
guard !Task.isCancelled else { break }
|
||||
books += outcome.books.filter { $0.name == "极道天魔" && $0.author.contains("滚开") }
|
||||
if books.count >= 24 { break }
|
||||
}
|
||||
return books
|
||||
}
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
if !Task.isCancelled { worker.cancel() }
|
||||
}
|
||||
let books = await worker.value
|
||||
deadline.cancel()
|
||||
await withTaskGroup(of: String.self) { group in
|
||||
for book in books {
|
||||
guard let source = sources.first(where: { $0.id == book.origin }) else { continue }
|
||||
group.addTask {
|
||||
let service = NetworkBookService()
|
||||
let detail = await service.loadDetail(searchBook: book, source: source)
|
||||
let toc = await service.loadChapters(book: detail.book, source: source)
|
||||
guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else {
|
||||
return "READABILITY \(source.bookSourceName): no directory; \((detail.diagnostics + toc.diagnostics).map(\.code))"
|
||||
}
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source,
|
||||
nextChapterURL: toc.chapters.dropFirst(chapter.index + 1).first(where: { !$0.isVolume })?.url)
|
||||
let errors = body.diagnostics.filter { $0.severity == .error }.map { "\($0.code): \($0.message)" }
|
||||
return "READABILITY \(source.bookSourceName): \(toc.chapters.count) chapters, \(body.content.count) characters; \(errors)"
|
||||
}
|
||||
}
|
||||
for await result in group { print(result) }
|
||||
}
|
||||
print("READABILITY audited \(books.count) search results")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SEARCH_BENCHMARK"] != nil))
|
||||
func liveMultiSourceSearchLatency() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let path = try #require(environment["LEGADO_SOURCE_CATALOG"])
|
||||
let decoded = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let sources = Dictionary(decoded.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
.values.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
|
||||
let started = ContinuousClock.now
|
||||
let worker = Task { () -> [String: Double] in
|
||||
var times: [String: Double] = [:]
|
||||
var completed = 0
|
||||
for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: sources) {
|
||||
guard !Task.isCancelled else { break }
|
||||
completed += 1
|
||||
let elapsed = started.duration(to: .now)
|
||||
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
if !outcome.books.isEmpty, times["firstResultSeconds"] == nil {
|
||||
times["firstResultSeconds"] = seconds
|
||||
print("Search benchmark first result: \(seconds)s, \(outcome.sourceID)")
|
||||
}
|
||||
if outcome.books.contains(where: { $0.name == "极道天魔" && $0.author == "滚开" }) {
|
||||
times["firstExactSeconds"] = seconds
|
||||
print("Search benchmark exact result: \(seconds)s, \(outcome.sourceID)")
|
||||
break
|
||||
}
|
||||
}
|
||||
times["completedSources"] = Double(completed)
|
||||
times["totalSources"] = Double(sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count)
|
||||
return times
|
||||
}
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
if !Task.isCancelled { worker.cancel() }
|
||||
}
|
||||
let measurements = await worker.value
|
||||
deadline.cancel()
|
||||
print("Search benchmark: \(measurements)")
|
||||
if let output = environment["LEGADO_SEARCH_BENCHMARK_OUTPUT"] {
|
||||
try JSONSerialization.data(withJSONObject: measurements, options: [.prettyPrinted, .sortedKeys])
|
||||
.write(to: URL(fileURLWithPath: output))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func qqArrayContentBecomesParagraphsWithoutOptionalCleanupRule() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "JSON 正文")
|
||||
var rule = ContentRule()
|
||||
rule.content = "<p>{{$.data.Content[0].Content}}</p>"
|
||||
source.ruleContent = rule
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.test/chapter/1": #"{"data":{"Content":[{"Content":["第一段\r\n 第二段","<p>第三段</p><img src='/image.jpg'>"]}]}}"#
|
||||
])
|
||||
let book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id)
|
||||
let chapter = BookChapter(url: "https://fixture.test/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 0)
|
||||
let report = await NetworkBookService(transport: transport).loadContent(book: book, chapter: chapter, source: source)
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
#expect(report.content == "第一段\n第二段\n第三段\n<img src=\"https://fixture.test/image.jpg\">")
|
||||
}
|
||||
|
||||
@Test func paginatedPostRequestsToSameEndpointAreDistinctPages() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "POST 分页")
|
||||
var tocRule = TocRule()
|
||||
tocRule.chapterList = "$.chapters"
|
||||
tocRule.chapterName = "$.name"
|
||||
tocRule.chapterUrl = "$.url"
|
||||
tocRule.nextTocUrl = "$.next"
|
||||
source.ruleToc = tocRule
|
||||
var contentRule = ContentRule()
|
||||
contentRule.content = "$.content"
|
||||
contentRule.nextContentUrl = "$.next"
|
||||
source.ruleContent = contentRule
|
||||
var book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id)
|
||||
book.tocUrl = #"https://fixture.test/pages,{"method":"POST","body":"page=1"}"#
|
||||
let service = NetworkBookService(transport: PostPaginationTransport())
|
||||
let toc = await service.loadChapters(book: book, source: source)
|
||||
#expect(toc.diagnostics.isEmpty)
|
||||
#expect(toc.chapters.map(\.title) == ["第1章", "第2章"])
|
||||
#expect(toc.pagesVisited == 2)
|
||||
let chapter = BookChapter(url: book.tocUrl, title: "第一章", bookUrl: book.bookUrl, index: 0)
|
||||
let content = await service.loadContent(book: book, chapter: chapter, source: source)
|
||||
#expect(content.diagnostics.isEmpty)
|
||||
#expect(content.content == "第1段\n第2段")
|
||||
#expect(content.pagesVisited == 2)
|
||||
}
|
||||
|
||||
@Test func responseHonorsLegacyHTMLCharsetWithoutRequestOverride() throws {
|
||||
let encoding = try #require(HTTPTextDecoder.encoding(named: "gbk"))
|
||||
let html = "<meta http-equiv='Content-Type' content='text/html; charset=gbk'><h1>中文正文</h1>"
|
||||
let data = try #require(html.data(using: encoding))
|
||||
let response = HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: URL(string: "https://fixture.test")!)
|
||||
#expect(response.decodedText == html)
|
||||
}
|
||||
|
||||
@Test func tableSearchCarriesRequestOptionsThroughDetailTocAndContent() async throws {
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.test/search": "<table class='grid'><tr><th>书名</th></tr><tr><td><a href='/42'>测试书</a></td><td>作者</td></tr></table>",
|
||||
"https://fixture.test/book/42": "<h1>测试书详情</h1><a class='toc' href='/toc'>目录</a>",
|
||||
"https://fixture.test/toc": "<div id='list'><a href='/chapter/1'>第一章</a><a href='/chapter/2'>第二章</a></div>",
|
||||
"https://fixture.test/chapter/1": "<div class='body'><p>真正正文</p><p>广告</p></div>"
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "传统表格源")
|
||||
source.searchUrl = "/search"
|
||||
source.ruleSearch = .init(
|
||||
bookList: "class.grid@tag.tr!0", name: "tag.td.0@text", author: "tag.td.1@text",
|
||||
bookUrl: #"@js:'/book/42,{"method":"POST","headers":{"X-Token":"token"},"body":"id=42"}'"#,
|
||||
coverUrl: "@js:book.origin + '/cover/' + key + '-' + page + '.jpg'"
|
||||
)
|
||||
var info = BookInfoRule()
|
||||
info.name = "tag.h1@text"
|
||||
info.tocUrl = "class.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "id.list@tag.a"
|
||||
toc.chapterName = "text"
|
||||
toc.chapterUrl = "href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "<js>result</js>class.body@html"
|
||||
content.replaceRegex = "##广告##"
|
||||
source.ruleContent = content
|
||||
|
||||
let search = await BookSearchService(transport: transport).search(keyword: "test", page: 2, source: source)
|
||||
#expect(search.diagnostics.isEmpty)
|
||||
let found = try #require(search.books.first)
|
||||
#expect(found.name == "测试书")
|
||||
#expect(found.author == "作者")
|
||||
#expect(found.coverURL == "https://fixture.test/cover/test-2.jpg")
|
||||
#expect(found.bookURL.contains(#",{"method":"POST""#))
|
||||
let service = NetworkBookService(transport: transport)
|
||||
let detail = await service.loadDetail(searchBook: found, source: source)
|
||||
#expect(detail.diagnostics.isEmpty)
|
||||
#expect(detail.book.name == "测试书详情")
|
||||
let chapters = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(chapters.diagnostics.isEmpty)
|
||||
#expect(chapters.chapters.map(\.title) == ["第一章", "第二章"])
|
||||
let chapter = try #require(chapters.chapters.first)
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source)
|
||||
#expect(body.diagnostics.isEmpty)
|
||||
#expect(body.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文")
|
||||
}
|
||||
|
||||
@Test func syntheticHTMLRulesExtractBareTocFieldsAndCleanContent() async throws {
|
||||
let fixture = try #require(Bundle.module.url(forResource: "synthetic-html-rules", withExtension: "json", subdirectory: "Fixtures"))
|
||||
let source = try #require(BookSourceImporter().decode(Data(contentsOf: fixture)).first)
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.invalid/toc": "<div id='chapter-list'><ul><li><a href='/chapter/2'>第二章</a></li><li><a href='/chapter/1'>第一章 新开端</a></li></ul></div>",
|
||||
"https://fixture.invalid/chapter/1": "<div class='chapter-body'><h1>第一章</h1><p>信息</p><p>第一章 新开端</p><p>真正正文</p><p>(本章完)</p></div>"
|
||||
])
|
||||
var book = Book(bookUrl: "https://fixture.invalid/book/1", name: "测试", origin: source.id)
|
||||
book.tocUrl = "https://fixture.invalid/toc"
|
||||
book.durChapterTitle = "第一章 新开端"
|
||||
let service = NetworkBookService(transport: transport)
|
||||
let toc = await service.loadChapters(book: book, source: source)
|
||||
#expect(toc.diagnostics.isEmpty)
|
||||
#expect(toc.chapters.map(\.title) == ["第一章 新开端", "第二章"])
|
||||
let first = try #require(toc.chapters.first)
|
||||
let content = await service.loadContent(book: book, chapter: first, source: source)
|
||||
#expect(content.diagnostics.isEmpty)
|
||||
#expect(content.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文")
|
||||
}
|
||||
|
||||
@Test func searchUsesJavaScriptObjectsAndKeepsRegexCaptureRows() async throws {
|
||||
let transport = CompatibilityTransport(pages: ["https://fixture.test/search": "name=甲;url=/a\nname=乙;url=/b"])
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "转换源")
|
||||
source.searchUrl = "/search"
|
||||
source.ruleSearch = .init(bookList: #":name=([^;]+);url=([^\n]+)"#, name: "$1", bookUrl: "$2")
|
||||
let regex = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source)
|
||||
#expect(regex.diagnostics.isEmpty)
|
||||
#expect(regex.books.map(\.name) == ["甲", "乙"])
|
||||
#expect(regex.books.map(\.bookURL) == ["https://fixture.test/a", "https://fixture.test/b"])
|
||||
|
||||
source.ruleSearch = .init(bookList: "<js>[{name: '对象结果', url: '/object'}]</js>", name: "name", bookUrl: "url")
|
||||
let objects = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source)
|
||||
#expect(objects.diagnostics.isEmpty)
|
||||
#expect(objects.books.first?.name == "对象结果")
|
||||
#expect(objects.books.first?.bookURL == "https://fixture.test/object")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"] != nil))
|
||||
func inspectsDownloadedCatalog() throws {
|
||||
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"])
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
let result = try BookSourceImporter().inspect(data)
|
||||
let raw = try JSONDecoder().decode([JSONValue].self, from: data)
|
||||
#expect(result.sources.count + result.rejected.count == raw.count)
|
||||
#expect(!result.sources.isEmpty)
|
||||
print("Catalog import: \(result.sources.count) valid, \(result.rejected.count) rejected of \(raw.count)")
|
||||
for item in result.rejected.prefix(10) {
|
||||
print("Rejected: \(item.source.bookSourceName): \(item.issues.map(\.message).joined(separator: "; "))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_SOURCE_ID"] != nil))
|
||||
func liveSearchToReading() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let path = try #require(environment["LEGADO_SOURCE_CATALOG"])
|
||||
let sourceID = try #require(environment["LEGADO_LIVE_SOURCE_ID"])
|
||||
let catalog = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let source = try #require(catalog.sources.last { $0.id == sourceID })
|
||||
let search = await BookSearchService().search(keyword: "极道天魔", page: 1, source: source)
|
||||
for issue in search.diagnostics { print("Live search diagnostic: \(issue.code): \(issue.message)") }
|
||||
print("Live search: \(search.books.count) books")
|
||||
let found = try #require(search.books.first { $0.name == "极道天魔" && $0.author == "滚开" })
|
||||
let service = NetworkBookService()
|
||||
let detail = await service.loadDetail(searchBook: found, source: source)
|
||||
#expect(detail.book.name == "极道天魔")
|
||||
let toc = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(toc.chapters.count > 2)
|
||||
for chapter in toc.chapters.prefix(2) {
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source)
|
||||
for issue in body.diagnostics { print("Live content diagnostic: \(issue.code): \(issue.message)") }
|
||||
print("Live content shape: numeric=\(body.content.prefix(100).allSatisfy { $0.isNumber || $0 == "," || $0 == "-" }), escapedNewlines=\(body.content.contains(#"\n"#)), paragraphs=\(body.content.components(separatedBy: "\n").count)")
|
||||
#expect(body.content.count > 500)
|
||||
#expect(!body.content.contains("<p>"))
|
||||
#expect(!body.content.contains(#"\r\n"#))
|
||||
#expect(body.content.components(separatedBy: "\n").count > 5)
|
||||
#expect(!body.diagnostics.contains { $0.severity == .error })
|
||||
print("Live reading: \(source.bookSourceName), \(chapter.title), \(body.content.count) characters")
|
||||
}
|
||||
for issue in detail.diagnostics + toc.diagnostics {
|
||||
print("Live diagnostic: \(issue.code): \(issue.message)")
|
||||
}
|
||||
print("Live directory: \(toc.chapters.count) chapters")
|
||||
if let replacementID = environment["LEGADO_LIVE_SWITCH_SOURCE_ID"] {
|
||||
let replacementSource = try #require(catalog.sources.last { $0.id == replacementID })
|
||||
var current = detail.book
|
||||
current.totalChapterNum = toc.chapters.count
|
||||
current.durChapterIndex = 1
|
||||
current.durChapterTitle = toc.chapters[1].title
|
||||
current.durChapterPos = 120
|
||||
let switcher = BookSourceSwitchService(chapterRepository: InMemoryBookChapterRepository())
|
||||
let candidates = await switcher.candidates(for: current, sources: [source, replacementSource])
|
||||
let candidate = try #require(candidates.first { $0.book.author == "滚开" })
|
||||
let switched = try #require(await switcher.prepareSwitch(current: current, candidate: candidate.book, source: replacementSource))
|
||||
#expect(switched.book.origin == replacementID)
|
||||
#expect(switched.exactChapterMatch)
|
||||
#expect(switched.book.durChapterPos == 120)
|
||||
let chapter = switched.chapters[switched.book.durChapterIndex]
|
||||
let body = await service.loadContent(book: switched.book, chapter: chapter, source: replacementSource)
|
||||
#expect(body.content.count > 500)
|
||||
#expect(!body.diagnostics.contains { $0.severity == .error })
|
||||
print("Live switched reading: \(replacementSource.bookSourceName), \(chapter.title), \(body.content.count) characters, position \(switched.book.durChapterPos)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct SourceNetworkPolicyTests {
|
||||
@Test func persistentCookieDomainGroupsSubdomains() {
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.example.com") == "example.com")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.content.example.com") == "example.com")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.biquge.com.cn") == "biquge.com.cn")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "1.2.3.4") == "1.2.3.4")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "localhost") == "localhost")
|
||||
}
|
||||
|
||||
@Test func parsesConcurrentRateFormats() {
|
||||
let burst = SourceRateLimit(rawValue: "3/1000")
|
||||
let serial = SourceRateLimit(rawValue: "250")
|
||||
#expect(burst?.accessLimit == 3)
|
||||
#expect(burst?.intervalMilliseconds == 1_000)
|
||||
#expect(serial?.accessLimit == 1)
|
||||
#expect(serial?.intervalMilliseconds == 250)
|
||||
#expect(SourceRateLimit(rawValue: "0") == nil)
|
||||
#expect(SourceRateLimit(rawValue: "broken") == nil)
|
||||
}
|
||||
|
||||
@Test func fixedWindowReservesAndReportsWait() {
|
||||
let limit = SourceRateLimit(rawValue: "2/100")!
|
||||
var window = SourceRateWindow()
|
||||
|
||||
#expect(window.reserve(now: 1_000, limit: limit) == 0)
|
||||
#expect(window.reserve(now: 1_010, limit: limit) == 0)
|
||||
#expect(window.reserve(now: 1_020, limit: limit) == 80)
|
||||
#expect(window.reserve(now: 1_100, limit: limit) == 0)
|
||||
}
|
||||
|
||||
@Test func cookiesAreIsolatedBySourceAndExplicitValuesWin() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://same.example/path")!
|
||||
await policy.setCookieHeader("token=A; shared=stored", sourceID: "source-a", url: url)
|
||||
await policy.setCookieHeader("token=B", sourceID: "source-b", url: url)
|
||||
|
||||
#expect(await policy.cookieHeader(sourceID: "source-a", url: url).contains("token=A"))
|
||||
#expect(await policy.cookieHeader(sourceID: "source-b", url: url) == "token=B")
|
||||
|
||||
var source = BookSource(bookSourceUrl: "source-a", bookSourceName: "A")
|
||||
source.enabledCookieJar = true
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://same.example/path, {"headers":{"Cookie":"shared=explicit"}}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(data: Data(), statusCode: 200, headers: [:], finalURL: url))])
|
||||
_ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
let cookie = await transport.requests().first?.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
#expect(cookie.contains("token=A"))
|
||||
#expect(cookie.contains("shared=explicit"))
|
||||
#expect(!cookie.contains("shared=stored"))
|
||||
}
|
||||
|
||||
@Test func retriesFailedHTTPResponsesConfiguredNumberAndHonorsDisabledCookieJar() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://retry.example/data")!
|
||||
await policy.setCookieHeader("session=secret", sourceID: "source", url: url)
|
||||
var source = BookSource(bookSourceUrl: "source", bookSourceName: "源")
|
||||
source.enabledCookieJar = false
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":2}"#)
|
||||
let response = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
let failed500 = HTTPResponse(data: Data(), statusCode: 500, headers: [:], finalURL: url)
|
||||
let failed502 = HTTPResponse(data: Data(), statusCode: 502, headers: [:], finalURL: url)
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.failure(HTTPClientError.statusResponse(failed500)),
|
||||
.failure(HTTPClientError.statusResponse(failed502)),
|
||||
.success(response)
|
||||
])
|
||||
|
||||
let result = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
|
||||
#expect(String(decoding: result.data, as: UTF8.self) == "ok")
|
||||
#expect(await transport.requests().count == 3)
|
||||
#expect(await transport.requests().allSatisfy { $0.value(forHTTPHeaderField: "Cookie") == nil })
|
||||
}
|
||||
|
||||
@Test func doesNotRetryTransportFailures() async throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":3}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.failure(URLError(.timedOut))])
|
||||
|
||||
await #expect(throws: URLError.self) {
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "retry", bookSourceName: "重试源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
}
|
||||
|
||||
#expect(await transport.requests().count == 1)
|
||||
}
|
||||
|
||||
@Test func capturesCookiesWhileRetryingFailedHTTPResponse() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://auth.example/data")!
|
||||
let failed = HTTPResponse(
|
||||
data: Data(),
|
||||
statusCode: 429,
|
||||
headers: ["Set-Cookie": "session=renewed; Path=/; Secure"],
|
||||
finalURL: url
|
||||
)
|
||||
let success = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.failure(HTTPClientError.statusResponse(failed)),
|
||||
.success(success)
|
||||
])
|
||||
let source = BookSource(bookSourceUrl: "auth-source", bookSourceName: "登录源")
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://auth.example/data, {"retry":1}"#)
|
||||
|
||||
_ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
|
||||
#expect(await policy.cookieHeader(sourceID: source.id, url: url).contains("session=renewed"))
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test func decodesExplicitLegacyCharsetAndHeaderCharset() throws {
|
||||
let gb18030 = try #require(HTTPTextDecoder.encoding(named: "GB18030"))
|
||||
let chinese = "旧编码正文"
|
||||
let legacyData = try #require(chinese.data(using: gb18030))
|
||||
let url = try #require(URL(string: "https://encoding.example"))
|
||||
|
||||
let explicit = HTTPResponse(data: legacyData, statusCode: 200, headers: [:], finalURL: url, requestedCharset: "gbk")
|
||||
let header = HTTPResponse(data: legacyData, statusCode: 200, headers: ["Content-Type": "text/html; charset=GB18030"], finalURL: url)
|
||||
|
||||
#expect(explicit.decodedText == chinese)
|
||||
#expect(header.decodedText == chinese)
|
||||
}
|
||||
|
||||
@Test func removesUnicodeBOMBeforeDecoding() throws {
|
||||
let url = try #require(URL(string: "https://encoding.example"))
|
||||
let text = "目录正文"
|
||||
var data = Data([0xFF, 0xFE])
|
||||
data.append(try #require(text.data(using: .utf16LittleEndian)))
|
||||
|
||||
#expect(HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: url).decodedText == text)
|
||||
}
|
||||
|
||||
@Test func appliesResponseBodyJavaScriptAndCarriesVariables() async throws {
|
||||
let url = try #require(URL(string: "https://script.example/content"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://script.example/content, {"bodyJs":"java.put('bodyChanged', 'yes'); result.replace('旧正文', '新正文')"}"#
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: Data("旧正文".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
))])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "script", bookSourceName: "脚本源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "新正文")
|
||||
#expect(response.requestVariables["bodyChanged"] == "yes")
|
||||
}
|
||||
|
||||
@Test func routesWebViewRequestsThroughExecutorAndStillAppliesBodyJS() async throws {
|
||||
let url = try #require(URL(string: "https://web.example/rendered"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://web.example/page, {"webView":true,"webJs":"document.body.dataset.ready='1'","webViewDelayTime":250,"bodyJs":"result.replace('原始', '渲染')"}"#
|
||||
)
|
||||
let executor = await MainActor.run { RecordingWebViewExecutor(response: .init(
|
||||
data: Data("<html>原始正文</html>".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
)) }
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "web", bookSourceName: "网页源"),
|
||||
transport: RecordingPolicyTransport(results: []),
|
||||
timeout: 2,
|
||||
webViewExecutor: executor
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "<html>渲染正文</html>")
|
||||
#expect(await executor.callCount == 1)
|
||||
#expect(await executor.lastPrepared?.webJavaScript?.contains("dataset.ready") == true)
|
||||
#expect(await executor.lastPrepared?.webViewDelayMilliseconds == 250)
|
||||
}
|
||||
|
||||
@Test func recordsRealPreparedRequestAndFinalResponseForDifferentialTesting() async throws {
|
||||
let url = try #require(URL(string: "https://snapshot.example/final"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://snapshot.example/search?q={{key}}, {"headers":{"Cookie":"session=secret","X-Test":"yes"},"retry":2}"#,
|
||||
context: .init(key: "测试")
|
||||
)
|
||||
let response = HTTPResponse(
|
||||
data: Data("正文".utf8),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "text/plain; charset=utf-8", "Set-Cookie": "token=private; Path=/"],
|
||||
finalURL: url
|
||||
)
|
||||
let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-001")
|
||||
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "snapshot-source", bookSourceName: "快照源"),
|
||||
transport: RecordingPolicyTransport(results: [.success(response)]),
|
||||
timeout: 12,
|
||||
snapshotRecorder: recorder,
|
||||
snapshotStage: .search
|
||||
)
|
||||
|
||||
let snapshots = await recorder.snapshots
|
||||
let snapshot = try #require(snapshots.first)
|
||||
#expect(snapshot.caseID == "html-001")
|
||||
#expect(snapshot.stage == .search)
|
||||
#expect(snapshot.request.ruleURL.contains("{{key}}"))
|
||||
#expect(snapshot.request.renderedURL.contains("%E6%B5%8B%E8%AF%95"))
|
||||
#expect(snapshot.request.headers["x-test"] == "yes")
|
||||
#expect(snapshot.request.headers["cookie"]?.contains("secret") == false)
|
||||
#expect(snapshot.response.finalURL == url.absoluteString)
|
||||
#expect(snapshot.response.bodySHA256.count == 64)
|
||||
#expect(snapshot.response.setCookies["token"]?.contains("private") == false)
|
||||
}
|
||||
|
||||
@Test func emitsBusinessSnapshotFromContentReport() throws {
|
||||
let report = ChapterContentReport(
|
||||
title: "第一章",
|
||||
content: "正文",
|
||||
supplementalContent: "副正文",
|
||||
diagnostics: [],
|
||||
pagesVisited: 2
|
||||
)
|
||||
let snapshot = DifferentialBusinessSnapshot.content(caseID: "json-001", report: report)
|
||||
let object = try JSONSerialization.jsonObject(with: snapshot.encoded()) as? [String: Any]
|
||||
let result = object?["result"] as? [String: Any]
|
||||
let content = result?["content"] as? [String: Any]
|
||||
|
||||
#expect(object?["platform"] as? String == "ios")
|
||||
#expect(object?["flow"] as? String == "content")
|
||||
#expect(content?["text"] as? String == "正文")
|
||||
#expect(content?["pagesVisited"] as? Int == 2)
|
||||
}
|
||||
|
||||
@Test func typeOptionReturnsLowercaseHex() async throws {
|
||||
let url = try #require(URL(string: "https://binary.example/image"))
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://binary.example/image, {"type":"image"}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: Data([0x00, 0x7F, 0xA5, 0xFF]), statusCode: 200, headers: [:], finalURL: url
|
||||
))])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "binary", bookSourceName: "二进制源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "007fa5ff")
|
||||
}
|
||||
|
||||
@Test func readsBase64DataURIWithoutCallingNetworkTransport() async throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"data:application/octet-stream;base64,AP+l, {"type":"image"}"#
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: [])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "data-uri", bookSourceName: "内嵌数据源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "00ffa5")
|
||||
#expect(response.headers["Content-Type"] == "application/octet-stream")
|
||||
#expect(await transport.requests().isEmpty)
|
||||
}
|
||||
|
||||
@Test func sourceProxyHeaderIsRemovedAndRoutedThroughProxyTransport() async throws {
|
||||
let url = try #require(URL(string: "https://proxy.example/data"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
"https://proxy.example/data",
|
||||
defaultHeaders: ["proxy": "socks5://127.0.0.1:1080"]
|
||||
)
|
||||
let transport = ProxyRecordingPolicyTransport(response: .init(
|
||||
data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url
|
||||
))
|
||||
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "proxy", bookSourceName: "代理源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(await transport.proxy == "socks5://127.0.0.1:1080")
|
||||
#expect(await transport.request?.value(forHTTPHeaderField: "proxy") == nil)
|
||||
}
|
||||
|
||||
@Test func recordsFinalHTTPFailureAndErrorBodyForDifferentialTesting() async throws {
|
||||
let url = try #require(URL(string: "https://failure.example/data"))
|
||||
let failed = HTTPResponse(
|
||||
data: Data("temporary unavailable".utf8),
|
||||
statusCode: 503,
|
||||
headers: ["Retry-After": "0"],
|
||||
finalURL: url
|
||||
)
|
||||
let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-002")
|
||||
let prepared = try LegadoURLBuilder().prepare("https://failure.example/data")
|
||||
|
||||
await #expect(throws: HTTPClientError.self) {
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "failure", bookSourceName: "失败源"),
|
||||
transport: RecordingPolicyTransport(results: [.failure(HTTPClientError.statusResponse(failed))]),
|
||||
timeout: 1,
|
||||
snapshotRecorder: recorder,
|
||||
snapshotStage: .detail
|
||||
)
|
||||
}
|
||||
|
||||
let snapshots = await recorder.snapshots
|
||||
let snapshot = try #require(snapshots.first)
|
||||
#expect(snapshot.response.statusCode == 503)
|
||||
#expect(snapshot.response.bodySHA256.count == 64)
|
||||
#expect(snapshot.response.error?.code == "http.503")
|
||||
#expect(snapshot.response.error?.legadoCode == -7)
|
||||
}
|
||||
|
||||
@Test func httpTTSRendersSpeakVariablesAndReturnsBinaryAudio() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/audio"))
|
||||
let audio = Data([0x52, 0x49, 0x46, 0x46])
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: audio,
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/wav; charset=binary"],
|
||||
finalURL: url
|
||||
))])
|
||||
var engine = HttpTTS(id: 9, name: "测试", url: #"https://tts.example/audio, {"method":"POST","body":"text={{speakText}}&speed={{speakSpeed}}"}"#)
|
||||
engine.contentType = #"audio/.*"#
|
||||
engine.header = #"{"X-Engine":"legado"}"#
|
||||
engine.loginCheckJs = #"if (result.headers().get('Content-Type').indexOf('audio/') !== 0) throw 'bad content'; result"#
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
let service = HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
)
|
||||
let result = try await service.audio(engine: engine, text: "第一段", speechRate: 7)
|
||||
let cached = try await service.audio(engine: engine, text: "第一段", speechRate: 7)
|
||||
|
||||
#expect(result.data == audio)
|
||||
#expect(cached.data == audio)
|
||||
#expect(result.contentType == "audio/wav")
|
||||
let requests = await transport.requests()
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(String(decoding: request.httpBody ?? Data(), as: UTF8.self).contains("speed=7"))
|
||||
#expect(request.value(forHTTPHeaderField: "X-Engine") == "legado")
|
||||
#expect(requests.count == 1)
|
||||
}
|
||||
|
||||
@Test func httpTTSStreamsChunksAndStoresCompletedResponseInCache() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/stream"))
|
||||
let first = Data(repeating: 0x41, count: 4)
|
||||
let second = Data(repeating: 0x42, count: 3)
|
||||
let transport = RecordingStreamingTransport(url: url, chunks: [first, second])
|
||||
var engine = HttpTTS(id: 12, name: "流式", url: url.absoluteString)
|
||||
engine.contentType = #"audio/.*"#
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
let service = HttpTTSService(networkPolicy: .init(), transport: transport, cache: cache)
|
||||
|
||||
let streamed = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10)
|
||||
#expect(streamed.isIncremental)
|
||||
#expect(streamed.expectedContentLength == 7)
|
||||
var received: [Data] = []
|
||||
for try await chunk in streamed.chunks { received.append(chunk) }
|
||||
#expect(received == [first, second])
|
||||
|
||||
let cached = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10)
|
||||
#expect(!cached.isIncremental)
|
||||
var cachedData = Data()
|
||||
for try await chunk in cached.chunks { cachedData.append(chunk) }
|
||||
#expect(cachedData == first + second)
|
||||
#expect(await transport.requestCount() == 1)
|
||||
}
|
||||
|
||||
@Test func httpTTSDecodesDefaultsWithMissingFieldsAndLoginUIArray() throws {
|
||||
let data = Data(#"{"id":-100,"name":"百度","url":"https://tts.example","loginUi":[{"name":"token","type":"text"}]}"#.utf8)
|
||||
let value = try JSONDecoder().decode(HttpTTS.self, from: data)
|
||||
#expect(value.id == -100)
|
||||
#expect(value.concurrentRate == nil)
|
||||
#expect(value.enabledCookieJar == false)
|
||||
#expect(value.loginUi?.contains("token") == true)
|
||||
#expect(value.lastUpdateTime > 0)
|
||||
}
|
||||
|
||||
@Test func httpTTSRejectsTextErrorResponse() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/error"))
|
||||
let response = HTTPResponse(
|
||||
data: Data("额度不足".utf8),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
finalURL: url
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: Array(repeating: .success(response), count: 6))
|
||||
let engine = HttpTTS(id: 10, name: "错误", url: url.absoluteString)
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
await #expect(throws: HttpTTSServiceError.self) {
|
||||
_ = try await HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
).audio(engine: engine, text: "测试", speechRate: 5)
|
||||
}
|
||||
#expect(await transport.requests().count == 6)
|
||||
}
|
||||
|
||||
@Test func httpTTSRebuildsRequestWhenLoginCheckInitializesURL() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/recover"))
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.success(.init(
|
||||
data: Data(#"{"status":40000001}"#.utf8),
|
||||
statusCode: 401,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
finalURL: url
|
||||
)),
|
||||
.success(.init(
|
||||
data: Data([0x49, 0x44, 0x33]),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url
|
||||
))
|
||||
])
|
||||
var engine = HttpTTS(id: 11, name: "恢复", url: url.absoluteString)
|
||||
engine.loginCheckJs = "java.initUrl(); java.getResponse()"
|
||||
engine.contentType = "audio/.*"
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
let audio = try await HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
).audio(engine: engine, text: "测试", speechRate: 5)
|
||||
|
||||
#expect(audio.contentType == "audio/mpeg")
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingPolicyTransport: HTTPTransport {
|
||||
private var results: [Result<HTTPResponse, any Error>]
|
||||
private var recorded: [URLRequest] = []
|
||||
|
||||
init(results: [Result<HTTPResponse, any Error>]) { self.results = results }
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
recorded.append(request)
|
||||
guard !results.isEmpty else { throw URLError(.badServerResponse) }
|
||||
return try results.removeFirst().get()
|
||||
}
|
||||
|
||||
func requests() -> [URLRequest] { recorded }
|
||||
}
|
||||
|
||||
private actor RecordingStreamingTransport: StreamingHTTPTransport {
|
||||
let url: URL
|
||||
let values: [Data]
|
||||
private var count = 0
|
||||
|
||||
init(url: URL, chunks: [Data]) {
|
||||
self.url = url
|
||||
values = chunks
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
count += 1
|
||||
return .init(
|
||||
data: values.reduce(into: Data()) { $0.append($1) },
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
|
||||
func stream(for request: URLRequest) async throws -> HTTPStreamResponse {
|
||||
count += 1
|
||||
let chunks = values
|
||||
return .init(
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url,
|
||||
expectedContentLength: Int64(chunks.reduce(0) { $0 + $1.count }),
|
||||
chunks: AsyncThrowingStream { continuation in
|
||||
for chunk in chunks { continuation.yield(chunk) }
|
||||
continuation.finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func requestCount() -> Int { count }
|
||||
}
|
||||
|
||||
private actor ProxyRecordingPolicyTransport: ProxyHTTPTransport {
|
||||
let response: HTTPResponse
|
||||
private(set) var proxy: String?
|
||||
private(set) var request: URLRequest?
|
||||
|
||||
init(response: HTTPResponse) { self.response = response }
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse { response }
|
||||
|
||||
func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse {
|
||||
self.request = request
|
||||
self.proxy = proxy
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RecordingWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable {
|
||||
let response: HTTPResponse
|
||||
private(set) var callCount = 0
|
||||
private(set) var lastPrepared: PreparedRequest?
|
||||
|
||||
init(response: HTTPResponse) { self.response = response }
|
||||
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
callCount += 1
|
||||
lastPrepared = prepared
|
||||
return response
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import Legado
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollGeometryTests: XCTestCase {
|
||||
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
|
||||
let chapters = (4...6).map { chapter($0) }
|
||||
let fixture = host(chapters: chapters, active: 5)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
let coordinator = fixture.coordinator, view = fixture.view
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let row = try XCTUnwrap(coordinator.blocks.firstIndex { $0.chapter.chapter.id == 5 })
|
||||
let id = coordinator.blocks[row].id
|
||||
view.setContentOffset(CGPoint(x: 0, y: layout.frames[row].minY + 71.25), animated: false)
|
||||
let screenY = layout.frames[row].minY - view.contentOffset.y
|
||||
let chapterLayout = coordinator.blocks[row].chapter
|
||||
|
||||
coordinator.parent = surface(chapters: [chapter(2), chapter(3)] + chapters, active: 5)
|
||||
coordinator.update(view)
|
||||
let insertedRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id })
|
||||
XCTAssertEqual(layout.frames[insertedRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale)
|
||||
XCTAssertTrue(coordinator.blocks[insertedRow].chapter === chapterLayout)
|
||||
|
||||
coordinator.parent = surface(chapters: [chapter(5), chapter(6), chapter(7)], active: 5)
|
||||
coordinator.update(view)
|
||||
let recycledRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id })
|
||||
XCTAssertEqual(layout.frames[recycledRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale)
|
||||
XCTAssertTrue(coordinator.blocks[recycledRow].chapter === chapterLayout)
|
||||
}
|
||||
|
||||
func testAppendingAndNaturalChapterChangesDoNotRebuildVisibleText() async throws {
|
||||
let chapters = (0...2).map { chapter($0) }
|
||||
let fixture = host(chapters: chapters, active: 0)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
let coordinator = fixture.coordinator, view = fixture.view
|
||||
let original = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||||
view.setContentOffset(CGPoint(x: 0, y: 123.75), animated: false)
|
||||
let offset = view.contentOffset
|
||||
coordinator.parent = surface(chapters: chapters + [chapter(3), chapter(4)], active: 1)
|
||||
coordinator.update(view)
|
||||
XCTAssertEqual(view.contentOffset.y, offset.y, accuracy: 0.01)
|
||||
XCTAssertTrue(coordinator.blocks[0].chapter === original)
|
||||
XCTAssertEqual(Set(coordinator.blocks.map { $0.chapter.chapter.id }), Set(0...4))
|
||||
}
|
||||
|
||||
func testReflowKeepsTheSameTextLineAtTheSameScreenPosition() async throws {
|
||||
let fixture = host(chapters: [chapter(0), chapter(1)], active: 0)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
let coordinator = fixture.coordinator, view = fixture.view
|
||||
let old = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||||
let glyph = old.manager.glyphIndexForCharacter(at: old.bodyStart + 200)
|
||||
let lineY = old.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
|
||||
let startOfLine = old.manager.characterIndexForGlyph(at: old.manager.glyphIndex(
|
||||
for: CGPoint(x: 0, y: lineY + 0.5), in: old.container))
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let chapterY = layout.frames[0].minY
|
||||
view.setContentOffset(CGPoint(x: 0, y: chapterY + lineY + 3.25), animated: false)
|
||||
let previousScreenY = chapterY + lineY - view.contentOffset.y
|
||||
coordinator.parent = surface(chapters: [chapter(0), chapter(1)], active: 0, fontSize: 25)
|
||||
coordinator.update(view)
|
||||
let new = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||||
let newGlyph = new.manager.glyphIndexForCharacter(at: startOfLine)
|
||||
let newLineY = new.manager.lineFragmentRect(forGlyphAt: newGlyph, effectiveRange: nil).minY
|
||||
XCTAssertEqual(layout.frames[0].minY + newLineY - view.contentOffset.y, previousScreenY, accuracy: 1 / fixture.window.screen.scale)
|
||||
}
|
||||
|
||||
func testChunksCoverAllGlyphsAndDoNotRetainTheirChapter() async {
|
||||
weak var released: ReaderScrollChapterLayout?
|
||||
autoreleasepool {
|
||||
let layout = ReaderScrollChapterLayout(chapter: chapter(0), configuration: configuration(), width: 390)
|
||||
released = layout
|
||||
XCTAssertGreaterThan(layout.blocks.count, 2)
|
||||
var nextGlyph = 0
|
||||
for block in layout.blocks {
|
||||
XCTAssertEqual(block.glyphRange.location, nextGlyph)
|
||||
XCTAssertGreaterThan(block.height, 0)
|
||||
nextGlyph = NSMaxRange(block.glyphRange)
|
||||
}
|
||||
XCTAssertEqual(nextGlyph, layout.manager.numberOfGlyphs)
|
||||
XCTAssertTrue(layout.storage.string.contains("👨👩👧👦"))
|
||||
}
|
||||
XCTAssertNil(released, "Recycled TextKit chapter layouts must be released")
|
||||
}
|
||||
|
||||
func testShortLastChapterCanReachTheTopWithoutChangingTheRequestedChapter() async throws {
|
||||
let short = ReaderScrollChapter(id: 8, title: "短章", content: "仅一行正文")
|
||||
let fixture = host(chapters: [chapter(7), short], active: 8)
|
||||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||||
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 8 })
|
||||
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
|
||||
}
|
||||
|
||||
private func chapter(_ index: Int) -> ReaderScrollChapter {
|
||||
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨👩👧👦,用于验证连续阅读的排版和位置。\n", count: 100))
|
||||
}
|
||||
|
||||
private func host(chapters: [ReaderScrollChapter], active: Int) -> (window: UIWindow, view: ReaderScrollCollectionView, coordinator: ReaderScrollView.Coordinator) {
|
||||
let parent = surface(chapters: chapters, active: active)
|
||||
let coordinator = parent.makeCoordinator()
|
||||
let view = coordinator.makeView()
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
let controller = UIViewController()
|
||||
controller.view.addSubview(view)
|
||||
view.frame = window.bounds
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
coordinator.update(view)
|
||||
view.layoutIfNeeded()
|
||||
return (window, view, coordinator)
|
||||
}
|
||||
|
||||
private func surface(chapters: [ReaderScrollChapter], active: Int, fontSize: Double = 20) -> ReaderScrollView {
|
||||
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
|
||||
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
|
||||
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
|
||||
onRetainChapters: { _ in }, onRetry: { _ in })
|
||||
}
|
||||
|
||||
private func configuration(fontSize: Double = 20) -> ReaderTextView {
|
||||
.init(text: "", title: "", previousTitle: nil, previousText: "", isPreviousReady: false,
|
||||
continuationTitle: nil, continuationText: "", isContinuationReady: false, isVolume: false,
|
||||
fontSize: fontSize, fontPath: "", letterSpacing: 0, lineSpacing: 8, paragraphSpacing: 12,
|
||||
paragraphIndent: " ", paddingLeft: 20, paddingRight: 20, paddingTop: 16, paddingBottom: 16,
|
||||
headerInset: 0, footerInset: 0, fontWeight: 0, titleMode: 0, titleSize: 4,
|
||||
titleTopSpacing: 16, titleBottomSpacing: 24, underlineMode: 0, isSelectable: false,
|
||||
fullJustify: true, pageAnimation: 3, isEInkMode: false, clickActions: Array(repeating: 0, count: 9),
|
||||
backgroundColor: .white, foregroundColor: .black, requestedPosition: 0, jumpPosition: nil,
|
||||
renderRevision: 0, readerPosition: .constant(0), pageRanges: .constant([]),
|
||||
currentPage: .constant(1), totalPages: .constant(1), isAutoReading: false,
|
||||
isAutoReadPaused: false, isLoadingNextChapter: false, secondsPerScreen: 10,
|
||||
onReachedEnd: { _ in }, onReachedStart: { _ in }, onEnteredPrevious: { _ in },
|
||||
onEnteredContinuation: { _ in }, onDeferredRender: {}, onCancelNextLoading: {},
|
||||
onPageChanged: {}, onTapAction: { _ in })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import UIKit
|
||||
import WebKit
|
||||
import XCTest
|
||||
@testable import Legado
|
||||
|
||||
@MainActor
|
||||
final class WebReaderBridgeTests: XCTestCase {
|
||||
func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws {
|
||||
let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试")
|
||||
let loaded = expectation(description: "HTML loaded")
|
||||
let surface = RSSWebReaderView(mode: .html(content: "<p>页面正文</p>", baseURL: source.sourceUrl), source: source,
|
||||
onTextChange: { _ in loaded.fulfill() })
|
||||
let coordinator = surface.makeCoordinator()
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
configuration.userContentController.add(coordinator, name: "legado")
|
||||
configuration.userContentController.addUserScript(.init(source: coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: configuration)
|
||||
webView.navigationDelegate = coordinator
|
||||
webView.uiDelegate = coordinator
|
||||
let window = UIWindow(frame: webView.frame)
|
||||
let controller = UIViewController()
|
||||
controller.view.addSubview(webView)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator)
|
||||
window.isHidden = true
|
||||
}
|
||||
coordinator.installRulesAndLoad(mode: surface.mode, in: webView)
|
||||
await fulfillment(of: [loaded], timeout: 20)
|
||||
|
||||
let result = try await webView.evaluateJavaScript("""
|
||||
java.put('key', '已保存');
|
||||
java.setContent('<div><p>规则正文</p></div>');
|
||||
[java.get('key'), java.getString('tag.p@text')].join('|');
|
||||
""")
|
||||
XCTAssertEqual(result as? String, "已保存|规则正文")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class LiveReadingFlowUITests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Xcode's runtime-issue callback can crash on Network.framework's log
|
||||
// quarantine with thousands of failing hosts. Keep server testing active.
|
||||
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
|
||||
}
|
||||
|
||||
func testSearchLatencyAndCachedQuery() throws {
|
||||
guard ProcessInfo.processInfo.environment["LEGADO_SEARCH_UI_BENCHMARK"] == "1" else {
|
||||
throw XCTSkip("Use the LegadoSearchPerformanceUITests scheme with the imported catalog")
|
||||
}
|
||||
continueAfterFailure = false
|
||||
app.launchArguments = ["-uiDemoTab", "search", "-uiAutomationSearch", "极道天魔", "-themeMode", "1"]
|
||||
let started = Date()
|
||||
app.launch()
|
||||
let exact = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "极道天魔, 滚开")).firstMatch
|
||||
XCTAssertTrue(exact.waitForExistence(timeout: 90))
|
||||
print("[SearchPerformance] launch_to_exact_seconds=\(Date().timeIntervalSince(started))")
|
||||
attach("all-sources-search-latency")
|
||||
app.buttons["搜索选项"].tap()
|
||||
if app.buttons["停止搜索"].exists { app.buttons["停止搜索"].tap() }
|
||||
let search = app.searchFields["书名或作者"]
|
||||
search.tap()
|
||||
search.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 4) + "极道天")
|
||||
let repeated = Date()
|
||||
search.typeText("\n")
|
||||
XCTAssertTrue(app.staticTexts["“极道天”"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(exact.exists)
|
||||
print("[SearchPerformance] cached_query_to_result_seconds=\(Date().timeIntervalSince(repeated))")
|
||||
attach("cached-search-result")
|
||||
exact.tap()
|
||||
XCTAssertTrue(app.navigationBars["书籍详情"].waitForExistence(timeout: 10))
|
||||
attach("detail-from-running-search")
|
||||
}
|
||||
|
||||
func testImportSearchDetailReadSwitchAndResume() throws {
|
||||
guard ProcessInfo.processInfo.environment["LEGADO_LIVE_UI_TESTS"] == "1" else {
|
||||
throw XCTSkip("Use the LegadoLiveReadingUITests scheme for real-server testing")
|
||||
}
|
||||
continueAfterFailure = false
|
||||
defer {
|
||||
if (testRun?.failureCount ?? 0) > 0 {
|
||||
attach("failure")
|
||||
let tree = XCTAttachment(string: app.debugDescription)
|
||||
tree.name = "failure-accessibility-tree"
|
||||
tree.lifetime = .keepAlways
|
||||
add(tree)
|
||||
}
|
||||
}
|
||||
app.launchArguments = ["-uiDemoTab", "search", "-uiAutomationSearch", "极道天魔", "-pageAnim", "0", "-themeMode", "1"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.navigationBars["搜索"].waitForExistence(timeout: 30))
|
||||
app.buttons["搜索选项"].tap()
|
||||
app.buttons["书源管理"].tap()
|
||||
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
|
||||
app.buttons["更多"].firstMatch.tap()
|
||||
app.buttons["URL 或粘贴 JSON"].tap()
|
||||
let url = app.textFields["https://example.com/sources.json"]
|
||||
XCTAssertTrue(url.waitForExistence(timeout: 10))
|
||||
url.tap()
|
||||
url.typeText("https://legado.aoaostar.com/sources/b778fe6b.json")
|
||||
app.navigationBars["导入书源"].buttons["导入"].tap()
|
||||
XCTAssertTrue(app.navigationBars["书源导入预检"].waitForExistence(timeout: 120))
|
||||
app.navigationBars["书源导入预检"].buttons["选择"].tap()
|
||||
app.buttons["全选"].tap()
|
||||
attach("01-import-preview")
|
||||
XCTAssertTrue(app.navigationBars["书源导入预检"].buttons["导入"].isEnabled)
|
||||
app.navigationBars["书源导入预检"].buttons["导入"].tap()
|
||||
XCTAssertTrue(app.alerts["书源导入完成"].waitForExistence(timeout: 90))
|
||||
attach("02-import-completed")
|
||||
app.alerts.buttons["好"].tap()
|
||||
app.navigationBars["书源"].buttons.element(boundBy: 0).tap()
|
||||
selectSearchSource("DragonQuestQBall")
|
||||
let search = app.searchFields["书名或作者"]
|
||||
search.tap()
|
||||
search.typeText("\n")
|
||||
let title = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "极道天魔, 滚开")).firstMatch
|
||||
XCTAssertTrue(title.waitForExistence(timeout: 90))
|
||||
attach("03-search")
|
||||
title.tap()
|
||||
XCTAssertTrue(app.navigationBars["书籍详情"].waitForExistence(timeout: 10))
|
||||
let start = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch
|
||||
waitUntil("Readable directory", timeout: 90) { start.exists && start.isEnabled }
|
||||
XCTAssertTrue(app.staticTexts["滚开"].exists)
|
||||
attach("04-detail")
|
||||
if app.buttons["继续阅读"].exists {
|
||||
let firstChapter = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "道法无情")).firstMatch
|
||||
XCTAssertTrue(firstChapter.exists)
|
||||
firstChapter.tap()
|
||||
} else {
|
||||
start.tap()
|
||||
}
|
||||
waitForBody(containing: "道法无情")
|
||||
attach("05-reading")
|
||||
showReaderMenu()
|
||||
let currentSourceName = app.buttons["reader.changeSource"].value as? String
|
||||
let replacementID = currentSourceName == "💰QQ阅读[浏览器]" ? "DragonQuestQBall" : "https://bookshelf.html5.qq.com/"
|
||||
let replacementName = currentSourceName == "💰QQ阅读[浏览器]" ? "●QB男生<qq♛" : "💰QQ阅读[浏览器]"
|
||||
app.buttons["下一章"].tap()
|
||||
waitForBody(containing: "作弊器")
|
||||
attach("06-next-chapter")
|
||||
showReaderMenu()
|
||||
app.buttons["换源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10))
|
||||
let replacement = app.buttons["sourceSwitch.candidate." + replacementID]
|
||||
XCTAssertTrue(replacement.waitForExistence(timeout: 240))
|
||||
attach("07-source-candidates")
|
||||
replacement.tap()
|
||||
waitUntil("Source switch sheet dismissed", timeout: 90) {
|
||||
!self.app.navigationBars["为《极道天魔》换源"].exists
|
||||
}
|
||||
waitForBody(containing: "作弊器")
|
||||
showReaderMenu()
|
||||
waitUntil("Replacement source active", timeout: 20) {
|
||||
self.app.buttons["reader.changeSource"].value as? String == replacementName
|
||||
}
|
||||
attach("08-switched-reading")
|
||||
app.buttons["返回"].tap()
|
||||
app.terminate()
|
||||
app.launchArguments = ["-pageAnim", "0", "-themeMode", "1"]
|
||||
app.launch()
|
||||
let resume = app.buttons["bookshelf.continue"]
|
||||
XCTAssertTrue(resume.waitForExistence(timeout: 30))
|
||||
resume.tap()
|
||||
waitForBody(containing: "作弊器")
|
||||
showReaderMenu()
|
||||
XCTAssertEqual(app.buttons["reader.changeSource"].value as? String, replacementName)
|
||||
attach("09-resumed-reading")
|
||||
}
|
||||
|
||||
private func selectSearchSource(_ sourceID: String) {
|
||||
app.buttons["搜索选项"].tap()
|
||||
if app.buttons["停止搜索"].exists {
|
||||
app.buttons["停止搜索"].tap()
|
||||
app.buttons["搜索选项"].tap()
|
||||
}
|
||||
app.buttons["分组或书源"].tap()
|
||||
app.segmentedControls.buttons["书源"].tap()
|
||||
let filter = app.searchFields["筛选书源"]
|
||||
filter.tap()
|
||||
filter.typeText(sourceID)
|
||||
let source = app.staticTexts[sourceID].firstMatch
|
||||
XCTAssertTrue(source.waitForExistence(timeout: 10))
|
||||
source.tap()
|
||||
app.buttons["确定"].tap()
|
||||
}
|
||||
|
||||
private func waitForBody(containing chapter: String) {
|
||||
let body = app.textViews["reader.text"]
|
||||
waitUntil("Body of \(chapter)", timeout: 90) {
|
||||
guard body.exists, body.isHittable else { return false }
|
||||
let value = body.value as? String ?? ""
|
||||
return value.contains(chapter) && value.count > 500
|
||||
}
|
||||
let value = body.value as? String ?? ""
|
||||
XCTAssertGreaterThan(value.components(separatedBy: "\n").count, 5)
|
||||
XCTAssertFalse(value.contains("<p>"))
|
||||
XCTAssertFalse(value.contains(#"\r\n"#))
|
||||
}
|
||||
|
||||
private func showReaderMenu() {
|
||||
if !app.buttons["换源"].exists {
|
||||
app.textViews["reader.text"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["换源"].waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
private func waitUntil(_ name: String, timeout: TimeInterval, condition: @escaping () -> Bool) {
|
||||
let ready = expectation(for: NSPredicate { _, _ in condition() }, evaluatedWith: nil)
|
||||
ready.expectationDescription = name
|
||||
wait(for: [ready], timeout: timeout)
|
||||
}
|
||||
|
||||
private func attach(_ name: String) {
|
||||
let screenshot = XCTAttachment(screenshot: app.screenshot())
|
||||
screenshot.name = name
|
||||
screenshot.lifetime = .keepAlways
|
||||
add(screenshot)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollGestureTests: XCTestCase {
|
||||
func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws {
|
||||
continueAfterFailure = false
|
||||
for mode in ["3", "0"] {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiReaderFailure", "-pageAnim", mode, "-themeMode", "0"]
|
||||
app.launch()
|
||||
let retry = app.buttons["reader.retryChapter"]
|
||||
XCTAssertTrue(retry.waitForExistence(timeout: 20))
|
||||
retry.tap()
|
||||
XCTAssertTrue(app.buttons["reader.failureChangeSource"].waitForExistence(timeout: 10))
|
||||
app.buttons["reader.failureChangeSource"].tap()
|
||||
XCTAssertTrue(app.navigationBars["为《长夜里的灯》换源"].waitForExistence(timeout: 10))
|
||||
app.buttons["取消"].tap()
|
||||
XCTAssertTrue(retry.waitForExistence(timeout: 10))
|
||||
if mode == "3" {
|
||||
app.collectionViews["reader.continuous"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap()
|
||||
} else {
|
||||
app.buttons["reader.failureMenu"].tap()
|
||||
}
|
||||
let changeSource = app.buttons["reader.changeSource"]
|
||||
XCTAssertTrue(changeSource.waitForExistence(timeout: 5))
|
||||
changeSource.tap()
|
||||
XCTAssertTrue(app.navigationBars["为《长夜里的灯》换源"].waitForExistence(timeout: 10))
|
||||
attachScreenshot(app)
|
||||
app.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func testPagedReadingReportsPageCountAndTurnsWithinTheSameChapter() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiScrollRegression", "-uiPagingDiagnostics", "-pageAnim", "4", "-themeMode", "1", "-tipFooterRight", "6", "-textSize", "28"]
|
||||
app.launch()
|
||||
let body = app.textViews["reader.text"]
|
||||
XCTAssertTrue(body.waitForExistence(timeout: 20))
|
||||
let page = app.staticTexts.matching(NSPredicate(format: "label MATCHES %@", "[0-9]+/[0-9]+.*")).firstMatch
|
||||
let ready = expectation(for: NSPredicate { _, _ in
|
||||
print("[PagedReader] \(body.label) footer=\(page.label)")
|
||||
let total = page.label.split(separator: "/").last?.split(separator: " ").first.flatMap { Int($0) } ?? 0
|
||||
return page.exists && page.label.hasPrefix("1/") && total > 1
|
||||
}, evaluatedWith: nil)
|
||||
wait(for: [ready], timeout: 10)
|
||||
let chapter = body.value as? String
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).tap()
|
||||
let next = expectation(for: NSPredicate { _, _ in page.label.hasPrefix("2/") }, evaluatedWith: nil)
|
||||
wait(for: [next], timeout: 5)
|
||||
XCTAssertEqual(body.value as? String, chapter)
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).tap()
|
||||
let previous = expectation(for: NSPredicate { _, _ in page.label.hasPrefix("1/") }, evaluatedWith: nil)
|
||||
wait(for: [previous], timeout: 5)
|
||||
XCTAssertEqual(body.value as? String, chapter)
|
||||
attachScreenshot(app)
|
||||
}
|
||||
|
||||
func testPagedReadingChapterNavigationDirectorySearchAndSettings() throws {
|
||||
try exerciseChapterNavigationAndSettings(usesLegacyUI: false)
|
||||
}
|
||||
|
||||
func testLegacyReadingChapterNavigationDirectorySearchAndPhotoPicker() throws {
|
||||
try exerciseChapterNavigationAndSettings(usesLegacyUI: true)
|
||||
}
|
||||
|
||||
private func exerciseChapterNavigationAndSettings(usesLegacyUI: Bool) throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-pageAnim", "0", "-themeMode", "1"]
|
||||
if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") }
|
||||
app.launch()
|
||||
let body = app.textViews["reader.text"]
|
||||
XCTAssertTrue(body.waitForExistence(timeout: 20))
|
||||
func waitForChapter(_ title: String) {
|
||||
let ready = expectation(for: NSPredicate { _, _ in
|
||||
(body.value as? String ?? "").contains(title)
|
||||
}, evaluatedWith: nil)
|
||||
wait(for: [ready], timeout: 15)
|
||||
}
|
||||
func showMenu() {
|
||||
if !app.buttons["目录"].exists {
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 5))
|
||||
}
|
||||
waitForChapter("第一章 · 雨夜")
|
||||
showMenu()
|
||||
XCTAssertFalse(app.buttons["上一章"].isEnabled)
|
||||
app.buttons["下一章"].tap()
|
||||
waitForChapter("第二章 · 灯塔")
|
||||
showMenu()
|
||||
app.buttons["上一章"].tap()
|
||||
waitForChapter("第一章 · 雨夜")
|
||||
|
||||
showMenu()
|
||||
app.buttons["目录"].tap()
|
||||
XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5))
|
||||
let chapterSearch = app.searchFields["搜索章节"]
|
||||
chapterSearch.tap()
|
||||
chapterSearch.typeText("天明")
|
||||
app.staticTexts["第五章 · 天明"].tap()
|
||||
waitForChapter("第五章 · 天明")
|
||||
showMenu()
|
||||
XCTAssertFalse(app.buttons["下一章"].isEnabled)
|
||||
|
||||
app.buttons["更多"].tap()
|
||||
app.buttons["搜索正文"].tap()
|
||||
XCTAssertTrue(app.navigationBars["正文搜索"].waitForExistence(timeout: 5))
|
||||
let contentSearch = app.searchFields["搜索本章"]
|
||||
contentSearch.tap()
|
||||
contentSearch.typeText("灯塔")
|
||||
let match = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "林深推开阁楼的窗")).firstMatch
|
||||
XCTAssertTrue(match.waitForExistence(timeout: 5))
|
||||
match.tap()
|
||||
XCTAssertFalse(app.navigationBars["正文搜索"].exists)
|
||||
|
||||
showMenu()
|
||||
app.buttons["书签"].tap()
|
||||
app.buttons["更多"].tap()
|
||||
app.buttons["书签列表"].tap()
|
||||
XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["第五章 · 天明"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["关闭"].tap()
|
||||
|
||||
showMenu()
|
||||
app.buttons["外观"].tap()
|
||||
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
|
||||
if usesLegacyUI {
|
||||
app.buttons["选择背景图片"].tap()
|
||||
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
|
||||
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
|
||||
cancel.tap()
|
||||
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
|
||||
}
|
||||
app.buttons["完成"].tap()
|
||||
showMenu()
|
||||
app.buttons["设置"].tap()
|
||||
XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5))
|
||||
app.buttons["完成"].tap()
|
||||
waitForChapter("第五章 · 天明")
|
||||
attachScreenshot(app)
|
||||
}
|
||||
|
||||
func testForwardAndReverseFlicksKeepMomentumAcrossChapterInsertion() throws {
|
||||
let (app, reader) = launchReader(shortChapters: false)
|
||||
let initial = try value("chapter", in: reader)
|
||||
for _ in 0..<6 { flick(reader, forward: true) }
|
||||
let forward = try value("chapter", in: reader)
|
||||
XCTAssertGreaterThan(forward, initial + 2)
|
||||
for _ in 0..<6 { flick(reader, forward: false) }
|
||||
XCTAssertLessThan(try value("chapter", in: reader), forward)
|
||||
XCTAssertGreaterThan(try value("decelInsertions", in: reader), 0)
|
||||
XCTAssertGreaterThan(try value("continued", in: reader), 0)
|
||||
XCTAssertEqual(try value("interrupted", in: reader), 0)
|
||||
attachScreenshot(app)
|
||||
}
|
||||
|
||||
func testManyShortChaptersFlowPastTheInitialWindow() throws {
|
||||
let (app, reader) = launchReader(shortChapters: true)
|
||||
let initial = try value("chapter", in: reader)
|
||||
for _ in 0..<4 { flick(reader, forward: true) }
|
||||
let forward = try value("chapter", in: reader)
|
||||
XCTAssertGreaterThan(forward, initial + 5)
|
||||
for _ in 0..<4 { flick(reader, forward: false) }
|
||||
XCTAssertLessThan(try value("chapter", in: reader), forward)
|
||||
XCTAssertEqual(try value("interrupted", in: reader), 0)
|
||||
attachScreenshot(app)
|
||||
}
|
||||
|
||||
private func launchReader(shortChapters: Bool) -> (XCUIApplication, XCUIElement) {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiScrollRegression", "-pageAnim", "3", "-themeMode", "0"]
|
||||
if shortChapters { app.launchArguments.append("-uiShortChapters") }
|
||||
app.launch()
|
||||
let reader = app.collectionViews["reader.continuous"]
|
||||
XCTAssertTrue(reader.waitForExistence(timeout: 20))
|
||||
let ready = expectation(for: NSPredicate { _, _ in
|
||||
let text = reader.value as? String ?? ""
|
||||
return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
|
||||
}, evaluatedWith: nil)
|
||||
wait(for: [ready], timeout: 20)
|
||||
return (app, reader)
|
||||
}
|
||||
|
||||
private func flick(_ reader: XCUIElement, forward: Bool) {
|
||||
let start = reader.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: forward ? 0.8 : 0.2))
|
||||
let end = reader.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: forward ? 0.2 : 0.8))
|
||||
start.press(forDuration: 0.01, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0)
|
||||
}
|
||||
|
||||
private func value(_ name: String, in reader: XCUIElement) throws -> Int {
|
||||
let fields = (reader.value as? String ?? "").split(separator: " ")
|
||||
let field = try XCTUnwrap(fields.first { $0.hasPrefix(name + "=") })
|
||||
let value = try XCTUnwrap(Int(field.dropFirst(name.count + 1)))
|
||||
print("[ContinuousReader] \(reader.value ?? "")")
|
||||
return value
|
||||
}
|
||||
|
||||
private func attachScreenshot(_ app: XCUIApplication) {
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class RuleEditorUITests: XCTestCase {
|
||||
func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
|
||||
try exerciseSourceEditor(usesLegacyUI: false)
|
||||
}
|
||||
|
||||
func testLegacySourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
|
||||
try exerciseSourceEditor(usesLegacyUI: true)
|
||||
}
|
||||
|
||||
private func exerciseSourceEditor(usesLegacyUI: Bool) throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"]
|
||||
if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") }
|
||||
app.launch()
|
||||
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20))
|
||||
app.buttons["更多"].firstMatch.tap()
|
||||
app.buttons["新建书源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10))
|
||||
app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "书源名称")).firstMatch.tap()
|
||||
let editor = app.textViews["syntaxCodeEditor"]
|
||||
XCTAssertTrue(editor.waitForExistence(timeout: 5))
|
||||
editor.tap(); editor.typeText("UI Source")
|
||||
attach(app, name: "Typed source name")
|
||||
XCTAssertEqual(editor.value as? String, "UI Source")
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "书源地址")).firstMatch.tap()
|
||||
editor.tap(); editor.typeText("https://ui-editor.test")
|
||||
XCTAssertEqual(editor.value as? String, "https://ui-editor.test")
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
app.segmentedControls.buttons["JSON"].tap()
|
||||
attach(app, name: "Source JSON editor")
|
||||
let json = editor.value as? String ?? ""
|
||||
XCTAssertTrue(json.contains("UI Source"), "Actual JSON: \(json)")
|
||||
XCTAssertTrue(json.contains("https://ui-editor.test"), "Actual JSON: \(json)")
|
||||
app.segmentedControls.buttons["字段编辑"].tap()
|
||||
app.buttons["保存"].tap()
|
||||
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
|
||||
XCTAssertTrue(app.staticTexts["UI Source"].waitForExistence(timeout: 10))
|
||||
}
|
||||
|
||||
func testPurificationDraftRunsThroughReaderPipeline() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoRules"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.buttons["新建规则"].waitForExistence(timeout: 20))
|
||||
app.buttons["新建规则"].tap()
|
||||
app.textFields["名称"].tap(); app.textFields["名称"].typeText("UI Rule")
|
||||
app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "匹配内容")).firstMatch.tap()
|
||||
let editor = app.textViews["syntaxCodeEditor"]
|
||||
XCTAssertTrue(editor.waitForExistence(timeout: 5))
|
||||
editor.tap(); editor.typeText(".")
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
app.segmentedControls.buttons["测试规则"].tap()
|
||||
app.buttons["运行当前草稿"].tap()
|
||||
XCTAssertTrue(app.staticTexts["规则已生效"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["(正文为空)"].exists)
|
||||
attach(app, name: "Purification test result")
|
||||
app.buttons["保存"].tap()
|
||||
XCTAssertTrue(app.navigationBars["净化规则"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["UI Rule"].exists)
|
||||
}
|
||||
|
||||
func testLegacySearchSubmitsAndFiltersSources() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "search", "-uiLegacyCompatibility"]
|
||||
app.launch()
|
||||
let search = app.textFields["书名或作者"]
|
||||
XCTAssertTrue(search.waitForExistence(timeout: 20))
|
||||
app.buttons["搜索选项"].tap()
|
||||
app.buttons["分组或书源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 10))
|
||||
app.segmentedControls.buttons["书源"].tap()
|
||||
let filter = app.textFields["筛选书源"]
|
||||
XCTAssertTrue(filter.waitForExistence(timeout: 5))
|
||||
filter.tap()
|
||||
filter.typeText("no-match")
|
||||
XCTAssertFalse(app.staticTexts["经典文学馆"].exists)
|
||||
app.navigationBars["分组或书源"].buttons["取消"].tap()
|
||||
XCTAssertTrue(search.waitForExistence(timeout: 5))
|
||||
search.tap()
|
||||
search.typeText("legacy-probe\n")
|
||||
XCTAssertTrue(app.buttons["修改关键词"].waitForExistence(timeout: 30))
|
||||
app.buttons["修改关键词"].tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
attach(app, name: "Legacy search after keyboard submission")
|
||||
}
|
||||
|
||||
func testLegacyAboutCanPresentSystemShareSheet() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoAbout", "-uiLegacyCompatibility"]
|
||||
app.launch()
|
||||
let share = app.buttons["分享版本信息"]
|
||||
XCTAssertTrue(share.waitForExistence(timeout: 20))
|
||||
share.tap()
|
||||
let copy = app.descendants(matching: .any).matching(NSPredicate(format: "label IN %@", ["Copy", "拷贝", "复制"])).firstMatch
|
||||
let appeared = copy.waitForExistence(timeout: 10)
|
||||
if !appeared { print("[Share sheet hierarchy] \(app.debugDescription)") }
|
||||
XCTAssertTrue(appeared)
|
||||
attach(app, name: "Legacy system share sheet")
|
||||
}
|
||||
|
||||
func testLegacyRuleMenuCanExportThroughSystemShareSheet() throws {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoRules", "-uiLegacyCompatibility"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.navigationBars["净化规则"].waitForExistence(timeout: 20))
|
||||
app.buttons["更多"].tap()
|
||||
app.buttons["导出全部规则"].tap()
|
||||
let copy = app.descendants(matching: .any).matching(NSPredicate(format: "label IN %@", ["Copy", "拷贝", "复制"])).firstMatch
|
||||
let appeared = copy.waitForExistence(timeout: 10)
|
||||
if !appeared { print("[Share sheet hierarchy] \(app.debugDescription)") }
|
||||
XCTAssertTrue(appeared)
|
||||
attach(app, name: "Legacy rule menu export")
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user