Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
TestFlight / Build and upload (push) Successful in 1m55s
This commit is contained in:
@@ -9,6 +9,7 @@ import UIKit
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listWorkspaceItems = 0
|
||||
var workspaceSearchQueries: [String] = []
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
@@ -43,6 +44,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let workspaceItemsResponse: [WorkspaceItem]
|
||||
private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
@@ -86,11 +88,15 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil,
|
||||
workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in
|
||||
WorkspaceSearchResponse(items: [], hasMore: false)
|
||||
},
|
||||
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
|
||||
self.workspaceSearchHandler = workspaceSearchHandler
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
@@ -186,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
return workspaceItemsResponse
|
||||
}
|
||||
|
||||
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
|
||||
snapshot.workspaceSearchQueries.append(query)
|
||||
return try await workspaceSearchHandler(query)
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
snapshot.listChats += 1
|
||||
if listChatsDelayNanoseconds > 0 {
|
||||
@@ -1998,3 +2009,173 @@ private final class MockQuickQuestionLifecycle {
|
||||
controller.stop()
|
||||
}
|
||||
#endif
|
||||
|
||||
// Deliberately ignores cancellation so stale-response protection is exercised.
|
||||
private actor WorkspaceSearchGate {
|
||||
private var pending: [String: CheckedContinuation<WorkspaceSearchResponse, any Error>] = [:]
|
||||
private var observers: [String: CheckedContinuation<Void, Never>] = [:]
|
||||
|
||||
func response(for query: String) async throws -> WorkspaceSearchResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
pending[query] = continuation
|
||||
observers.removeValue(forKey: query)?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilRequested(_ query: String) async {
|
||||
if pending[query] != nil { return }
|
||||
await withCheckedContinuation { observers[query] = $0 }
|
||||
}
|
||||
|
||||
func complete(_ query: String, with response: WorkspaceSearchResponse) {
|
||||
pending.removeValue(forKey: query)?.resume(returning: response)
|
||||
}
|
||||
|
||||
func fail(_ query: String) {
|
||||
pending.removeValue(forKey: query)?.resume(throwing: APIError.networkError(message: "Offline"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func historySearchKeepsRecentItemsAndOpensOlderChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let recent = makeChatSummary(id: "recent", date: date)
|
||||
let old = makeChatSummary(id: "old", date: date.addingTimeInterval(-100))
|
||||
var match = WorkspaceItem(chat: old)
|
||||
match.searchSnippet = "The nebula we discussed"
|
||||
let response = WorkspaceSearchResponse(items: [match, WorkspaceItem(search: makeSearchSummary(id: "web", date: date))], hasMore: true)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [recent],
|
||||
chatDetails: ["old": makeChatDetail(id: "old", date: date, body: "The nebula we discussed")],
|
||||
workspaceSearchHandler: { _ in response }
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: recent)]
|
||||
viewModel.chats = [recent]
|
||||
viewModel.selectedItem = .chat("recent")
|
||||
viewModel.setSidebarSearchQuery(" nebula ")
|
||||
await viewModel.refreshSidebarSearch()
|
||||
#expect(viewModel.selectedItem == .chat("recent"))
|
||||
#expect(viewModel.workspaceItems == [WorkspaceItem(chat: recent)])
|
||||
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("old"), .search("web")])
|
||||
#expect(viewModel.visibleSidebarItems.first?.searchSnippet == "The nebula we discussed")
|
||||
#expect(viewModel.sidebarSearchHasMore)
|
||||
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
|
||||
await viewModel.selectForNavigation(.chat("old"))
|
||||
#expect(viewModel.selectedChat?.id == "old")
|
||||
viewModel.setSidebarSearchQuery("")
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
#expect(viewModel.selectedItem == .chat("old"))
|
||||
#expect(viewModel.selectedChat?.id == "old")
|
||||
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat("old") }))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func historySearchDebouncesAndClearingRestoresRecentItems() async throws {
|
||||
let client = MockSybilClient()
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
let recent = WorkspaceItem(chat: makeChatSummary(id: "recent", date: Date()))
|
||||
viewModel.workspaceItems = [recent]
|
||||
viewModel.setSidebarSearchQuery("n")
|
||||
viewModel.setSidebarSearchQuery("ne")
|
||||
viewModel.setSidebarSearchQuery("nebula")
|
||||
#expect(viewModel.isSearchingSidebar)
|
||||
#expect(await client.currentSnapshot().workspaceSearchQueries.isEmpty)
|
||||
try await Task.sleep(for: .milliseconds(600))
|
||||
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
|
||||
#expect(viewModel.visibleSidebarItems.isEmpty)
|
||||
#expect(!viewModel.isSearchingSidebar)
|
||||
viewModel.setSidebarSearchQuery(" ")
|
||||
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("recent")])
|
||||
#expect(!viewModel.isSidebarSearchActive)
|
||||
#expect(viewModel.sidebarSearchError == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func historySearchDiscardsOutOfOrderAndClearedResponses() async throws {
|
||||
let gate = WorkspaceSearchGate()
|
||||
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.setSidebarSearchQuery("first")
|
||||
let first = Task { await viewModel.refreshSidebarSearch() }
|
||||
await gate.waitUntilRequested("first")
|
||||
viewModel.setSidebarSearchQuery("second")
|
||||
let second = Task { await viewModel.refreshSidebarSearch() }
|
||||
await gate.waitUntilRequested("second")
|
||||
let match = WorkspaceItem(chat: makeChatSummary(id: "second", date: Date()))
|
||||
await gate.complete("second", with: WorkspaceSearchResponse(items: [match], hasMore: false))
|
||||
await second.value
|
||||
await gate.fail("first")
|
||||
await first.value
|
||||
#expect(viewModel.sidebarSearchResults == [match])
|
||||
#expect(viewModel.sidebarSearchError == nil)
|
||||
#expect(!viewModel.isSearchingSidebar)
|
||||
|
||||
viewModel.setSidebarSearchQuery("third")
|
||||
let third = Task { await viewModel.refreshSidebarSearch() }
|
||||
await gate.waitUntilRequested("third")
|
||||
viewModel.setSidebarSearchQuery("")
|
||||
await gate.complete("third", with: WorkspaceSearchResponse(items: [match], hasMore: true))
|
||||
await third.value
|
||||
#expect(viewModel.sidebarSearchResults.isEmpty)
|
||||
#expect(!viewModel.sidebarSearchHasMore)
|
||||
#expect(!viewModel.isSearchingSidebar)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func historySearchFailureCanRetryWithoutChangingSelection() async throws {
|
||||
let gate = WorkspaceSearchGate()
|
||||
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.selectedItem = .chat("current")
|
||||
viewModel.setSidebarSearchQuery("nebula")
|
||||
let failed = Task { await viewModel.refreshSidebarSearch() }
|
||||
await gate.waitUntilRequested("nebula")
|
||||
await gate.fail("nebula")
|
||||
await failed.value
|
||||
#expect(viewModel.sidebarSearchError != nil)
|
||||
#expect(viewModel.selectedItem == .chat("current"))
|
||||
let retry = Task { await viewModel.refreshSidebarSearch() }
|
||||
await gate.waitUntilRequested("nebula")
|
||||
await gate.complete("nebula", with: WorkspaceSearchResponse(items: [], hasMore: false))
|
||||
await retry.value
|
||||
#expect(viewModel.sidebarSearchError == nil)
|
||||
#expect(!viewModel.isSearchingSidebar)
|
||||
#expect(viewModel.selectedItem == .chat("current"))
|
||||
viewModel.setSidebarSearchQuery(String(repeating: "a", count: 201))
|
||||
#expect(viewModel.sidebarSearchError == "Use 200 characters or fewer.")
|
||||
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula", "nebula"])
|
||||
}
|
||||
|
||||
private final class HistorySearchURLProtocol: 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 url = request.url!
|
||||
#expect(url.path == "/api/v1/workspace-items")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
||||
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "q", value: "café + AI & #ideas?")])
|
||||
let json = #"{"items":[{"type":"chat","id":"old","title":"Notes","createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z","searchSnippet":"Some café ideas"}],"hasMore":false}"#
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data(json.utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Test func historySearchEncodesQueryAndDecodesSnippet() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [HistorySearchURLProtocol.self]
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: APIConfiguration(baseURL: URL(string: "https://example.com/api")!, authToken: "test-token"), session: session)
|
||||
let response = try await client.searchWorkspaceItems(query: "café + AI & #ideas?")
|
||||
#expect(response.items.first?.searchSnippet == "Some café ideas")
|
||||
#expect(!response.hasMore)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user