import Foundation import Testing @testable import LegadoCore struct ChapterContentCacheTests { @Test func savesLoadsAndSeparatesBooks() async throws { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: directory) } let cache = ChapterContentCache(directory: directory) let first = CachedChapterContent(bookURL: "book-a", chapterURL: "same", title: "一", content: "正文 A") let second = CachedChapterContent(bookURL: "book-b", chapterURL: "same", title: "二", content: "正文 B") try await cache.save(first) try await cache.save(second) #expect(try await cache.load(bookURL: "book-a", chapterURL: "same") == first) #expect(try await cache.load(bookURL: "book-b", chapterURL: "same") == second) let statistics = try await cache.statistics() #expect(statistics.entries == 2) } @Test func removesOnlyRequestedBookAndRejectsEmptyEntries() async throws { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: directory) } let cache = ChapterContentCache(directory: directory) try await cache.save(.init(bookURL: "a", chapterURL: "1", content: "A")) try await cache.save(.init(bookURL: "b", chapterURL: "1", content: "B")) try await cache.remove(bookURL: "a") #expect(try await cache.load(bookURL: "a", chapterURL: "1") == nil) #expect(try await cache.load(bookURL: "b", chapterURL: "1")?.content == "B") await #expect(throws: ChapterContentCacheError.invalidEntry) { try await cache.save(.init(bookURL: "a", chapterURL: "2", content: "")) } } @Test func coordinatorUsesDiskCacheBeforeNetwork() async throws { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: directory) } let loader = StubChapterLoader(content: "网络正文") let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory)) let book = Book(bookUrl: "book", name: "书", origin: "source") let chapter = BookChapter(url: "chapter", title: "章", bookUrl: book.bookUrl, index: 0) let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") let first = await coordinator.load(book: book, chapter: chapter, source: source) let second = await coordinator.load(book: book, chapter: chapter, source: source) #expect(!first.loadedFromCache) #expect(second.loadedFromCache) #expect(second.report.content == "网络正文") #expect(await loader.calls() == 1) } @Test func removesOneChapterAndForceRefreshesIt() async throws { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: directory) } let cache = ChapterContentCache(directory: directory) try await cache.save(.init(bookURL: "book", chapterURL: "one", content: "旧正文")) try await cache.save(.init(bookURL: "book", chapterURL: "two", content: "保留正文")) try await cache.remove(bookURL: "book", chapterURL: "one") #expect(try await cache.load(bookURL: "book", chapterURL: "one") == nil) #expect(try await cache.load(bookURL: "book", chapterURL: "two")?.content == "保留正文") let loader = StubChapterLoader(content: "刷新正文") let coordinator = ChapterContentCoordinator(loader: loader, cache: cache) let book = Book(bookUrl: "book", name: "书", origin: "source") let chapter = BookChapter(url: "two", title: "章", bookUrl: book.bookUrl, index: 0) let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") let refreshed = await coordinator.load(book: book, chapter: chapter, source: source, forceRefresh: true) #expect(!refreshed.loadedFromCache) #expect(refreshed.report.content == "刷新正文") #expect(await loader.calls() == 1) } @Test func coordinatorPrefetchesOnce() async { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: directory) } let loader = StubChapterLoader(content: "下一章") let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory)) let book = Book(bookUrl: "book", name: "书", origin: "source") let chapter = BookChapter(url: "next", title: "下一章", bookUrl: book.bookUrl, index: 1) let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") #expect(await coordinator.prefetch(book: book, chapter: chapter, source: source)) #expect(!(await coordinator.prefetch(book: book, chapter: chapter, source: source))) #expect(await loader.calls() == 1) } @Test func cachePlannerSelectsRangeAndSkipsVolumeRows() { var chapters = (0..<5).map { BookChapter(url: "c\($0)", title: "第 \($0) 章", bookUrl: "book", index: $0) } chapters[2].isVolume = true let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: 1, limit: 2) #expect(jobs.map(\.chapter.index) == [1, 3]) #expect(jobs.map(\.nextChapterURL) == ["c3", "c4"]) } @Test func cachePlannerClampsStartAndRejectsEmptyLimit() { let chapters = (0..<2).map { BookChapter(url: "c\($0)", title: "章", bookUrl: "book", index: $0) } #expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 99).map(\.chapter.index) == [1]) #expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 0, limit: 0).isEmpty) } } private actor StubChapterLoader: ChapterContentLoading { private let content: String private var callCount = 0 init(content: String) { self.content = content } func loadContent( book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int ) async -> ChapterContentReport { callCount += 1 return .init(title: chapter.title, content: content) } func calls() -> Int { callCount } }