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") }