mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
271 lines
14 KiB
Swift
271 lines
14 KiB
Swift
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<SourceSearchOutcome>
|
|
func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport {
|
|
.init(books: [], outcomes: [])
|
|
}
|
|
func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> { 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)])
|
|
}
|
|
}
|