mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
@Suite struct AddBookURLServiceTests {
|
||||
@Test func explicitOriginHasPriority() {
|
||||
var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源")
|
||||
exact.customOrder = 0
|
||||
var explicit = BookSource(bookSourceUrl: "https://chosen.example", bookSourceName: "指定源")
|
||||
explicit.customOrder = 9
|
||||
let matched = AddBookURLService.matchingSource(
|
||||
bookURL: "https://site.example/book/1,{\"origin\":\"https://chosen.example\"}",
|
||||
sources: [exact, explicit]
|
||||
)
|
||||
#expect(matched?.bookSourceUrl == explicit.bookSourceUrl)
|
||||
}
|
||||
|
||||
@Test func exactBasePrecedesPattern() {
|
||||
var pattern = BookSource(bookSourceUrl: "https://fallback.example", bookSourceName: "正则源")
|
||||
pattern.bookUrlPattern = #"https://site\.example/book/.*"#
|
||||
pattern.customOrder = 0
|
||||
var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源")
|
||||
exact.customOrder = 10
|
||||
let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/1", sources: [pattern, exact])
|
||||
#expect(matched?.bookSourceUrl == exact.bookSourceUrl)
|
||||
}
|
||||
|
||||
@Test func patternRequiresWholeURLMatchAndEnabledSource() {
|
||||
var disabled = BookSource(bookSourceUrl: "https://disabled.example", bookSourceName: "停用")
|
||||
disabled.bookUrlPattern = #"https://site\.example/book/.*"#; disabled.enabled = false
|
||||
var enabled = BookSource(bookSourceUrl: "https://enabled.example", bookSourceName: "启用")
|
||||
enabled.bookUrlPattern = #"https://site\.example/book/\d+"#
|
||||
let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/12", sources: [disabled, enabled])
|
||||
#expect(matched?.bookSourceUrl == enabled.bookSourceUrl)
|
||||
#expect(AddBookURLService.matchingSource(bookURL: "https://site.example/book/12/extra", sources: [enabled]) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ApplicationSettingsTests {
|
||||
@Test func validatesAndPersistsWithoutOverwritingOnFailure() throws {
|
||||
let suite = "legado.settings.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
#expect(store.settings == ApplicationSettings())
|
||||
var value = ApplicationSettings()
|
||||
value.userAgent = " Test Browser "
|
||||
value.timeoutSeconds = 60
|
||||
value.concurrentSources = 3
|
||||
value.hosts = "127.0.0.1 books.example.com"
|
||||
try store.save(value)
|
||||
#expect(store.settings.userAgent == "Test Browser")
|
||||
#expect(store.settings.timeoutSeconds == 60)
|
||||
value.userAgent = "invalid\r\nHeader: injected"
|
||||
#expect(throws: SettingsError.self) { try store.save(value) }
|
||||
#expect(store.settings.userAgent == "Test Browser")
|
||||
}
|
||||
|
||||
@Test func parsesIPv4IPv6CommentsAndMultipleDomains() throws {
|
||||
let hosts = try ApplicationSettings.parseHosts("""
|
||||
# local mirror
|
||||
127.0.0.1 Books.Example.com mirror.example.com # comment
|
||||
::1 ipv6.example.com
|
||||
192.168.0.2 books.example.com
|
||||
""")
|
||||
#expect(hosts["books.example.com"] == "192.168.0.2")
|
||||
#expect(hosts["mirror.example.com"] == "127.0.0.1")
|
||||
#expect(hosts["ipv6.example.com"] == "::1")
|
||||
for invalid in ["no-ip example.com", "256.0.0.1 example.com", "127.0.0.1 https://example.com", "127.0.0.1 bad..example", "127.0.0.1 -bad.com"] {
|
||||
#expect(throws: SettingsError.self) { _ = try ApplicationSettings.parseHosts(invalid) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func proxyAndRequestPrecedence() throws {
|
||||
var settings = ApplicationSettings()
|
||||
settings.proxy = "socks5://127.0.0.1:1089"
|
||||
settings.userAgent = "Global UA"
|
||||
settings.timeoutSeconds = 45
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
try settings.configure(config)
|
||||
#expect(config.connectionProxyDictionary?["SOCKSPort"] as? Int == 1089)
|
||||
try settings.configure(config, proxyOverride: "http://localhost:8000")
|
||||
#expect(config.connectionProxyDictionary?["HTTPPort"] as? Int == 8000)
|
||||
var request = URLRequest(url: URL(string: "https://example.com")!, timeoutInterval: 5)
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Global UA")
|
||||
request.setValue("Source UA", forHTTPHeaderField: "user-agent")
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Source UA")
|
||||
#expect(settings.configure(request).timeoutInterval == 45)
|
||||
request.setValue("", forHTTPHeaderField: "User-Agent")
|
||||
#expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "")
|
||||
for invalid in ["ftp://localhost:3", "http://localhost:0", "http://localhost:65536", "http://localhost/path", "http://user:pass@localhost:3"] {
|
||||
#expect(throws: SettingsError.self) { _ = try ApplicationSettings.proxyDictionary(invalid) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func existingTransportUsesSavedSettingsOnNextRequest() async throws {
|
||||
let suite = "legado.transport.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [SettingsEchoURLProtocol.self]
|
||||
let transport = URLSessionHTTPTransport(configuration: config, settingsStore: store)
|
||||
let request = URLRequest(url: URL(string: "https://settings.example/test")!)
|
||||
var settings = ApplicationSettings()
|
||||
settings.userAgent = "First UA"
|
||||
try store.save(settings)
|
||||
#expect(try await transport.data(for: request).decodedText == "First UA")
|
||||
settings.userAgent = "Second UA"
|
||||
settings.timeoutSeconds = 15
|
||||
try store.save(settings)
|
||||
#expect(try await transport.data(for: request).decodedText == "Second UA")
|
||||
var explicit = request
|
||||
explicit.setValue("Source UA", forHTTPHeaderField: "User-Agent")
|
||||
#expect(try await transport.data(for: explicit).decodedText == "Source UA")
|
||||
}
|
||||
|
||||
@Test func suppressedSourceUAStaysSuppressedInURLRequest() throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://example.com,{headers:{'User-Agent':'null'}}"#)
|
||||
#expect(prepared.headers["User-Agent"] == nil)
|
||||
#expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
var settings = ApplicationSettings()
|
||||
settings.userAgent = "Global UA"
|
||||
#expect(settings.configure(prepared.urlRequest()).value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SettingsEchoURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data((request.value(forHTTPHeaderField: "User-Agent") ?? "").utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookChapterRepositoryTests {
|
||||
@Test func normalizesAndPersistsChaptersPerBook() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookChapterRepository(directory: directory)
|
||||
var chapter = BookChapter(url: "1", title: "第一章", bookUrl: "wrong", index: 9)
|
||||
chapter.wordCount = "1000"
|
||||
|
||||
try await repository.save([chapter], bookURL: "book-a")
|
||||
let restored = try await repository.load(bookURL: "book-a")
|
||||
|
||||
#expect(restored.first?.bookUrl == "book-a")
|
||||
#expect(restored.first?.index == 0)
|
||||
#expect(restored.first?.wordCount == "1000")
|
||||
#expect(try await repository.load(bookURL: "book-b").isEmpty)
|
||||
}
|
||||
|
||||
@Test func removesOnlyRequestedBook() async throws {
|
||||
let repository = InMemoryBookChapterRepository()
|
||||
let a = BookChapter(url: "1", title: "A", bookUrl: "a", index: 0)
|
||||
let b = BookChapter(url: "1", title: "B", bookUrl: "b", index: 0)
|
||||
await repository.save([a], bookURL: "a")
|
||||
await repository.save([b], bookURL: "b")
|
||||
|
||||
await repository.remove(bookURL: "a")
|
||||
|
||||
#expect(await repository.load(bookURL: "a").isEmpty)
|
||||
#expect(await repository.load(bookURL: "b") == [b])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct ExploreMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString])
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookExploreServiceTests {
|
||||
@Test func parsesLegacyAndJavaScriptExploreKinds() async {
|
||||
var legacy = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "传统发现")
|
||||
legacy.exploreUrl = "热门::/hot?page={{page}}&&完结::/finished?page={{page}}\n新书::/new"
|
||||
let service = BookExploreService()
|
||||
|
||||
let legacyReport = await service.kinds(for: legacy)
|
||||
|
||||
#expect(legacyReport.kinds.map(\.title) == ["热门", "完结", "新书"])
|
||||
#expect(legacyReport.kinds[0].url == "/hot?page={{page}}")
|
||||
#expect(legacyReport.diagnostics.isEmpty)
|
||||
|
||||
var scripted = legacy
|
||||
scripted.exploreUrl = "@js:JSON.stringify([{title:'科幻',url:'/sci?page={{page}}'},{title:'文学',url:'/lit'}])"
|
||||
let scriptedReport = await service.kinds(for: scripted)
|
||||
|
||||
#expect(scriptedReport.kinds.map(\.title) == ["科幻", "文学"])
|
||||
#expect(scriptedReport.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func distinguishesExploreDestinationsFromDynamicControlsAndPlaceholders() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "动态发现")
|
||||
source.exploreUrl = #"[{"title":"关键词","type":"text"},{"title":"刷新","type":"button","action":"refresh()"},{"title":"占位"},{"title":"热门","url":" /hot?page={{page}} "}]"#
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.count == 4)
|
||||
#expect(report.kinds[0].navigableURL == nil)
|
||||
#expect(report.kinds[1].navigableURL == nil)
|
||||
#expect(report.kinds[2].navigableURL == nil)
|
||||
#expect(report.kinds[3].navigableURL == "/hot?page={{page}}")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func toleratesGsonStyleScalarValuesInExploreJSON() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "宽松分类")
|
||||
source.exploreUrl = #"[{"title":2026,"url":123,"type":true,"action":7,"chars":["全部",2,false,null],"default":0}]"#
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.count == 1)
|
||||
#expect(report.kinds[0].title == "2026")
|
||||
#expect(report.kinds[0].url == "123")
|
||||
#expect(report.kinds[0].type == "true")
|
||||
#expect(report.kinds[0].action == "7")
|
||||
#expect(report.kinds[0].chars == ["全部", "2", "false", nil])
|
||||
#expect(report.kinds[0].defaultValue == "0")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func toleratesLegacyJSON5ExploreSyntaxAcceptedByGson() async {
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "旧式分类")
|
||||
source.exploreUrl = "[{title:'热门', url:'/hot',}, // old source comment\n]"
|
||||
|
||||
let report = await BookExploreService().kinds(for: source)
|
||||
|
||||
#expect(report.kinds.map(\.title) == ["热门"])
|
||||
#expect(report.kinds.first?.url == "/hot")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func loadsPaginatedJSONExploreBooksAndFallsBackToSearchRules() async throws {
|
||||
let transport = ExploreMockTransport(pages: [
|
||||
"https://example.test/hot?page=2": #"{"data":[{"id":2,"name":"第二页新书","author":"作者乙","cover":"/2.jpg"}]}"#
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "发现源")
|
||||
source.exploreUrl = "热门::/hot?page={{page}}"
|
||||
source.ruleSearch = SearchRule(
|
||||
bookList: "$.data",
|
||||
name: "$.name",
|
||||
author: "$.author",
|
||||
bookUrl: "$.id@js:'/book/' + result",
|
||||
coverUrl: "$.cover"
|
||||
)
|
||||
let kind = ExploreKind(title: "热门", url: "/hot?page={{page}}")
|
||||
|
||||
let outcome = await BookExploreService(transport: transport).explore(source: source, kind: kind, page: 2)
|
||||
|
||||
let book = try #require(outcome.books.first)
|
||||
#expect(book.name == "第二页新书")
|
||||
#expect(book.bookURL == "https://example.test/book/2")
|
||||
#expect(book.coverURL == "https://example.test/2.jpg")
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookRepositoryTests {
|
||||
@Test func persistsBooksAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
let fileURL = directory.appending(path: "books.json")
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookRepository(fileURL: fileURL)
|
||||
var book = Book(bookUrl: "https://example.test/book/1", name: "测试书", author: "作者", origin: "https://example.test")
|
||||
book.durChapterIndex = 3
|
||||
|
||||
try await repository.save([book])
|
||||
let restored = try await repository.load()
|
||||
|
||||
#expect(restored == [book])
|
||||
#expect(restored.first?.durChapterIndex == 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct DebuggerMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString], "缺少调试页面:\(url.absoluteString)")
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookSourceDebuggerTests {
|
||||
@Test func runsSearchDetailTocAndContentInOrder() async throws {
|
||||
let pages = [
|
||||
"https://debug.example.test/search?q=demo": #"{"books":[{"name":"调试之书","url":"/book/1"}]}"#,
|
||||
"https://debug.example.test/book/1": #"<h1>调试之书</h1><a class="toc" href="/toc/1">目录</a>"#,
|
||||
"https://debug.example.test/toc/1": #"<li class="chapter"><a href="/chapter/1">第一章</a></li>"#,
|
||||
"https://debug.example.test/chapter/1": "<article>调试正文成功</article>"
|
||||
]
|
||||
var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "调试源")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
|
||||
var info = BookInfoRule()
|
||||
info.name = "@CSS:h1@text"
|
||||
info.tocUrl = "@CSS:a.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article@text"
|
||||
source.ruleContent = content
|
||||
|
||||
let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages))
|
||||
.run(source: source, keyword: "demo")
|
||||
|
||||
#expect(report.steps.map(\.stage) == SourceDebugStage.allCases)
|
||||
#expect(report.steps.allSatisfy { $0.status == .passed })
|
||||
#expect(report.sampleBookName == "调试之书")
|
||||
#expect(report.sampleChapterTitle == "第一章")
|
||||
#expect(report.contentPreview == "调试正文成功")
|
||||
#expect(report.passed)
|
||||
}
|
||||
|
||||
@Test func reportsConfigurationAndSkipsAfterEmptySearch() async {
|
||||
let pages = ["https://debug.example.test/search?q=none": #"{"books":[]}"#]
|
||||
var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "空源")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url")
|
||||
|
||||
let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages))
|
||||
.run(source: source, keyword: "none")
|
||||
|
||||
#expect(report.steps.first?.status == .failed)
|
||||
#expect(report.steps.first(where: { $0.stage == .search })?.status == .failed)
|
||||
#expect(report.steps.first(where: { $0.stage == .detail })?.status == .skipped)
|
||||
#expect(!report.passed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Compression
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookSourceImporterTests {
|
||||
private let importer = BookSourceImporter()
|
||||
|
||||
@Test func decodesArrayAndObjectRules() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
[{
|
||||
"bookSourceUrl": "https://example.test",
|
||||
"bookSourceName": "示例书源",
|
||||
"enabled": true,
|
||||
"bookSourceType": 0,
|
||||
"searchUrl": "https://example.test/search?q={{key}}",
|
||||
"ruleSearch": {
|
||||
"bookList": "@css:.book",
|
||||
"name": "@css:h2@text",
|
||||
"bookUrl": "@css:a@href"
|
||||
},
|
||||
"ruleContent": "{\\\"content\\\":\\\"@css:article@html\\\"}"
|
||||
}]
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let sources = try importer.decode(data)
|
||||
|
||||
#expect(sources.count == 1)
|
||||
#expect(sources[0].bookSourceName == "示例书源")
|
||||
#expect(sources[0].ruleSearch?.bookList == "@css:.book")
|
||||
#expect(sources[0].ruleContent?.content == "@css:article@html")
|
||||
}
|
||||
|
||||
@Test func acceptsUTF8BOMAndLossyPrimitiveTypes() throws {
|
||||
var data = Data([0xEF, 0xBB, 0xBF])
|
||||
data.append(
|
||||
contentsOf: """
|
||||
{
|
||||
"bookSourceUrl": "https://example.test",
|
||||
"bookSourceName": "兼容书源",
|
||||
"enabled": "1",
|
||||
"enabledExplore": 0,
|
||||
"bookSourceType": "2",
|
||||
"respondTime": "1200"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
|
||||
#expect(source.enabled)
|
||||
#expect(!source.enabledExplore)
|
||||
#expect(source.bookSourceType == 2)
|
||||
#expect(source.respondTime == 1_200)
|
||||
}
|
||||
|
||||
@Test func preservesEnabledCookieJarDefaultAndExplicitNull() throws {
|
||||
let sources = try importer.decode(Data(
|
||||
#"[{"bookSourceUrl":"https://default-cookie.test","bookSourceName":"默认"},{"bookSourceUrl":"https://null-cookie.test","bookSourceName":"空值","enabledCookieJar":null}]"#.utf8
|
||||
))
|
||||
|
||||
#expect(sources[0].enabledCookieJar == true)
|
||||
#expect(sources[1].enabledCookieJar == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsWholeBatchBeforeMutation() throws {
|
||||
let existing = [BookSource(bookSourceUrl: "https://kept.test", bookSourceName: "保留")]
|
||||
let data = Data(
|
||||
"""
|
||||
[
|
||||
{"bookSourceUrl":"https://valid.test","bookSourceName":"有效"},
|
||||
{"bookSourceUrl":"","bookSourceName":"无效"}
|
||||
]
|
||||
""".utf8
|
||||
)
|
||||
|
||||
#expect(throws: BookSourceImportError.self) {
|
||||
try importer.prepare(data: data, existing: existing)
|
||||
}
|
||||
#expect(existing.count == 1)
|
||||
}
|
||||
|
||||
@Test func handlesDuplicatePolicies() throws {
|
||||
var old = BookSource(bookSourceUrl: "https://same.test", bookSourceName: "旧名称")
|
||||
old.enabled = false
|
||||
let data = Data(
|
||||
"""
|
||||
{"bookSourceUrl":"https://same.test","bookSourceName":"新名称"}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let replaced = try importer.prepare(data: data, existing: [old], duplicatePolicy: .replace)
|
||||
#expect(replaced.replaced == 1)
|
||||
#expect(replaced.sources[0].bookSourceName == "新名称")
|
||||
|
||||
let skipped = try importer.prepare(data: data, existing: [old], duplicatePolicy: .skip)
|
||||
#expect(skipped.skipped == 1)
|
||||
#expect(skipped.sources[0].bookSourceName == "旧名称")
|
||||
|
||||
#expect(throws: BookSourceImportError.self) {
|
||||
try importer.prepare(data: data, existing: [old], duplicatePolicy: .fail)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func roundTripsWithoutLosingCompatibilityFields() throws {
|
||||
let originalData = Data(
|
||||
"""
|
||||
{
|
||||
"bookSourceUrl":"https://roundtrip.test",
|
||||
"bookSourceName":"往返测试",
|
||||
"bookSourceGroup":"测试,文本",
|
||||
"header":"{\\"User-Agent\\":\\"Legado\\"}",
|
||||
"eventListener":true,
|
||||
"customButton":true,
|
||||
"ruleToc":{"chapterList":"//li","chapterName":"a/text()"}
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
let source = try #require(importer.decode(originalData).first)
|
||||
|
||||
let encoded = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(BookSource.self, from: encoded)
|
||||
|
||||
#expect(decoded == source)
|
||||
}
|
||||
|
||||
@Test func importsDesensitizedJavaScriptFixture() throws {
|
||||
let url = try #require(Bundle.module.url(
|
||||
forResource: "legado-js-audio-source",
|
||||
withExtension: "json",
|
||||
subdirectory: "Fixtures"
|
||||
))
|
||||
let source = try #require(importer.decode(Data(contentsOf: url)).first)
|
||||
|
||||
#expect(source.bookSourceType == 1)
|
||||
#expect(source.searchUrl?.contains("searchKey") == true)
|
||||
#expect(source.ruleSearch?.bookUrl?.contains("@js:") == true)
|
||||
#expect(source.ruleToc?.chapterUrl?.contains("java.get('bookId')") == true)
|
||||
#expect(source.ruleReview?.reviewUrl == "/review/{{chapterId}}")
|
||||
|
||||
let roundTrip = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(source))
|
||||
#expect(roundTrip == source)
|
||||
}
|
||||
|
||||
@Test func importsSourceDocumentsLargerThanTwentyMegabytes() throws {
|
||||
var data = Data(#"[{"bookSourceUrl":"https://large.test","bookSourceName":"大书源"}]"#.utf8)
|
||||
data.append(Data(repeating: 0x20, count: 21 * 1_024 * 1_024))
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
#expect(source.bookSourceName == "大书源")
|
||||
}
|
||||
|
||||
@Test func decodesGBKEncodedSourceDocuments() throws {
|
||||
let json = #"[{"bookSourceUrl":"https://gbk.test","bookSourceName":"编码书源","searchUrl":"/search?q={{key}}"}]"#
|
||||
let gbk = String.Encoding(
|
||||
rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
|
||||
)
|
||||
let data = try #require(json.data(using: gbk))
|
||||
|
||||
let source = try #require(importer.decode(data).first)
|
||||
|
||||
#expect(source.bookSourceName == "编码书源")
|
||||
#expect(source.searchUrl == "/search?q={{key}}")
|
||||
}
|
||||
|
||||
@Test func decodesGzipCompressedSourceDocuments() throws {
|
||||
let json = Data(#"[{"bookSourceUrl":"https://gzip.test","bookSourceName":"压缩书源"}]"#.utf8)
|
||||
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: json.count + 4_096)
|
||||
defer { destination.deallocate() }
|
||||
let produced = json.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
|
||||
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||
return compression_encode_buffer(
|
||||
destination, json.count + 4_096,
|
||||
base, json.count,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
#expect(produced > 0)
|
||||
var gzipped = Data([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF])
|
||||
gzipped.append(destination, count: produced)
|
||||
gzipped.append(Data(repeating: 0, count: 8))
|
||||
|
||||
let source = try #require(importer.decode(gzipped).first)
|
||||
|
||||
#expect(source.bookSourceName == "压缩书源")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookSourceRepositoryTests {
|
||||
@Test func fileRepositoryPersistsAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileBookSourceRepository(fileURL: directory.appending(path: "sources.json"))
|
||||
let expected = [BookSource(bookSourceUrl: "https://saved.test", bookSourceName: "已保存")]
|
||||
|
||||
try await repository.save(expected)
|
||||
let loaded = try await repository.load()
|
||||
|
||||
#expect(loaded == expected)
|
||||
}
|
||||
|
||||
@Test func groupOperationsSplitSeparatorsAndDeduplicate() {
|
||||
var source = BookSource(bookSourceUrl: "https://groups.test", bookSourceName: "分组源")
|
||||
source.bookSourceGroup = "文学, 科幻;文学;听书"
|
||||
|
||||
#expect(source.groups == ["文学", "科幻", "听书"])
|
||||
#expect(source.hasGroup("科幻"))
|
||||
|
||||
source.addGroups("悬疑,科幻")
|
||||
#expect(source.groups == ["文学", "科幻", "听书", "悬疑"])
|
||||
|
||||
source.removeGroups("文学;听书")
|
||||
#expect(source.bookSourceGroup == "科幻,悬疑")
|
||||
}
|
||||
|
||||
@Test func renamesAndDeletesGroupsWithoutLeavingEmptyText() {
|
||||
var source = BookSource(bookSourceUrl: "https://rename.test", bookSourceName: "重命名源")
|
||||
source.bookSourceGroup = "旧组,保留"
|
||||
|
||||
source.replaceGroup("旧组", with: "新组")
|
||||
#expect(source.groups == ["保留", "新组"])
|
||||
source.replaceGroup("保留", with: nil)
|
||||
source.removeGroups("新组")
|
||||
|
||||
#expect(source.groups.isEmpty)
|
||||
#expect(source.bookSourceGroup == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct BookUpdateCoordinatorTests {
|
||||
@Test func countsOnlyGrowthAndPreservesChapterMetadata() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.totalChapterNum = 2
|
||||
book.durChapterIndex = 1
|
||||
book.durChapterTitle = "第二章 旧雨"
|
||||
var oldFirst = BookChapter(url: "old-1", title: "第一章", bookUrl: "book", index: 0)
|
||||
oldFirst.wordCount = "一千字"
|
||||
oldFirst.variable = #"{"token":"keep"}"#
|
||||
let oldSecond = BookChapter(url: "old-2", title: "第二章 旧雨", bookUrl: "book", index: 1)
|
||||
let incoming = [
|
||||
BookChapter(url: "new-1", title: "第一章", bookUrl: "book", index: 8),
|
||||
BookChapter(url: "new-2", title: "第二章 旧雨", bookUrl: "book", index: 9),
|
||||
BookChapter(url: "new-3", title: "第三章 天晴", bookUrl: "book", index: 10)
|
||||
]
|
||||
|
||||
let result = BookUpdateMerger().merge(
|
||||
book: book,
|
||||
oldChapters: [oldFirst, oldSecond],
|
||||
newChapters: incoming,
|
||||
now: 99
|
||||
)
|
||||
|
||||
#expect(result.addedChapterCount == 1)
|
||||
#expect(result.book.lastCheckCount == 1)
|
||||
#expect(result.book.totalChapterNum == 3)
|
||||
#expect(result.book.latestChapterTitle == "第三章 天晴")
|
||||
#expect(result.book.durChapterIndex == 1)
|
||||
#expect(result.chapters[0].wordCount == "一千字")
|
||||
#expect(result.chapters[0].variable == oldFirst.variable)
|
||||
#expect(result.chapters.map(\.index) == [0, 1, 2])
|
||||
}
|
||||
|
||||
@Test func retainsUnreadCountWhenDirectoryDoesNotGrow() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.totalChapterNum = 2
|
||||
book.lastCheckCount = 4
|
||||
let chapters = [
|
||||
BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0),
|
||||
BookChapter(url: "2", title: "第二章", bookUrl: "book", index: 1)
|
||||
]
|
||||
|
||||
let result = BookUpdateMerger().merge(book: book, oldChapters: chapters, newChapters: chapters, now: 10)
|
||||
|
||||
#expect(result.addedChapterCount == 0)
|
||||
#expect(result.book.lastCheckCount == 4)
|
||||
#expect(result.book.latestChapterTime == 0)
|
||||
#expect(result.book.lastCheckTime == 10)
|
||||
}
|
||||
|
||||
@Test func remapsProgressByTitleAndResetsPositionWhenChapterIsRemoved() {
|
||||
var book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
book.durChapterIndex = 2
|
||||
book.durChapterTitle = "第二章"
|
||||
book.durChapterPos = 130
|
||||
let old = [
|
||||
BookChapter(url: "notice", title: "后记", bookUrl: "book", index: 0),
|
||||
BookChapter(url: "first", title: "第一章", bookUrl: "book", index: 1),
|
||||
BookChapter(url: "second", title: "第二章", bookUrl: "book", index: 2)
|
||||
]
|
||||
let refreshed = Array(old.dropFirst())
|
||||
|
||||
let remapped = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10)
|
||||
#expect(remapped.book.durChapterIndex == 1)
|
||||
#expect(remapped.book.durChapterPos == 130)
|
||||
|
||||
book.durChapterIndex = 0
|
||||
book.durChapterTitle = "后记"
|
||||
book.durChapterPos = 130
|
||||
let removed = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10)
|
||||
#expect(removed.book.durChapterIndex == 0)
|
||||
#expect(removed.book.durChapterTitle == "第一章")
|
||||
#expect(removed.book.durChapterPos == 0)
|
||||
}
|
||||
|
||||
@Test func coordinatorFallsBackToStoredDirectoryWhenRefreshFails() async throws {
|
||||
let old = [BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0)]
|
||||
let repository = InMemoryBookChapterRepository(values: ["book": old])
|
||||
let loader = StubChapterListLoader(report: .init(
|
||||
chapters: [],
|
||||
diagnostics: [.init(severity: .error, code: "toc.failed", message: "失败", rule: "toc")]
|
||||
))
|
||||
let coordinator = BookUpdateCoordinator(repository: repository, loader: loader)
|
||||
let book = Book(bookUrl: "book", name: "书", origin: "source")
|
||||
let source = BookSource(bookSourceUrl: "source", bookSourceName: "源")
|
||||
|
||||
let result = await coordinator.refresh(book: book, source: source, now: 20)
|
||||
|
||||
#expect(!result.refreshed)
|
||||
#expect(result.chapters == old)
|
||||
#expect(result.diagnostics.first?.code == "toc.failed")
|
||||
}
|
||||
}
|
||||
|
||||
private struct StubChapterListLoader: ChapterListLoading {
|
||||
let report: ChapterListReport
|
||||
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { report }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ContentSanitizerTests {
|
||||
@Test func metadataCleanerRemovesHTMLDecodesEntitiesAndKeepsParagraphs() {
|
||||
let input = "<p>第一段 文字</p><div>第二段<br>换行</div><script>bad()</script>"
|
||||
|
||||
let value = MetadataTextSanitizer().clean(input)
|
||||
|
||||
#expect(value == "第一段 文字\n第二段\n换行")
|
||||
#expect(MetadataTextSanitizer().clean(" 纯文本简介 ") == "纯文本简介")
|
||||
#expect(MetadataTextSanitizer().clean("<br><p></p>") == nil)
|
||||
}
|
||||
|
||||
@Test func appliesOrderedScopedRulesAndRemovesDuplicateTitle() {
|
||||
let book = Book(bookUrl: "https://source.test/book/1", name: "测试书", origin: "https://source.test")
|
||||
let chapter = BookChapter(url: "chapter/1", title: "第一章 开始", bookUrl: book.bookUrl, index: 0)
|
||||
var titleRule = ReplaceRule(id: 1, name: "标题", pattern: "开始", replacement: "启程")
|
||||
titleRule.scopeTitle = true
|
||||
titleRule.scopeContent = false
|
||||
titleRule.scope = "测试书"
|
||||
var adRule = ReplaceRule(id: 2, name: "广告", pattern: "广告[^\n]*", replacement: "")
|
||||
adRule.scopeContent = true
|
||||
adRule.sortOrder = 1
|
||||
var excluded = ReplaceRule(id: 3, name: "排除", pattern: "正文", replacement: "错误")
|
||||
excluded.excludeScope = "https://source.test"
|
||||
|
||||
let report = ContentSanitizer().process(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
content: "第一章 开始\n 正文第一段 \n广告请访问 example.com\n正文第二段",
|
||||
rules: [excluded, adRule, titleRule]
|
||||
)
|
||||
|
||||
#expect(report.title == "第一章 启程")
|
||||
#expect(report.content == "正文第一段\n\n正文第二段")
|
||||
#expect(report.removedDuplicateTitle)
|
||||
#expect(report.effectiveRuleIDs == [1, 2])
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func rejectsHighRiskRegexWithoutChangingContent() {
|
||||
let book = Book(bookUrl: "local", name: "书", origin: "local")
|
||||
let chapter = BookChapter(url: "1", title: "章节", bookUrl: book.bookUrl, index: 0)
|
||||
let risky = ReplaceRule(id: 7, name: "危险", pattern: "(a+)+$", replacement: "")
|
||||
|
||||
let report = ContentSanitizer().process(book: book, chapter: chapter, content: "aaaa!", rules: [risky])
|
||||
|
||||
#expect(report.content == "aaaa!")
|
||||
#expect(report.diagnostics.contains { $0.code == "rule.resource_limit" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct DiagnosticLogTests {
|
||||
@Test func boundedJournalSurvivesRelaunchAndCanBeCleared() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let log = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true })
|
||||
log.record(category: "test", message: "first")
|
||||
log.record(.warning, category: "test", message: "second")
|
||||
log.record(.error, category: "test", message: "third")
|
||||
let restored = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true })
|
||||
#expect(restored.snapshot().map(\.message) == ["third", "second"])
|
||||
#expect(restored.export().contains("[ERROR] [test] third"))
|
||||
#expect(!restored.export().contains("first"))
|
||||
try restored.clear()
|
||||
#expect(restored.snapshot().isEmpty)
|
||||
#expect(DiagnosticLog(directory: directory).snapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func disabledLoggingAndSensitiveRequestFields() {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let disabled = DiagnosticLog(directory: directory, isEnabled: { false })
|
||||
disabled.record(category: "JS", message: "secret")
|
||||
#expect(disabled.snapshot().isEmpty)
|
||||
let log = DiagnosticLog(directory: directory, isEnabled: { true })
|
||||
var request = URLRequest(url: URL(string: "https://user:[email protected]/chapter?token=TOP_SECRET#private")!)
|
||||
request.setValue("Bearer PRIVATE_HEADER", forHTTPHeaderField: "Authorization")
|
||||
request.httpBody = Data("PRIVATE_BODY".utf8)
|
||||
log.network(request, status: 200, startedAt: Date())
|
||||
log.record(category: "JS", message: "token=PRIVATE_TOKEN password=PRIVATE_PASS https://example.com/?key=PRIVATE_QUERY")
|
||||
log.record(category: "JS", message: "Authorization: Bearer PRIVATE_AUTH")
|
||||
log.record(category: "JS", message: #"{"password": "PRIVATE_JSON"}"#)
|
||||
let exported = log.export()
|
||||
for secret in ["TOP_SECRET", "private", "user:pass", "PRIVATE_HEADER", "PRIVATE_BODY", "PRIVATE_TOKEN", "PRIVATE_PASS", "PRIVATE_QUERY", "PRIVATE_AUTH", "PRIVATE_JSON"] {
|
||||
#expect(!exported.contains(secret))
|
||||
}
|
||||
#expect(exported.contains("https://example.com/chapter"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# 规则测试样本
|
||||
|
||||
这些文件是自动化测试输入,不是 App 的内置书源库。测试包通过 SwiftPM 单独复制该目录,App 目标不会打包它。
|
||||
|
||||
- `synthetic-html-rules.json`:最小 HTML 规则,使用保留域名 `fixture.invalid`,验证倒序目录、裸字段提取和带章节变量的内容净化。网络响应在测试中自造,不访问真实网站。
|
||||
- `legado-js-audio-source.json`:脱敏 JS 听书规则结构样本。地址使用 `fixture.invalid`,用于测试导入字段及脚本保留,不在常规导入测试中请求网络或执行脚本。
|
||||
|
||||
新增样本优先自造数据;第三方材料须保留版权与许可。不要把账号凭据或真实付费内容放入 fixture。
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"bookSourceUrl": "https://fixture.invalid",
|
||||
"bookSourceName": "脱敏 JS 听书源",
|
||||
"bookSourceGroup": "兼容夹具",
|
||||
"bookSourceType": 1,
|
||||
"enabled": true,
|
||||
"enabledExplore": true,
|
||||
"searchUrl": "https://fixture.invalid/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}",
|
||||
"ruleSearch": {
|
||||
"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"
|
||||
},
|
||||
"ruleBookInfo": {},
|
||||
"ruleToc": {
|
||||
"chapterList": "$.content.content",
|
||||
"chapterName": "$.chapterTitle",
|
||||
"chapterUrl": "$.id@js:java.put('chapterId', result);'/audio?bookId='+java.get('bookId')+'&chapterId='+result"
|
||||
},
|
||||
"ruleContent": {
|
||||
"content": "@js:result"
|
||||
},
|
||||
"ruleReview": {
|
||||
"reviewUrl": "/review/{{chapterId}}",
|
||||
"contentRule": "$.content",
|
||||
"postTimeRule": "$.time"
|
||||
},
|
||||
"eventListener": false,
|
||||
"customButton": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"bookSourceName": "合成 HTML 规则",
|
||||
"bookSourceUrl": "https://fixture.invalid",
|
||||
"ruleToc": {
|
||||
"chapterList": "[email protected][-1:0]",
|
||||
"chapterName": "text",
|
||||
"chapterUrl": "href"
|
||||
},
|
||||
"ruleContent": {
|
||||
"content": "class.chapter-body@html",
|
||||
"replaceRegex": "##(?m)^(?:第一章|信息|{{book.durChapterTitle}}|\\(本章完\\))\\s*$"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct HTMLRuleEvaluatorTests {
|
||||
private let evaluator = HTMLRuleEvaluator()
|
||||
private let html = """
|
||||
<!doctype html><html><body>
|
||||
<ul id="books">
|
||||
<li class="book"><a href="/a"><span class="title">A & One</span></a><p>Intro <b>A</b></p></li>
|
||||
<li class="book"><a href="/b"><span class="title">B</span></a><p>Intro B</p></li>
|
||||
</ul>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@Test func legacyListChainsPreserveTableRowsAndCurrentElementOutputs() throws {
|
||||
let page = "<table class='grid'><tr><th>书名</th></tr><tr data-id='42'><td><a href='/42'>测试书</a></td><td>作者</td></tr></table>"
|
||||
let rows = try evaluator.selectCSSFragments("[email protected]!0", html: page)
|
||||
#expect(rows.count == 1)
|
||||
let row = try #require(rows.first)
|
||||
#expect(try evaluator.extractCSS("tag.td.0@text", html: row, isFragment: true) == ["测试书"])
|
||||
#expect(try evaluator.extractCSS("data-id", html: row, isFragment: true) == ["42"])
|
||||
let links = try evaluator.selectCSSFragments("tag.a", html: row, isFragment: true)
|
||||
let link = try #require(links.first)
|
||||
#expect(try evaluator.extractCSS("text", html: link, isFragment: true) == ["测试书"])
|
||||
#expect(try evaluator.extractCSS("href", html: link, isFragment: true) == ["/42"])
|
||||
#expect(try evaluator.extractXPath("./@href", html: link, isFragment: true) == ["/42"])
|
||||
#expect(try evaluator.extractXPath("./text()", html: link, isFragment: true) == ["测试书"])
|
||||
}
|
||||
|
||||
@Test func selectsCSSFragmentsAndExtractsOutputs() throws {
|
||||
let fragments = try evaluator.selectCSSFragments("li.book", html: html)
|
||||
|
||||
#expect(fragments.count == 2)
|
||||
#expect(try evaluator.extractCSS(".title@text", html: fragments[0]) == ["A & One"])
|
||||
#expect(try evaluator.extractCSS("a@href", html: fragments[0]) == ["/a"])
|
||||
#expect(try evaluator.extractCSS("p@html", html: fragments[0]) == ["<p>Intro <b>A</b></p>"])
|
||||
#expect(try evaluator.extractCSS("a@all", html: fragments[0]).first?.contains("href=\"/a\"") == true)
|
||||
}
|
||||
|
||||
@Test func selectsXPathFragmentsAndScalarValues() throws {
|
||||
let fragments = try evaluator.selectXPathFragments("//li[@class='book']", html: html)
|
||||
|
||||
#expect(fragments.count == 2)
|
||||
#expect(try evaluator.extractXPath("//span[@class='title']/text()", html: fragments[1]) == ["B"])
|
||||
#expect(try evaluator.extractXPath("count(//li[@class='book'])", html: html) == ["2"])
|
||||
}
|
||||
|
||||
@Test func ruleEngineAppliesHTMLCombination() async {
|
||||
let engine = RuleEngine()
|
||||
|
||||
let result = await engine.evaluate("@CSS:.missing@text||@CSS:.title@text", inText: html)
|
||||
|
||||
#expect(result.strings == ["A & One", "B"])
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func supportsLegadoChainAndIndexedSelectors() throws {
|
||||
#expect(try evaluator.extractCSS("[email protected][1]@class.title@text", html: html) == ["B"])
|
||||
#expect(try evaluator.extractCSS("li.book[-1:0]@class.title@text", html: html) == ["B", "A & One"])
|
||||
#expect(try evaluator.extractCSS("li.book[!0]@class.title@text", html: html) == ["B"])
|
||||
}
|
||||
|
||||
@Test func supportsLegacyOutputOnlyRulesOnCurrentDocument() throws {
|
||||
#expect(try evaluator.extractCSS("@text", html: "<main><b>正文</b></main>") == ["正文"])
|
||||
#expect(try evaluator.extractCSS("@html", html: "<main><b>正文</b></main>").first?.contains("<main>") == true)
|
||||
#expect(try evaluator.extractCSS("meta@content", html: "<!doctype html><html><head><meta content='书名'></head><body>正文</body></html>", isFragment: true) == ["书名"])
|
||||
}
|
||||
|
||||
@Test func reportsSwiftSoupSelectorMessageInsteadOfOpaqueNSError() throws {
|
||||
do {
|
||||
_ = try evaluator.extractCSS("div[", html: html)
|
||||
Issue.record("应拒绝未闭合的 CSS selector")
|
||||
} catch let error as RuleEngineError {
|
||||
#expect(!error.localizedDescription.contains("SwiftSoup.Exception error 0"))
|
||||
#expect(error.localizedDescription.contains("CSS 规则执行失败"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func supportsLegacyDiscreteNegativeIndexes() throws {
|
||||
#expect(try evaluator.extractCSS("tag.li.-1:[email protected]@text", html: html) == ["B", "A & One"])
|
||||
#expect(try evaluator.selectCSSFragments("li.book[-1]", html: html).count == 1)
|
||||
}
|
||||
|
||||
@Test func htmlAndAllCombineOuterHTMLAndDeduplicateAttributes() throws {
|
||||
let value = """
|
||||
<div class="item"><script>bad()</script><span>A</span></div>
|
||||
<div class="item"><style>.bad{}</style><span>B</span></div>
|
||||
<a class="same" href="/same">one</a><a class="same" href="/same">two</a>
|
||||
"""
|
||||
|
||||
let sanitized = try evaluator.extractCSS(".item@html", html: value)
|
||||
let all = try evaluator.extractCSS(".item@all", html: value)
|
||||
let links = try evaluator.extractCSS("a.same@href", html: value)
|
||||
|
||||
#expect(sanitized.count == 1)
|
||||
#expect(sanitized[0].contains("<div class=\"item\">") == true)
|
||||
#expect(sanitized[0].contains("script") == false)
|
||||
#expect(sanitized[0].contains("style") == false)
|
||||
#expect(all.count == 1)
|
||||
#expect(links == ["/same"])
|
||||
}
|
||||
|
||||
@Test func negativeRangeStepUsesListLengthAdjustment() throws {
|
||||
let value = "<p>0</p><p>1</p><p>2</p><p>3</p><p>4</p>"
|
||||
|
||||
#expect(try evaluator.extractCSS("p[0:4:-2]@text", html: value) == ["0", "3"])
|
||||
}
|
||||
|
||||
@Test func rangeWhollyOutsideOneSideIsIgnoredInsteadOfClamped() throws {
|
||||
let value = "<p>0</p><p>1</p><p>2</p>"
|
||||
|
||||
#expect(try evaluator.extractCSS("p[8:9]@text", html: value).isEmpty)
|
||||
#expect(try evaluator.extractCSS("p[-9:-8]@text", html: value).isEmpty)
|
||||
#expect(try evaluator.extractCSS("p[-9:1]@text", html: value) == ["0", "1"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct HostsHTTPTransportTests {
|
||||
let url = URL(string: "https://books.example.com/chapter")!
|
||||
|
||||
@Test func handlesPartialContentLengthAndHeadResponses() throws {
|
||||
let partial = Data("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel".utf8)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(partial, url: url) == nil)
|
||||
var complete = partial
|
||||
complete.append(Data("lo".utf8))
|
||||
let response = try HostsHTTPResponseDecoder.decode(complete, url: url)
|
||||
#expect(response?.decodedText == "hello")
|
||||
#expect(response?.finalURL == url)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(partial, url: url, method: "HEAD")?.data.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test func handlesChunkExtensionsTrailersAndInterimResponses() throws {
|
||||
let raw = "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3;ext=a\r\nhel\r\n2\r\nlo\r\n0\r\nX-Trailer: yes\r\n\r\n"
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url)?.decodedText == "hello")
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.dropLast(2).utf8), url: url) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsMalformedFramesAndHandlesConnectionClose() throws {
|
||||
let raw = "HTTP/1.1 200 OK\r\n\r\nbody"
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url) == nil)
|
||||
#expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url, complete: true)?.decodedText == "body")
|
||||
for malformed in ["Content-Length: -1\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n-1\r\n", "Transfer-Encoding: chunked\r\n\r\n1\r\nxXX"] {
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try HostsHTTPResponseDecoder.decode(Data(("HTTP/1.1 200 OK\r\n" + malformed).utf8), url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func hostsOverrideConnectsToIPAndRetainsOriginHost() async throws {
|
||||
let server = try HostsLoopbackServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
let origin = URL(string: "http://books.invalid:\(port)/chapter?lang=zh")!
|
||||
let response = try await HostsHTTPTransport.data(for: URLRequest(url: origin, timeoutInterval: 3), address: "127.0.0.1", ignoreCertificateErrors: false)
|
||||
#expect(response.finalURL == origin)
|
||||
#expect(response.decodedText.contains("GET /chapter?lang=zh HTTP/1.1"))
|
||||
#expect(response.decodedText.contains("Host: books.invalid:\(port)"))
|
||||
}
|
||||
|
||||
@Test func redirectIntoHostsUsesOverrideAndDropsCrossOriginCredentials() async throws {
|
||||
let server = try HostsLoopbackServer()
|
||||
let port = try await server.start()
|
||||
defer { server.stop() }
|
||||
let suite = "legado.hosts.redirect.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let store = ApplicationSettingsStore(defaults: defaults)
|
||||
var settings = ApplicationSettings()
|
||||
settings.hosts = "127.0.0.1 books.invalid"
|
||||
try store.save(settings)
|
||||
let transport = URLSessionHTTPTransport(settingsStore: store)
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/redirect")!, timeoutInterval: 3)
|
||||
request.setValue("Bearer PRIVATE", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("origin=PRIVATE", forHTTPHeaderField: "Cookie")
|
||||
let response = try await transport.data(for: request)
|
||||
#expect(response.finalURL.host == "books.invalid")
|
||||
#expect(response.finalURL.path == "/chapter")
|
||||
#expect(response.decodedText.contains("Host: books.invalid:\(port)"))
|
||||
#expect(!response.decodedText.contains("PRIVATE"))
|
||||
}
|
||||
|
||||
@Test func databaseCompactionPreservesUserData() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "test.sqlite3"))
|
||||
let sources = [BookSource(bookSourceUrl: "https://example.com", bookSourceName: "test")]
|
||||
try await database.replaceBookSources(sources)
|
||||
try await database.compact()
|
||||
#expect(try await database.loadBookSources() == sources)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HostsLoopbackServer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "legado.hosts.loopback.test")
|
||||
private let listener: NWListener
|
||||
private var continuation: CheckedContinuation<UInt16, Error>?
|
||||
|
||||
init() throws {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
||||
listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
func start() async throws -> UInt16 {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
self.continuation = continuation
|
||||
self.listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self, let continuation = self.continuation else { return }
|
||||
if case .ready = state, let port = self.listener.port {
|
||||
self.continuation = nil
|
||||
continuation.resume(returning: port.rawValue)
|
||||
} else if case .failed(let error) = state {
|
||||
self.continuation = nil
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
self.listener.newConnectionHandler = { connection in
|
||||
connection.start(queue: self.queue)
|
||||
Self.receive(connection, buffer: Data())
|
||||
}
|
||||
self.listener.start(queue: self.queue)
|
||||
self.queue.asyncAfter(deadline: .now() + 3) {
|
||||
guard let continuation = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
self.listener.cancel()
|
||||
continuation.resume(throwing: URLError(.timedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() { listener.cancel() }
|
||||
|
||||
private static func receive(_ connection: NWConnection, buffer: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, complete, error in
|
||||
var buffer = buffer
|
||||
if let data { buffer.append(data) }
|
||||
if buffer.range(of: Data("\r\n\r\n".utf8)) != nil {
|
||||
let text = String(decoding: buffer, as: UTF8.self)
|
||||
var response: Data
|
||||
if text.hasPrefix("GET /redirect "), let port = connection.currentPath?.localEndpoint,
|
||||
case .hostPort(_, let localPort) = port {
|
||||
response = Data("HTTP/1.1 302 Found\r\nLocation: http://books.invalid:\(localPort.rawValue)/chapter\r\nContent-Length: 0\r\n\r\n".utf8)
|
||||
} else {
|
||||
response = Data("HTTP/1.1 200 OK\r\nContent-Length: \(buffer.count)\r\n\r\n".utf8)
|
||||
response.append(buffer)
|
||||
}
|
||||
connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })
|
||||
} else if complete || error != nil || buffer.count > 65536 { connection.cancel() }
|
||||
else { receive(connection, buffer: buffer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct ImportedSourceTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let text: String
|
||||
switch url.path {
|
||||
case "/search":
|
||||
text = #"{"books":[{"name":"第一本","url":"/book/1"},{"name":"第二本","url":"/book/2"}]}"#
|
||||
case "/book/2":
|
||||
text = #"<h1>第二本</h1><a class="toc" href="/toc/2">目录</a>"#
|
||||
case "/toc/2":
|
||||
text = #"<li><a href="/chapter/2">第一章</a></li>"#
|
||||
case "/chapter/2": text = "<article>导入后正文解析成功</article>"
|
||||
default: throw URLError(.fileDoesNotExist)
|
||||
}
|
||||
return .init(data: Data(text.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportedSourcePipelineTests {
|
||||
@Test func importedAndEditedSourceRunsSearchDetailTocAndContentInOrder() async throws {
|
||||
let json = #"{"bookSourceUrl":"https://pipeline.test","bookSourceName":"导入源","searchUrl":"/search?q={{key}}","ruleSearch":"{\"bookList\":\"-$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\"}","ruleBookInfo":{"name":"@CSS:h1@text","tocUrl":"@CSS:a.toc@href"},"ruleToc":{"chapterList":"@CSS:li","chapterName":"@CSS:a@text","chapterUrl":"@CSS:a@href"},"ruleContent":{"content":"@CSS:missing@text","future":true}}"#
|
||||
let imported = try #require(BookSourceImporter().decode(Data(json.utf8)).first)
|
||||
var draft = try BookSourceEditorDocument(source: imported)
|
||||
draft.set(.string("@CSS:article@text"), at: "ruleContent.content")
|
||||
let saved = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(draft.source()))
|
||||
let report = await BookSourceDebugger(transport: ImportedSourceTransport()).run(source: saved, keyword: "任意")
|
||||
#expect(report.passed)
|
||||
#expect(report.sampleBookName == "第二本")
|
||||
#expect(report.sampleChapterTitle == "第一章")
|
||||
#expect(report.contentPreview == "导入后正文解析成功")
|
||||
#expect(saved.additionalFields["ruleContent"]?["future"] == .bool(true))
|
||||
}
|
||||
|
||||
@Test func plusListPrefixKeepsSearchOrder() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://pipeline.test", bookSourceName: "正序")
|
||||
source.searchUrl = "/search?q={{key}}"
|
||||
source.ruleSearch = .init(bookList: "+$.books", name: "$.name", bookUrl: "$.url")
|
||||
var iterator = BookSearchService(transport: ImportedSourceTransport()).searchOutcomes(keyword: "任意", sources: [source]).makeAsyncIterator()
|
||||
let outcome = try #require(await iterator.next())
|
||||
#expect(outcome.books.map(\.name) == ["第一本", "第二本"])
|
||||
#expect(outcome.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct JSONPathEvaluatorTests {
|
||||
private let evaluator = JSONPathEvaluator()
|
||||
|
||||
private var fixture: JSONValue {
|
||||
get throws {
|
||||
try JSONValue.decode(data: Data(
|
||||
"""
|
||||
{
|
||||
"store": {
|
||||
"books": [
|
||||
{"title":"A","price":10,"tags":["x","y"]},
|
||||
{"title":"B","price":20,"tags":["z"]},
|
||||
{"title":"C","price":30,"tags":[]}
|
||||
]
|
||||
},
|
||||
"title":"Root"
|
||||
}
|
||||
""".utf8
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func evaluatesChildrenWildcardsAndNegativeIndex() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[*].title", in: fixture).map(\.stringValue) == ["A", "B", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[-1].title", in: fixture).map(\.stringValue) == ["C"])
|
||||
}
|
||||
|
||||
@Test func evaluatesUnionSliceAndRecursiveDescent() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[0,2].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[0:3:2].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$..title", in: fixture).map(\.stringValue) == ["Root", "A", "B", "C"])
|
||||
}
|
||||
|
||||
@Test func filtersByComparisonExistenceAndBooleanLogic() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.price > 10)].title", in: fixture).map(\.stringValue) == ["B", "C"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.price >= 20 && @.price < 30)].title", in: fixture).map(\.stringValue) == ["B"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.tags[0])].title", in: fixture).map(\.stringValue) == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func filtersByRegexRootReferenceAndNestedBrackets() throws {
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.title =~ /a|b/i)].title", in: fixture).map(\.stringValue) == ["A", "B"])
|
||||
#expect(try evaluator.evaluate(path: "$.store.books[?(@.title != $.title && (@.price == 10 || @.price == 30))].title", in: fixture).map(\.stringValue) == ["A", "C"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct JavaScriptNetworkMock: JavaScriptNetworkLoading {
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
#expect(request.url?.absoluteString == "https://js-network.example.test/api?page=2")
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(String(data: request.httpBody ?? Data(), encoding: .utf8) == #"{"id":7}"#)
|
||||
#expect(request.value(forHTTPHeaderField: "X-Source") == "source")
|
||||
#expect(request.value(forHTTPHeaderField: "X-Extra") == "extra")
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data(#"{"ok":true}"#.utf8),
|
||||
statusCode: 201,
|
||||
headers: ["X-Reply": "yes"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RetryingLegacyJavaScriptNetworkMock: JavaScriptNetworkLoading, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
lock.lock()
|
||||
count += 1
|
||||
let current = count
|
||||
lock.unlock()
|
||||
if current == 1 {
|
||||
return .init(
|
||||
data: Data("temporary".utf8),
|
||||
statusCode: 503,
|
||||
headers: [:],
|
||||
finalURL: try #require(request.url)
|
||||
)
|
||||
}
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie")?.contains("explicit=yes") == true)
|
||||
let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030"))
|
||||
return .init(
|
||||
data: try #require("旧编码辅助请求".data(using: encoding)),
|
||||
statusCode: 200,
|
||||
headers: ["Set-Cookie": "session=js-ok; Path=/"],
|
||||
finalURL: try #require(request.url)
|
||||
)
|
||||
}
|
||||
|
||||
func calls() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private struct JavaScriptAjaxAllMock: JavaScriptNetworkLoading {
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data("body:\(url.lastPathComponent)".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class JavaScriptDirectRequestMock: JavaScriptRedirectNetworkLoading, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var recorded: [(URLRequest, Bool)] = []
|
||||
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
try load(request, followRedirects: true)
|
||||
}
|
||||
|
||||
func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse {
|
||||
lock.lock()
|
||||
recorded.append((request, followRedirects))
|
||||
lock.unlock()
|
||||
let url = try #require(request.url)
|
||||
return .init(
|
||||
data: Data("reply-\(request.httpMethod ?? "")".utf8),
|
||||
statusCode: request.httpMethod == "GET" ? 302 : 200,
|
||||
headers: ["X-Reply": "direct"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
|
||||
func calls() -> [(URLRequest, Bool)] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
}
|
||||
|
||||
struct JavaScriptRuleEvaluatorTests {
|
||||
@Test func decryptsSourceContentUsingRhinoPackageImportsAndAESByteArrays() throws {
|
||||
// OpenSSL AES-128-CBC/PKCS7 fixture, with a 16-byte IV prepended.
|
||||
let script = #"""
|
||||
var javaImport = new JavaImporter();
|
||||
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
|
||||
Packages.javax.crypto, Packages.java.util);
|
||||
with (javaImport) {
|
||||
var encrypted = Base64.getDecoder().decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
|
||||
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
|
||||
var iv = IvParameterSpec(Arrays.copyOfRange(encrypted, 0, 16));
|
||||
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, iv);
|
||||
var decoded = String(cipher.doFinal(Arrays.copyOfRange(encrypted, 16, encrypted.length)));
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
|
||||
var encoded = Base64.getEncoder().encodeToString(cipher.doFinal(decoded.getBytes()));
|
||||
decoded + '|' + encoded;
|
||||
}
|
||||
"""#
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
#expect(value.value.stringValue == "第一段正文\n第二段正文。|uB3e+ZcJb8o8pStt5rXNGONNFrAK5INFVB+WH+YdxgC/+CbXYZApQvQ2GK2xxkx3")
|
||||
}
|
||||
|
||||
@Test func javaCipherRejectsInvalidInputsAndUnsupportedPackageImports() throws {
|
||||
for script in [
|
||||
"var i = new JavaImporter(); i.importPackage(Packages.java.io);",
|
||||
"Packages.javax.crypto.Cipher.getInstance('DES/ECB/NoPadding');",
|
||||
"var i = new JavaImporter(Packages.java.lang, Packages.javax.crypto.spec, Packages.javax.crypto); with(i) { var c = Cipher.getInstance('AES/CBC/PKCS5Padding'); c.init(2, SecretKeySpec(String('short').getBytes(), 'AES'), IvParameterSpec(String('0123456789abcdef').getBytes())); c.doFinal([1, 2, 3]); }"
|
||||
] {
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func importedAESHelperDeclaredInsideWithRetainsJavaStringByteDecoding() throws {
|
||||
let script = #"""
|
||||
var javaImport = new JavaImporter();
|
||||
javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec,
|
||||
Packages.javax.crypto, Packages.java.util);
|
||||
with (javaImport) {
|
||||
function decode(content) {
|
||||
var bytes = Base64.getDecoder().decode(String(content));
|
||||
var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES');
|
||||
var iv = IvParameterSpec(Arrays.copyOfRange(bytes, 0, 16));
|
||||
var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding');
|
||||
cipher.init(2, key, iv);
|
||||
return String(cipher.doFinal(Arrays.copyOfRange(bytes, 16, bytes.length)));
|
||||
}
|
||||
}
|
||||
decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw==');
|
||||
"""#
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init())
|
||||
#expect(value.value.stringValue == "第一段正文\n第二段正文。")
|
||||
}
|
||||
|
||||
@Test func supportsBundledHttpTTSJavaImporterHmacSHA1Script() throws {
|
||||
let script = #"""
|
||||
var aly = new JavaImporter(
|
||||
Packages.javax.crypto.Mac,
|
||||
Packages.javax.crypto.spec.SecretKeySpec,
|
||||
Packages.javax.xml.bind.DatatypeConverter,
|
||||
Packages.java.net.URLEncoder,
|
||||
Packages.java.lang.String,
|
||||
Packages.android.util.Base64
|
||||
);
|
||||
with (aly) {
|
||||
var mac = Mac.getInstance('HmacSHA1');
|
||||
mac.init(new SecretKeySpec(String('key').getBytes('UTF-8'), 'HmacSHA1'));
|
||||
var signature = Base64.encodeToString(
|
||||
mac.doFinal(String('The quick brown fox jumps over the lazy dog').getBytes('UTF-8')),
|
||||
Base64.NO_WRAP
|
||||
);
|
||||
signature + ':' + URLEncoder.encode('a b*~中', 'UTF-8');
|
||||
}
|
||||
"""#
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "3nybhbi3iqa8ino29wqQcBydtNk=:a+b*%7E%E4%B8%AD")
|
||||
}
|
||||
|
||||
@Test func javaImporterDoesNotExposeArbitraryNativeClasses() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "typeof Packages.java.io + ':' + typeof Packages.java.lang.Runtime + ':' + Object.isFrozen(Packages)",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "undefined:undefined:true")
|
||||
}
|
||||
|
||||
@Test func exposesDollarAsCurrentJSONForLegadoInlineExpressions() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "$.book_id",
|
||||
result: .object(["book_id": .string("42")]),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "42")
|
||||
}
|
||||
|
||||
@Test func exposesJavaGetStringRuleBridge() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let html = #"<article><a href="/chapter/1">第一章</a></article>"#
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.getString('a@text', result) + ':' + java.getString('a@href', result, true)",
|
||||
result: .string(html),
|
||||
executionContext: .init(baseURL: "https://reader.example/book/")
|
||||
)
|
||||
let directJSON = try evaluator.evaluate(
|
||||
script: "java.getString('name', result) + ':' + java.getString('$.id', result)",
|
||||
result: .object(["name": .string("书名"), "id": .number(42)]),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "第一章:https://reader.example/chapter/1")
|
||||
#expect(directJSON.value.stringValue == "书名:42")
|
||||
}
|
||||
|
||||
@Test func jsonParseKeepsPlainTextResponsesButStillRejectsBrokenJSON() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let plain = try evaluator.evaluate(
|
||||
script: "JSON.parse(result)",
|
||||
result: .string("The server is temporarily unavailable"),
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(plain.value.stringValue == "The server is temporarily unavailable")
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
_ = try evaluator.evaluate(
|
||||
script: "JSON.parse(result)",
|
||||
result: .string(#"{"broken":}"#),
|
||||
executionContext: .init()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func directJavaGetResolvesRelativeURLAndAppliesSourceHeaders() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://direct.example.test/base/", bookSourceName: "相对请求")
|
||||
source.header = #"{"X-Source":"yes"}"#
|
||||
let loader = JavaScriptDirectRequestMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
|
||||
_ = try evaluator.evaluate(
|
||||
script: "java.get('/chapter/1').body()",
|
||||
result: .null,
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
|
||||
let call = try #require(loader.calls().first)
|
||||
#expect(call.0.url?.absoluteString == "https://direct.example.test/chapter/1")
|
||||
#expect(call.0.value(forHTTPHeaderField: "X-Source") == "yes")
|
||||
#expect(call.0.value(forHTTPHeaderField: "User-Agent") != nil)
|
||||
}
|
||||
|
||||
@Test func loadsSharedSourceJavaScriptLibrary() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://js-lib.example.test", bookSourceName: "共享脚本源")
|
||||
source.jsLib = "function decorate(value) { return 'lib:' + value; }"
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "decorate(result)",
|
||||
result: .string("ok"),
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "lib:ok")
|
||||
}
|
||||
@Test func providesAjaxAndConnectWithLegadoURLOptions() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://js-network.example.test", bookSourceName: "JS 网络源")
|
||||
source.header = #"{"X-Source":"source"}"#
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptNetworkMock())
|
||||
let url = #"/api?page=2,{"method":"POST","body":{"id":7},"headers":{"X-Extra":"extra"}}"#
|
||||
|
||||
let ajax = try evaluator.evaluate(
|
||||
script: "java.ajax(result)",
|
||||
result: .string(url),
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
let connect = try evaluator.evaluate(
|
||||
script: "var r=java.connect(result); r.body + ':' + r.code + ':' + r.headers['X-Reply']",
|
||||
result: .string(url),
|
||||
executionContext: .init(baseURL: source.bookSourceUrl, source: source)
|
||||
)
|
||||
|
||||
#expect(ajax.value.stringValue == #"{"ok":true}"#)
|
||||
#expect(connect.value.stringValue == #"{"ok":true}:201:yes"#)
|
||||
}
|
||||
@Test func ajaxUsesCharsetHTTPRetryCookieMergeAndBodyJavaScript() throws {
|
||||
let host = "https://legacy-js.example.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
LegadoCookieStore.shared.setCookie("stored=one; Path=/", for: host)
|
||||
var source = BookSource(bookSourceUrl: host, bookSourceName: "旧编码 JS 源")
|
||||
source.enabledCookieJar = true
|
||||
let loader = RetryingLegacyJavaScriptNetworkMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
let rule = #"/api, {"charset":"gb18030","retry":1,"headers":{"Cookie":"explicit=yes"},"bodyJs":"result.replace('辅助', '内部')"}"#
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.ajax(result) + ':' + java.getCookie('\(host)', 'session')",
|
||||
result: .string(rule),
|
||||
executionContext: .init(baseURL: host, source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "旧编码内部请求:js-ok")
|
||||
#expect(loader.calls() == 2)
|
||||
}
|
||||
@Test func readsAndWritesSharedCookies() throws {
|
||||
let host = "https://cookie-js-bridge.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.setCookie('\(host)', 'token=abc; Path=/'); java.getCookie('\(host)', 'token') + ':' + java.getCookie('\(host)').indexOf('token=abc')",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:0")
|
||||
#expect(LegadoCookieStore.shared.cookieHeader(for: host).contains("token=abc"))
|
||||
}
|
||||
@Test func exposesGlobalCookieStore() throws {
|
||||
// Use a separate registrable domain because Swift Testing runs these
|
||||
// cookie-store tests concurrently.
|
||||
let host = "https://global-cookie.test"
|
||||
LegadoCookieStore.shared.removeCookies(for: host)
|
||||
defer { LegadoCookieStore.shared.removeCookies(for: host) }
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "cookie.setCookie('\(host)', 'token=abc; Path=/'); var map=cookie.cookieToMap(cookie.getCookie('\(host)')); var header=cookie.mapToCookie(map); cookie.getKey('\(host)', 'token') + ':' + header",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:token=abc")
|
||||
|
||||
let removed = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "cookie.removeCookie('\(host)'); cookie.getCookie('\(host)')",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
#expect(removed.value.stringValue.isEmpty)
|
||||
}
|
||||
@Test func ajaxAllReturnsResponseObjectsInInputOrder() throws {
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptAjaxAllMock())
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "var rows=java.ajaxAll(['https://ajax-all.test/a','https://ajax-all.test/b'], false); rows[0].body + '|' + rows[1].url",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "body:a|https://ajax-all.test/b")
|
||||
}
|
||||
@Test func getHeadPostExposeJsoupStyleResponseAndDisableRedirects() throws {
|
||||
let loader = JavaScriptDirectRequestMock()
|
||||
let evaluator = JavaScriptRuleEvaluator(networkLoader: loader)
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "var g=java.get('https://direct.test/get', {'X-Test':'yes'}, 5000); var h=java.head('https://direct.test/head', {}); var p=java.post('https://direct.test/post', 'raw=正文', {'Content-Type':'text/plain'}); g.statusCode() + ':' + g.header('x-reply') + ':' + h.body() + ':' + p.body()",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
let calls = loader.calls()
|
||||
#expect(value.value.stringValue == "302:direct:reply-HEAD:reply-POST")
|
||||
#expect(calls.count == 3)
|
||||
#expect(calls.allSatisfy { !$0.1 })
|
||||
#expect(calls[0].0.value(forHTTPHeaderField: "X-Test") == "yes")
|
||||
#expect(calls[1].0.httpBody == nil)
|
||||
#expect(String(decoding: calls[2].0.httpBody ?? Data(), as: UTF8.self) == "raw=正文")
|
||||
}
|
||||
@Test func executesPureJavaScriptAndJSONPathPipeline() async throws {
|
||||
let engine = RuleEngine()
|
||||
let root = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试"}"#.utf8))
|
||||
|
||||
let pure = await engine.evaluate("@js:result.name + '-JS'", in: root)
|
||||
#expect(pure.firstString == "测试-JS")
|
||||
#expect(pure.diagnostics.isEmpty)
|
||||
|
||||
let pipeline = await engine.evaluate(
|
||||
"$.id@js:java.put('bookId', result); '/book/' + result",
|
||||
in: root
|
||||
)
|
||||
#expect(pipeline.firstString == "/book/42")
|
||||
#expect(pipeline.variables["bookId"] == "42")
|
||||
#expect(pipeline.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func carriesVariablesAndProvidesLegadoHelpers() async {
|
||||
let engine = RuleEngine()
|
||||
let context = RuleExecutionContext(variables: ["bookId": "1001"])
|
||||
|
||||
let result = await engine.evaluate(
|
||||
"@js:java.md5Encode(java.get('bookId')).length + ':' + java.get('bookId')",
|
||||
in: .null,
|
||||
context: context
|
||||
)
|
||||
|
||||
#expect(result.firstString == "32:1001")
|
||||
#expect(result.variables["bookId"] == "1001")
|
||||
}
|
||||
|
||||
@Test func providesByteHexTimeAndUUIDHelpers() throws {
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "java.bytesToStr(java.strToBytes('正文')) + ':' + java.hexDecodeToString(java.hexEncodeToString('A')) + ':' + java.timeFormatUTC(0, 'yyyy-MM-dd HH:mm', 0) + ':' + java.randomUUID().length",
|
||||
result: .null,
|
||||
executionContext: .init()
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "正文:A:1970-01-01 00:00:36")
|
||||
}
|
||||
|
||||
@Test func exposesSourceTagBase64BytesAndStableInstallationIdentifier() throws {
|
||||
let source = BookSource(bookSourceUrl: "https://bridge-source.test", bookSourceName: "桥接源")
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let value = try evaluator.evaluate(
|
||||
script: "java.getSource().bookSourceName + ':' + java.getTag() + ':' + java.bytesToStr(java.base64DecodeToByteArray('b2s=')) + ':' + (java.androidId() === java.androidId())",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "桥接源:https://bridge-source.test:ok:true")
|
||||
}
|
||||
|
||||
@Test func exposesEntityPropertiesAndFormStyleEncodeURI() throws {
|
||||
var book = Book(bookUrl: "https://book.test/1", name: "书名", author: "作者")
|
||||
book.kind = "玄幻"
|
||||
let chapter = BookChapter(url: "/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 3)
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "book.name + ':' + book.kind + ':' + chapter.title + ':' + chapter.index + ':' + java.encodeURI('a b&中')",
|
||||
result: .null,
|
||||
executionContext: .init(book: book, chapter: chapter)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "书名:玄幻:第一章:3:a+b%26%E4%B8%AD")
|
||||
}
|
||||
|
||||
@Test func keepsJavaSpecialVariablesAndSourceVariablesInSeparateScopes() throws {
|
||||
let source = BookSource(bookSourceUrl: "https://variable-source.test", bookSourceName: "变量源")
|
||||
let book = Book(bookUrl: "https://book.test/variable", name: "变量书")
|
||||
let chapter = BookChapter(url: "/one", title: "变量章", bookUrl: book.bookUrl, index: 0)
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
|
||||
let first = try evaluator.evaluate(
|
||||
script: "source.put('token','persisted'); java.get('bookName') + ':' + java.get('title')",
|
||||
result: .null,
|
||||
executionContext: .init(source: source, book: book, chapter: chapter)
|
||||
)
|
||||
let second = try evaluator.evaluate(
|
||||
script: "source.get('token')",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(first.value.stringValue == "变量书:变量章")
|
||||
#expect(second.value.stringValue == "persisted")
|
||||
}
|
||||
|
||||
@Test func resolvesDynamicLenientAndLoginHeadersByPriority() throws {
|
||||
var source = BookSource(bookSourceUrl: "https://header-source.test", bookSourceName: "Header源")
|
||||
source.header = #"@js:"{'X-Static':'base','X-Dynamic':'js'}""#
|
||||
BookSourceRuntimeStore.shared.put(
|
||||
sourceID: source.id,
|
||||
key: "loginHeader",
|
||||
value: #"{"X-Static":"login","Cookie":"token=ok"}"#
|
||||
)
|
||||
|
||||
let headers = try BookSourceHeaderResolver().resolve(source)
|
||||
|
||||
#expect(headers["X-Dynamic"] == "js")
|
||||
#expect(headers["X-Static"] == "login")
|
||||
#expect(headers["Cookie"] == "token=ok")
|
||||
}
|
||||
|
||||
@Test func exposesSourceLoginInfoMapAndHeaderMap() throws {
|
||||
var source = BookSource(bookSourceUrl: "httpTts:99", bookSourceName: "在线朗读")
|
||||
source.header = #"{"X-Base":"one"}"#
|
||||
BookSourceRuntimeStore.shared.put(sourceID: source.id, key: "loginHeader", value: #"{"X-Login":"two"}"#)
|
||||
|
||||
let value = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "source.putLoginInfo(JSON.stringify({token:'abc'})); source.getLoginInfoMap().get('token') + ':' + source.getHeaderMap(true)['X-Login'] + ':' + source.getKey()",
|
||||
result: .null,
|
||||
executionContext: .init(source: source)
|
||||
)
|
||||
|
||||
#expect(value.value.stringValue == "abc:two:httpTts:99")
|
||||
}
|
||||
|
||||
@Test func rejectsObviousUnboundedScripts() async {
|
||||
let result = await RuleEngine().evaluate("@js:while(true){}", in: .null)
|
||||
|
||||
#expect(result.values.isEmpty)
|
||||
#expect(result.diagnostics.contains { $0.code == "rule.resource_limit" })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LegadoDatabaseTests {
|
||||
@Test func decodesBookAndChapterFieldsWithoutBreakingLegacyPayloads() throws {
|
||||
let payload = Data(#"""
|
||||
{
|
||||
"bookUrl":"book","name":"书","durVolumeIndex":2,"chapterInVolumeIndex":4,
|
||||
"readConfig":{"reverseToc":true,"dailyChapters":5,"playSpeed":1.25}
|
||||
}
|
||||
"""#.utf8)
|
||||
let book = try JSONDecoder().decode(Book.self, from: payload)
|
||||
#expect(book.durVolumeIndex == 2)
|
||||
#expect(book.chapterInVolumeIndex == 4)
|
||||
#expect(book.readConfig?.reverseToc == true)
|
||||
#expect(book.readConfig?.dailyChapters == 5)
|
||||
|
||||
let chapter = try JSONDecoder().decode(
|
||||
BookChapter.self,
|
||||
from: Data(#"{"url":"/1","bookUrl":"book","startFragmentId":"a","endFragmentId":"b","imgUrl":"cover"}"#.utf8)
|
||||
)
|
||||
#expect(chapter.startFragmentId == "a")
|
||||
#expect(chapter.endFragmentId == "b")
|
||||
#expect(chapter.imageURL == "cover")
|
||||
let encoded = String(decoding: try JSONEncoder().encode(chapter), as: UTF8.self)
|
||||
#expect(encoded.contains("\"imgUrl\":\"cover\""))
|
||||
}
|
||||
|
||||
@Test func storesSourcesBooksAndChapterKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let sources = SQLiteBookSourceRepository(database: database)
|
||||
let books = SQLiteBookRepository(database: database)
|
||||
let chapters = SQLiteBookChapterRepository(database: database)
|
||||
|
||||
var source = BookSource(bookSourceUrl: "https://source.example", bookSourceName: "示例")
|
||||
source.customOrder = 7
|
||||
try await sources.save([source])
|
||||
|
||||
var book = Book(bookUrl: "https://source.example/book/1", name: "书", author: "作者", origin: source.bookSourceUrl)
|
||||
book.durChapterPos = 42
|
||||
try await books.save([book])
|
||||
|
||||
var first = BookChapter(url: "/1", title: "第一章", bookUrl: book.bookUrl, index: 99)
|
||||
first.variable = #"{"token":"x"}"#
|
||||
let second = BookChapter(url: "/2", title: "第二章", bookUrl: book.bookUrl, index: 100)
|
||||
try await chapters.save([first, second], bookURL: book.bookUrl)
|
||||
|
||||
#expect(try await sources.load() == [source])
|
||||
#expect(try await books.load() == [book])
|
||||
let stored = try await chapters.load(bookURL: book.bookUrl)
|
||||
#expect(stored.map(\.index) == [0, 1])
|
||||
#expect(stored.first?.variable == first.variable)
|
||||
|
||||
try await books.save([])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
|
||||
}
|
||||
|
||||
@Test func importsLegacyJSONOnlyIntoEmptyTables() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let sourceURL = directory.appending(path: "book-sources.json")
|
||||
let booksURL = directory.appending(path: "books.json")
|
||||
let source = BookSource(bookSourceUrl: "https://legacy.example", bookSourceName: "旧源")
|
||||
let book = Book(bookUrl: "https://legacy.example/1", name: "旧书", origin: source.bookSourceUrl)
|
||||
try JSONEncoder().encode([source]).write(to: sourceURL)
|
||||
try JSONEncoder().encode([book]).write(to: booksURL)
|
||||
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
|
||||
#expect(try await database.loadBookSources() == [source])
|
||||
#expect(try await database.loadBooks() == [book])
|
||||
|
||||
try JSONEncoder().encode([BookSource(bookSourceUrl: "https://ignored", bookSourceName: "忽略")])
|
||||
.write(to: sourceURL)
|
||||
try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL)
|
||||
#expect(try await database.loadBookSources() == [source])
|
||||
}
|
||||
|
||||
@Test func doesNotReimportLegacyChaptersAfterRemovingBook() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let legacyDirectory = directory.appending(path: "Chapters", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true)
|
||||
let book = Book(bookUrl: "https://source.example/book", name: "书", origin: "https://source.example")
|
||||
let stale = BookChapter(url: "https://source.example/old", title: "后记", bookUrl: book.bookUrl, index: 0)
|
||||
try JSONEncoder().encode([stale]).write(to: legacyDirectory.appending(path: "old.json"))
|
||||
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let books = SQLiteBookRepository(database: database)
|
||||
let chapters = SQLiteBookChapterRepository(database: database, legacyDirectory: legacyDirectory)
|
||||
try await books.save([book])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).map(\.title) == ["后记"])
|
||||
|
||||
try await books.save([])
|
||||
try await books.save([book])
|
||||
#expect(try await chapters.load(bookURL: book.bookUrl).isEmpty)
|
||||
}
|
||||
|
||||
@Test func storesBookmarksAndCompositeReadRecords() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteReadingActivityRepository(database: database)
|
||||
let bookmark = Bookmark(time: 123, bookName: "书", bookAuthor: "作者")
|
||||
let records = [
|
||||
ReadRecord(deviceId: "A", bookName: "书", readTime: 10, lastRead: 100),
|
||||
ReadRecord(deviceId: "B", bookName: "书", readTime: 20, lastRead: 200)
|
||||
]
|
||||
try await repository.saveBookmarks([bookmark])
|
||||
try await repository.saveReadRecords(records)
|
||||
#expect(try await repository.loadBookmarks() == [bookmark])
|
||||
#expect(try await repository.loadReadRecords() == Array(records.reversed()))
|
||||
}
|
||||
|
||||
@Test func storesBookGroupsInExplicitOrder() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteBookGroupRepository(database: database)
|
||||
let groups = [
|
||||
BookGroup(groupId: 2, groupName: "二", order: 9),
|
||||
BookGroup(groupId: 1, groupName: "一", order: 1)
|
||||
]
|
||||
try await repository.save(groups)
|
||||
#expect(try await repository.load() == groups)
|
||||
}
|
||||
|
||||
@Test func searchKeywordUsageOrderingUsesCountAndTimestamp() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteSearchKeywordRepository(database: database, legacyWords: ["旧词"])
|
||||
|
||||
#expect(try await repository.load(order: .lastUseTime).map(\.word) == ["旧词"])
|
||||
_ = try await repository.record("甲", at: 100)
|
||||
_ = try await repository.record("乙", at: 200)
|
||||
let updated = try await repository.record("甲", at: 300)
|
||||
#expect(updated.usage == 2)
|
||||
#expect(try await repository.load(order: .usage).map(\.word).first == "甲")
|
||||
#expect(try await repository.load(order: .lastUseTime).map(\.word).first == "甲")
|
||||
try await repository.delete("甲")
|
||||
#expect(try await repository.load(order: .usage).allSatisfy { $0.word != "甲" })
|
||||
}
|
||||
|
||||
@Test func preservesCacheDeadlinesSourceCleanupAndCookieKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let caches = SQLiteLegadoCacheRepository(database: database)
|
||||
let cookies = SQLiteLegadoCookieRepository(database: database)
|
||||
|
||||
try await caches.put(.init(key: "alive", value: "A", deadline: 200))
|
||||
try await caches.put(.init(key: "expired", value: "E", deadline: 50))
|
||||
try await caches.put(.init(key: "loginHeader_source", value: "H"))
|
||||
#expect(try await caches.value(for: "alive", now: 100) == "A")
|
||||
#expect(try await caches.value(for: "expired", now: 100) == nil)
|
||||
try await caches.clearExpired(now: 100)
|
||||
try await caches.deleteSourceVariables("source")
|
||||
#expect(try await caches.value(for: "loginHeader_source", now: 100) == nil)
|
||||
|
||||
try await cookies.put(.init(url: "host", cookie: "a=1"))
|
||||
try await cookies.put(.init(url: "source|example.com|/", cookie: "b=2"))
|
||||
#expect(try await cookies.get("host")?.cookie == "a=1")
|
||||
#expect(try await cookies.getOkHTTP().count == 1)
|
||||
try await cookies.deleteOkHTTP()
|
||||
#expect(try await cookies.getOkHTTP().isEmpty)
|
||||
}
|
||||
|
||||
@Test func searchBookStoreUsesSourceEnableGroupAndOrderForChangeSource() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let sourceRepository = SQLiteBookSourceRepository(database: database)
|
||||
let repository = SQLiteSearchBookRepository(database: database)
|
||||
var firstSource = BookSource(bookSourceUrl: "https://one", bookSourceName: "一号")
|
||||
firstSource.bookSourceGroup = "文字"
|
||||
firstSource.customOrder = 20
|
||||
var secondSource = BookSource(bookSourceUrl: "https://two", bookSourceName: "二号")
|
||||
secondSource.bookSourceGroup = "文字,精选"
|
||||
secondSource.customOrder = 10
|
||||
try await sourceRepository.save([firstSource, secondSource])
|
||||
var one = SearchBook(bookURL: "https://one/book", origin: firstSource.bookSourceUrl, originName: "一号", type: 0, name: "书", author: "作者")
|
||||
one.latestChapterTitle = "第一章"
|
||||
one.originOrder = 20
|
||||
var two = SearchBook(bookURL: "https://two/book", origin: secondSource.bookSourceUrl, originName: "二号", type: 0, name: "书", author: "作者")
|
||||
two.coverURL = "cover"
|
||||
two.originOrder = 10
|
||||
try await repository.put([one, two])
|
||||
|
||||
#expect(try await repository.first(name: "书", author: "作者")?.bookURL == two.bookURL)
|
||||
let changed = try await repository.changeSources(name: "书", author: "作", sourceGroup: "文字", key: "二号")
|
||||
#expect(changed.map(\.bookURL) == [two.bookURL])
|
||||
#expect(changed.first?.originOrder == 10)
|
||||
#expect(try await repository.enabledWithCover(name: "书", author: "作者").map(\.bookURL) == [two.bookURL])
|
||||
|
||||
secondSource.enabled = false
|
||||
try await sourceRepository.save([firstSource, secondSource])
|
||||
#expect(try await repository.enabledWithCover(name: "书", author: "作者").isEmpty)
|
||||
#expect(try await repository.get(bookURL: two.bookURL) != nil)
|
||||
}
|
||||
|
||||
@Test func storesConfigurationEntitiesWithOrderingAndKeys() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteConfigurationRepository(database: database)
|
||||
|
||||
let toc = [
|
||||
TxtTocRule(id: 2, name: "后", serialNumber: 2),
|
||||
TxtTocRule(id: 1, name: "前", serialNumber: 1)
|
||||
]
|
||||
try await repository.saveTxtTocRules(toc)
|
||||
#expect(try await repository.loadTxtTocRules().map(\.id) == [1, 2])
|
||||
|
||||
let engines = [HttpTTS(id: 2, name: "B"), HttpTTS(id: 1, name: "A")]
|
||||
try await repository.saveHttpTTS(engines)
|
||||
#expect(try await repository.loadHttpTTS().map(\.name) == ["A", "B"])
|
||||
|
||||
let subscriptions = [
|
||||
RuleSub(id: 2, name: "后", url: "2"),
|
||||
RuleSub(id: 1, name: "前", url: "1")
|
||||
]
|
||||
var orderedSubscriptions = subscriptions
|
||||
orderedSubscriptions[0].customOrder = 2
|
||||
orderedSubscriptions[1].customOrder = 1
|
||||
try await repository.saveRuleSubs(orderedSubscriptions)
|
||||
#expect(try await repository.loadRuleSubs().map(\.id) == [1, 2])
|
||||
|
||||
let keys = [
|
||||
KeyboardAssist(type: 1, key: "b", value: "B", serialNo: 2),
|
||||
KeyboardAssist(type: 0, key: "a", value: "A", serialNo: 1)
|
||||
]
|
||||
try await repository.saveKeyboardAssists(keys)
|
||||
#expect(try await repository.loadKeyboardAssists().map(\.key) == ["a", "b"])
|
||||
|
||||
var serverB = Server(id: 2, name: "B")
|
||||
serverB.sortNumber = 2
|
||||
var serverA = Server(id: 1, name: "A")
|
||||
serverA.sortNumber = 1
|
||||
try await repository.saveServers([serverB, serverA])
|
||||
#expect(try await repository.loadServers().map(\.id) == [1, 2])
|
||||
}
|
||||
|
||||
@Test func decodesRssModelsAndPreservesDefaults() throws {
|
||||
let source = try JSONDecoder().decode(
|
||||
RssSource.self,
|
||||
from: Data(#"{"sourceUrl":"https://rss","sourceName":"订阅","enabled":"false","articleStyle":"2","loginUi":{"type":"input"}}"#.utf8)
|
||||
)
|
||||
#expect(source.enabled == false)
|
||||
#expect(source.articleStyle == 2)
|
||||
#expect(source.loginUi?.contains("input") == true)
|
||||
#expect(source.enableJs == true)
|
||||
|
||||
let article = try JSONDecoder().decode(
|
||||
RssArticle.self,
|
||||
from: Data(#"{"origin":"https://rss","sort":"all","title":"文章","link":"/1","order":"42","read":1}"#.utf8)
|
||||
)
|
||||
#expect(article.order == 42)
|
||||
#expect(article.read)
|
||||
#expect(article.record(at: 100).record == "/1")
|
||||
#expect(article.star(at: 200).starTime == 200)
|
||||
}
|
||||
|
||||
@Test func rssSourceRepositorySupportsGroupsSearchAndOrdering() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let repository = SQLiteRssSourceRepository(database: database)
|
||||
var later = RssSource(sourceUrl: "https://b", sourceName: "科技日报")
|
||||
later.sourceGroup = "资讯,科技"; later.customOrder = 20
|
||||
var earlier = RssSource(sourceUrl: "https://a", sourceName: "新闻")
|
||||
earlier.sourceGroup = "资讯"; earlier.sourceComment = "每日"; earlier.customOrder = 10
|
||||
try await repository.save([later, earlier])
|
||||
|
||||
#expect(try await repository.all().map(\.sourceUrl) == ["https://a", "https://b"])
|
||||
#expect(Set(try await repository.groups()) == Set(["资讯", "科技"]))
|
||||
#expect(try await repository.inGroup("科技").map(\.sourceUrl) == ["https://b"])
|
||||
#expect(try await repository.search("每日").map(\.sourceUrl) == ["https://a"])
|
||||
try await repository.setEnabled(false, sourceURL: "https://a")
|
||||
#expect(try await repository.disabled().map(\.sourceUrl) == ["https://a"])
|
||||
}
|
||||
|
||||
@Test func rssArticlesUseCompositeKeyIgnoreAppendAndGlobalReadLink() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let articles = SQLiteRssArticleRepository(database: database)
|
||||
let records = SQLiteRssReadRecordRepository(database: database)
|
||||
var old = RssArticle(origin: "one", sort: "all", title: "旧标题", link: "/same")
|
||||
old.order = 1
|
||||
try await articles.append([old])
|
||||
var duplicate = old; duplicate.title = "不应覆盖"; duplicate.order = 99
|
||||
var secondSort = old; secondSort.sort = "other"; secondSort.title = "另一分类"
|
||||
try await articles.append([duplicate, secondSort])
|
||||
|
||||
#expect(try await articles.articles(origin: "one", sort: "all").first?.title == "旧标题")
|
||||
#expect(try await articles.articles(origin: "one", sort: "other").count == 1)
|
||||
try await records.insertIgnoringExisting(old.record(at: 100))
|
||||
var sameLinkOtherOrigin = RssArticle(origin: "two", sort: "all", title: "同链接", link: "/same")
|
||||
sameLinkOtherOrigin.order = 2
|
||||
try await articles.append([sameLinkOtherOrigin])
|
||||
#expect(try await articles.articles(origin: "two", sort: "all").first?.read == true)
|
||||
try await articles.clearOld(origin: "one", sort: "all", before: 2)
|
||||
#expect(try await articles.articles(origin: "one", sort: "all").isEmpty)
|
||||
try await articles.updateOrigin(from: "one", to: "renamed")
|
||||
#expect(try await articles.article(origin: "renamed", link: "/same", sort: "other")?.origin == "renamed")
|
||||
}
|
||||
|
||||
@Test func rssStarsReplaceCompositeKeyAndSortNewestFirst() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3"))
|
||||
let stars = SQLiteRssStarRepository(database: database)
|
||||
var firstArticle = RssArticle(origin: "one", sort: "all", title: "甲", link: "/1")
|
||||
firstArticle.group = "技术"
|
||||
var secondArticle = RssArticle(origin: "one", sort: "all", title: "乙", link: "/2")
|
||||
secondArticle.group = "生活"
|
||||
try await stars.save(firstArticle.star(at: 100))
|
||||
try await stars.save(secondArticle.star(at: 200))
|
||||
var replacement = firstArticle.star(at: 300); replacement.title = "甲更新"
|
||||
try await stars.save(replacement)
|
||||
|
||||
#expect(try await stars.all().map(\.title) == ["甲更新", "乙"])
|
||||
#expect(Set(try await stars.groups()) == Set(["技术", "生活"]))
|
||||
try await stars.updateOrigin(from: "one", to: "renamed")
|
||||
#expect(try await stars.star(origin: "renamed", link: "/1")?.origin == "renamed")
|
||||
try await stars.remove(origin: "renamed", link: "/1")
|
||||
#expect(try await stars.all().map(\.link) == ["/2"])
|
||||
}
|
||||
|
||||
@Test func defaultRssParserExtractsItemFieldsAndPrioritizesImages() throws {
|
||||
let xml = #"""
|
||||
<rss xmlns:media="urn:media" xmlns:content="urn:content"><channel>
|
||||
<item><title> 第一篇 </title><link> /one </link>
|
||||
<description><![CDATA[<p>x</p><img src="description.jpg">]]></description>
|
||||
<content:encoded><![CDATA[<img src="content.jpg">正文]]></content:encoded>
|
||||
<pubDate> today </pubDate>
|
||||
</item>
|
||||
<item><title>第二篇</title><link>/two</link>
|
||||
<media:thumbnail url="thumb.jpg" />
|
||||
<enclosure type="image/jpeg" url="enclosure.jpg" />
|
||||
<time>later</time>
|
||||
</item>
|
||||
</channel></rss>
|
||||
"""#
|
||||
let result = try RSSDefaultParser.parse(sortName: "全部", xml: xml, sourceURL: "https://rss")
|
||||
#expect(result.nextURL == nil)
|
||||
#expect(result.articles.count == 2)
|
||||
#expect(result.articles[0].title == "第一篇")
|
||||
#expect(result.articles[0].image == "description.jpg")
|
||||
#expect(result.articles[0].content?.contains("正文") == true)
|
||||
#expect(result.articles[0].origin == "https://rss")
|
||||
#expect(result.articles[0].sort == "全部")
|
||||
#expect(result.articles[1].image == "enclosure.jpg")
|
||||
#expect(result.articles[1].pubDate == "later")
|
||||
}
|
||||
|
||||
@Test func rssRuleParserExtractsFieldsReversesAndSupportsPageSentinel() async throws {
|
||||
var source = RssSource(sourceUrl: "https://rss.example/root/", sourceName: "规则源")
|
||||
source.ruleArticles = "-@CSS:.item"
|
||||
source.ruleTitle = "@CSS:h2@text"
|
||||
source.rulePubDate = "@CSS:time@text"
|
||||
source.ruleDescription = "@CSS:p@text"
|
||||
source.ruleImage = "@CSS:img@src"
|
||||
source.ruleLink = "@CSS:a@href"
|
||||
source.ruleNextPage = "PAGE"
|
||||
source.type = 2
|
||||
let body = #"""
|
||||
<div class="item"><h2>甲</h2><time>1</time><p>A</p><img src="a.jpg"><a href="/a">go</a></div>
|
||||
<div class="item"><h2>乙</h2><time>2</time><p>B</p><img src="b.jpg"><a href="/b">go</a></div>
|
||||
"""#
|
||||
let report = try await RSSRuleParser().parse(
|
||||
sortName: "分类", sortURL: "https://rss.example/list?page=1",
|
||||
redirectURL: "https://rss.example/list?page=1", body: body, source: source
|
||||
)
|
||||
#expect(report.result.articles.map(\.title) == ["乙", "甲"])
|
||||
#expect(report.result.articles.first?.link == "https://rss.example/b")
|
||||
#expect(report.result.articles.first?.image == "https://rss.example/root/b.jpg")
|
||||
#expect(report.result.articles.first?.type == 2)
|
||||
#expect(report.result.nextURL == "https://rss.example/list?page=1")
|
||||
}
|
||||
|
||||
@Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws {
|
||||
let importer = RSSSourceImporter()
|
||||
let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8))
|
||||
#expect(object.sources.map(\.sourceUrl) == ["https://one"])
|
||||
let array = try importer.decode(Data(#"[{"sourceUrl":"https://two","sourceName":"二"}]"#.utf8))
|
||||
#expect(array.sources.map(\.sourceUrl) == ["https://two"])
|
||||
let manifest = try importer.decode(Data(#"{"sourceUrls":["https://list/one.json","https://list/two.json"]}"#.utf8))
|
||||
#expect(manifest.subscriptionURLs.count == 2)
|
||||
|
||||
var old = RssSource(sourceUrl: "https://one", sourceName: "旧"); old.lastUpdateTime = 100
|
||||
var newer = RssSource(sourceUrl: "https://one", sourceName: "新"); newer.lastUpdateTime = 200
|
||||
#expect(importer.defaultSelection(incoming: [newer], existing: [old]) == [true])
|
||||
let merged = try importer.merge([newer], into: [old], addGroup: "导入")
|
||||
#expect(merged.first?.sourceName == "新")
|
||||
#expect(merged.first?.groups.contains("导入") == true)
|
||||
#expect(try importer.decode(importer.encode(merged)).sources.first?.sourceUrl == "https://one")
|
||||
}
|
||||
|
||||
@Test func rssSortResolverParsesSeparatorsAndFallback() async {
|
||||
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
|
||||
source.sortUrl = "新闻::/news&&科技::/tech\n生活::/life"
|
||||
let sorts = await RSSSortResolver().resolve(source)
|
||||
#expect(sorts.map(\.name) == ["新闻", "科技", "生活"])
|
||||
#expect(sorts.map(\.url) == ["/news", "/tech", "/life"])
|
||||
source.sortUrl = "invalid"
|
||||
#expect(await RSSSortResolver().resolve(source) == [.init(name: "", url: "https://rss")])
|
||||
}
|
||||
|
||||
@Test func rssReadingPolicyHandlesDescriptionRuleAndMediaBranches() {
|
||||
let policy = RSSReadingPolicy()
|
||||
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源")
|
||||
var article = RssArticle(origin: source.sourceUrl, sort: "", title: "文章", link: "/post/1")
|
||||
article.description = "<p>摘要正文</p>"
|
||||
#expect(policy.mode(article: article, source: source) == .html(content: "<p>摘要正文</p>", baseURL: "https://rss.example/post/1"))
|
||||
article.description = nil
|
||||
#expect(policy.mode(article: article, source: source) == .web(url: "https://rss.example/post/1"))
|
||||
source.ruleContent = "@CSS:article@html"
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "<article>正文</article>") == .html(content: "<article>正文</article>", baseURL: "https://rss.example/post/1"))
|
||||
article.type = 1
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "../cover.jpg") == .image(url: "https://rss.example/cover.jpg"))
|
||||
article.type = 2
|
||||
#expect(policy.mode(article: article, source: source, resolvedContent: "video.mp4") == .video(url: "https://rss.example/post/video.mp4"))
|
||||
}
|
||||
|
||||
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
|
||||
let processor = RSSHTMLProcessor()
|
||||
#expect(processor.contentHTML("<head></head><body>x</body>", style: "body{color:red}").contains("<style>body{color:red}</style></head>"))
|
||||
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.defaultStyle)</style>"))
|
||||
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
|
||||
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
|
||||
let start = processor.startHTML("<body><p>x</p></body>", source: source)
|
||||
#expect(start.contains("<script>window.ready=true</script></body>"))
|
||||
#expect(start.contains("<style>p{font-size:20px}</style>"))
|
||||
|
||||
let blacklistWins = RSSResourcePolicy(blacklist: "https://ads,.*\\.track\\.js", whitelist: "https://ads/ok")
|
||||
#expect(!blacklistWins.allows("https://ads/ok"))
|
||||
#expect(!blacklistWins.allows("https://cdn.track.js"))
|
||||
#expect(blacklistWins.allows("https://cdn.example/a.js"))
|
||||
let whitelist = RSSResourcePolicy(blacklist: nil, whitelist: "https://safe,https://cdn\\.example/.*")
|
||||
#expect(whitelist.allows("https://safe/image"))
|
||||
#expect(!whitelist.allows("https://other/image"))
|
||||
#expect(blacklistWins.contentBlockerJSON()?.contains("\"type\":\"block\"") == true)
|
||||
#expect(whitelist.contentBlockerJSON()?.contains("ignore-previous-rules") == true)
|
||||
}
|
||||
|
||||
@Test func rssStartPageUsesDedicatedStartJSAndStyle() throws {
|
||||
var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源")
|
||||
source.startHtml = "<body><p>入口</p></body>"
|
||||
source.startJs = "window.started=true"
|
||||
source.startStyle = "p{color:green}"
|
||||
#expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "<body><p>入口</p></body>", baseURL: "https://rss.example"))
|
||||
source.startHtml = "@js:'<body>脚本入口</body>'"
|
||||
#expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "<body>脚本入口</body>", baseURL: "https://rss.example"))
|
||||
source.startHtml = nil
|
||||
#expect(try RSSStartPageResolver().resolve(source) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LegadoURLBuilderTests {
|
||||
@Test func canDisableCookieHandlingPerSourceRequest() throws {
|
||||
let prepared = try LegadoURLBuilder().prepare("https://example.test/books")
|
||||
#expect(prepared.urlRequest(handleCookies: false).httpShouldHandleCookies == false)
|
||||
#expect(prepared.urlRequest().httpShouldHandleCookies)
|
||||
}
|
||||
private let builder = LegadoURLBuilder()
|
||||
|
||||
@Test func relativeRequestOptionsAndHeaderCasingSurviveResolution() throws {
|
||||
let base = try #require(URL(string: "https://example.test/book/1"))
|
||||
let resolved = LegadoURLBuilder.resolve(#"../chapter,{"method":"POST","headers":{"referer":"custom"},"body":"id=1"}"#, against: base)
|
||||
let request = try builder.prepare(resolved, defaultHeaders: ["Referer": "old"])
|
||||
#expect(request.url.absoluteString == "https://example.test/chapter")
|
||||
#expect(request.method == .post)
|
||||
#expect(request.headers.filter { $0.key.lowercased() == "referer" }.count == 1)
|
||||
#expect(request.headers["referer"] == "custom")
|
||||
#expect(String(decoding: try #require(request.body), as: UTF8.self) == "id=1")
|
||||
}
|
||||
|
||||
@Test func rendersKeywordPageAndRequestOptions() throws {
|
||||
let template = """
|
||||
/search?q={{key}}&page=<0,1,2>, {"method":"POST","headers":{"X-Test":"yes"},"body":"token={{token}}","retry":2}
|
||||
"""
|
||||
let prepared = try builder.prepare(
|
||||
template,
|
||||
context: .init(key: "三体", page: 2, variables: ["token": "abc"]),
|
||||
baseURL: URL(string: "https://example.test/base/"),
|
||||
defaultHeaders: ["User-Agent": "Legado-iOS"]
|
||||
)
|
||||
|
||||
#expect(prepared.url.absoluteString.contains("q=%E4%B8%89%E4%BD%93"))
|
||||
#expect(prepared.url.absoluteString.contains("page=1"))
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.headers["X-Test"] == "yes")
|
||||
#expect(prepared.headers["User-Agent"] == "Legado-iOS")
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "token=abc")
|
||||
#expect(prepared.retry == 2)
|
||||
}
|
||||
|
||||
@Test func evaluatesInlineJavaScriptWithSearchBindings() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/{{key.toUpperCase()}}?page={{page + 1}}",
|
||||
context: .init(key: "swift", page: 2)
|
||||
)
|
||||
|
||||
#expect(prepared.url.absoluteString == "https://example.test/SWIFT?page=3")
|
||||
}
|
||||
|
||||
@Test func evaluatesWholeURLAndOptionJavaScriptWhileCarryingVariables() throws {
|
||||
let wholeScript = try builder.prepare(
|
||||
"@js:'/search?q=' + key",
|
||||
context: .init(key: "book", page: 4),
|
||||
baseURL: URL(string: "https://example.test")
|
||||
)
|
||||
let prepared = try builder.prepare(
|
||||
#"/search?q=book, {"js":"java.put('signature', 's-' + page); result + '&sig=' + java.get('signature')"}"#,
|
||||
context: .init(key: "book", page: 4),
|
||||
baseURL: URL(string: "https://example.test")
|
||||
)
|
||||
|
||||
#expect(wholeScript.url.absoluteString == "https://example.test/search?q=book")
|
||||
#expect(prepared.url.absoluteString == "https://example.test/search?q=book&sig=s-4")
|
||||
#expect(prepared.variables["signature"] == "s-4")
|
||||
}
|
||||
|
||||
@Test func supportsLegacySearchTokensAndJSONObjectBody() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/search, {\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}",
|
||||
context: .init(key: "三体", page: 3)
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
let body = try #require(prepared.body)
|
||||
let json = try JSONValue.decode(data: body)
|
||||
#expect(json["title"]?.stringValue == "三体")
|
||||
#expect(json["page"]?.stringValue == "3")
|
||||
}
|
||||
|
||||
@Test func encodesLegacyCharsetQueryAndFormBody() throws {
|
||||
let query = try builder.prepare(
|
||||
#"https://example.test/search?q={{key}}, {"charset":"gb18030"}"#,
|
||||
context: .init(key: "旧城")
|
||||
)
|
||||
let form = try builder.prepare(
|
||||
#"https://example.test/search, {"method":"POST","charset":"gb18030","body":"q={{key}}&tag=中文 空格"}"#,
|
||||
context: .init(key: "旧城")
|
||||
)
|
||||
|
||||
#expect(query.url.absoluteString.contains("q=%BE%C9%B3%C7"))
|
||||
#expect(String(decoding: try #require(form.body), as: UTF8.self) == "q=%BE%C9%B3%C7&tag=%D6%D0%CE%C4+%BF%D5%B8%F1")
|
||||
#expect(form.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=gb18030")
|
||||
}
|
||||
|
||||
@Test func parsesGsonLenientStyleOptions() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/search?q={{key}}, {'method':'POST', body:'key={{key}}', charset:'utf-8',}"#,
|
||||
context: .init(key: "三体")
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.charset == "utf-8")
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E4%B8%89%E4%BD%93")
|
||||
}
|
||||
|
||||
@Test func keepsJsonInQueryWhenLaterSplitParses() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/search?data={"q":"{{key}}"}, {"method":"POST","body":"key={{key}}"}"#,
|
||||
context: .init(key: "剑来")
|
||||
)
|
||||
|
||||
#expect(prepared.method == .post)
|
||||
#expect(prepared.url.absoluteString.contains("data="))
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E5%89%91%E6%9D%A5")
|
||||
}
|
||||
|
||||
@Test func appliesDefaultBrowserUserAgentWhenMissing() throws {
|
||||
let prepared = try builder.prepare("https://example.test/books")
|
||||
#expect(prepared.headers["User-Agent"] == LegadoURLBuilder.defaultUserAgent)
|
||||
#expect(prepared.headers["User-Agent"]?.contains("Chrome/128.0.0.0") == true)
|
||||
let explicit = try builder.prepare(
|
||||
"https://example.test/books",
|
||||
defaultHeaders: ["User-Agent": "Custom/1.0"]
|
||||
)
|
||||
#expect(explicit.headers["User-Agent"] == "Custom/1.0")
|
||||
}
|
||||
|
||||
@Test func nullUserAgentSuppressesDefaultHeader() throws {
|
||||
let prepared = try builder.prepare(
|
||||
"https://example.test/books",
|
||||
defaultHeaders: ["user-agent": "null"]
|
||||
)
|
||||
|
||||
#expect(prepared.headers.keys.allSatisfy { $0.caseInsensitiveCompare("User-Agent") != .orderedSame })
|
||||
#expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil)
|
||||
}
|
||||
|
||||
@Test func preservesTypeDnsServerAndProxyOptions() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/file, {"type":"image","dnsIp":"1.1.1.1,8.8.8.8","serverID":12}"#,
|
||||
defaultHeaders: ["proxy": "http://127.0.0.1:7890", "X-Test": "yes"]
|
||||
)
|
||||
|
||||
#expect(prepared.responseType == "image")
|
||||
#expect(prepared.dnsIP == "1.1.1.1,8.8.8.8")
|
||||
#expect(prepared.serverID == 12)
|
||||
#expect(prepared.proxy == "http://127.0.0.1:7890")
|
||||
#expect(prepared.headers["proxy"] == nil)
|
||||
#expect(prepared.headers["X-Test"] == "yes")
|
||||
}
|
||||
|
||||
@Test func rendersPostFormAndIgnoresBodyForGetAndHead() throws {
|
||||
let post = try builder.prepare(
|
||||
#"https://example.test/search, {"method":"POST","body":"q={{key}}&space=空 格"}"#,
|
||||
context: .init(key: "三体")
|
||||
)
|
||||
let emptyPost = try builder.prepare(#"https://example.test/search, {"method":"POST"}"#)
|
||||
let get = try builder.prepare(#"https://example.test/search, {"body":"ignored=1"}"#)
|
||||
let head = try builder.prepare(#"https://example.test/search, {"method":"HEAD","body":"ignored=1"}"#)
|
||||
|
||||
#expect(String(decoding: try #require(post.body), as: UTF8.self) == "q=%E4%B8%89%E4%BD%93&space=%E7%A9%BA+%E6%A0%BC")
|
||||
#expect(emptyPost.body == Data())
|
||||
#expect(post.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=utf-8")
|
||||
#expect(get.urlRequest().httpBody == nil)
|
||||
#expect(head.urlRequest().httpBody == nil)
|
||||
}
|
||||
|
||||
@Test func explicitContentTypeKeepsRawPostBody() throws {
|
||||
let prepared = try builder.prepare(
|
||||
#"https://example.test/raw, {"method":"POST","headers":{"content-type":"text/plain; charset=utf-8"},"body":"原始 空格"}"#
|
||||
)
|
||||
|
||||
#expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "原始 空格")
|
||||
#expect(prepared.headers["content-type"] == "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LocalBookServiceTests {
|
||||
@Test func decodesDefaultTocRuleWithMissingReplacement() throws {
|
||||
let data = Data(#"{"id":-1,"name":"目录","rule":"^第.+章$","serialNumber":0,"enable":true}"#.utf8)
|
||||
let value = try JSONDecoder().decode(TxtTocRule.self, from: data)
|
||||
#expect(value.replacement == "")
|
||||
#expect(value.example == nil)
|
||||
#expect(value.enable)
|
||||
}
|
||||
|
||||
@Test func importsUTF8TextSplitsChaptersAndReadsContent() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "测试书.txt")
|
||||
let sourceData = Data("序言\n第一章 相遇\n这是第一章正文\n第二章 远行\n这是第二章正文".utf8)
|
||||
try sourceData.write(to: source)
|
||||
|
||||
let service = LocalBookService()
|
||||
let report = try service.importTextFile(at: source, into: root.appending(path: "library"))
|
||||
|
||||
#expect(report.book.origin == "local")
|
||||
#expect(report.book.charset == "UTF-8")
|
||||
#expect(report.chapters.map(\.title) == ["前言", "第一章 相遇", "第二章 远行"])
|
||||
#expect(report.chapters[1].url == "82e56047094237f3")
|
||||
let firstTitle = try #require(sourceData.range(of: Data("第一章 相遇".utf8)))
|
||||
let secondTitle = try #require(sourceData.range(of: Data("第二章 远行".utf8)))
|
||||
#expect(report.chapters[1].start == Int64(firstTitle.upperBound))
|
||||
#expect(report.chapters[1].end == Int64(secondTitle.lowerBound))
|
||||
#expect(report.chapters.map(\.wordCount) == ["3字", "9字", "8字"])
|
||||
#expect(report.book.wordCount == "20字")
|
||||
#expect(report.book.intro == "序言\n")
|
||||
let content = try service.loadContent(book: report.book, chapter: report.chapters[1])
|
||||
#expect(content.content == " 这是第一章正文\n")
|
||||
#expect(FileManager.default.fileExists(atPath: try #require(URL(string: report.book.bookUrl)).path))
|
||||
}
|
||||
|
||||
@Test func createsSingleChapterWhenNoHeadingExists() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "短篇.txt")
|
||||
try Data("这是一个没有章节标题的短篇。".utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService().importTextFile(at: source, into: root.appending(path: "library"))
|
||||
|
||||
#expect(report.chapters.count == 1)
|
||||
#expect(report.chapters[0].title == "短篇")
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[0]).content == "这是一个没有章节标题的短篇。")
|
||||
}
|
||||
|
||||
@Test func splitsLongHeadinglessTextAtTenKiBThreshold() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "无目录长文.txt")
|
||||
let paragraph = String(repeating: "a", count: 10_500)
|
||||
try Data("\(paragraph)\n\(paragraph)\n尾声".utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService(tocRules: []).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.count == 2)
|
||||
#expect(report.chapters.map(\.title) == ["第1章(1)", "第1章(2)"])
|
||||
#expect(report.chapters[0].end == report.chapters[1].start)
|
||||
#expect(report.book.wordCount == "2.1万字")
|
||||
#expect(report.book.intro == nil)
|
||||
}
|
||||
|
||||
@Test func numbersHeadinglessChaptersBy512KBBlock() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "多块无目录.txt")
|
||||
let paragraph = String(repeating: "a", count: 10_500) + "\n"
|
||||
try Data(String(repeating: paragraph, count: 55).utf8).write(to: source)
|
||||
|
||||
let report = try LocalBookService(tocRules: []).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.first?.title == "第1章(1)")
|
||||
#expect(report.chapters.contains { $0.title.hasPrefix("第2章(") })
|
||||
for index in 1..<report.chapters.count {
|
||||
#expect(report.chapters[index - 1].end == report.chapters[index].start)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func selectsCustomTocRuleAndRunsTitleScript() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "自定义目录.txt")
|
||||
let body = String(repeating: "正文", count: 650)
|
||||
try Data("卷1 开始\n\(body)\n卷2 继续\n\(body)\n卷3 结束\n\(body)".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "卷标题",
|
||||
rule: #"(?m)^卷\d+[^\r\n]*$"#,
|
||||
replacement: "result.replace('卷', '第')",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.book.tocUrl == rule.rule + TxtTocRuleSelector.separator + rule.replacement)
|
||||
#expect(report.chapters.map(\.title) == ["第1 开始", "第2 继续", "第3 结束"])
|
||||
}
|
||||
|
||||
@Test func tocRuleSelectorKeepsEarlierRuleUnlessLaterWinsByMoreThanTwo() {
|
||||
let body = String(repeating: "字", count: 1_100)
|
||||
let content = "A1\n\(body)\nA2\n\(body)\nA3\n\(body)\nB1\n\(body)\nB2\n\(body)\nB3\n\(body)\nB4"
|
||||
let first = TxtTocRule(id: 1, name: "A", rule: #"(?m)^A\d$"#, serialNumber: 1)
|
||||
let second = TxtTocRule(id: 2, name: "B", rule: #"(?m)^B\d$"#, serialNumber: 2)
|
||||
|
||||
#expect(TxtTocRuleSelector.select(content: content, rules: [first, second])?.id == first.id)
|
||||
}
|
||||
|
||||
@Test func titleScriptPutVolumeInsertsVolumeChapter() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "分卷.txt")
|
||||
let body = String(repeating: "正文", count: 650)
|
||||
try Data("章1\n\(body)\n章2\n\(body)\n章3\n\(body)".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "脚本分卷",
|
||||
rule: #"(?m)^章\d$"#,
|
||||
replacement: "if (index === 2) java.putVolume('第二卷'); result;",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.map(\.title) == ["章1", "第二卷", "章2", "章3"])
|
||||
#expect(report.chapters.map(\.isVolume) == [false, true, false, false])
|
||||
#expect(report.chapters[1].start == report.chapters[1].end)
|
||||
}
|
||||
|
||||
@Test func scansHeadingsAcross512KBBlocksAndReadsByByteRange() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "跨块.txt")
|
||||
let longBody = String(repeating: "段落内容\n", count: 50_000)
|
||||
let sourceData = Data("第一章 开始\n\(longBody)第二章 结束\n结尾".utf8)
|
||||
try sourceData.write(to: source)
|
||||
|
||||
let report = try LocalBookService().importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
let secondTitle = try #require(sourceData.range(of: Data("第二章 结束".utf8)))
|
||||
|
||||
#expect(report.chapters.map(\.title) == ["第一章 开始", "第二章 结束"])
|
||||
#expect(report.chapters[0].end == Int64(secondTitle.lowerBound))
|
||||
#expect(report.chapters[1].start == Int64(secondTitle.upperBound))
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[1]).content == " 结尾")
|
||||
}
|
||||
|
||||
@Test func preservesUTF16EndianBoundariesAcross512KBBlocks() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let text = "第一章 开始\n\(String(repeating: "段落内容\n", count: 70_000))第二章 结束\n结尾"
|
||||
let fixtures: [(String, String.Encoding, [UInt8], String)] = [
|
||||
("LE", .utf16LittleEndian, [0xFF, 0xFE], "UTF-16LE"),
|
||||
("BE", .utf16BigEndian, [0xFE, 0xFF], "UTF-16BE")
|
||||
]
|
||||
|
||||
for (suffix, encoding, bom, charset) in fixtures {
|
||||
let source = root.appending(path: "UTF16\(suffix).txt")
|
||||
var sourceData = Data(bom)
|
||||
sourceData.append(try #require(text.data(using: encoding)))
|
||||
try sourceData.write(to: source)
|
||||
let report = try LocalBookService().importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library-\(suffix)")
|
||||
)
|
||||
let titleData = try #require("第二章 结束".data(using: encoding))
|
||||
let titleRange = try #require(sourceData.range(of: titleData))
|
||||
let second = try #require(report.chapters.first(where: { $0.title == "第二章 结束" }))
|
||||
|
||||
#expect(report.book.charset == charset)
|
||||
#expect(second.start == Int64(titleRange.upperBound))
|
||||
#expect(try LocalBookService().loadContent(book: report.book, chapter: second).content == " 结尾")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func splitsOversizedTocChapterIntoVolumeAndUpdatesFollowingScriptState() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let source = root.appending(path: "超长章节.txt")
|
||||
let longBody = String(repeating: "正文内容\n", count: 12_000)
|
||||
try Data("第一章\n\(longBody)第二章\n结尾".utf8).write(to: source)
|
||||
let rule = TxtTocRule(
|
||||
name: "长章脚本",
|
||||
rule: #"(?m)^第[一二]章$"#,
|
||||
replacement: "result === '第二章' ? lastVolumeTitle + '|' + index + '|' + result : result",
|
||||
serialNumber: 1
|
||||
)
|
||||
|
||||
let report = try LocalBookService(tocRules: [rule]).importTextFile(
|
||||
at: source,
|
||||
into: root.appending(path: "library")
|
||||
)
|
||||
|
||||
#expect(report.chapters.first?.title == "第一章")
|
||||
#expect(report.chapters.first?.isVolume == true)
|
||||
#expect(report.chapters.dropFirst().contains { $0.title == "第一章(1)" })
|
||||
#expect(report.chapters.last?.title.hasPrefix("第一章|") == true)
|
||||
#expect(report.chapters.last?.title.hasSuffix("|第二章") == true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct LocalFileServiceTests {
|
||||
private func root() throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func importsSameNamedFilesWithoutOverwritingAndIgnoresSymlinks() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
try "第一章\n正文".write(to: source, atomically: true, encoding: .utf8)
|
||||
let directory = root.appendingPathComponent("books", isDirectory: true)
|
||||
let service = LocalFileService(directory: directory)
|
||||
let first = try await service.importFile(from: source)
|
||||
let second = try await service.importFile(from: source)
|
||||
try FileManager.default.createSymbolicLink(at: directory.appendingPathComponent("link.txt"), withDestinationURL: source)
|
||||
let files = try await service.list()
|
||||
#expect(files.count == 2)
|
||||
#expect(Set(files.map { $0.url.lastPathComponent }) == ["书.txt", "书 (2).txt"])
|
||||
#expect(try Data(contentsOf: first) == Data(contentsOf: second))
|
||||
}
|
||||
|
||||
@Test func rejectsEscapesSymlinksAndOverwriteDuringRename() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let directory = root.appendingPathComponent("books", isDirectory: true)
|
||||
let service = LocalFileService(directory: directory)
|
||||
let source = root.appendingPathComponent("source.txt")
|
||||
try "正文".write(to: source, atomically: true, encoding: .utf8)
|
||||
let file = try await service.importFile(from: source)
|
||||
let second = try await service.importFile(from: source)
|
||||
await #expect(throws: LocalFileError.invalidName) { try await service.rename(file, to: "../escape") }
|
||||
await #expect(throws: LocalFileError.alreadyExists) { try await service.rename(file, to: second.lastPathComponent) }
|
||||
await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(source) }
|
||||
let link = directory.appendingPathComponent("link.txt")
|
||||
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: source)
|
||||
await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(link) }
|
||||
#expect(FileManager.default.fileExists(atPath: source.path))
|
||||
#expect(FileManager.default.fileExists(atPath: file.path))
|
||||
}
|
||||
|
||||
@Test func stagedDeletionCanRestoreOriginalBytes() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
let data = Data([0xff, 0xfe, 0x61, 0x00])
|
||||
try data.write(to: source)
|
||||
let service = LocalFileService(directory: root)
|
||||
let staged = try #require(try await service.stageRemoval(source))
|
||||
#expect(!FileManager.default.fileExists(atPath: source.path))
|
||||
#expect(try await service.list().isEmpty)
|
||||
try await service.restore(staged)
|
||||
#expect(try Data(contentsOf: source) == data)
|
||||
let final = try #require(try await service.stageRemoval(source))
|
||||
try await service.finishRemoval(final)
|
||||
#expect(!FileManager.default.fileExists(atPath: final.staged.path))
|
||||
}
|
||||
|
||||
@Test func originalExportPreservesEncodingAndNeverMovesSource() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let source = root.appendingPathComponent("书.txt")
|
||||
let data = Data([0xff, 0xfe, 0x61, 0x00])
|
||||
try data.write(to: source)
|
||||
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"))
|
||||
let result = try await exporter.originalFile(source, filename: "../书.txt")
|
||||
#expect(try Data(contentsOf: result.url) == data)
|
||||
#expect(try Data(contentsOf: source) == data)
|
||||
#expect(result.url.lastPathComponent == ".._书.txt")
|
||||
try await exporter.discard(result)
|
||||
#expect(!FileManager.default.fileExists(atPath: result.url.path))
|
||||
#expect(FileManager.default.fileExists(atPath: source.path))
|
||||
}
|
||||
|
||||
@Test func cachedExportUsesDirectoryOrderAndMarksMissingChapters() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let cache = ChapterContentCache(directory: root.appendingPathComponent("cache"))
|
||||
let book = Book(bookUrl: "book", name: "书", author: "作者")
|
||||
let chapters = (0..<3).map { BookChapter(url: "c\($0)", title: "第\($0 + 1)章", bookUrl: "book", index: $0) }
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "c2", content: "第三章正文", supplementalContent: "补充"))
|
||||
try await cache.save(.init(bookURL: "book", chapterURL: "c0", content: "第一章正文"))
|
||||
let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"), cache: cache)
|
||||
let result = try await exporter.cachedBook(book, chapters: chapters.reversed())
|
||||
let text = try String(contentsOf: result.url, encoding: .utf8)
|
||||
#expect(result.cachedChapters == 2)
|
||||
#expect(result.totalChapters == 3)
|
||||
#expect(result.isPartial)
|
||||
#expect(text.contains("第2章\n\n【本章尚未缓存】"))
|
||||
#expect(text.contains("第三章正文\n补充"))
|
||||
#expect(try #require(text.range(of: "第一章正文")).lowerBound < #require(text.range(of: "第三章正文")).lowerBound)
|
||||
}
|
||||
|
||||
@Test func emptyCacheCannotProduceMisleadingEmptyBook() async throws {
|
||||
let root = try root()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let exports = root.appendingPathComponent("exports")
|
||||
let exporter = BookFileExporter(directory: exports, cache: ChapterContentCache(directory: root.appendingPathComponent("cache")))
|
||||
let book = Book(bookUrl: "book", name: "书")
|
||||
let chapter = BookChapter(url: "c", title: "第一章", bookUrl: "book", index: 0)
|
||||
await #expect(throws: LocalFileError.emptyCache) { try await exporter.cachedBook(book, chapters: [chapter]) }
|
||||
#expect(try FileManager.default.contentsOfDirectory(atPath: exports.path).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct BookFlowMockTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let body = try #require(pages[url.absoluteString], "缺少 mock 页面:\(url.absoluteString)")
|
||||
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkBookServiceTests {
|
||||
@Test func loadsDetailPaginatedTocAndPaginatedContent() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://reader.example.test/book/1": """
|
||||
<main class="book"><h1>迁移实战</h1><a class="author">作者甲</a>
|
||||
<img class="cover" src="/cover.jpg"><p class="intro">一本测试书</p>
|
||||
<a class="toc" href="/book/1/toc/1">目录</a></main>
|
||||
""",
|
||||
"https://reader.example.test/book/1/toc/1": """
|
||||
<ul><li class="chapter"><a href="/chapter/1">第一章</a></li></ul>
|
||||
<a class="next" href="/book/1/toc/2">下一页</a>
|
||||
""",
|
||||
"https://reader.example.test/book/1/toc/2": """
|
||||
<ul><li class="chapter"><a href="/chapter/2">第二章</a></li></ul>
|
||||
""",
|
||||
"https://reader.example.test/chapter/1": """
|
||||
<h1>第一章 新标题</h1><article><p>第一段</p></article>
|
||||
<a class="next" href="/chapter/1?page=2">下一页</a>
|
||||
""",
|
||||
"https://reader.example.test/chapter/1?page=2": """
|
||||
<article><p>第二段</p></article>
|
||||
"""
|
||||
])
|
||||
var source = BookSource(
|
||||
bookSourceUrl: "https://reader.example.test",
|
||||
bookSourceName: "完整链路源"
|
||||
)
|
||||
var info = BookInfoRule()
|
||||
info.`init` = "@CSS:main.book"
|
||||
info.name = "@CSS:h1@text"
|
||||
info.author = "@CSS:.author@text"
|
||||
info.intro = "@CSS:.intro@text"
|
||||
info.coverUrl = "@CSS:img.cover@src"
|
||||
info.tocUrl = "@CSS:a.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
toc.nextTocUrl = "@CSS:a.next@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article p@text"
|
||||
content.title = "@CSS:h1@text"
|
||||
content.nextContentUrl = "@CSS:a.next@href"
|
||||
source.ruleContent = content
|
||||
let searchBook = SearchBook(
|
||||
bookURL: "https://reader.example.test/book/1",
|
||||
origin: source.bookSourceUrl,
|
||||
originName: source.bookSourceName,
|
||||
type: 0,
|
||||
name: "搜索结果名"
|
||||
)
|
||||
let service = NetworkBookService(transport: transport)
|
||||
|
||||
let detail = await service.loadDetail(searchBook: searchBook, source: source)
|
||||
#expect(detail.book.name == "迁移实战")
|
||||
#expect(detail.book.author == "作者甲")
|
||||
#expect(detail.book.coverUrl == "https://reader.example.test/cover.jpg")
|
||||
#expect(detail.book.tocUrl == "https://reader.example.test/book/1/toc/1")
|
||||
#expect(detail.diagnostics.isEmpty)
|
||||
|
||||
let tocReport = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(tocReport.chapters.map(\.title) == ["第一章", "第二章"])
|
||||
#expect(tocReport.chapters.map(\.index) == [0, 1])
|
||||
#expect(tocReport.pagesVisited == 2)
|
||||
#expect(tocReport.diagnostics.isEmpty)
|
||||
|
||||
let chapter = try #require(tocReport.chapters.first)
|
||||
let contentReport = await service.loadContent(
|
||||
book: detail.book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: tocReport.chapters[1].url
|
||||
)
|
||||
#expect(contentReport.title == "第一章 新标题")
|
||||
#expect(contentReport.content == "第一段\n第二段")
|
||||
#expect(contentReport.pagesVisited == 2)
|
||||
#expect(contentReport.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func returnsDiagnosticsForEmptyContentAndStopsTocLoops() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://reader.example.test/toc": """
|
||||
<li class="chapter"><a href="/chapter/empty">空章</a></li>
|
||||
<a class="next" href="/toc">循环</a>
|
||||
""",
|
||||
"https://reader.example.test/chapter/empty": "<article></article>"
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://reader.example.test", bookSourceName: "诊断源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "@CSS:li.chapter"
|
||||
toc.chapterName = "@CSS:a@text"
|
||||
toc.chapterUrl = "@CSS:a@href"
|
||||
toc.nextTocUrl = "@CSS:a.next@href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "@CSS:article@text"
|
||||
source.ruleContent = content
|
||||
var book = Book(
|
||||
bookUrl: "https://reader.example.test/book/empty",
|
||||
name: "空书",
|
||||
origin: source.bookSourceUrl
|
||||
)
|
||||
book.tocUrl = "https://reader.example.test/toc"
|
||||
let service = NetworkBookService(transport: transport)
|
||||
|
||||
let tocReport = await service.loadChapters(book: book, source: source)
|
||||
#expect(tocReport.pagesVisited == 1)
|
||||
#expect(tocReport.chapters.count == 1)
|
||||
let chapter = try #require(tocReport.chapters.first)
|
||||
let contentReport = await service.loadContent(book: book, chapter: chapter, source: source)
|
||||
|
||||
#expect(contentReport.content.isEmpty)
|
||||
#expect(contentReport.diagnostics.contains { $0.code == "content.empty" })
|
||||
}
|
||||
|
||||
@Test func carriesBookVariablesIntoJavaScriptChapterRules() async throws {
|
||||
let transport = BookFlowMockTransport(pages: [
|
||||
"https://audio.example.test/toc": #"{"content":{"content":[{"id":2002,"chapterTitle":"第一集"}]}}"#
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "变量源")
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "$.content.content"
|
||||
toc.chapterName = "$.chapterTitle"
|
||||
toc.chapterUrl = "$.id@js:java.put('chapterId', result); '/audio?bookId=' + java.get('bookId') + '&chapterId=' + result"
|
||||
source.ruleToc = toc
|
||||
var book = Book(
|
||||
bookUrl: "https://audio.example.test/book/1001",
|
||||
name: "听书",
|
||||
origin: source.bookSourceUrl
|
||||
)
|
||||
book.tocUrl = "https://audio.example.test/toc"
|
||||
book.variable = #"{"bookId":"1001"}"#
|
||||
|
||||
let report = await NetworkBookService(transport: transport).loadChapters(book: book, source: source)
|
||||
|
||||
let chapter = try #require(report.chapters.first)
|
||||
#expect(chapter.url == "https://audio.example.test/audio?bookId=1001&chapterId=2002")
|
||||
let variables = try JSONDecoder().decode(
|
||||
[String: String].self,
|
||||
from: Data(try #require(chapter.variable).utf8)
|
||||
)
|
||||
#expect(variables["bookId"] == "1001")
|
||||
#expect(variables["chapterId"] == "2002")
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
@testable import LegadoCore
|
||||
|
||||
struct QRCodeDecoderTests {
|
||||
@Test func decodesActualPNGQRCodeAndResolvesImportPayload() throws {
|
||||
let payload = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json"
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(payload.utf8)
|
||||
let output = try #require(filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)))
|
||||
let image = try #require(CIContext().createCGImage(output, from: output.extent))
|
||||
let data = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil))
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
#expect(CGImageDestinationFinalize(destination))
|
||||
let values = try QRCodeDecoder().detect(in: data as Data)
|
||||
#expect(values == [payload])
|
||||
#expect(try RuleImportInput.parse(values[0]) == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: false))
|
||||
}
|
||||
|
||||
@Test func invalidImageReportsDecodeFailure() {
|
||||
#expect(throws: (any Error).self) { try QRCodeDecoder().detect(in: Data("invalid image".utf8)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ReadingActivityRepositoryTests {
|
||||
@Test func decodesAndEncodesBookmarkPosition() throws {
|
||||
let data = Data(#"{"time":7,"bookName":"书","bookAuthor":"作者","chapterIndex":2,"chapterPos":88,"chapterName":"第二章","bookText":"片段","content":"备注"}"#.utf8)
|
||||
let bookmark = try JSONDecoder().decode(Bookmark.self, from: data)
|
||||
let encoded = try JSONEncoder().encode(bookmark)
|
||||
let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
|
||||
|
||||
#expect(bookmark.chapterPosition == 88)
|
||||
#expect(object["chapterPos"] as? Int == 88)
|
||||
#expect(object["chapterPosition"] == nil)
|
||||
}
|
||||
|
||||
@Test func accumulatesPerDeviceAndSummarizesAcrossDevices() {
|
||||
let service = ReadingActivityService()
|
||||
let now: Int64 = 2_000
|
||||
var records = [ReadRecord(deviceId: "a", bookName: "书", readTime: 1_000, lastRead: 1_000)]
|
||||
records = service.addingReadTime(500, deviceID: "a", bookName: "书", now: now, to: records)
|
||||
records = service.addingReadTime(700, deviceID: "b", bookName: "书", now: now + 1, to: records)
|
||||
|
||||
let summary = service.summaries(records)
|
||||
#expect(records.count == 2)
|
||||
#expect(summary == [.init(bookName: "书", readTime: 2_200, lastRead: 2_001)])
|
||||
}
|
||||
|
||||
@Test func decodesLegacyReadRecordWithoutDailyStatistics() throws {
|
||||
let data = Data(#"{"deviceId":"ios","bookName":"旧书","readTime":120000,"lastRead":7}"#.utf8)
|
||||
let record = try JSONDecoder().decode(ReadRecord.self, from: data)
|
||||
|
||||
#expect(record.dailyReadTimes.isEmpty)
|
||||
#expect(record.readTime == 120_000)
|
||||
}
|
||||
|
||||
@Test func calculatesTodayStreakTotalsAndUniqueBooks() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 12)))
|
||||
let service = ReadingActivityService()
|
||||
let records = [
|
||||
ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "第一本",
|
||||
readTime: 9_000_000,
|
||||
lastRead: Int64(now.timeIntervalSince1970 * 1_000),
|
||||
dailyReadTimes: ["2026-08-18": 600_000, "2026-08-19": 1_200_000, "2026-08-20": 1_800_000]
|
||||
),
|
||||
ReadRecord(
|
||||
deviceId: "b",
|
||||
bookName: "第一本",
|
||||
readTime: 3_000_000,
|
||||
dailyReadTimes: ["2026-08-20": 300_000]
|
||||
),
|
||||
ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "第二本",
|
||||
readTime: 4_000_000,
|
||||
dailyReadTimes: ["2026-08-17": 60_000]
|
||||
)
|
||||
]
|
||||
|
||||
let statistics = service.statistics(records, now: now, calendar: calendar)
|
||||
|
||||
#expect(statistics.todayReadTime == 2_100_000)
|
||||
#expect(statistics.currentStreak == 4)
|
||||
#expect(statistics.totalReadTime == 16_000_000)
|
||||
#expect(statistics.bookCount == 2)
|
||||
#expect(statistics.recentDays.count == 7)
|
||||
}
|
||||
|
||||
@Test func keepsCurrentStreakUntilTheDayAfterLastReading() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 8)))
|
||||
let records = [ReadRecord(
|
||||
deviceId: "a",
|
||||
bookName: "书",
|
||||
readTime: 120_000,
|
||||
dailyReadTimes: ["2026-08-18": 60_000, "2026-08-19": 60_000]
|
||||
)]
|
||||
|
||||
let statistics = ReadingActivityService().statistics(records, now: now, calendar: calendar)
|
||||
|
||||
#expect(statistics.todayReadTime == 0)
|
||||
#expect(statistics.currentStreak == 2)
|
||||
}
|
||||
|
||||
@Test func filtersAndSortsReadRecords() {
|
||||
let records = [
|
||||
ReadRecord(deviceId: "a", bookName: "Beta", readTime: 5_000, lastRead: 20),
|
||||
ReadRecord(deviceId: "a", bookName: "Alpha", readTime: 1_000, lastRead: 30),
|
||||
ReadRecord(deviceId: "b", bookName: "Beta", readTime: 2_000, lastRead: 40)
|
||||
]
|
||||
let service = ReadingActivityService()
|
||||
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 0).map(\.bookName) == ["Alpha", "Beta"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 1).map(\.bookName) == ["Beta", "Alpha"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "", sortMode: 2).map(\.bookName) == ["Beta", "Alpha"])
|
||||
#expect(service.displayedSummaries(records, searchKey: "alp", sortMode: 0).map(\.bookName) == ["Alpha"])
|
||||
}
|
||||
|
||||
@Test func sortsBookmarksByBookAndPosition() {
|
||||
var later = Bookmark(time: 1, bookName: "B", bookAuthor: "作者")
|
||||
later.chapterIndex = 1
|
||||
later.chapterPosition = 20
|
||||
var earlierPosition = Bookmark(time: 2, bookName: "A", bookAuthor: "作者")
|
||||
earlierPosition.chapterIndex = 2
|
||||
earlierPosition.chapterPosition = 30
|
||||
var first = Bookmark(time: 3, bookName: "A", bookAuthor: "作者")
|
||||
first.chapterIndex = 1
|
||||
first.chapterPosition = 40
|
||||
|
||||
let sorted = ReadingActivityService().sortedBookmarks([later, earlierPosition, first])
|
||||
|
||||
#expect(sorted.map(\.time) == [first.time, earlierPosition.time, later.time])
|
||||
}
|
||||
|
||||
@Test func persistsBookmarksAndRecordsAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileReadingActivityRepository(
|
||||
bookmarksURL: directory.appending(path: "bookmarks.json"),
|
||||
recordsURL: directory.appending(path: "records.json")
|
||||
)
|
||||
var bookmark = Bookmark(time: 4, bookName: "书")
|
||||
bookmark.chapterPosition = 19
|
||||
let record = ReadRecord(deviceId: "ios", bookName: "书", readTime: 3_000, lastRead: 5)
|
||||
|
||||
try await repository.saveBookmarks([bookmark])
|
||||
try await repository.saveReadRecords([record])
|
||||
|
||||
#expect(try await repository.loadBookmarks() == [bookmark])
|
||||
#expect(try await repository.loadReadRecords() == [record])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct ReplaceRuleRepositoryTests {
|
||||
@Test func decodesOrderFieldAndDefaults() throws {
|
||||
let data = Data(#"{"id":9,"name":"广告","pattern":"ad","replacement":"","order":12}"#.utf8)
|
||||
let rule = try #require(ReplaceRuleImporter().decode(data).first)
|
||||
|
||||
#expect(rule.id == 9)
|
||||
#expect(rule.sortOrder == 12)
|
||||
#expect(rule.scopeContent)
|
||||
#expect(rule.isEnabled)
|
||||
#expect(rule.isRegex)
|
||||
#expect(rule.timeoutMillisecond == 3_000)
|
||||
}
|
||||
|
||||
@Test func mergesByIdentifierAndSorts() {
|
||||
var old = ReplaceRule(id: 1, name: "旧", pattern: "old")
|
||||
old.sortOrder = 10
|
||||
var replacement = ReplaceRule(id: 1, name: "新", pattern: "new")
|
||||
replacement.sortOrder = 3
|
||||
var first = ReplaceRule(id: 2, name: "先", pattern: "first")
|
||||
first.sortOrder = 1
|
||||
|
||||
let merged = ReplaceRuleImporter().merge([replacement, first], into: [old])
|
||||
|
||||
#expect(merged.map(\.id) == [2, 1])
|
||||
#expect(merged.last?.name == "新")
|
||||
}
|
||||
|
||||
@Test func persistsRulesAtomically() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let repository = FileReplaceRuleRepository(fileURL: directory.appending(path: "rules.json"))
|
||||
let rule = ReplaceRule(id: 3, name: "空行", pattern: "\\n{3,}", replacement: "\n\n")
|
||||
|
||||
try await repository.save([rule])
|
||||
let restored = try await repository.load()
|
||||
|
||||
#expect(restored == [rule])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleEditingCompatibilityTests {
|
||||
@Test func unknownFieldsSurviveImportFieldEditingAndRepositoryRoundTrip() async throws {
|
||||
let input = #"{"bookSourceUrl":"https://roundtrip.test","bookSourceName":"扩展源","futureOption":{"token":"keep"},"ruleSearch":"{\"bookList\":\"$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\",\"futureRule\":{\"x\":true}}"}"#
|
||||
let original = try #require(BookSourceImporter().decode(Data(input.utf8)).first)
|
||||
var document = try BookSourceEditorDocument(source: original)
|
||||
document.set(.string("已编辑"), at: "bookSourceName")
|
||||
document.set(.string("$.title"), at: "ruleSearch.name")
|
||||
let edited = try document.source()
|
||||
let repository = InMemoryBookSourceRepository()
|
||||
try await repository.save([edited])
|
||||
let restored = try #require(await repository.load().first)
|
||||
let json = try JSONValue.decode(data: JSONEncoder().encode(restored))
|
||||
#expect(json["futureOption"]?["token"] == .string("keep"))
|
||||
#expect(json["ruleSearch"]?["futureRule"]?["x"] == .bool(true))
|
||||
#expect(restored.ruleSearch?.name == "$.title")
|
||||
#expect(restored.bookSourceName == "已编辑")
|
||||
#expect(json["additionalFields"] == nil)
|
||||
}
|
||||
|
||||
@Test func clearingKnownRuleDoesNotRestoreOldValueFromPreservedFields() throws {
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"书源","ruleContent":{"content":"article@text","future":42}}"#.utf8))
|
||||
var document = try BookSourceEditorDocument(source: source)
|
||||
document.set(nil, at: "ruleContent.content")
|
||||
let json = try JSONValue.decode(data: JSONEncoder().encode(document.source()))
|
||||
#expect(json["ruleContent"]?["content"] == nil)
|
||||
#expect(json["ruleContent"]?["future"] == .number(42))
|
||||
}
|
||||
|
||||
@Test func emptyRuleBlocksAndExplicitCookieNullRoundTrip() throws {
|
||||
let original = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"空规则","ruleExplore":{},"enabledCookieJar":null}"#.utf8))
|
||||
let restored = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(original))
|
||||
#expect(restored.ruleExplore != nil)
|
||||
#expect(restored.enabledCookieJar == nil)
|
||||
#expect(restored == original)
|
||||
}
|
||||
|
||||
@Test func partialInspectionReportsMalformedEntriesAndKeepsValidSiblings() throws {
|
||||
let input = #"[{"bookSourceUrl":"https://good","bookSourceName":"有效"},{"bookSourceUrl":"https://bad","bookSourceName":"错误","ruleSearch":{"name":12}},17,{"bookSourceName":"缺少地址"}]"#
|
||||
let inspected = try BookSourceImporter().inspect(Data(input.utf8))
|
||||
#expect(inspected.sources.map(\.bookSourceName) == ["有效"])
|
||||
#expect(inspected.rejected.count == 3)
|
||||
#expect(throws: BookSourceImportError.self) { try BookSourceImporter().decode(Data(input.utf8)) }
|
||||
}
|
||||
|
||||
@Test func editorRetainsRawInvalidJSONAndRejectsIncompleteSourceAtSave() throws {
|
||||
#expect(throws: (any Error).self) { try BookSourceEditorDocument(json: "{") }
|
||||
#expect(throws: BookSourceImportError.self) { try BookSourceEditorDocument(json: "[]") }
|
||||
let draft = try BookSourceEditorDocument(json: #"{"bookSourceName":"未完成"}"#)
|
||||
#expect(throws: BookSourceImportError.self) { try draft.source() }
|
||||
}
|
||||
|
||||
@Test func importInputAcceptsQRJSONDeepLinksAndWithoutUA() throws {
|
||||
let deep = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json%23requestWithoutUA"
|
||||
let parsed = try RuleImportInput.parse(deep)
|
||||
#expect(parsed == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: true))
|
||||
#expect(parsed.request()?.value(forHTTPHeaderField: "User-Agent") == "null")
|
||||
#expect(try RuleImportInput.parse("[合集](https://example.test/source.json)") == .remote(URL(string: "https://example.test/source.json")!, withoutUserAgent: false))
|
||||
#expect(try RuleImportInput.parse(#"{"bookSourceName":"测试"}"#) == .json(#"{"bookSourceName":"测试"}"#))
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("legado://import/replaceRule?src=https://example.test/rules") }
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("file:///etc/passwd") }
|
||||
#expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("https://") }
|
||||
}
|
||||
|
||||
@Test func purificationTestMatchesReaderScopesAndCaptures() throws {
|
||||
var rule = ReplaceRule(id: 1, name: "替换", pattern: "第([0-9]+)章", replacement: "Chapter $1")
|
||||
rule.scopeTitle = true
|
||||
rule.scopeContent = true
|
||||
rule.scope = "示例书;https://test"
|
||||
let test = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test")
|
||||
#expect(test.report.title == "Chapter 12")
|
||||
#expect(test.report.content == "Chapter 34 正文")
|
||||
#expect(test.report.effectiveRuleIDs == [1])
|
||||
rule.excludeScope = "示例书"
|
||||
let excluded = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test")
|
||||
#expect(excluded.report.title == "第12章")
|
||||
#expect(excluded.report.effectiveRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test func purificationLiteralReplacementKeepsDollarSignsAndWhitespace() throws {
|
||||
var rule = ReplaceRule(id: 2, name: "字面", pattern: "[广告]", replacement: "$1")
|
||||
rule.isRegex = false
|
||||
let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: " 正文\n[广告]\n", bookName: "", sourceURL: "")
|
||||
#expect(result.report.content == " 正文\n$1\n")
|
||||
}
|
||||
|
||||
@Test func invalidPurificationRegexReportsErrorInsteadOfLiteralFallback() throws {
|
||||
let rule = ReplaceRule(id: 3, name: "损坏", pattern: "[")
|
||||
#expect(throws: ReplaceRuleImportError.self) { try ReplaceRuleValidator.validate(rule) }
|
||||
let book = Book(bookUrl: "test", name: "书", origin: "test")
|
||||
let chapter = BookChapter(url: "chapter", title: "标题", bookUrl: "test", index: 0)
|
||||
let report = ContentSanitizer().process(book: book, chapter: chapter, content: "[原文", rules: [rule])
|
||||
#expect(report.content == "[原文")
|
||||
#expect(report.diagnostics.first?.code == "rule.malformed")
|
||||
}
|
||||
|
||||
@Test func regexDeadlineInterruptsBacktrackingAndDoesNotReturnPartialReplacement() {
|
||||
let input = String(repeating: "a", count: 100) + "!"
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try RegexEvaluator().replacingStrict(pattern: "(a|aa)+$", in: input, with: "changed", timeoutMilliseconds: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func purificationResourceLimitsPreserveInputAndReportDiagnostic() throws {
|
||||
let rule = ReplaceRule(id: 3, name: "嵌套", pattern: "(a+)+$", replacement: "changed")
|
||||
let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: "aaaa!", bookName: "书", sourceURL: "test")
|
||||
#expect(result.report.content == "aaaa!")
|
||||
#expect(result.report.diagnostics.first?.code == "rule.resource_limit")
|
||||
let regex = RegexEvaluator(limits: .init(maximumMatches: 2))
|
||||
#expect(throws: RuleEngineError.self) { try regex.replacingStrict(pattern: "a", in: "aaa", with: "b") }
|
||||
#expect(try regex.replacingStrict(pattern: "a", in: "aa", with: "b") == "bb")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleEngineTests {
|
||||
@Test func scalarResultMatchesLegadoJoinAndLeadingURLSemantics() {
|
||||
let result = RuleEvaluation(values: [.string(""), .string("乙"), .string("丙")])
|
||||
#expect(result.joinedString == "\n乙\n丙")
|
||||
#expect(result.leadingString == nil)
|
||||
#expect(result.firstString == "乙")
|
||||
}
|
||||
|
||||
private let engine = RuleEngine()
|
||||
|
||||
@Test func javaScriptStagesContinueIntoSelectorsAndReplacementOnlyRules() async {
|
||||
let result = await engine.evaluate(
|
||||
"<js>result.replace('旧', '新')</js>class.body@text<js>result + ' 广告'</js>## 广告",
|
||||
inText: "<div class='body'>旧正文</div>"
|
||||
)
|
||||
#expect(result.strings == ["新正文"])
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
let replaced = await engine.evaluate("##广告##", inText: "正文广告")
|
||||
#expect(replaced.strings == ["正文"])
|
||||
#expect(replaced.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func listJavaScriptReceivesWholeListAndScalarJavaScriptReceivesJoinedText() async {
|
||||
let list = await engine.evaluate(
|
||||
"tag.a<js>result.reverse()</js>",
|
||||
inText: "<a>A</a><a>B</a>", listMode: true
|
||||
)
|
||||
#expect(list.diagnostics.isEmpty)
|
||||
#expect(list.values.first?[0]?.stringValue.contains(">B<") == true)
|
||||
let scalar = await engine.evaluate("tag.p@text@js:result.split('\\n').reverse().join('|')", inText: "<p>A</p><p>B</p>")
|
||||
#expect(scalar.strings == ["B|A"])
|
||||
}
|
||||
|
||||
@Test func javaScriptCanReadOriginalContentAfterExtractionAndReturnJSON() async {
|
||||
let result = await engine.evaluate(
|
||||
"tag.h1@text@js:result + ':' + java.getString('tag.p@text')",
|
||||
inText: "<h1>书名</h1><p>作者</p>"
|
||||
)
|
||||
#expect(result.strings == ["书名:作者"])
|
||||
let json = await engine.evaluate("<js>JSON.parse(result.slice(4))</js>$.data.name", inText: "data{\"data\":{\"name\":\"书名\"}}")
|
||||
#expect(json.strings == ["书名"])
|
||||
#expect(json.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func standaloneDynamicRulesReturnValuesAndJavaScriptIsNotParsedAsRuleSyntax() async {
|
||||
let variable = await engine.evaluate("@get:{token}", inText: "<p/>", context: .init(variables: ["token": "abc"]))
|
||||
#expect(variable.strings == ["abc"])
|
||||
let inline = await engine.evaluate("{{'https://example.test/' + 42}}", inText: "<p/>")
|
||||
#expect(inline.strings == ["https://example.test/42"])
|
||||
let script = await engine.evaluate("@js:result.replace('##', '')", inText: "正文##")
|
||||
#expect(script.strings == ["正文"])
|
||||
let nested = await engine.evaluate("{{@CSS:h1@text}}-{{//p/text()}}", inText: "<h1>书名</h1><p>作者</p>")
|
||||
#expect(nested.strings == ["书名-作者"])
|
||||
let jsonString = await engine.evaluate("<js>result</js>{{$.name}}", inText: #"{"name":"书名"}"#)
|
||||
#expect(jsonString.strings == ["书名"])
|
||||
let captures = await engine.evaluate("$1##(甲)##$1乙", in: .array([.string("甲"), .string("甲")]))
|
||||
#expect(captures.strings == ["甲乙"])
|
||||
}
|
||||
|
||||
@Test func importedQQBookURLsAndNumericCoverIDsUseStringPipelineSemantics() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"bid":1009704712}"#.utf8))
|
||||
let url = await engine.evaluate(
|
||||
"$.bid\n<js>1100000000+parseInt(result)</js>\nhttps://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid={{result}}",
|
||||
in: input
|
||||
)
|
||||
#expect(url.strings == ["https://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid=2109704712"])
|
||||
#expect(url.diagnostics.isEmpty)
|
||||
let cover = await engine.evaluate("$.bid@js:result.slice(-3)", in: input)
|
||||
#expect(cover.strings == ["712"])
|
||||
#expect(cover.diagnostics.isEmpty)
|
||||
let literal = await engine.evaluate("https://example.test/@get:{id}", in: input, context: .init(variables: ["id": "42"]))
|
||||
#expect(literal.strings == ["https://example.test/42"])
|
||||
}
|
||||
|
||||
@Test func combinesFallbackAndConcatenation() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"a":"A","b":"B"}"#.utf8))
|
||||
|
||||
let fallback = await engine.evaluate("$.missing||$.b", in: input)
|
||||
#expect(fallback.strings == ["B"])
|
||||
#expect(fallback.diagnostics.isEmpty)
|
||||
|
||||
let concatenated = await engine.evaluate("$.a&&$.b", in: input)
|
||||
#expect(concatenated.strings == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func appliesRegexReplacement() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"name":"A B"}"#.utf8))
|
||||
|
||||
let result = await engine.evaluate("$.name##\\s+## ", in: input)
|
||||
|
||||
#expect(result.firstString == "A B")
|
||||
}
|
||||
|
||||
@Test func regexCapturesAndRejectsHighRiskPattern() throws {
|
||||
let evaluator = RegexEvaluator()
|
||||
#expect(try evaluator.captureGroups(pattern: #"(\w+):(\d+)"#, input: "a:1 b:2") == [
|
||||
["a:1", "a", "1"], ["b:2", "b", "2"]
|
||||
])
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try evaluator.captureGroups(pattern: "(a+)+", input: String(repeating: "a", count: 100))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func interleaveStopsAtFirstBranchLength() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"a":["A"],"b":["B1","B2"]}"#.utf8))
|
||||
let result = await engine.evaluate("$.a[*]%%$.b[*]", in: input)
|
||||
#expect(result.strings == ["A", "B1"])
|
||||
}
|
||||
|
||||
@Test func replacementUsesFirstMatchAndInvalidRegexFallback() throws {
|
||||
let evaluator = RegexEvaluator()
|
||||
|
||||
#expect(try evaluator.replacing(pattern: #"\d+"#, in: "a12b34", with: "<$0>", firstOnly: true) == "<12>")
|
||||
#expect(try evaluator.replacing(pattern: #"\d+"#, in: "abc", with: "x", firstOnly: true) == "")
|
||||
#expect(try evaluator.replacing(pattern: "[", in: "a[b[", with: "x", firstOnly: false) == "axbx")
|
||||
#expect(try evaluator.replacing(pattern: "[", in: "unchanged", with: "fallback", firstOnly: true) == "fallback")
|
||||
}
|
||||
|
||||
@Test func supportsPutGetAndInlineJavaScriptInRules() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"selector":"field","field":"ok"}"#.utf8))
|
||||
|
||||
let putGet = await engine.evaluate(#"@put:{"selected":"$.selector"}$.@get:{selected}"#, in: input)
|
||||
let inline = await engine.evaluate(#"$.{{java.put('inlineUsed','yes'); 'field'}}"#, in: input)
|
||||
|
||||
#expect(putGet.firstString == "ok")
|
||||
#expect(putGet.variables["selected"] == "field")
|
||||
#expect(inline.firstString == "ok")
|
||||
#expect(inline.variables["inlineUsed"] == "yes")
|
||||
}
|
||||
|
||||
@Test func unquotedPutRulesPreserveBookIDsAndReturnTheSelectedTitle() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"id":42,"title":"书名","selector":"field","field":"正文"}"#.utf8))
|
||||
let result = await engine.evaluate("title@put:{bid:id, selected:$.selector}", in: input)
|
||||
#expect(result.diagnostics.isEmpty)
|
||||
#expect(result.firstString == "书名")
|
||||
#expect(result.variables["bid"] == "42")
|
||||
#expect(result.variables["selected"] == "field")
|
||||
let body = await engine.evaluate("$.@get:{selected}", in: input, context: .init(variables: result.variables))
|
||||
#expect(body.firstString == "正文")
|
||||
let html = await engine.evaluate("h1@text@put:{chapter:a@href}", inText: "<h1>章节</h1><a href='/1'>目录</a>")
|
||||
#expect(html.diagnostics.isEmpty)
|
||||
#expect(html.firstString == "章节")
|
||||
#expect(html.variables["chapter"] == "/1")
|
||||
}
|
||||
|
||||
@Test func dynamicDoubleBracesTreatJSONPathAsNestedRuleInsteadOfJavaScript() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"book_id":"42","category":"科幻"}"#.utf8))
|
||||
|
||||
let value = await engine.evaluate(#"{{$.category}}-{{$.book_id}}"#, in: input)
|
||||
let piped = await engine.evaluate(#"{{$.category}}@js:result + '-已处理'"#, in: input)
|
||||
|
||||
#expect(value.firstString == "科幻-42")
|
||||
#expect(value.diagnostics.isEmpty)
|
||||
#expect(piped.firstString == "科幻-已处理")
|
||||
#expect(piped.diagnostics.isEmpty)
|
||||
}
|
||||
|
||||
@Test func putOnlyRuleKeepsCurrentResultWhileWritingVariable() async throws {
|
||||
let input = try JSONValue.decode(data: Data(#"{"name":"保留"}"#.utf8))
|
||||
|
||||
let result = await engine.evaluate(#"@put:{"saved":"$.name"}"#, in: input)
|
||||
|
||||
#expect(result.values == [input])
|
||||
#expect(result.variables["saved"] == "保留")
|
||||
}
|
||||
|
||||
@Test func allInOneRegexProducesCaptureRowsAndDollarFields() async throws {
|
||||
let rows = await engine.evaluate(
|
||||
#":name=([^;]+);url=([^\n]+)"#,
|
||||
inText: "name=甲;url=/a\nname=乙;url=/b",
|
||||
listMode: true
|
||||
)
|
||||
let first = try #require(rows.values.first)
|
||||
|
||||
let name = await engine.evaluate("$1", in: first)
|
||||
let url = await engine.evaluate("$2", in: first)
|
||||
|
||||
#expect(rows.values.count == 2)
|
||||
#expect(name.firstString == "甲")
|
||||
#expect(url.firstString == "/a")
|
||||
}
|
||||
|
||||
@Test func allInOneRegexSupportsSequentialAndChain() async {
|
||||
let rows = await engine.evaluate(
|
||||
#":<row>.*?</row>&&name=(\w+)"#,
|
||||
inText: "<row>name=first</row><skip>x</skip><row>name=second</row>",
|
||||
listMode: true
|
||||
)
|
||||
|
||||
#expect(rows.values.count == 2)
|
||||
#expect(rows.values[0][1]?.stringValue == "first")
|
||||
#expect(rows.values[1][1]?.stringValue == "second")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct RuleExpressionParserTests {
|
||||
private let parser = RuleExpressionParser()
|
||||
|
||||
@Test func detectsModesAndReplacement() throws {
|
||||
let plan = try parser.parse("@Json:$.book.name##\\s+## ")
|
||||
|
||||
#expect(plan.combination == .single)
|
||||
#expect(plan.branches.count == 1)
|
||||
#expect(plan.branches[0].mode == .jsonPath)
|
||||
#expect(plan.branches[0].expression == "$.book.name")
|
||||
#expect(plan.branches[0].replacePattern == "\\s+")
|
||||
#expect(plan.branches[0].replacement == " ")
|
||||
}
|
||||
|
||||
@Test func doesNotSplitOperatorsInsideJSONPathFilter() throws {
|
||||
let plan = try parser.parse("$.books[?(@.enabled && @.score > 1)]||$.fallback")
|
||||
|
||||
#expect(plan.combination == .fallback)
|
||||
#expect(plan.branches.count == 2)
|
||||
#expect(plan.branches[0].expression.contains("&&"))
|
||||
}
|
||||
|
||||
@Test func rejectsUnbalancedRules() {
|
||||
#expect(throws: RuleEngineError.self) {
|
||||
try parser.parse("$.books[0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct SearchPerformanceTests {
|
||||
@Test @MainActor func complexSourcesCannotOccupyEverySearchSlot() async throws {
|
||||
let webView = DelayedPerformanceWebView()
|
||||
var sources = (0..<8).map { source("web\($0)") }
|
||||
for index in sources.indices {
|
||||
sources[index].searchUrl = #"/search,{"webView":true}"#
|
||||
}
|
||||
let fast = source("fast")
|
||||
sources.append(fast)
|
||||
var outcomes: [SourceSearchOutcome] = []
|
||||
let service = BookSearchService(transport: PerformanceSearchTransport(), webViewExecutor: webView, history: .init())
|
||||
for await value in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 8) {
|
||||
outcomes.append(value)
|
||||
}
|
||||
#expect(outcomes.first?.sourceID == fast.id)
|
||||
#expect(webView.maximumRunning == 4)
|
||||
#expect(outcomes.count == 9)
|
||||
}
|
||||
|
||||
@Test func preferredSourcesRunBeforeUnknownSourcesAndDuplicateIDsRunOnce() async {
|
||||
let transport = PerformanceSearchTransport()
|
||||
let service = BookSearchService(transport: transport, history: .init())
|
||||
let sources = [source("a"), source("b"), source("a")]
|
||||
var results: [String] = []
|
||||
for await outcome in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 1, preferredSourceIDs: [sources[1].id]) {
|
||||
results.append(outcome.sourceID)
|
||||
}
|
||||
#expect(results == [sources[1].id, sources[0].id])
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test func historySurvivesRelaunchAndFailuresRemainEligibleForRecovery() async {
|
||||
let suite = "search.history.\(UUID().uuidString)"
|
||||
defer { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) }
|
||||
let history = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!)
|
||||
let a = source("a"), b = source("b")
|
||||
let now = Date()
|
||||
let book = SearchBook(bookURL: "book", origin: a.id, originName: "A", type: 0, name: "书")
|
||||
await history.record(.init(sourceID: a.id, books: [book], diagnostics: []), now: now)
|
||||
await history.flush()
|
||||
let reloaded = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!)
|
||||
#expect(await reloaded.ordered([b, a], preferred: [], now: now).map(\.id) == [a.id, b.id])
|
||||
await reloaded.record(.init(sourceID: a.id, books: [], diagnostics: [
|
||||
.init(severity: .error, code: "search.request", message: "offline", rule: "")
|
||||
]), now: now)
|
||||
#expect(await reloaded.ordered([a, b], preferred: [], now: now).map(\.id) == [b.id, a.id])
|
||||
#expect(await reloaded.ordered([a, b], preferred: [], now: now.addingTimeInterval(301)).map(\.id) == [a.id, b.id])
|
||||
}
|
||||
|
||||
@Test func multiSourceRequestsHaveShorterTimeoutWithoutChangingSingleSourceAllowance() async {
|
||||
let transport = PerformanceSearchTransport()
|
||||
let service = BookSearchService(transport: transport, history: .init())
|
||||
let a = source("a"), b = source("b")
|
||||
_ = await service.search(keyword: "书", sources: [a, b])
|
||||
let multi = await transport.requests()
|
||||
#expect(multi.allSatisfy { $0.timeoutInterval <= 8 })
|
||||
_ = await service.search(keyword: "书", page: 1, source: a)
|
||||
#expect(await transport.requests().last?.timeoutInterval == 30)
|
||||
}
|
||||
|
||||
@Test func incrementalMergeKeepsRelevanceOriginsAndCanReplaceCachedMetadata() {
|
||||
var accumulator = BookSearchAccumulator()
|
||||
let cached = SearchBook(bookURL: "old", origin: "a", originName: "A", type: 0, name: "书", author: "作者")
|
||||
let fresh = SearchBook(bookURL: "fresh", origin: "b", originName: "B", type: 0, name: "书", author: "作者")
|
||||
accumulator.add([.init(bookURL: "other", origin: "a", originName: "A", type: 0, name: "其他"), cached])
|
||||
accumulator.add([fresh], replacingExisting: true)
|
||||
let results = accumulator.results(keyword: "书")
|
||||
#expect(results.map(\.bookURL) == ["fresh", "other"])
|
||||
#expect(results[0].origins == ["a", "b"])
|
||||
}
|
||||
|
||||
@Test func cacheQueryUsesLiteralKeywordAndDoesNotRequireDecodingSourceCatalog() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let database = try LegadoDatabase(url: directory.appending(path: "cache.sqlite3"))
|
||||
try await database.replaceBookSources([.init(bookSourceUrl: "source", bookSourceName: "源")])
|
||||
let repository = SQLiteSearchBookRepository(database: database)
|
||||
let exact = SearchBook(bookURL: "exact", origin: "source", originName: "源", type: 0, name: "Swift")
|
||||
let literal = SearchBook(bookURL: "literal", origin: "source", originName: "源", type: 0, name: "100% 小说")
|
||||
try await repository.put([exact, literal])
|
||||
#expect(try await repository.cachedMatches(keyword: "%").map(\.bookURL) == ["literal"])
|
||||
#expect(try await repository.cachedMatches(keyword: "swift").map(\.bookURL) == ["exact"])
|
||||
#expect(try await repository.cachedMatches(keyword: "").isEmpty)
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1))) func blockingBodyJavaScriptDoesNotBlockOtherSourcesNetworkPolicy() async throws {
|
||||
let gate = BlockingSearchJavaScriptLoader()
|
||||
defer { gate.release() }
|
||||
let policy = SourceNetworkPolicy(javaScript: .init(networkLoader: gate))
|
||||
var prepared = try LegadoURLBuilder().prepare("https://body.example/search")
|
||||
prepared.bodyJavaScript = "java.ajax('https://gate.example/body')"
|
||||
let blockingPrepared = prepared
|
||||
let transport = PerformanceSearchTransport()
|
||||
let slow = Task {
|
||||
try await policy.execute(blockingPrepared, source: source("body"), transport: transport, timeout: 1)
|
||||
}
|
||||
var started = gate.started.makeAsyncIterator()
|
||||
_ = await started.next()
|
||||
let plain = try await policy.execute(LegadoURLBuilder().prepare("https://plain.example/search"),
|
||||
source: source("plain"), transport: transport, timeout: 1)
|
||||
#expect(plain.statusCode == 200)
|
||||
#expect(!gate.didTimeOut)
|
||||
gate.release()
|
||||
#expect(try await slow.value.decodedText == "released")
|
||||
}
|
||||
|
||||
private func source(_ name: String) -> BookSource {
|
||||
var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name)
|
||||
value.searchUrl = "/search?q={{key}}"
|
||||
value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class DelayedPerformanceWebView: SourceWebViewRequestExecuting {
|
||||
private var running = 0
|
||||
private(set) var maximumRunning = 0
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
running += 1
|
||||
maximumRunning = max(maximumRunning, running)
|
||||
defer { running -= 1 }
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: prepared.url)
|
||||
}
|
||||
}
|
||||
|
||||
private actor PerformanceSearchTransport: HTTPTransport {
|
||||
private var recorded: [URLRequest] = []
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
recorded.append(request)
|
||||
return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8),
|
||||
statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
func requests() -> [URLRequest] { recorded }
|
||||
}
|
||||
|
||||
private final class BlockingSearchJavaScriptLoader: JavaScriptNetworkLoading, @unchecked Sendable {
|
||||
let started: AsyncStream<Void>
|
||||
private let continuation: AsyncStream<Void>.Continuation
|
||||
private let semaphore = DispatchSemaphore(value: 0)
|
||||
private let lock = NSLock()
|
||||
private var timedOut = false
|
||||
var didTimeOut: Bool { lock.withLock { timedOut } }
|
||||
init() {
|
||||
(started, continuation) = AsyncStream.makeStream(of: Void.self)
|
||||
}
|
||||
func load(_ request: URLRequest) throws -> HTTPResponse {
|
||||
continuation.yield(())
|
||||
if semaphore.wait(timeout: .now() + 2) == .timedOut {
|
||||
lock.withLock { timedOut = true }
|
||||
throw URLError(.timedOut)
|
||||
}
|
||||
return .init(data: Data("released".utf8), statusCode: 200, headers: [:], finalURL: request.url!)
|
||||
}
|
||||
func release() { semaphore.signal() }
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
private struct CompatibilityTransport: HTTPTransport {
|
||||
let pages: [String: String]
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
let page = try #require(pages[url.absoluteString], "Unexpected URL: \(url)")
|
||||
if url.path == "/book/42" {
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "X-Token") == "token")
|
||||
#expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "id=42")
|
||||
}
|
||||
return .init(data: Data(page.utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PostPaginationTransport: HTTPTransport {
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
let url = try #require(request.url)
|
||||
#expect(request.httpMethod == "POST")
|
||||
let second = request.httpBody == Data("page=2".utf8)
|
||||
let page = second ? "2" : "1"
|
||||
let next = second ? "" : #"/pages,{"method":"POST","body":"page=2"}"#
|
||||
let data = try JSONSerialization.data(withJSONObject: [
|
||||
"chapters": [["name": "第\(page)章", "url": "/chapter/\(page)"]],
|
||||
"content": "第\(page)段", "next": next
|
||||
])
|
||||
return .init(data: data, statusCode: 200, headers: [:], finalURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceCompatibilityTests {
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_READABILITY_AUDIT"] != nil))
|
||||
func liveSearchResultReadability() async throws {
|
||||
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"])
|
||||
let imported = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let sources = Dictionary(imported.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }).values
|
||||
.filter { $0.enabled && $0.bookSourceType == 0 }
|
||||
let worker = Task { () -> [SearchBook] in
|
||||
var books: [SearchBook] = []
|
||||
for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: Array(sources)) {
|
||||
guard !Task.isCancelled else { break }
|
||||
books += outcome.books.filter { $0.name == "极道天魔" && $0.author.contains("滚开") }
|
||||
if books.count >= 24 { break }
|
||||
}
|
||||
return books
|
||||
}
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
if !Task.isCancelled { worker.cancel() }
|
||||
}
|
||||
let books = await worker.value
|
||||
deadline.cancel()
|
||||
await withTaskGroup(of: String.self) { group in
|
||||
for book in books {
|
||||
guard let source = sources.first(where: { $0.id == book.origin }) else { continue }
|
||||
group.addTask {
|
||||
let service = NetworkBookService()
|
||||
let detail = await service.loadDetail(searchBook: book, source: source)
|
||||
let toc = await service.loadChapters(book: detail.book, source: source)
|
||||
guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else {
|
||||
return "READABILITY \(source.bookSourceName): no directory; \((detail.diagnostics + toc.diagnostics).map(\.code))"
|
||||
}
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source,
|
||||
nextChapterURL: toc.chapters.dropFirst(chapter.index + 1).first(where: { !$0.isVolume })?.url)
|
||||
let errors = body.diagnostics.filter { $0.severity == .error }.map { "\($0.code): \($0.message)" }
|
||||
return "READABILITY \(source.bookSourceName): \(toc.chapters.count) chapters, \(body.content.count) characters; \(errors)"
|
||||
}
|
||||
}
|
||||
for await result in group { print(result) }
|
||||
}
|
||||
print("READABILITY audited \(books.count) search results")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SEARCH_BENCHMARK"] != nil))
|
||||
func liveMultiSourceSearchLatency() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let path = try #require(environment["LEGADO_SOURCE_CATALOG"])
|
||||
let decoded = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let sources = Dictionary(decoded.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
|
||||
.values.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
|
||||
let started = ContinuousClock.now
|
||||
let worker = Task { () -> [String: Double] in
|
||||
var times: [String: Double] = [:]
|
||||
var completed = 0
|
||||
for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: sources) {
|
||||
guard !Task.isCancelled else { break }
|
||||
completed += 1
|
||||
let elapsed = started.duration(to: .now)
|
||||
let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
|
||||
if !outcome.books.isEmpty, times["firstResultSeconds"] == nil {
|
||||
times["firstResultSeconds"] = seconds
|
||||
print("Search benchmark first result: \(seconds)s, \(outcome.sourceID)")
|
||||
}
|
||||
if outcome.books.contains(where: { $0.name == "极道天魔" && $0.author == "滚开" }) {
|
||||
times["firstExactSeconds"] = seconds
|
||||
print("Search benchmark exact result: \(seconds)s, \(outcome.sourceID)")
|
||||
break
|
||||
}
|
||||
}
|
||||
times["completedSources"] = Double(completed)
|
||||
times["totalSources"] = Double(sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count)
|
||||
return times
|
||||
}
|
||||
let deadline = Task {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
if !Task.isCancelled { worker.cancel() }
|
||||
}
|
||||
let measurements = await worker.value
|
||||
deadline.cancel()
|
||||
print("Search benchmark: \(measurements)")
|
||||
if let output = environment["LEGADO_SEARCH_BENCHMARK_OUTPUT"] {
|
||||
try JSONSerialization.data(withJSONObject: measurements, options: [.prettyPrinted, .sortedKeys])
|
||||
.write(to: URL(fileURLWithPath: output))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func qqArrayContentBecomesParagraphsWithoutOptionalCleanupRule() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "JSON 正文")
|
||||
var rule = ContentRule()
|
||||
rule.content = "<p>{{$.data.Content[0].Content}}</p>"
|
||||
source.ruleContent = rule
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.test/chapter/1": #"{"data":{"Content":[{"Content":["第一段\r\n 第二段","<p>第三段</p><img src='/image.jpg'>"]}]}}"#
|
||||
])
|
||||
let book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id)
|
||||
let chapter = BookChapter(url: "https://fixture.test/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 0)
|
||||
let report = await NetworkBookService(transport: transport).loadContent(book: book, chapter: chapter, source: source)
|
||||
#expect(report.diagnostics.isEmpty)
|
||||
#expect(report.content == "第一段\n第二段\n第三段\n<img src=\"https://fixture.test/image.jpg\">")
|
||||
}
|
||||
|
||||
@Test func paginatedPostRequestsToSameEndpointAreDistinctPages() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "POST 分页")
|
||||
var tocRule = TocRule()
|
||||
tocRule.chapterList = "$.chapters"
|
||||
tocRule.chapterName = "$.name"
|
||||
tocRule.chapterUrl = "$.url"
|
||||
tocRule.nextTocUrl = "$.next"
|
||||
source.ruleToc = tocRule
|
||||
var contentRule = ContentRule()
|
||||
contentRule.content = "$.content"
|
||||
contentRule.nextContentUrl = "$.next"
|
||||
source.ruleContent = contentRule
|
||||
var book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id)
|
||||
book.tocUrl = #"https://fixture.test/pages,{"method":"POST","body":"page=1"}"#
|
||||
let service = NetworkBookService(transport: PostPaginationTransport())
|
||||
let toc = await service.loadChapters(book: book, source: source)
|
||||
#expect(toc.diagnostics.isEmpty)
|
||||
#expect(toc.chapters.map(\.title) == ["第1章", "第2章"])
|
||||
#expect(toc.pagesVisited == 2)
|
||||
let chapter = BookChapter(url: book.tocUrl, title: "第一章", bookUrl: book.bookUrl, index: 0)
|
||||
let content = await service.loadContent(book: book, chapter: chapter, source: source)
|
||||
#expect(content.diagnostics.isEmpty)
|
||||
#expect(content.content == "第1段\n第2段")
|
||||
#expect(content.pagesVisited == 2)
|
||||
}
|
||||
|
||||
@Test func responseHonorsLegacyHTMLCharsetWithoutRequestOverride() throws {
|
||||
let encoding = try #require(HTTPTextDecoder.encoding(named: "gbk"))
|
||||
let html = "<meta http-equiv='Content-Type' content='text/html; charset=gbk'><h1>中文正文</h1>"
|
||||
let data = try #require(html.data(using: encoding))
|
||||
let response = HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: URL(string: "https://fixture.test")!)
|
||||
#expect(response.decodedText == html)
|
||||
}
|
||||
|
||||
@Test func tableSearchCarriesRequestOptionsThroughDetailTocAndContent() async throws {
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.test/search": "<table class='grid'><tr><th>书名</th></tr><tr><td><a href='/42'>测试书</a></td><td>作者</td></tr></table>",
|
||||
"https://fixture.test/book/42": "<h1>测试书详情</h1><a class='toc' href='/toc'>目录</a>",
|
||||
"https://fixture.test/toc": "<div id='list'><a href='/chapter/1'>第一章</a><a href='/chapter/2'>第二章</a></div>",
|
||||
"https://fixture.test/chapter/1": "<div class='body'><p>真正正文</p><p>广告</p></div>"
|
||||
])
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "传统表格源")
|
||||
source.searchUrl = "/search"
|
||||
source.ruleSearch = .init(
|
||||
bookList: "[email protected]!0", name: "tag.td.0@text", author: "tag.td.1@text",
|
||||
bookUrl: #"@js:'/book/42,{"method":"POST","headers":{"X-Token":"token"},"body":"id=42"}'"#,
|
||||
coverUrl: "@js:book.origin + '/cover/' + key + '-' + page + '.jpg'"
|
||||
)
|
||||
var info = BookInfoRule()
|
||||
info.name = "tag.h1@text"
|
||||
info.tocUrl = "class.toc@href"
|
||||
source.ruleBookInfo = info
|
||||
var toc = TocRule()
|
||||
toc.chapterList = "[email protected]"
|
||||
toc.chapterName = "text"
|
||||
toc.chapterUrl = "href"
|
||||
source.ruleToc = toc
|
||||
var content = ContentRule()
|
||||
content.content = "<js>result</js>class.body@html"
|
||||
content.replaceRegex = "##广告##"
|
||||
source.ruleContent = content
|
||||
|
||||
let search = await BookSearchService(transport: transport).search(keyword: "test", page: 2, source: source)
|
||||
#expect(search.diagnostics.isEmpty)
|
||||
let found = try #require(search.books.first)
|
||||
#expect(found.name == "测试书")
|
||||
#expect(found.author == "作者")
|
||||
#expect(found.coverURL == "https://fixture.test/cover/test-2.jpg")
|
||||
#expect(found.bookURL.contains(#",{"method":"POST""#))
|
||||
let service = NetworkBookService(transport: transport)
|
||||
let detail = await service.loadDetail(searchBook: found, source: source)
|
||||
#expect(detail.diagnostics.isEmpty)
|
||||
#expect(detail.book.name == "测试书详情")
|
||||
let chapters = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(chapters.diagnostics.isEmpty)
|
||||
#expect(chapters.chapters.map(\.title) == ["第一章", "第二章"])
|
||||
let chapter = try #require(chapters.chapters.first)
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source)
|
||||
#expect(body.diagnostics.isEmpty)
|
||||
#expect(body.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文")
|
||||
}
|
||||
|
||||
@Test func syntheticHTMLRulesExtractBareTocFieldsAndCleanContent() async throws {
|
||||
let fixture = try #require(Bundle.module.url(forResource: "synthetic-html-rules", withExtension: "json", subdirectory: "Fixtures"))
|
||||
let source = try #require(BookSourceImporter().decode(Data(contentsOf: fixture)).first)
|
||||
let transport = CompatibilityTransport(pages: [
|
||||
"https://fixture.invalid/toc": "<div id='chapter-list'><ul><li><a href='/chapter/2'>第二章</a></li><li><a href='/chapter/1'>第一章 新开端</a></li></ul></div>",
|
||||
"https://fixture.invalid/chapter/1": "<div class='chapter-body'><h1>第一章</h1><p>信息</p><p>第一章 新开端</p><p>真正正文</p><p>(本章完)</p></div>"
|
||||
])
|
||||
var book = Book(bookUrl: "https://fixture.invalid/book/1", name: "测试", origin: source.id)
|
||||
book.tocUrl = "https://fixture.invalid/toc"
|
||||
book.durChapterTitle = "第一章 新开端"
|
||||
let service = NetworkBookService(transport: transport)
|
||||
let toc = await service.loadChapters(book: book, source: source)
|
||||
#expect(toc.diagnostics.isEmpty)
|
||||
#expect(toc.chapters.map(\.title) == ["第一章 新开端", "第二章"])
|
||||
let first = try #require(toc.chapters.first)
|
||||
let content = await service.loadContent(book: book, chapter: first, source: source)
|
||||
#expect(content.diagnostics.isEmpty)
|
||||
#expect(content.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文")
|
||||
}
|
||||
|
||||
@Test func searchUsesJavaScriptObjectsAndKeepsRegexCaptureRows() async throws {
|
||||
let transport = CompatibilityTransport(pages: ["https://fixture.test/search": "name=甲;url=/a\nname=乙;url=/b"])
|
||||
var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "转换源")
|
||||
source.searchUrl = "/search"
|
||||
source.ruleSearch = .init(bookList: #":name=([^;]+);url=([^\n]+)"#, name: "$1", bookUrl: "$2")
|
||||
let regex = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source)
|
||||
#expect(regex.diagnostics.isEmpty)
|
||||
#expect(regex.books.map(\.name) == ["甲", "乙"])
|
||||
#expect(regex.books.map(\.bookURL) == ["https://fixture.test/a", "https://fixture.test/b"])
|
||||
|
||||
source.ruleSearch = .init(bookList: "<js>[{name: '对象结果', url: '/object'}]</js>", name: "name", bookUrl: "url")
|
||||
let objects = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source)
|
||||
#expect(objects.diagnostics.isEmpty)
|
||||
#expect(objects.books.first?.name == "对象结果")
|
||||
#expect(objects.books.first?.bookURL == "https://fixture.test/object")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"] != nil))
|
||||
func inspectsDownloadedCatalog() throws {
|
||||
let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"])
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
let result = try BookSourceImporter().inspect(data)
|
||||
let raw = try JSONDecoder().decode([JSONValue].self, from: data)
|
||||
#expect(result.sources.count + result.rejected.count == raw.count)
|
||||
#expect(!result.sources.isEmpty)
|
||||
print("Catalog import: \(result.sources.count) valid, \(result.rejected.count) rejected of \(raw.count)")
|
||||
for item in result.rejected.prefix(10) {
|
||||
print("Rejected: \(item.source.bookSourceName): \(item.issues.map(\.message).joined(separator: "; "))")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_SOURCE_ID"] != nil))
|
||||
func liveSearchToReading() async throws {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let path = try #require(environment["LEGADO_SOURCE_CATALOG"])
|
||||
let sourceID = try #require(environment["LEGADO_LIVE_SOURCE_ID"])
|
||||
let catalog = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path)))
|
||||
let source = try #require(catalog.sources.last { $0.id == sourceID })
|
||||
let search = await BookSearchService().search(keyword: "极道天魔", page: 1, source: source)
|
||||
for issue in search.diagnostics { print("Live search diagnostic: \(issue.code): \(issue.message)") }
|
||||
print("Live search: \(search.books.count) books")
|
||||
let found = try #require(search.books.first { $0.name == "极道天魔" && $0.author == "滚开" })
|
||||
let service = NetworkBookService()
|
||||
let detail = await service.loadDetail(searchBook: found, source: source)
|
||||
#expect(detail.book.name == "极道天魔")
|
||||
let toc = await service.loadChapters(book: detail.book, source: source)
|
||||
#expect(toc.chapters.count > 2)
|
||||
for chapter in toc.chapters.prefix(2) {
|
||||
let body = await service.loadContent(book: detail.book, chapter: chapter, source: source)
|
||||
for issue in body.diagnostics { print("Live content diagnostic: \(issue.code): \(issue.message)") }
|
||||
print("Live content shape: numeric=\(body.content.prefix(100).allSatisfy { $0.isNumber || $0 == "," || $0 == "-" }), escapedNewlines=\(body.content.contains(#"\n"#)), paragraphs=\(body.content.components(separatedBy: "\n").count)")
|
||||
#expect(body.content.count > 500)
|
||||
#expect(!body.content.contains("<p>"))
|
||||
#expect(!body.content.contains(#"\r\n"#))
|
||||
#expect(body.content.components(separatedBy: "\n").count > 5)
|
||||
#expect(!body.diagnostics.contains { $0.severity == .error })
|
||||
print("Live reading: \(source.bookSourceName), \(chapter.title), \(body.content.count) characters")
|
||||
}
|
||||
for issue in detail.diagnostics + toc.diagnostics {
|
||||
print("Live diagnostic: \(issue.code): \(issue.message)")
|
||||
}
|
||||
print("Live directory: \(toc.chapters.count) chapters")
|
||||
if let replacementID = environment["LEGADO_LIVE_SWITCH_SOURCE_ID"] {
|
||||
let replacementSource = try #require(catalog.sources.last { $0.id == replacementID })
|
||||
var current = detail.book
|
||||
current.totalChapterNum = toc.chapters.count
|
||||
current.durChapterIndex = 1
|
||||
current.durChapterTitle = toc.chapters[1].title
|
||||
current.durChapterPos = 120
|
||||
let switcher = BookSourceSwitchService(chapterRepository: InMemoryBookChapterRepository())
|
||||
let candidates = await switcher.candidates(for: current, sources: [source, replacementSource])
|
||||
let candidate = try #require(candidates.first { $0.book.author == "滚开" })
|
||||
let switched = try #require(await switcher.prepareSwitch(current: current, candidate: candidate.book, source: replacementSource))
|
||||
#expect(switched.book.origin == replacementID)
|
||||
#expect(switched.exactChapterMatch)
|
||||
#expect(switched.book.durChapterPos == 120)
|
||||
let chapter = switched.chapters[switched.book.durChapterIndex]
|
||||
let body = await service.loadContent(book: switched.book, chapter: chapter, source: replacementSource)
|
||||
#expect(body.content.count > 500)
|
||||
#expect(!body.diagnostics.contains { $0.severity == .error })
|
||||
print("Live switched reading: \(replacementSource.bookSourceName), \(chapter.title), \(body.content.count) characters, position \(switched.book.durChapterPos)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import LegadoCore
|
||||
|
||||
struct SourceNetworkPolicyTests {
|
||||
@Test func persistentCookieDomainGroupsSubdomains() {
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.example.com") == "example.com")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.content.example.com") == "example.com")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "www.biquge.com.cn") == "biquge.com.cn")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "1.2.3.4") == "1.2.3.4")
|
||||
#expect(LegadoCookieStore.persistenceDomain(for: "localhost") == "localhost")
|
||||
}
|
||||
|
||||
@Test func parsesConcurrentRateFormats() {
|
||||
let burst = SourceRateLimit(rawValue: "3/1000")
|
||||
let serial = SourceRateLimit(rawValue: "250")
|
||||
#expect(burst?.accessLimit == 3)
|
||||
#expect(burst?.intervalMilliseconds == 1_000)
|
||||
#expect(serial?.accessLimit == 1)
|
||||
#expect(serial?.intervalMilliseconds == 250)
|
||||
#expect(SourceRateLimit(rawValue: "0") == nil)
|
||||
#expect(SourceRateLimit(rawValue: "broken") == nil)
|
||||
}
|
||||
|
||||
@Test func fixedWindowReservesAndReportsWait() {
|
||||
let limit = SourceRateLimit(rawValue: "2/100")!
|
||||
var window = SourceRateWindow()
|
||||
|
||||
#expect(window.reserve(now: 1_000, limit: limit) == 0)
|
||||
#expect(window.reserve(now: 1_010, limit: limit) == 0)
|
||||
#expect(window.reserve(now: 1_020, limit: limit) == 80)
|
||||
#expect(window.reserve(now: 1_100, limit: limit) == 0)
|
||||
}
|
||||
|
||||
@Test func cookiesAreIsolatedBySourceAndExplicitValuesWin() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://same.example/path")!
|
||||
await policy.setCookieHeader("token=A; shared=stored", sourceID: "source-a", url: url)
|
||||
await policy.setCookieHeader("token=B", sourceID: "source-b", url: url)
|
||||
|
||||
#expect(await policy.cookieHeader(sourceID: "source-a", url: url).contains("token=A"))
|
||||
#expect(await policy.cookieHeader(sourceID: "source-b", url: url) == "token=B")
|
||||
|
||||
var source = BookSource(bookSourceUrl: "source-a", bookSourceName: "A")
|
||||
source.enabledCookieJar = true
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://same.example/path, {"headers":{"Cookie":"shared=explicit"}}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(data: Data(), statusCode: 200, headers: [:], finalURL: url))])
|
||||
_ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
let cookie = await transport.requests().first?.value(forHTTPHeaderField: "Cookie") ?? ""
|
||||
#expect(cookie.contains("token=A"))
|
||||
#expect(cookie.contains("shared=explicit"))
|
||||
#expect(!cookie.contains("shared=stored"))
|
||||
}
|
||||
|
||||
@Test func retriesFailedHTTPResponsesConfiguredNumberAndHonorsDisabledCookieJar() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://retry.example/data")!
|
||||
await policy.setCookieHeader("session=secret", sourceID: "source", url: url)
|
||||
var source = BookSource(bookSourceUrl: "source", bookSourceName: "源")
|
||||
source.enabledCookieJar = false
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":2}"#)
|
||||
let response = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
let failed500 = HTTPResponse(data: Data(), statusCode: 500, headers: [:], finalURL: url)
|
||||
let failed502 = HTTPResponse(data: Data(), statusCode: 502, headers: [:], finalURL: url)
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.failure(HTTPClientError.statusResponse(failed500)),
|
||||
.failure(HTTPClientError.statusResponse(failed502)),
|
||||
.success(response)
|
||||
])
|
||||
|
||||
let result = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
|
||||
#expect(String(decoding: result.data, as: UTF8.self) == "ok")
|
||||
#expect(await transport.requests().count == 3)
|
||||
#expect(await transport.requests().allSatisfy { $0.value(forHTTPHeaderField: "Cookie") == nil })
|
||||
}
|
||||
|
||||
@Test func doesNotRetryTransportFailures() async throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":3}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.failure(URLError(.timedOut))])
|
||||
|
||||
await #expect(throws: URLError.self) {
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "retry", bookSourceName: "重试源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
}
|
||||
|
||||
#expect(await transport.requests().count == 1)
|
||||
}
|
||||
|
||||
@Test func capturesCookiesWhileRetryingFailedHTTPResponse() async throws {
|
||||
let policy = SourceNetworkPolicy()
|
||||
let url = URL(string: "https://auth.example/data")!
|
||||
let failed = HTTPResponse(
|
||||
data: Data(),
|
||||
statusCode: 429,
|
||||
headers: ["Set-Cookie": "session=renewed; Path=/; Secure"],
|
||||
finalURL: url
|
||||
)
|
||||
let success = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url)
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.failure(HTTPClientError.statusResponse(failed)),
|
||||
.success(success)
|
||||
])
|
||||
let source = BookSource(bookSourceUrl: "auth-source", bookSourceName: "登录源")
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://auth.example/data, {"retry":1}"#)
|
||||
|
||||
_ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1)
|
||||
|
||||
#expect(await policy.cookieHeader(sourceID: source.id, url: url).contains("session=renewed"))
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
|
||||
@Test func decodesExplicitLegacyCharsetAndHeaderCharset() throws {
|
||||
let gb18030 = try #require(HTTPTextDecoder.encoding(named: "GB18030"))
|
||||
let chinese = "旧编码正文"
|
||||
let legacyData = try #require(chinese.data(using: gb18030))
|
||||
let url = try #require(URL(string: "https://encoding.example"))
|
||||
|
||||
let explicit = HTTPResponse(data: legacyData, statusCode: 200, headers: [:], finalURL: url, requestedCharset: "gbk")
|
||||
let header = HTTPResponse(data: legacyData, statusCode: 200, headers: ["Content-Type": "text/html; charset=GB18030"], finalURL: url)
|
||||
|
||||
#expect(explicit.decodedText == chinese)
|
||||
#expect(header.decodedText == chinese)
|
||||
}
|
||||
|
||||
@Test func removesUnicodeBOMBeforeDecoding() throws {
|
||||
let url = try #require(URL(string: "https://encoding.example"))
|
||||
let text = "目录正文"
|
||||
var data = Data([0xFF, 0xFE])
|
||||
data.append(try #require(text.data(using: .utf16LittleEndian)))
|
||||
|
||||
#expect(HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: url).decodedText == text)
|
||||
}
|
||||
|
||||
@Test func appliesResponseBodyJavaScriptAndCarriesVariables() async throws {
|
||||
let url = try #require(URL(string: "https://script.example/content"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://script.example/content, {"bodyJs":"java.put('bodyChanged', 'yes'); result.replace('旧正文', '新正文')"}"#
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: Data("旧正文".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
))])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "script", bookSourceName: "脚本源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "新正文")
|
||||
#expect(response.requestVariables["bodyChanged"] == "yes")
|
||||
}
|
||||
|
||||
@Test func routesWebViewRequestsThroughExecutorAndStillAppliesBodyJS() async throws {
|
||||
let url = try #require(URL(string: "https://web.example/rendered"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://web.example/page, {"webView":true,"webJs":"document.body.dataset.ready='1'","webViewDelayTime":250,"bodyJs":"result.replace('原始', '渲染')"}"#
|
||||
)
|
||||
let executor = await MainActor.run { RecordingWebViewExecutor(response: .init(
|
||||
data: Data("<html>原始正文</html>".utf8),
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
finalURL: url
|
||||
)) }
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "web", bookSourceName: "网页源"),
|
||||
transport: RecordingPolicyTransport(results: []),
|
||||
timeout: 2,
|
||||
webViewExecutor: executor
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "<html>渲染正文</html>")
|
||||
#expect(await executor.callCount == 1)
|
||||
#expect(await executor.lastPrepared?.webJavaScript?.contains("dataset.ready") == true)
|
||||
#expect(await executor.lastPrepared?.webViewDelayMilliseconds == 250)
|
||||
}
|
||||
|
||||
@Test func recordsRealPreparedRequestAndFinalResponseForDifferentialTesting() async throws {
|
||||
let url = try #require(URL(string: "https://snapshot.example/final"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"https://snapshot.example/search?q={{key}}, {"headers":{"Cookie":"session=secret","X-Test":"yes"},"retry":2}"#,
|
||||
context: .init(key: "测试")
|
||||
)
|
||||
let response = HTTPResponse(
|
||||
data: Data("正文".utf8),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "text/plain; charset=utf-8", "Set-Cookie": "token=private; Path=/"],
|
||||
finalURL: url
|
||||
)
|
||||
let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-001")
|
||||
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "snapshot-source", bookSourceName: "快照源"),
|
||||
transport: RecordingPolicyTransport(results: [.success(response)]),
|
||||
timeout: 12,
|
||||
snapshotRecorder: recorder,
|
||||
snapshotStage: .search
|
||||
)
|
||||
|
||||
let snapshots = await recorder.snapshots
|
||||
let snapshot = try #require(snapshots.first)
|
||||
#expect(snapshot.caseID == "html-001")
|
||||
#expect(snapshot.stage == .search)
|
||||
#expect(snapshot.request.ruleURL.contains("{{key}}"))
|
||||
#expect(snapshot.request.renderedURL.contains("%E6%B5%8B%E8%AF%95"))
|
||||
#expect(snapshot.request.headers["x-test"] == "yes")
|
||||
#expect(snapshot.request.headers["cookie"]?.contains("secret") == false)
|
||||
#expect(snapshot.response.finalURL == url.absoluteString)
|
||||
#expect(snapshot.response.bodySHA256.count == 64)
|
||||
#expect(snapshot.response.setCookies["token"]?.contains("private") == false)
|
||||
}
|
||||
|
||||
@Test func emitsBusinessSnapshotFromContentReport() throws {
|
||||
let report = ChapterContentReport(
|
||||
title: "第一章",
|
||||
content: "正文",
|
||||
supplementalContent: "副正文",
|
||||
diagnostics: [],
|
||||
pagesVisited: 2
|
||||
)
|
||||
let snapshot = DifferentialBusinessSnapshot.content(caseID: "json-001", report: report)
|
||||
let object = try JSONSerialization.jsonObject(with: snapshot.encoded()) as? [String: Any]
|
||||
let result = object?["result"] as? [String: Any]
|
||||
let content = result?["content"] as? [String: Any]
|
||||
|
||||
#expect(object?["platform"] as? String == "ios")
|
||||
#expect(object?["flow"] as? String == "content")
|
||||
#expect(content?["text"] as? String == "正文")
|
||||
#expect(content?["pagesVisited"] as? Int == 2)
|
||||
}
|
||||
|
||||
@Test func typeOptionReturnsLowercaseHex() async throws {
|
||||
let url = try #require(URL(string: "https://binary.example/image"))
|
||||
let prepared = try LegadoURLBuilder().prepare(#"https://binary.example/image, {"type":"image"}"#)
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: Data([0x00, 0x7F, 0xA5, 0xFF]), statusCode: 200, headers: [:], finalURL: url
|
||||
))])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "binary", bookSourceName: "二进制源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "007fa5ff")
|
||||
}
|
||||
|
||||
@Test func readsBase64DataURIWithoutCallingNetworkTransport() async throws {
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
#"data:application/octet-stream;base64,AP+l, {"type":"image"}"#
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: [])
|
||||
|
||||
let response = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "data-uri", bookSourceName: "内嵌数据源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(response.decodedText == "00ffa5")
|
||||
#expect(response.headers["Content-Type"] == "application/octet-stream")
|
||||
#expect(await transport.requests().isEmpty)
|
||||
}
|
||||
|
||||
@Test func sourceProxyHeaderIsRemovedAndRoutedThroughProxyTransport() async throws {
|
||||
let url = try #require(URL(string: "https://proxy.example/data"))
|
||||
let prepared = try LegadoURLBuilder().prepare(
|
||||
"https://proxy.example/data",
|
||||
defaultHeaders: ["proxy": "socks5://127.0.0.1:1080"]
|
||||
)
|
||||
let transport = ProxyRecordingPolicyTransport(response: .init(
|
||||
data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url
|
||||
))
|
||||
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "proxy", bookSourceName: "代理源"),
|
||||
transport: transport,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
#expect(await transport.proxy == "socks5://127.0.0.1:1080")
|
||||
#expect(await transport.request?.value(forHTTPHeaderField: "proxy") == nil)
|
||||
}
|
||||
|
||||
@Test func recordsFinalHTTPFailureAndErrorBodyForDifferentialTesting() async throws {
|
||||
let url = try #require(URL(string: "https://failure.example/data"))
|
||||
let failed = HTTPResponse(
|
||||
data: Data("temporary unavailable".utf8),
|
||||
statusCode: 503,
|
||||
headers: ["Retry-After": "0"],
|
||||
finalURL: url
|
||||
)
|
||||
let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-002")
|
||||
let prepared = try LegadoURLBuilder().prepare("https://failure.example/data")
|
||||
|
||||
await #expect(throws: HTTPClientError.self) {
|
||||
_ = try await SourceNetworkPolicy().execute(
|
||||
prepared,
|
||||
source: .init(bookSourceUrl: "failure", bookSourceName: "失败源"),
|
||||
transport: RecordingPolicyTransport(results: [.failure(HTTPClientError.statusResponse(failed))]),
|
||||
timeout: 1,
|
||||
snapshotRecorder: recorder,
|
||||
snapshotStage: .detail
|
||||
)
|
||||
}
|
||||
|
||||
let snapshots = await recorder.snapshots
|
||||
let snapshot = try #require(snapshots.first)
|
||||
#expect(snapshot.response.statusCode == 503)
|
||||
#expect(snapshot.response.bodySHA256.count == 64)
|
||||
#expect(snapshot.response.error?.code == "http.503")
|
||||
#expect(snapshot.response.error?.legadoCode == -7)
|
||||
}
|
||||
|
||||
@Test func httpTTSRendersSpeakVariablesAndReturnsBinaryAudio() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/audio"))
|
||||
let audio = Data([0x52, 0x49, 0x46, 0x46])
|
||||
let transport = RecordingPolicyTransport(results: [.success(.init(
|
||||
data: audio,
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/wav; charset=binary"],
|
||||
finalURL: url
|
||||
))])
|
||||
var engine = HttpTTS(id: 9, name: "测试", url: #"https://tts.example/audio, {"method":"POST","body":"text={{speakText}}&speed={{speakSpeed}}"}"#)
|
||||
engine.contentType = #"audio/.*"#
|
||||
engine.header = #"{"X-Engine":"legado"}"#
|
||||
engine.loginCheckJs = #"if (result.headers().get('Content-Type').indexOf('audio/') !== 0) throw 'bad content'; result"#
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
let service = HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
)
|
||||
let result = try await service.audio(engine: engine, text: "第一段", speechRate: 7)
|
||||
let cached = try await service.audio(engine: engine, text: "第一段", speechRate: 7)
|
||||
|
||||
#expect(result.data == audio)
|
||||
#expect(cached.data == audio)
|
||||
#expect(result.contentType == "audio/wav")
|
||||
let requests = await transport.requests()
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(String(decoding: request.httpBody ?? Data(), as: UTF8.self).contains("speed=7"))
|
||||
#expect(request.value(forHTTPHeaderField: "X-Engine") == "legado")
|
||||
#expect(requests.count == 1)
|
||||
}
|
||||
|
||||
@Test func httpTTSStreamsChunksAndStoresCompletedResponseInCache() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/stream"))
|
||||
let first = Data(repeating: 0x41, count: 4)
|
||||
let second = Data(repeating: 0x42, count: 3)
|
||||
let transport = RecordingStreamingTransport(url: url, chunks: [first, second])
|
||||
var engine = HttpTTS(id: 12, name: "流式", url: url.absoluteString)
|
||||
engine.contentType = #"audio/.*"#
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
let service = HttpTTSService(networkPolicy: .init(), transport: transport, cache: cache)
|
||||
|
||||
let streamed = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10)
|
||||
#expect(streamed.isIncremental)
|
||||
#expect(streamed.expectedContentLength == 7)
|
||||
var received: [Data] = []
|
||||
for try await chunk in streamed.chunks { received.append(chunk) }
|
||||
#expect(received == [first, second])
|
||||
|
||||
let cached = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10)
|
||||
#expect(!cached.isIncremental)
|
||||
var cachedData = Data()
|
||||
for try await chunk in cached.chunks { cachedData.append(chunk) }
|
||||
#expect(cachedData == first + second)
|
||||
#expect(await transport.requestCount() == 1)
|
||||
}
|
||||
|
||||
@Test func httpTTSDecodesDefaultsWithMissingFieldsAndLoginUIArray() throws {
|
||||
let data = Data(#"{"id":-100,"name":"百度","url":"https://tts.example","loginUi":[{"name":"token","type":"text"}]}"#.utf8)
|
||||
let value = try JSONDecoder().decode(HttpTTS.self, from: data)
|
||||
#expect(value.id == -100)
|
||||
#expect(value.concurrentRate == nil)
|
||||
#expect(value.enabledCookieJar == false)
|
||||
#expect(value.loginUi?.contains("token") == true)
|
||||
#expect(value.lastUpdateTime > 0)
|
||||
}
|
||||
|
||||
@Test func httpTTSRejectsTextErrorResponse() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/error"))
|
||||
let response = HTTPResponse(
|
||||
data: Data("额度不足".utf8),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
finalURL: url
|
||||
)
|
||||
let transport = RecordingPolicyTransport(results: Array(repeating: .success(response), count: 6))
|
||||
let engine = HttpTTS(id: 10, name: "错误", url: url.absoluteString)
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
await #expect(throws: HttpTTSServiceError.self) {
|
||||
_ = try await HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
).audio(engine: engine, text: "测试", speechRate: 5)
|
||||
}
|
||||
#expect(await transport.requests().count == 6)
|
||||
}
|
||||
|
||||
@Test func httpTTSRebuildsRequestWhenLoginCheckInitializesURL() async throws {
|
||||
let url = try #require(URL(string: "https://tts.example/recover"))
|
||||
let transport = RecordingPolicyTransport(results: [
|
||||
.success(.init(
|
||||
data: Data(#"{"status":40000001}"#.utf8),
|
||||
statusCode: 401,
|
||||
headers: ["Content-Type": "application/json"],
|
||||
finalURL: url
|
||||
)),
|
||||
.success(.init(
|
||||
data: Data([0x49, 0x44, 0x33]),
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url
|
||||
))
|
||||
])
|
||||
var engine = HttpTTS(id: 11, name: "恢复", url: url.absoluteString)
|
||||
engine.loginCheckJs = "java.initUrl(); java.getResponse()"
|
||||
engine.contentType = "audio/.*"
|
||||
let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString))
|
||||
|
||||
let audio = try await HttpTTSService(
|
||||
networkPolicy: .init(),
|
||||
transport: transport,
|
||||
cache: cache
|
||||
).audio(engine: engine, text: "测试", speechRate: 5)
|
||||
|
||||
#expect(audio.contentType == "audio/mpeg")
|
||||
#expect(await transport.requests().count == 2)
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingPolicyTransport: HTTPTransport {
|
||||
private var results: [Result<HTTPResponse, any Error>]
|
||||
private var recorded: [URLRequest] = []
|
||||
|
||||
init(results: [Result<HTTPResponse, any Error>]) { self.results = results }
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
recorded.append(request)
|
||||
guard !results.isEmpty else { throw URLError(.badServerResponse) }
|
||||
return try results.removeFirst().get()
|
||||
}
|
||||
|
||||
func requests() -> [URLRequest] { recorded }
|
||||
}
|
||||
|
||||
private actor RecordingStreamingTransport: StreamingHTTPTransport {
|
||||
let url: URL
|
||||
let values: [Data]
|
||||
private var count = 0
|
||||
|
||||
init(url: URL, chunks: [Data]) {
|
||||
self.url = url
|
||||
values = chunks
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse {
|
||||
count += 1
|
||||
return .init(
|
||||
data: values.reduce(into: Data()) { $0.append($1) },
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url
|
||||
)
|
||||
}
|
||||
|
||||
func stream(for request: URLRequest) async throws -> HTTPStreamResponse {
|
||||
count += 1
|
||||
let chunks = values
|
||||
return .init(
|
||||
statusCode: 200,
|
||||
headers: ["Content-Type": "audio/mpeg"],
|
||||
finalURL: url,
|
||||
expectedContentLength: Int64(chunks.reduce(0) { $0 + $1.count }),
|
||||
chunks: AsyncThrowingStream { continuation in
|
||||
for chunk in chunks { continuation.yield(chunk) }
|
||||
continuation.finish()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func requestCount() -> Int { count }
|
||||
}
|
||||
|
||||
private actor ProxyRecordingPolicyTransport: ProxyHTTPTransport {
|
||||
let response: HTTPResponse
|
||||
private(set) var proxy: String?
|
||||
private(set) var request: URLRequest?
|
||||
|
||||
init(response: HTTPResponse) { self.response = response }
|
||||
|
||||
func data(for request: URLRequest) async throws -> HTTPResponse { response }
|
||||
|
||||
func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse {
|
||||
self.request = request
|
||||
self.proxy = proxy
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RecordingWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable {
|
||||
let response: HTTPResponse
|
||||
private(set) var callCount = 0
|
||||
private(set) var lastPrepared: PreparedRequest?
|
||||
|
||||
init(response: HTTPResponse) { self.response = response }
|
||||
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
callCount += 1
|
||||
lastPrepared = prepared
|
||||
return response
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user