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( """
iOS 阅读作者甲
Swift 阅读作者乙
""".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 = #"
旧城故事林舟
"# 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(#"
渲染后的书网页作者
"#.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 } }