feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -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!)
}
}