import Testing @testable import LegadoCore struct BookSourceSwitchServiceTests { @Test(.timeLimit(.minutes(1))) func automaticRecoveryUsesEarlyValidSourceWithoutWaitingForSlowSources() async throws { let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self) defer { continuation.finish() } let loader = CandidateAwareSwitchLoader() let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream), detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(), chapterRepository: InMemoryBookChapterRepository()) var source = BookSource(bookSourceUrl: "valid", bookSourceName: "好源") source.searchUrl = "/search" let candidate = SearchBook(bookURL: "valid-book", origin: "valid", originName: "好源", type: 0, name: "书") continuation.yield(.init(sourceID: "valid", books: [candidate], diagnostics: [])) let result = await service.firstAvailableSource(for: Book(bookUrl: "old-book", name: "书", origin: "old"), sources: [source]) #expect(result?.source.id == "valid") #expect(result?.report.chapters.first?.title == "有效目录") } @Test(.timeLimit(.minutes(1))) func streamsCandidatesBeforeAllSourcesFinishAndKeepsDistinctOrigins() async throws { let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self) defer { continuation.finish() } let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream)) let book = Book(bookUrl: "old-book", name: "书", author: "作者", origin: "old") let sources = ["a", "b"].map { id in var source = BookSource(bookSourceUrl: id, bookSourceName: id) source.searchUrl = "/search" return source } var iterator = await service.candidateUpdates(for: book, sources: sources).makeAsyncIterator() let first = SearchBook(bookURL: "shared-url", origin: "a", originName: "A", type: 0, name: "书", author: "作者") continuation.yield(.init(sourceID: "a", books: [first], diagnostics: [])) let early = try #require(await iterator.next()) #expect(early.map(\.book.origin) == ["a"]) var second = first second.origin = "b" continuation.yield(.init(sourceID: "b", books: [second, second], diagnostics: [])) let updated = try #require(await iterator.next()) #expect(Set(updated.map(\.book.origin)) == ["a", "b"]) #expect(Set(updated.map(\.id)).count == 2) continuation.finish() #expect(await iterator.next() == nil) } @Test func ranksExactAuthorAndExcludesCurrentSource() async { var current = Book(bookUrl: "old", name: "长夜里的灯", author: "林舟", origin: "old-source") current.originName = "旧源" let exact = SearchBook(bookURL: "new-a", origin: "source-a", originName: "甲源", type: 0, name: "长夜里的灯", author: "作者:林舟") let otherAuthor = SearchBook(bookURL: "new-b", origin: "source-b", originName: "乙源", type: 0, name: "长夜里的灯", author: "另一位") let wrongTitle = SearchBook(bookURL: "new-c", origin: "source-c", originName: "丙源", type: 0, name: "长夜的灯", author: "林舟") let report = BookSearchReport(books: [], outcomes: [ .init(sourceID: "source-a", books: [exact], diagnostics: []), .init(sourceID: "source-b", books: [otherAuthor], diagnostics: []), .init(sourceID: "source-c", books: [wrongTitle], diagnostics: []) ]) let service = BookSourceSwitchService(searcher: StubSwitchSearch(report: report)) let sources = ["old-source", "source-a", "source-b", "source-c"].map { var source = BookSource(bookSourceUrl: $0, bookSourceName: $0) source.searchUrl = "/search" return source } let candidates = await service.candidates(for: current, sources: sources) #expect(candidates.map(\.book.bookURL) == ["new-a", "new-b"]) #expect(candidates[0].score > candidates[1].score) } @Test func preservesCharacterPositionWhenChapterTitleMatches() { var current = Book(bookUrl: "old", name: "书", origin: "old") current.customTag = "收藏" current.group = 8 current.totalChapterNum = 10 current.durChapterIndex = 4 current.durChapterTitle = "第五章 · 重逢" current.durChapterPos = 388 current.durChapterTime = 77 var readConfig = BookReadConfig() readConfig.pageAnim = 3 readConfig.useReplaceRule = false current.readConfig = readConfig let replacement = Book(bookUrl: "new", name: "书", origin: "new") let chapters = (0..<8).map { BookChapter(url: "n\($0)", title: $0 == 6 ? "第五章 重逢" : "第\($0)章", bookUrl: "new", index: $0) } let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100) #expect(report.exactChapterMatch) #expect(report.book.durChapterIndex == 6) #expect(report.book.durChapterPos == 388) #expect(report.book.durChapterTime == 77) #expect(report.book.customTag == "收藏") #expect(report.book.group == 8) #expect(report.book.readConfig == current.readConfig) } @Test func fallsBackToRelativeProgressAndResetsCharacterOffset() { var current = Book(bookUrl: "old", name: "书", origin: "old") current.totalChapterNum = 11 current.durChapterIndex = 5 current.durChapterTitle = "旧目录章节" current.durChapterPos = 999 let replacement = Book(bookUrl: "new", name: "书", origin: "new") let chapters = (0..<21).map { BookChapter(url: "n\($0)", title: "新\($0)", bookUrl: "new", index: $0) } let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100) #expect(!report.exactChapterMatch) #expect(report.book.durChapterIndex == 10) #expect(report.book.durChapterPos == 0) } @Test func refusesSwitchWhenCandidateDirectoryCannotBeLoaded() async { let repository = InMemoryBookChapterRepository() let service = BookSourceSwitchService( searcher: StubSwitchSearch(report: .init(books: [], outcomes: [])), detailLoader: StubSwitchLoader(chapters: []), chapterLoader: StubSwitchLoader(chapters: []), chapterRepository: repository ) let current = Book(bookUrl: "old", name: "书", origin: "old") let candidate = SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书") let source = BookSource(bookSourceUrl: "new", bookSourceName: "新源") #expect(await service.prepareSwitch(current: current, candidate: candidate, source: source) == nil) #expect(await repository.load(bookURL: "new").isEmpty) } @Test func automaticallySkipsBrokenCandidateAndUsesFirstValidDirectory() async { let broken = SearchBook(bookURL: "broken-book", origin: "broken", originName: "A 坏源", type: 0, name: "书") let valid = SearchBook(bookURL: "valid-book", origin: "valid", originName: "B 好源", type: 0, name: "书") let search = StubSwitchSearch(report: .init(books: [], outcomes: [ .init(sourceID: "broken", books: [broken], diagnostics: []), .init(sourceID: "valid", books: [valid], diagnostics: []) ])) let loader = CandidateAwareSwitchLoader() let service = BookSourceSwitchService( searcher: search, detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(), chapterRepository: InMemoryBookChapterRepository() ) let current = Book(bookUrl: "old-book", name: "书", origin: "old") let sources = ["old", "broken", "valid"].map { id in var source = BookSource(bookSourceUrl: id, bookSourceName: id) source.searchUrl = "/search" return source } let available = await service.firstAvailableSource(for: current, sources: sources, now: 100) #expect(available?.source.bookSourceUrl == "valid") #expect(available?.report.chapters.map(\.title) == ["有效目录"]) } @Test func refusesCandidateWithDirectoryButNoBodyWithoutSavingItsDirectory() async { let repository = InMemoryBookChapterRepository() let chapter = BookChapter(url: "new/1", title: "第一章", bookUrl: "new", index: 0) let loader = StubSwitchLoader(chapters: [chapter]) for body in ["", " \n\t"] { let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(body: body), chapterRepository: repository) let report = await service.prepareSwitch( current: Book(bookUrl: "old", name: "书", origin: "old"), candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"), source: BookSource(bookSourceUrl: "new", bookSourceName: "新源") ) #expect(report == nil) #expect(await repository.load(bookURL: "new").isEmpty) } } @Test func automaticRecoverySkipsSearchableSourcesWithDirectoriesButBrokenBodies() async throws { let candidates = ["broken-body", "valid"].map { SearchBook(bookURL: $0 + "-book", origin: $0, originName: $0, type: 0, name: "书") } let sources = candidates.map { candidate in var source = BookSource(bookSourceUrl: candidate.origin, bookSourceName: candidate.originName) source.searchUrl = "/search" return source } let loader = StubSwitchLoader(chapters: [BookChapter(url: "chapter", title: "第一章", bookUrl: "new", index: 0)]) let service = BookSourceSwitchService( searcher: StubSwitchSearch(report: .init(books: [], outcomes: candidates.map { .init(sourceID: $0.origin, books: [$0], diagnostics: []) })), detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(), chapterRepository: InMemoryBookChapterRepository() ) let available = try #require(await service.firstAvailableSource(for: Book(bookUrl: "old", name: "书", origin: "old"), sources: sources)) #expect(available.source.id == "valid") #expect(available.report.verifiedContent?.content == "验证后的正文") } @Test func validatesMappedChapterWithNextReadableURLAndKeepsReadingPosition() async throws { var current = Book(bookUrl: "old", name: "书", origin: "old") current.durChapterTitle = "第二章" current.durChapterPos = 88 var volume = BookChapter(url: "volume", title: "卷二", bookUrl: "new", index: 2) volume.isVolume = true let chapters = [BookChapter(url: "first", title: "第一章", bookUrl: "new", index: 0), BookChapter(url: "second", title: "第二章", bookUrl: "new", index: 1), volume, BookChapter(url: "third", title: "第三章", bookUrl: "new", index: 3)] let loader = StubSwitchLoader(chapters: chapters) let contentLoader = RecordingSwitchContentLoader() let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader, contentLoader: contentLoader, chapterRepository: InMemoryBookChapterRepository()) let report = try #require(await service.prepareSwitch(current: current, candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"), source: BookSource(bookSourceUrl: "new", bookSourceName: "新源"))) #expect(await contentLoader.chapterURL == "second") #expect(await contentLoader.nextURL == "third") #expect(report.book.durChapterPos == 88) #expect(report.verifiedContent?.content == "验证后的正文") } } private struct SwitchContentLoader: ChapterContentLoading { var body = "验证后的正文" func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { .init(content: source.id == "broken-body" ? "" : body) } } private actor RecordingSwitchContentLoader: ChapterContentLoading { var chapterURL: String? var nextURL: String? func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { chapterURL = chapter.url nextURL = nextChapterURL return .init(content: "验证后的正文") } } private struct StreamingSwitchSearch: BookSourceSearching { let stream: AsyncStream func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport { .init(books: [], outcomes: []) } func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream { stream } } private struct StubSwitchSearch: BookSourceSearching { let report: BookSearchReport func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport { report } } private struct StubSwitchLoader: BookDetailLoading, ChapterListLoading { let chapters: [BookChapter] func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport { .init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, author: searchBook.author, origin: source.bookSourceUrl)) } func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { .init(chapters: chapters) } } private struct CandidateAwareSwitchLoader: BookDetailLoading, ChapterListLoading { func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport { .init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, origin: source.bookSourceUrl)) } func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { guard source.bookSourceUrl == "valid" else { return .init(chapters: []) } return .init(chapters: [BookChapter(url: "chapter", title: "有效目录", bookUrl: book.bookUrl, index: 0)]) } }