mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51: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!)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user