import CoreGraphics 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 let createChatResponse: ChatSummary? private var snapshot = MockClientCallSnapshot() private var getChatDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? private var completionStreamDelayNanoseconds: UInt64 = 0 private var searchStreamNetworkErrorMessage: String? private var searchStreamDelayNanoseconds: UInt64 = 0 init( chatsResponse: [ChatSummary] = [], searchesResponse: [SearchSummary] = [], chatDetails: [String: ChatDetail] = [:], searchDetails: [String: SearchDetail] = [:], createChatResponse: ChatSummary? = nil ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse self.chatDetails = chatDetails self.searchDetails = searchDetails self.createChatResponse = createChatResponse } func currentSnapshot() -> MockClientCallSnapshot { snapshot } func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { completionStreamNetworkErrorMessage = message completionStreamDelayNanoseconds = delayNanoseconds } func setGetChatDelay(_ delayNanoseconds: UInt64) { getChatDelayNanoseconds = delayNanoseconds } func setGetSearchDelay(_ delayNanoseconds: UInt64) { getSearchDelayNanoseconds = delayNanoseconds } func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) { searchStreamNetworkErrorMessage = message searchStreamDelayNanoseconds = delayNanoseconds } 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 { if let createChatResponse { return createChatResponse } throw UnexpectedClientCall() } func getChat(chatID: String) async throws -> ChatDetail { snapshot.getChat += 1 if getChatDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: getChatDelayNanoseconds) } 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 if getSearchDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: getSearchDelayNanoseconds) } 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 { if completionStreamDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds) } if let completionStreamNetworkErrorMessage { throw APIError.networkError(message: completionStreamNetworkErrorMessage) } throw UnexpectedClientCall() } func runSearchStream( searchID: String, body: SearchRunRequest, onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void ) async throws { if searchStreamDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds) } if let searchStreamNetworkErrorMessage { throw APIError.networkError(message: searchStreamNetworkErrorMessage) } 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") } @MainActor @Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws { let date = Date(timeIntervalSince1970: 1_700_000_210) let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript") let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript") let client = MockSybilClient(chatDetails: ["chat-new": freshDetail]) await client.setGetChatDelay(50_000_000) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.selectedItem = .chat("chat-old") viewModel.selectedChat = staleDetail viewModel.select(.chat("chat-new")) #expect(viewModel.displayedMessages.isEmpty) #expect(viewModel.isLoadingSelection) try await Task.sleep(nanoseconds: 90_000_000) #expect(viewModel.displayedMessages.first?.content == "fresh transcript") #expect(!viewModel.isLoadingSelection) } @MainActor @Test func navigationSelectionWaitsForFastTranscriptLoad() async throws { let date = Date(timeIntervalSince1970: 1_700_000_220) let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push") let client = MockSybilClient(chatDetails: ["chat-fast": detail]) await client.setGetChatDelay(20_000_000) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500)) #expect(viewModel.selectedItem == .chat("chat-fast")) #expect(viewModel.displayedMessages.first?.content == "loaded before push") #expect(!viewModel.isLoadingSelection) } @MainActor @Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws { let date = Date(timeIntervalSince1970: 1_700_000_230) let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push") let client = MockSybilClient(chatDetails: ["chat-slow": detail]) await client.setGetChatDelay(100_000_000) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10)) #expect(viewModel.selectedItem == .chat("chat-slow")) #expect(viewModel.displayedMessages.isEmpty) #expect(viewModel.isLoadingSelection) try await Task.sleep(nanoseconds: 150_000_000) #expect(viewModel.displayedMessages.first?.content == "loaded after push") #expect(!viewModel.isLoadingSelection) } @MainActor @Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws { let date = Date(timeIntervalSince1970: 1_700_000_240) let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript") let client = MockSybilClient(chatDetails: ["chat-typing": detail]) await client.setCompletionStreamNetworkError( "Network error -1005 while requesting POST: The network connection was lost.", delayNanoseconds: 50_000_000 ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.selectedItem = .chat("chat-typing") viewModel.selectedChat = detail viewModel.composer = "continue" let sendTask = Task { await viewModel.sendComposer() } try await Task.sleep(nanoseconds: 10_000_000) #expect(viewModel.isSendingVisibleChat) viewModel.startNewChat() #expect(viewModel.displayedMessages.isEmpty) #expect(!viewModel.isSendingVisibleChat) await sendTask.value } @MainActor @Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws { let date = Date(timeIntervalSince1970: 1_700_000_300) let chat = makeChatSummary(id: "chat-3", date: date) let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript") let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript") let client = MockSybilClient( chatsResponse: [chat], chatDetails: ["chat-3": refreshedDetail] ) await client.setCompletionStreamNetworkError( "Network error -1005 while requesting POST: The network connection was lost.", delayNanoseconds: 50_000_000 ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.selectedItem = .chat("chat-3") viewModel.selectedChat = initialDetail viewModel.composer = "continue" let sendTask = Task { await viewModel.sendComposer() } try await Task.sleep(nanoseconds: 10_000_000) viewModel.markAppInactiveForNetwork() await sendTask.value #expect(viewModel.errorMessage == nil) #expect(viewModel.composer.isEmpty) #expect(!viewModel.isSending) #expect(viewModel.selectedChat?.messages.first?.content == "stale transcript") await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true) let snapshot = await client.currentSnapshot() #expect(snapshot.getChat == 1) #expect(viewModel.errorMessage == nil) #expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript") } @MainActor @Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws { let date = Date(timeIntervalSince1970: 1_700_000_400) let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer") let client = MockSybilClient( searchDetails: ["search-3": refreshedDetail] ) await client.setSearchStreamNetworkError( "Network error -1005 while requesting POST: The network connection was lost.", delayNanoseconds: 50_000_000 ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.selectedItem = .search("search-3") viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer") viewModel.composer = "refresh me" let sendTask = Task { await viewModel.sendComposer() } try await Task.sleep(nanoseconds: 10_000_000) viewModel.markAppInactiveForNetwork() await sendTask.value #expect(viewModel.errorMessage == nil) #expect(viewModel.composer.isEmpty) #expect(!viewModel.isSending) await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true) let snapshot = await client.currentSnapshot() #expect(snapshot.getSearch == 1) #expect(viewModel.errorMessage == nil) #expect(viewModel.selectedSearch?.answerText == "fresh answer") } @Test func newChatSwipeMetricsClampProgressAndLatch() async throws { let width: CGFloat = 390 let maxTravel = NewChatSwipeMetrics.maxTravel(for: width) let latchDistance = NewChatSwipeMetrics.latchDistance(for: width) #expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel) #expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5) #expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10) #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width)) #expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width)) #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true)) #expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true)) #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0)) #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30)) #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140)) #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90)) #expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false)) #expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false)) #expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true)) #expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel) #expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5) #expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width)) #expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0)) #expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140)) #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false)) #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true)) }