mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
369 lines
21 KiB
Swift
369 lines
21 KiB
Swift
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
|
|
}
|
|
}
|