mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
287 lines
14 KiB
Swift
287 lines
14 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import LegadoCore
|
|
|
|
private struct SearchMockTransport: HTTPTransport {
|
|
let response: HTTPResponse
|
|
var expectedQueryFragment: String?
|
|
|
|
func data(for request: URLRequest) async throws -> HTTPResponse {
|
|
if let expectedQueryFragment {
|
|
#expect(request.url?.absoluteString.contains(expectedQueryFragment) == true)
|
|
}
|
|
return response
|
|
}
|
|
}
|
|
|
|
private struct DelayedSearchTransport: HTTPTransport {
|
|
func data(for request: URLRequest) async throws -> HTTPResponse {
|
|
let isSlow = request.url?.host == "slow.example.test"
|
|
try await Task.sleep(for: isSlow ? .milliseconds(200) : .milliseconds(10))
|
|
let name = isSlow ? "慢源结果" : "快源结果"
|
|
let data = Data(#"{"books":[{"name":"\#(name)","author":"作者","url":"/book/1"}]}"#.utf8)
|
|
return .init(data: data, statusCode: 200, headers: [:], finalURL: request.url!)
|
|
}
|
|
}
|
|
|
|
struct BookSearchServiceTests {
|
|
@Test func streamsFastSourceWithoutWaitingForSlowSource() async throws {
|
|
func makeSource(host: String, name: String) -> BookSource {
|
|
var source = BookSource(bookSourceUrl: "https://\(host)", bookSourceName: name)
|
|
source.searchUrl = "https://\(host)/search?q={{key}}"
|
|
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url")
|
|
return source
|
|
}
|
|
let slow = makeSource(host: "slow.example.test", name: "慢源")
|
|
let fast = makeSource(host: "fast.example.test", name: "快源")
|
|
let stream = BookSearchService(transport: DelayedSearchTransport())
|
|
.searchOutcomes(keyword: "结果", sources: [slow, fast])
|
|
var iterator = stream.makeAsyncIterator()
|
|
|
|
let first = try #require(await iterator.next())
|
|
|
|
#expect(first.sourceID == fast.id)
|
|
#expect(first.books.first?.name == "快源结果")
|
|
}
|
|
|
|
@Test func ordersMergedResultsBySearchRelevance() async throws {
|
|
let finalURL = try #require(URL(string: "https://api.example.test/search"))
|
|
let data = Data(#"{"books":[{"name":"其他","author":"甲","kind":"普通","url":"/4"},{"name":"关键词扩展","author":"乙","kind":"普通","url":"/3"},{"name":"分类书","author":"丙","kind":"关键词","url":"/2"},{"name":"关键词","author":"丁","kind":"普通","url":"/1"}]}"#.utf8)
|
|
let transport = SearchMockTransport(
|
|
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
|
expectedQueryFragment: nil
|
|
)
|
|
var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "排序源")
|
|
source.searchUrl = "https://api.example.test/search?q={{key}}"
|
|
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", kind: "kind", bookUrl: "url")
|
|
|
|
let report = await BookSearchService(transport: transport).search(keyword: "关键词", sources: [source])
|
|
|
|
#expect(report.books.map(\.name) == ["关键词", "分类书", "关键词扩展", "其他"])
|
|
}
|
|
|
|
@Test func searchesJSONSourceAndResolvesRelativeURLs() async throws {
|
|
let finalURL = try #require(URL(string: "https://api.example.test/search?q=swift"))
|
|
let data = Data(
|
|
"""
|
|
{"data":{"books":[
|
|
{"name":"Swift 入门","author":"A","url":"/book/1","cover":"/cover/1.jpg","tags":["编程","Swift"]},
|
|
{"name":"Swift 进阶","author":"B","url":"/book/2","cover":"/cover/2.jpg","tags":["编程"]}
|
|
]}}
|
|
""".utf8
|
|
)
|
|
let transport = SearchMockTransport(response: .init(
|
|
data: data,
|
|
statusCode: 200,
|
|
headers: [:],
|
|
finalURL: finalURL
|
|
), expectedQueryFragment: "q=swift")
|
|
var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "JSON 源")
|
|
source.searchUrl = "/search?q={{key}}&page=<1,2,3>"
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "$.data.books[*]",
|
|
name: "name",
|
|
author: "author",
|
|
kind: "tags[*]",
|
|
bookUrl: "url",
|
|
coverUrl: "cover"
|
|
)
|
|
let service = BookSearchService(transport: transport)
|
|
|
|
let report = await service.search(keyword: "swift", sources: [source])
|
|
|
|
#expect(report.books.count == 2)
|
|
#expect(report.books.map(\.name).sorted() == ["Swift 入门", "Swift 进阶"])
|
|
#expect(report.books.allSatisfy { $0.bookURL.hasPrefix("https://api.example.test/book/") })
|
|
#expect(report.outcomes[0].diagnostics.isEmpty)
|
|
}
|
|
|
|
@Test func mergesSameBookAcrossSources() async throws {
|
|
let finalURL = try #require(URL(string: "https://api.example.test/search"))
|
|
let data = Data(#"{"books":[{"name":"同一本书","author":"作者","url":"/book/1"}]}"#.utf8)
|
|
let transport = SearchMockTransport(
|
|
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
|
expectedQueryFragment: nil
|
|
)
|
|
func makeSource(_ id: String) -> BookSource {
|
|
var source = BookSource(bookSourceUrl: id, bookSourceName: id)
|
|
source.searchUrl = "https://api.example.test/search?q={{key}}"
|
|
source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url")
|
|
return source
|
|
}
|
|
let service = BookSearchService(transport: transport)
|
|
|
|
let report = await service.search(keyword: "同一本书", sources: [makeSource("https://one.test"), makeSource("https://two.test")])
|
|
|
|
#expect(report.books.count == 1)
|
|
#expect(report.books[0].origins.count == 2)
|
|
}
|
|
|
|
@Test func mergesDecoratedAuthorAndPunctuatedTitleAcrossSources() {
|
|
let plain = SearchBook(bookURL: "one", origin: "one", originName: "一源", type: 0, name: "长夜里的灯", author: "林舟")
|
|
let decorated = SearchBook(bookURL: "two", origin: "two", originName: "二源", type: 0, name: "长夜里的灯!", author: "作者:林舟 著")
|
|
let outcomes = [
|
|
SourceSearchOutcome(sourceID: "one", books: [plain], diagnostics: []),
|
|
SourceSearchOutcome(sourceID: "two", books: [decorated], diagnostics: [])
|
|
]
|
|
|
|
let merged = BookSearchService().merge(outcomes: outcomes, keyword: "长夜里的灯")
|
|
|
|
#expect(merged.count == 1)
|
|
#expect(merged[0].origins == ["one", "two"])
|
|
}
|
|
|
|
@Test func searchesHTMLSourceWithCSSRules() async throws {
|
|
let finalURL = try #require(URL(string: "https://html.example.test/search?q=ios"))
|
|
let data = Data(
|
|
"""
|
|
<html><body><section class="results">
|
|
<article class="book"><a class="name" href="/book/ios">iOS 阅读</a><span class="author">作者甲</span><img src="/ios.jpg"></article>
|
|
<article class="book"><a class="name" href="/book/swift">Swift 阅读</a><span class="author">作者乙</span><img src="/swift.jpg"></article>
|
|
</section></body></html>
|
|
""".utf8
|
|
)
|
|
let transport = SearchMockTransport(
|
|
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
|
expectedQueryFragment: "q=ios"
|
|
)
|
|
var source = BookSource(bookSourceUrl: "https://html.example.test", bookSourceName: "HTML 源")
|
|
source.searchUrl = "/search?q={{key}}"
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "@CSS:article.book",
|
|
name: "@CSS:a.name@text",
|
|
author: "@CSS:.author@text",
|
|
bookUrl: "@CSS:a.name@href",
|
|
coverUrl: "@CSS:img@src"
|
|
)
|
|
let service = BookSearchService(transport: transport)
|
|
|
|
let report = await service.search(keyword: "ios", sources: [source])
|
|
|
|
#expect(report.books.count == 2)
|
|
#expect(report.books.first(where: { $0.name == "iOS 阅读" })?.bookURL == "https://html.example.test/book/ios")
|
|
#expect(report.outcomes[0].diagnostics.isEmpty)
|
|
}
|
|
|
|
@Test func searchesLegadoStyleSourceWithJavaScriptPipelineAndVariables() async throws {
|
|
let finalURL = try #require(URL(string: "https://audio.example.test/search"))
|
|
let data = Data(
|
|
#"{"content":{"content":[{"id":1001,"title":"听书测试","author":"播音员","cover":{"path":"cover/a.jpg"}}]}}"#.utf8
|
|
)
|
|
let transport = SearchMockTransport(
|
|
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
|
expectedQueryFragment: nil
|
|
)
|
|
var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "JS 源")
|
|
source.searchUrl = "https://audio.example.test/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}"
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "$.content.content",
|
|
name: "$.title",
|
|
author: "$.author",
|
|
bookUrl: "$.id@js:java.put('bookId', result); '/toc?bookId=' + result",
|
|
coverUrl: "$.cover@js:var cover = JSON.parse(result); '/file/' + cover.path"
|
|
)
|
|
|
|
let report = await BookSearchService(transport: transport).search(keyword: "听书", sources: [source])
|
|
|
|
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
|
let book = try #require(report.books.first)
|
|
#expect(book.bookURL == "https://audio.example.test/toc?bookId=1001")
|
|
#expect(book.coverURL == "https://audio.example.test/file/cover/a.jpg")
|
|
#expect(book.variables["bookId"] == "1001")
|
|
#expect(report.outcomes[0].diagnostics.isEmpty)
|
|
}
|
|
|
|
@Test func searchesLegacyEncodedHTMLUsingURLCharsetOption() async throws {
|
|
let finalURL = try #require(URL(string: "https://legacy.example.test/search"))
|
|
let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030"))
|
|
let html = #"<article class="book"><a class="name" href="/book/1">旧城故事</a><span class="author">林舟</span></article>"#
|
|
let data = try #require(html.data(using: encoding))
|
|
let transport = SearchMockTransport(
|
|
response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL),
|
|
expectedQueryFragment: nil
|
|
)
|
|
var source = BookSource(bookSourceUrl: "https://legacy.example.test", bookSourceName: "旧编码源")
|
|
source.searchUrl = #"/search?q={{key}}, {"charset":"gb18030"}"#
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "@CSS:article.book",
|
|
name: "@CSS:a.name@text",
|
|
author: "@CSS:.author@text",
|
|
bookUrl: "@CSS:a.name@href"
|
|
)
|
|
|
|
let report = await BookSearchService(transport: transport).search(keyword: "旧城", sources: [source])
|
|
|
|
#expect(report.books.first?.name == "旧城故事")
|
|
#expect(report.books.first?.author == "林舟")
|
|
}
|
|
|
|
@Test func evaluatesDynamicRequestJavaScriptAndPassesVariablesToRules() async throws {
|
|
let finalURL = try #require(URL(string: "https://dynamic.example.test/search?q=DUNE"))
|
|
let transport = SearchMockTransport(
|
|
response: .init(
|
|
data: Data(#"{"books":[{"name":"沙丘","author":"Frank","id":"42"}]}"#.utf8),
|
|
statusCode: 200,
|
|
headers: [:],
|
|
finalURL: finalURL
|
|
),
|
|
expectedQueryFragment: "q=DUNE"
|
|
)
|
|
var source = BookSource(bookSourceUrl: "https://dynamic.example.test", bookSourceName: "动态源")
|
|
source.searchUrl = "@js:java.put('requestToken', key.toUpperCase()); '/search?q=' + java.get('requestToken')"
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "$.books[*]",
|
|
name: "name",
|
|
author: "author",
|
|
bookUrl: "$.id@js:'/book/' + result + '?token=' + java.get('requestToken')"
|
|
)
|
|
|
|
let report = await BookSearchService(transport: transport).search(keyword: "dune", sources: [source])
|
|
|
|
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
|
#expect(report.books.first?.bookURL == "https://dynamic.example.test/book/42?token=DUNE")
|
|
#expect(report.books.first?.variables["requestToken"] == "DUNE")
|
|
}
|
|
|
|
@Test func searchesRenderedWebViewHTMLThroughInjectedExecutor() async throws {
|
|
let url = try #require(URL(string: "https://web-search.example/results"))
|
|
let executor = await MainActor.run { SearchWebViewExecutor(response: .init(
|
|
data: Data(#"<article class="book"><a href="/book/9">渲染后的书</a><span>网页作者</span></article>"#.utf8),
|
|
statusCode: 200,
|
|
headers: [:],
|
|
finalURL: url
|
|
)) }
|
|
var source = BookSource(bookSourceUrl: "https://web-search.example", bookSourceName: "WebView 源")
|
|
source.searchUrl = #"/search?q={{key}}, {"webView":true,"webJs":"document.body.dataset.loaded='yes'"}"#
|
|
source.ruleSearch = SearchRule(
|
|
bookList: "@CSS:article.book",
|
|
name: "@CSS:a@text",
|
|
author: "@CSS:span@text",
|
|
bookUrl: "@CSS:a@href"
|
|
)
|
|
|
|
let report = await BookSearchService(
|
|
transport: SearchMockTransport(response: .init(data: Data(), statusCode: 500, headers: [:], finalURL: url)),
|
|
webViewExecutor: executor
|
|
).search(keyword: "网页", sources: [source])
|
|
|
|
#expect(report.outcomes.flatMap(\.diagnostics).isEmpty)
|
|
#expect(report.books.first?.name == "渲染后的书")
|
|
#expect(report.books.first?.bookURL == "https://web-search.example/book/9")
|
|
#expect(await executor.callCount == 1)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SearchWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable {
|
|
let response: HTTPResponse
|
|
private(set) var callCount = 0
|
|
|
|
init(response: HTTPResponse) { self.response = response }
|
|
|
|
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
|
callCount += 1
|
|
return response
|
|
}
|
|
}
|