807 lines
31 KiB
Swift
807 lines
31 KiB
Swift
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
|
|
)
|
|
}
|