Files
legado-ios/Tests/LegadoAppModelsTests/SearchRegressionTests.swift
T

130 lines
6.4 KiB
Swift

import Foundation
import Testing
import LegadoCore
@testable import LegadoAppModels
@MainActor
struct SearchRegressionTests {
@Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws {
let source = source("slow")
let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者")
let transport = GatedModelSearchTransport()
let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [cached]))
defer { model.cancel(reset: true) }
model.search(keyword: "书", sources: [source])
try await waitUntil { model.isShowingCachedResults }
#expect(model.books.map(\.bookURL) == [cached.bookURL])
#expect(model.state == .searching)
#expect(model.completedSourceCount == 0)
await transport.release()
try await waitUntil { model.state == .finished }
#expect(model.books.first?.bookURL == source.id + "/fresh")
#expect(!model.isShowingCachedResults)
}
@Test func pausedSearchPersistsEarlyResultsAndResumesOnlyUnfinishedSourcesAtSamePage() async throws {
let transport = GatedModelSearchTransport(fastHost: "fast.example")
let repository = InMemorySearchBookRepository()
let model = model(transport: transport, repository: repository)
defer { model.cancel(reset: true) }
let fast = source("fast"), slow = source("slow")
model.search(keyword: "书", sources: [fast, slow])
try await waitUntil("first source result") { !model.books.isEmpty }
model.cancel()
try await waitUntil("paused result persistence") { try await repository.cachedMatches(keyword: "书").count == 1 }
#expect(model.isPaused)
#expect(model.completedSourceCount == 1)
model.resume()
await transport.release()
try await waitUntil("resume completion") { model.state == .finished && !model.isPaused }
let requests = await transport.requests()
#expect(requests.filter { $0.url?.host == "fast.example" }.count == 1)
#expect(requests.allSatisfy { URLComponents(url: $0.url!, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "page" })?.value == "1" })
#expect(model.completedSourceCount == 2)
#expect(model.books.first?.origins == [fast.id, slow.id])
model.loadMore()
try await waitUntil("second page completion") { !model.isLoadingMore }
#expect(!model.hasMore)
}
@Test func disabledOutOfScopeExpiredAndEditedSourceCachesAreNotDisplayed() async throws {
var active = source("slow")
let other = source("other")
var expired = SearchBook(bookURL: active.id + "/expired", origin: active.id, originName: "源", type: 0, name: "书")
expired.time = 0
let outOfScope = SearchBook(bookURL: other.id + "/cached", origin: other.id, originName: "源", type: 0, name: "书")
let edited = SearchBook(bookURL: active.id + "/edited", origin: active.id, originName: "源", type: 0, name: "书")
active.lastUpdateTime = edited.time + 1
let transport = GatedModelSearchTransport()
let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [expired, outOfScope, edited]))
defer { model.cancel(reset: true) }
var started = transport.started.makeAsyncIterator()
model.search(keyword: "书", sources: [active])
_ = await started.next()
#expect(model.books.isEmpty)
#expect(!model.isShowingCachedResults)
active.enabled = false
model.search(keyword: "书", sources: [active])
#expect(model.state == .unavailable)
#expect(model.books.isEmpty)
}
private func source(_ name: String) -> BookSource {
var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name)
value.searchUrl = "/search?q={{key}}&page={{page}}"
value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
return value
}
private func model(transport: GatedModelSearchTransport, repository: any SearchBookRepository) -> SearchViewModel {
.init(service: BookSearchService(transport: transport, history: .init()),
keywordRepository: InMemorySearchKeywordRepository(), searchBookRepository: repository)
}
private func waitUntil(_ reason: String = "Search state did not settle", _ condition: @MainActor () async throws -> Bool) async throws {
let deadline = ContinuousClock.now + .seconds(3)
while try await !condition() {
try #require(ContinuousClock.now < deadline, Comment(rawValue: reason))
try await Task.sleep(for: .milliseconds(5))
}
}
}
private actor GatedModelSearchTransport: HTTPTransport {
nonisolated let started: AsyncStream<Void>
private let continuation: AsyncStream<Void>.Continuation
private let fastHost: String?
private var released = false
private var pending: [UUID: (URLRequest, CheckedContinuation<HTTPResponse, Error>)] = [:]
private var recorded: [URLRequest] = []
init(fastHost: String? = nil) {
self.fastHost = fastHost
(started, continuation) = AsyncStream.makeStream(of: Void.self)
}
func data(for request: URLRequest) async throws -> HTTPResponse {
recorded.append(request)
if released || request.url?.host == fastHost { return response(request) }
let id = UUID()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { value in
pending[id] = (request, value)
continuation.yield(())
}
} onCancel: { Task { await self.cancel(id) } }
}
private func cancel(_ id: UUID) {
pending.removeValue(forKey: id)?.1.resume(throwing: CancellationError())
}
func release() {
released = true
let values = pending.values
pending = [:]
for (request, continuation) in values { continuation.resume(returning: response(request)) }
}
func requests() -> [URLRequest] { recorded }
private func response(_ request: URLRequest) -> HTTPResponse {
.init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/fresh"}]}"#.utf8),
statusCode: 200, headers: [:], finalURL: request.url!)
}
}