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 bookmarkSelectionUsesSavedPositionAndCurrentPageAfterReopening() { let book = Book(bookUrl: "demo://bookmarks", name: "书", author: "作者") let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } var saved = Bookmark(time: 1, bookName: book.name, bookAuthor: book.author) saved.chapterIndex = 0 saved.chapterName = chapters[0].title saved.chapterPosition = 40 var otherBook = saved otherBook.bookAuthor = "其他作者" var otherChapter = saved otherChapter.chapterIndex = 1 otherChapter.chapterName = chapters[1].title let bookmarks = [saved, otherBook, otherChapter] let reopened = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文") #expect(reopened.bookmarks(at: 40, in: bookmarks) == [saved]) #expect(reopened.bookmarks(at: 0, in: bookmarks, pageRange: NSRange(location: 0, length: 80)) == [saved]) #expect(reopened.bookmarks(at: 80, in: bookmarks, pageRange: NSRange(location: 80, length: 80)).isEmpty) #expect(reopened.bookmarks(at: 41, in: bookmarks).isEmpty) #expect(reopened.bookmarks(at: 40, in: []).isEmpty) reopened.select(index: 1) #expect(reopened.bookmarks(at: 40, in: bookmarks) == [otherChapter]) } @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? private var started: CheckedContinuation? 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 } }