ios: better backgrounding/resume
This commit is contained in:
@@ -1,6 +1,186 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private struct MockClientCallSnapshot: Sendable {
|
||||
var listChats = 0
|
||||
var listSearches = 0
|
||||
var getChat = 0
|
||||
var getSearch = 0
|
||||
}
|
||||
|
||||
private struct UnexpectedClientCall: Error {}
|
||||
|
||||
private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatsResponse: [ChatSummary]
|
||||
private let searchesResponse: [SearchSummary]
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
|
||||
private var snapshot = MockClientCallSnapshot()
|
||||
|
||||
init(
|
||||
chatsResponse: [ChatSummary] = [],
|
||||
searchesResponse: [SearchSummary] = [],
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:]
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
}
|
||||
|
||||
func currentSnapshot() -> MockClientCallSnapshot {
|
||||
snapshot
|
||||
}
|
||||
|
||||
func verifySession() async throws -> AuthSession {
|
||||
AuthSession(authenticated: true, mode: "open")
|
||||
}
|
||||
|
||||
func listChats() async throws -> [ChatSummary] {
|
||||
snapshot.listChats += 1
|
||||
return chatsResponse
|
||||
}
|
||||
|
||||
func createChat(title: String?) async throws -> ChatSummary {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func getChat(chatID: String) async throws -> ChatDetail {
|
||||
snapshot.getChat += 1
|
||||
guard let detail = chatDetails[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func deleteChat(chatID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func listSearches() async throws -> [SearchSummary] {
|
||||
snapshot.listSearches += 1
|
||||
return searchesResponse
|
||||
}
|
||||
|
||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||
snapshot.getSearch += 1
|
||||
guard let detail = searchDetails[searchID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func listModels() async throws -> ModelCatalogResponse {
|
||||
ModelCatalogResponse(providers: [:])
|
||||
}
|
||||
|
||||
func runCompletionStream(
|
||||
body: CompletionStreamRequest,
|
||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||
) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
|
||||
func runSearchStream(
|
||||
searchID: String,
|
||||
body: SearchRunRequest,
|
||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||
) async throws {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func testSettings(named name: String) -> SybilSettingsStore {
|
||||
let defaults = UserDefaults(suiteName: name)!
|
||||
defaults.removePersistentDomain(forName: name)
|
||||
let settings = SybilSettingsStore(defaults: defaults)
|
||||
settings.apiBaseURL = "http://127.0.0.1:8787"
|
||||
return settings
|
||||
}
|
||||
|
||||
private func makeChatSummary(id: String, date: Date) -> ChatSummary {
|
||||
ChatSummary(
|
||||
id: id,
|
||||
title: "Chat \(id)",
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail {
|
||||
ChatDetail(
|
||||
id: id,
|
||||
title: "Chat \(id)",
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
initiatedProvider: .openai,
|
||||
initiatedModel: "gpt-4.1-mini",
|
||||
lastUsedProvider: .openai,
|
||||
lastUsedModel: "gpt-4.1-mini",
|
||||
messages: [
|
||||
Message(
|
||||
id: "message-\(id)",
|
||||
createdAt: date,
|
||||
role: .assistant,
|
||||
content: body,
|
||||
name: nil
|
||||
)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSearchSummary(id: String, date: Date) -> SearchSummary {
|
||||
SearchSummary(
|
||||
id: id,
|
||||
title: "Search \(id)",
|
||||
query: "query-\(id)",
|
||||
createdAt: date,
|
||||
updatedAt: date
|
||||
)
|
||||
}
|
||||
|
||||
private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail {
|
||||
SearchDetail(
|
||||
id: id,
|
||||
title: "Search \(id)",
|
||||
query: "query-\(id)",
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
requestId: "request-\(id)",
|
||||
latencyMs: 42,
|
||||
error: nil,
|
||||
answerText: answer,
|
||||
answerRequestId: "answer-\(id)",
|
||||
answerCitations: [],
|
||||
answerError: nil,
|
||||
results: []
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -22,3 +202,66 @@ import Testing
|
||||
|
||||
#expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let chat = makeChatSummary(id: "chat-1", date: date)
|
||||
let search = makeSearchSummary(id: "search-1", date: date)
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
searchesResponse: [search],
|
||||
chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-1")
|
||||
|
||||
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listChats == 1)
|
||||
#expect(snapshot.listSearches == 1)
|
||||
#expect(snapshot.getChat == 0)
|
||||
#expect(snapshot.getSearch == 0)
|
||||
#expect(viewModel.selectedItem == .chat("chat-1"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_100)
|
||||
let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
|
||||
let client = MockSybilClient(chatDetails: ["chat-2": detail])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .chat("chat-2")
|
||||
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getChat == 1)
|
||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||
let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer")
|
||||
let client = MockSybilClient(searchDetails: ["search-2": detail])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.selectedItem = .search("search-2")
|
||||
|
||||
await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.listChats == 0)
|
||||
#expect(snapshot.listSearches == 0)
|
||||
#expect(snapshot.getSearch == 1)
|
||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user