import CoreGraphics import Foundation import Testing @testable import Sybil private struct MockClientCallSnapshot: Sendable { var listChats = 0 var listSearches = 0 var createChat = 0 var getChat = 0 var getSearch = 0 var getActiveRuns = 0 var runCompletionStream = 0 var attachCompletionStream = 0 var attachSearchStream = 0 } private struct ChatCreateCallSnapshot: Sendable { var title: String? var provider: Provider? var model: String? var messages: [CompletionRequestMessage]? } 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 let activeRunsResponse: ActiveRunsResponse private var snapshot = MockClientCallSnapshot() private var lastCreateChatCall: ChatCreateCallSnapshot? private var lastCompletionStreamBody: CompletionStreamRequest? private var completionStreamEvents: [CompletionStreamEvent]? private var getChatDelayNanoseconds: UInt64 = 0 private var getSearchDelayNanoseconds: UInt64 = 0 private var completionStreamNetworkErrorMessage: String? private var completionStreamDelayNanoseconds: UInt64 = 0 private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:] private var completionAttachDelayNanoseconds: UInt64 = 0 private var searchStreamNetworkErrorMessage: String? private var searchStreamDelayNanoseconds: UInt64 = 0 private var searchAttachEvents: [String: [SearchStreamEvent]] = [:] private var searchAttachDelayNanoseconds: UInt64 = 0 init( chatsResponse: [ChatSummary] = [], searchesResponse: [SearchSummary] = [], chatDetails: [String: ChatDetail] = [:], searchDetails: [String: SearchDetail] = [:], createChatResponse: ChatSummary? = nil, activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse() ) { self.chatsResponse = chatsResponse self.searchesResponse = searchesResponse self.chatDetails = chatDetails self.searchDetails = searchDetails self.createChatResponse = createChatResponse self.activeRunsResponse = activeRunsResponse } func currentSnapshot() -> MockClientCallSnapshot { snapshot } func currentCreateChatCall() -> ChatCreateCallSnapshot? { lastCreateChatCall } func currentCompletionStreamBody() -> CompletionStreamRequest? { lastCompletionStreamBody } func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) { completionStreamEvents = events completionStreamDelayNanoseconds = delayNanoseconds } 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 setCompletionAttachEvents( chatID: String, events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0 ) { completionAttachEvents[chatID] = events completionAttachDelayNanoseconds = delayNanoseconds } func setSearchAttachEvents( searchID: String, events: [SearchStreamEvent], delayNanoseconds: UInt64 = 0 ) { searchAttachEvents[searchID] = events searchAttachDelayNanoseconds = 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?, provider: Provider?, model: String?, messages: [CompletionRequestMessage]? ) async throws -> ChatSummary { snapshot.createChat += 1 lastCreateChatCall = ChatCreateCallSnapshot( title: title, provider: provider, model: model, messages: messages ) 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 getActiveRuns() async throws -> ActiveRunsResponse { snapshot.getActiveRuns += 1 return activeRunsResponse } func runCompletionStream( body: CompletionStreamRequest, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void ) async throws { snapshot.runCompletionStream += 1 lastCompletionStreamBody = body if completionStreamDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds) } if let completionStreamNetworkErrorMessage { throw APIError.networkError(message: completionStreamNetworkErrorMessage) } if let completionStreamEvents { for event in completionStreamEvents { await onEvent(event) } return } throw UnexpectedClientCall() } func attachCompletionStream( chatID: String, onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void ) async throws { snapshot.attachCompletionStream += 1 let events = completionAttachEvents[chatID] ?? [] for event in events { await onEvent(event) } if completionAttachDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds) } } 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() } func attachSearchStream( searchID: String, onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void ) async throws { snapshot.attachSearchStream += 1 let events = searchAttachEvents[searchID] ?? [] for event in events { await onEvent(event) } if searchAttachDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds) } } } @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 quickQuestionRunsNonPersistentCompletionStream() async throws { let client = MockSybilClient() await client.setCompletionStreamEvents([ .delta(CompletionStreamDelta(text: "Reset it from ")), .done(CompletionStreamDone(text: "Reset it from Settings.")) ]) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.quickQuestionPrompt = "How do I reset my password?" let task = viewModel.sendQuickQuestion() await task?.value let snapshot = await client.currentSnapshot() let body = await client.currentCompletionStreamBody() #expect(snapshot.runCompletionStream == 1) #expect(body?.persist == false) #expect(body?.chatId == nil) #expect(body?.provider == .openai) #expect(body?.messages.first?.role == .user) #expect(body?.messages.first?.content == "How do I reset my password?") #expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.") #expect(!viewModel.isQuickQuestionSending) } @MainActor @Test func quickQuestionConvertCreatesSeededChat() async throws { let date = Date(timeIntervalSince1970: 1_700_000_250) let chat = makeChatSummary(id: "quick-chat", date: date) let detail = ChatDetail( id: chat.id, title: chat.title, createdAt: chat.createdAt, updatedAt: chat.updatedAt, initiatedProvider: .openai, initiatedModel: "gpt-4.1-mini", lastUsedProvider: .openai, lastUsedModel: "gpt-4.1-mini", messages: [ Message(id: "quick-user", createdAt: date, role: .user, content: "How do I reset my password?", name: nil), Message(id: "quick-assistant", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil) ] ) let client = MockSybilClient( chatsResponse: [chat], chatDetails: [chat.id: detail], createChatResponse: chat ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.isAuthenticated = true viewModel.isCheckingSession = false viewModel.quickQuestionSubmittedPrompt = "How do I reset my password?" viewModel.quickQuestionSubmittedProvider = .openai viewModel.quickQuestionSubmittedModel = "gpt-4.1-mini" viewModel.quickQuestionMessages = [ Message( id: "temp-assistant-quick", createdAt: date, role: .assistant, content: "Reset it from Settings.", name: nil ) ] let didConvert = await viewModel.convertQuickQuestionToChat() let snapshot = await client.currentSnapshot() let createCall = await client.currentCreateChatCall() #expect(didConvert) #expect(snapshot.createChat == 1) #expect(createCall?.title == "How do I reset my password?") #expect(createCall?.provider == .openai) #expect(createCall?.model == "gpt-4.1-mini") #expect(createCall?.messages?.map(\.role) == [.user, .assistant]) #expect(createCall?.messages?.map(\.content) == ["How do I reset my password?", "Reset it from Settings."]) #expect(viewModel.selectedItem == .chat("quick-chat")) #expect(viewModel.quickQuestionPrompt.isEmpty) } @MainActor @Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws { let defaults = UserDefaults(suiteName: #function)! defaults.removePersistentDomain(forName: #function) let settings = SybilSettingsStore(defaults: defaults) settings.apiBaseURL = "http://127.0.0.1:8787" let viewModel = SybilViewModel(settings: settings) { _ in MockSybilClient() } viewModel.modelCatalog = [ .openai: ProviderModelInfo(models: ["gpt-4.1-mini", "gpt-4o"], loadedAt: nil, error: nil), .anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest", "claude-3-haiku"], loadedAt: nil, error: nil) ] viewModel.setQuickQuestionProvider(.anthropic) viewModel.setQuickQuestionModel("claude-3-haiku") #expect(viewModel.quickQuestionProvider == .anthropic) #expect(viewModel.quickQuestionModel == "claude-3-haiku") #expect(settings.preferredProvider == .openai) let reloadedSettings = SybilSettingsStore(defaults: defaults) #expect(reloadedSettings.quickQuestionPreferredProvider == .anthropic) #expect(reloadedSettings.quickQuestionPreferredModelByProvider[.anthropic] == "claude-3-haiku") #expect(reloadedSettings.preferredProvider == .openai) let reloadedViewModel = SybilViewModel(settings: reloadedSettings) { _ in MockSybilClient() } #expect(reloadedViewModel.quickQuestionProvider == .anthropic) #expect(reloadedViewModel.quickQuestionModel == "claude-3-haiku") #expect(reloadedViewModel.provider == .openai) } @MainActor @Test func reconnectAttachesSelectedActiveChatStream() async throws { let date = Date(timeIntervalSince1970: 1_700_000_260) let chat = makeChatSummary(id: "chat-active", date: date) let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript") let client = MockSybilClient( chatsResponse: [chat], chatDetails: ["chat-active": detail], activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"]) ) await client.setCompletionAttachEvents( chatID: "chat-active", events: [.delta(CompletionStreamDelta(text: "streaming"))], delayNanoseconds: 100_000_000 ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } await viewModel.reconnect() try await Task.sleep(nanoseconds: 20_000_000) let snapshot = await client.currentSnapshot() #expect(snapshot.getActiveRuns >= 1) #expect(snapshot.attachCompletionStream == 1) #expect(viewModel.sidebarItems.first?.isRunning == true) #expect(viewModel.isSendingVisibleChat) #expect(viewModel.displayedMessages.last?.content == "streaming") } @MainActor @Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws { let date = Date(timeIntervalSince1970: 1_700_000_270) let activeChat = makeChatSummary(id: "chat-active", date: date) let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1)) let client = MockSybilClient( chatsResponse: [idleChat, activeChat], chatDetails: [ "chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"), "chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript") ], activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"]) ) let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client } viewModel.selectedItem = .chat("chat-idle") viewModel.composer = "new message" await viewModel.reconnect() #expect(viewModel.selectedItem == .chat("chat-idle")) #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true) #expect(!viewModel.isActiveSelectionSending) #expect(viewModel.canSendComposer) } @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)) } @Test func transcriptTailSpacerContractsAsContentGrows() async throws { let targetHeight: CGFloat = 320 let baselineAssistantHeight: CGFloat = 28 #expect( SybilTranscriptTailSpacer.placeholderHeight( targetHeight: targetHeight, baselineAssistantHeight: baselineAssistantHeight, currentAssistantHeight: baselineAssistantHeight + 120 ) == 200 ) #expect( SybilTranscriptTailSpacer.placeholderHeight( targetHeight: targetHeight, baselineAssistantHeight: baselineAssistantHeight, currentAssistantHeight: baselineAssistantHeight + 500 ) == SybilTranscriptTailSpacer.minimumHeight ) }