268 lines
8.4 KiB
Swift
268 lines
8.4 KiB
Swift
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)!
|
|
defaults.removePersistentDomain(forName: #function)
|
|
|
|
let settings = SybilSettingsStore(defaults: defaults)
|
|
settings.apiBaseURL = "https://sybil.bajor.cloud/api/"
|
|
|
|
#expect(settings.normalizedAPIBaseURL?.absoluteString == "https://sybil.bajor.cloud/api")
|
|
}
|
|
|
|
@MainActor
|
|
@Test func normalizedAPIBaseURLTrimsWhitespaceAndTrailingSlashes() async throws {
|
|
let defaults = UserDefaults(suiteName: #function)!
|
|
defaults.removePersistentDomain(forName: #function)
|
|
|
|
let settings = SybilSettingsStore(defaults: defaults)
|
|
settings.apiBaseURL = " http://127.0.0.1:8787/// "
|
|
|
|
#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")
|
|
}
|