Phase 2: tests for the provider layer and other exposed pure helpers
66 new tests across 8 files (93 total in the suite now): - GitSyncServiceTests: convertToSSH/injectCredentials/sanitizeFilename/ detectSecretsInText, including a below-threshold false-positive check on the secret regex. - ChatViewModelPureLogicTests: detectGoodbyePhrase, inferProvider, calculateCost -- including the cache-read (0.1x) / cache-write (1.25x) pricing multipliers, which is real billing-affecting logic. - EmbeddingServiceTests / ContextSelectionServiceTests: embedding (de)serialization round-trip, importance-score weighting, and the token-estimate fallback (content.count / 4) when no real count exists. - OpenRouterProviderTests / OllamaProviderTests / OpenAIProviderTests / AnthropicProviderTests: request-building (attachments, online mode, cache_control breakpoints, o1/o3 temperature omission, tool schema conversion) and response-parsing (text, tool_use blocks, empty-choices fallback behavior) for all four providers, with no network involved. Also marks calculateCost/inferProvider/detectGoodbyePhrase (ChatViewModel) and serializeEmbedding/deserializeEmbedding (EmbeddingService) as `nonisolated` -- discovered via the actual test failures, not speculation: the project's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` setting isolates the classes that carry an explicit `@MainActor` (ChatViewModel), so calling their static members from a plain synchronous @Test needs the pure ones marked `nonisolated`. Matches the existing convention already used elsewhere in EmbeddingService (cosineSimilarity was already nonisolated before this change). Phase 2 of the test-suite rollout plan (peaceful-baking-kurzweil).
This commit is contained in:
@@ -348,7 +348,7 @@ final class EmbeddingService {
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize embedding to binary data (4 bytes per float, little-endian)
|
||||
func serializeEmbedding(_ embedding: [Float]) -> Data {
|
||||
nonisolated func serializeEmbedding(_ embedding: [Float]) -> Data {
|
||||
var data = Data(capacity: embedding.count * 4)
|
||||
for value in embedding {
|
||||
var littleEndian = value.bitPattern.littleEndian
|
||||
@@ -360,7 +360,7 @@ final class EmbeddingService {
|
||||
}
|
||||
|
||||
/// Deserialize embedding from binary data
|
||||
func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||
var embedding: [Float] = []
|
||||
embedding.reserveCapacity(data.count / 4)
|
||||
|
||||
|
||||
@@ -421,7 +421,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
|
||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { Self.inferProvider(from: modelId) }
|
||||
|
||||
static func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
nonisolated static func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||
if modelId.contains("/") { return .openrouter }
|
||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
||||
@@ -2038,7 +2038,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
/// Detect goodbye phrases in user message
|
||||
static func detectGoodbyePhrase(in text: String) -> Bool {
|
||||
nonisolated static func detectGoodbyePhrase(in text: String) -> Bool {
|
||||
let lowercased = text.lowercased()
|
||||
let goodbyePhrases = [
|
||||
"bye", "goodbye", "bye bye", "good bye",
|
||||
@@ -2259,7 +2259,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
/// pricing when present: cache writes cost 1.25x the base input rate, cache
|
||||
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
|
||||
/// it does not need cache tokens subtracted from it.
|
||||
static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||
nonisolated static func calculateCost(usage: ChatResponse.Usage, pricing: ModelInfo.Pricing) -> Double {
|
||||
let inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
|
||||
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 1_000_000
|
||||
let cacheWriteCost = Double(usage.cacheCreationInputTokens ?? 0) * pricing.prompt * 1.25 / 1_000_000
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// AnthropicProviderTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("AnthropicProvider request/response conversion")
|
||||
struct AnthropicProviderTests {
|
||||
|
||||
private var provider: AnthropicProvider { AnthropicProvider(apiKey: "test-key") }
|
||||
|
||||
private func decodeBody(_ urlRequest: URLRequest) throws -> [String: Any] {
|
||||
try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||
}
|
||||
|
||||
// MARK: - buildURLRequest
|
||||
|
||||
@Test("System messages are pulled out of the message list into a top-level system field")
|
||||
func systemMessageExtracted() throws {
|
||||
let messages = [
|
||||
Message(role: .system, content: "be concise"),
|
||||
Message(role: .user, content: "hi")
|
||||
]
|
||||
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
|
||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try decodeBody(urlRequest)
|
||||
|
||||
let apiMessages = body["messages"] as? [[String: Any]]
|
||||
#expect(apiMessages?.count == 1) // system message removed from the array
|
||||
#expect(apiMessages?.first?["role"] as? String == "user")
|
||||
|
||||
let system = body["system"] as? [[String: Any]]
|
||||
#expect(system?.first?["text"] as? String == "be concise")
|
||||
let cacheControl = system?.first?["cache_control"] as? [String: String]
|
||||
#expect(cacheControl?["type"] == "ephemeral")
|
||||
}
|
||||
|
||||
@Test("The last message gets a cache_control breakpoint on its content block")
|
||||
func lastMessageGetsCacheBreakpoint() throws {
|
||||
let messages = [
|
||||
Message(role: .user, content: "first"),
|
||||
Message(role: .assistant, content: "second"),
|
||||
Message(role: .user, content: "third")
|
||||
]
|
||||
let request = ChatRequest(messages: messages, model: "claude-sonnet-4-5")
|
||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try decodeBody(urlRequest)
|
||||
let apiMessages = body["messages"] as? [[String: Any]]
|
||||
|
||||
// Earlier messages keep plain string content (no cache breakpoint).
|
||||
#expect(apiMessages?[0]["content"] as? String == "first")
|
||||
#expect(apiMessages?[1]["content"] as? String == "second")
|
||||
|
||||
// The last message's content becomes an array with a cache_control block.
|
||||
let lastContent = apiMessages?[2]["content"] as? [[String: Any]]
|
||||
#expect(lastContent?.first?["text"] as? String == "third")
|
||||
let cacheControl = lastContent?.first?["cache_control"] as? [String: String]
|
||||
#expect(cacheControl?["type"] == "ephemeral")
|
||||
}
|
||||
|
||||
@Test("max_tokens defaults to 16000 when not specified on the request")
|
||||
func defaultMaxTokens() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5")
|
||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try decodeBody(urlRequest)
|
||||
#expect(body["max_tokens"] as? Int == 16000)
|
||||
}
|
||||
|
||||
@Test("Online mode adds a web_search tool even with no explicit tools requested")
|
||||
func onlineModeAddsWebSearchTool() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "claude-sonnet-4-5", onlineMode: true)
|
||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try decodeBody(urlRequest)
|
||||
let tools = body["tools"] as? [[String: Any]]
|
||||
#expect(tools?.first?["name"] as? String == "web_search")
|
||||
// tool_choice is only added when the caller supplied explicit tools.
|
||||
#expect(body["tool_choice"] == nil)
|
||||
}
|
||||
|
||||
@Test("Empty message content is replaced with a placeholder rather than sent blank")
|
||||
func emptyContentGetsPlaceholder() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: " ")], model: "claude-sonnet-4-5")
|
||||
let (urlRequest, _) = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try decodeBody(urlRequest)
|
||||
let apiMessages = body["messages"] as? [[String: Any]]
|
||||
// This is also the last message, so it gets wrapped in a cache-control array.
|
||||
let content = apiMessages?.first?["content"] as? [[String: Any]]
|
||||
#expect(content?.first?["text"] as? String == "[Image]")
|
||||
}
|
||||
|
||||
// MARK: - parseResponse
|
||||
|
||||
@Test("Parses text content, usage, and stop_reason from a well-formed response")
|
||||
func parsesWellFormedResponse() throws {
|
||||
let json: [String: Any] = [
|
||||
"id": "msg_123",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [["type": "text", "text": "hello there"]],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": ["input_tokens": 10, "output_tokens": 5]
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: json)
|
||||
let response = try provider.parseResponse(data: data)
|
||||
#expect(response.id == "msg_123")
|
||||
#expect(response.content == "hello there")
|
||||
#expect(response.finishReason == "end_turn")
|
||||
#expect(response.usage?.promptTokens == 10)
|
||||
#expect(response.usage?.completionTokens == 5)
|
||||
#expect(response.toolCalls == nil)
|
||||
}
|
||||
|
||||
@Test("Parses a tool_use block into a ToolCallInfo with JSON-encoded arguments")
|
||||
func parsesToolUseBlock() throws {
|
||||
let json: [String: Any] = [
|
||||
"id": "msg_1",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"content": [
|
||||
["type": "tool_use", "id": "tool_1", "name": "get_weather", "input": ["city": "Oslo"]]
|
||||
],
|
||||
"usage": ["input_tokens": 1, "output_tokens": 1]
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: json)
|
||||
let response = try provider.parseResponse(data: data)
|
||||
#expect(response.toolCalls?.count == 1)
|
||||
#expect(response.toolCalls?.first?.functionName == "get_weather")
|
||||
#expect(response.toolCalls?.first?.arguments.contains("Oslo") == true)
|
||||
}
|
||||
|
||||
@Test("Throws invalidResponse for non-JSON data")
|
||||
func throwsOnInvalidJSON() {
|
||||
let data = "not json".data(using: .utf8)!
|
||||
#expect(throws: (any Error).self) {
|
||||
try provider.parseResponse(data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - convertParametersToDict
|
||||
|
||||
@Test("Converts tool parameters to Anthropic's input_schema dict shape")
|
||||
func convertsParametersToInputSchema() {
|
||||
let params = Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: [
|
||||
"city": Tool.Function.Parameters.Property(type: "string", description: "City name"),
|
||||
"unit": Tool.Function.Parameters.Property(type: "string", description: "Unit", enum: ["celsius", "fahrenheit"])
|
||||
],
|
||||
required: ["city"]
|
||||
)
|
||||
let dict = provider.convertParametersToDict(params)
|
||||
#expect(dict["type"] as? String == "object")
|
||||
#expect(dict["required"] as? [String] == ["city"])
|
||||
let properties = dict["properties"] as? [String: Any]
|
||||
let cityProp = properties?["city"] as? [String: Any]
|
||||
#expect(cityProp?["type"] as? String == "string")
|
||||
let unitProp = properties?["unit"] as? [String: Any]
|
||||
#expect(unitProp?["enum"] as? [String] == ["celsius", "fahrenheit"])
|
||||
}
|
||||
|
||||
@Test("Array-typed properties include a nested items schema")
|
||||
func convertsArrayItemsSchema() {
|
||||
let params = Tool.Function.Parameters(
|
||||
type: "object",
|
||||
properties: [
|
||||
"tags": Tool.Function.Parameters.Property(type: "array", description: "Tags", items: .init(type: "string"))
|
||||
],
|
||||
required: nil
|
||||
)
|
||||
let dict = provider.convertParametersToDict(params)
|
||||
let properties = dict["properties"] as? [String: Any]
|
||||
let tagsProp = properties?["tags"] as? [String: Any]
|
||||
let items = tagsProp?["items"] as? [String: Any]
|
||||
#expect(items?["type"] as? String == "string")
|
||||
#expect(dict["required"] == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// ChatViewModelPureLogicTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Suite("ChatViewModel pure static helpers")
|
||||
struct ChatViewModelPureLogicTests {
|
||||
|
||||
// MARK: - detectGoodbyePhrase
|
||||
|
||||
@Test("Recognizes a clear farewell phrase")
|
||||
func detectsGoodbye() {
|
||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "OK, bye!"))
|
||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "That's all, thanks."))
|
||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "Have a good day"))
|
||||
}
|
||||
|
||||
@Test("Does not false-positive on a word that merely contains a phrase as a substring")
|
||||
func doesNotMatchSubstring() {
|
||||
// "bye" is a whole-word match target -- "goodbyeee" should not match "bye"
|
||||
// as a substring because of the \b word-boundary regex.
|
||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "goodbyeee is not a real word"))
|
||||
}
|
||||
|
||||
@Test("Does not match ordinary polite phrases that aren't farewells")
|
||||
func doesNotMatchPoliteRequests() {
|
||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Thanks, that's helpful!"))
|
||||
#expect(!ChatViewModel.detectGoodbyePhrase(in: "Done with step one, what's next?"))
|
||||
}
|
||||
|
||||
@Test("Matching is case-insensitive")
|
||||
func matchIsCaseInsensitive() {
|
||||
#expect(ChatViewModel.detectGoodbyePhrase(in: "BYE BYE"))
|
||||
}
|
||||
|
||||
// MARK: - inferProvider
|
||||
|
||||
@Test("Model ID with a slash is inferred as OpenRouter")
|
||||
func infersOpenRouter() {
|
||||
#expect(ChatViewModel.inferProvider(from: "anthropic/claude-3-5-sonnet") == .openrouter)
|
||||
}
|
||||
|
||||
@Test("claude- prefixed model is inferred as direct Anthropic")
|
||||
func infersAnthropic() {
|
||||
#expect(ChatViewModel.inferProvider(from: "claude-sonnet-4-5-20250929") == .anthropic)
|
||||
}
|
||||
|
||||
@Test("gpt-/o1/o3/dall-e/chatgpt prefixed models are inferred as OpenAI")
|
||||
func infersOpenAI() {
|
||||
#expect(ChatViewModel.inferProvider(from: "gpt-4o") == .openai)
|
||||
#expect(ChatViewModel.inferProvider(from: "o1-preview") == .openai)
|
||||
#expect(ChatViewModel.inferProvider(from: "o3-mini") == .openai)
|
||||
#expect(ChatViewModel.inferProvider(from: "dall-e-3") == .openai)
|
||||
#expect(ChatViewModel.inferProvider(from: "chatgpt-4o-latest") == .openai)
|
||||
}
|
||||
|
||||
@Test("A bare local model name with no recognized prefix falls back to Ollama")
|
||||
func fallsBackToOllama() {
|
||||
#expect(ChatViewModel.inferProvider(from: "llama3.2") == .ollama)
|
||||
}
|
||||
|
||||
// MARK: - calculateCost
|
||||
|
||||
@Test("Base prompt and completion cost with no cache usage")
|
||||
func calculatesBaseCost() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 18.0)
|
||||
}
|
||||
|
||||
@Test("Cache read tokens are charged at 0.1x the prompt rate")
|
||||
func calculatesCacheReadCost() {
|
||||
let usage = ChatResponse.Usage(
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
cacheReadInputTokens: 1_000_000
|
||||
)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.3)
|
||||
}
|
||||
|
||||
@Test("Cache write tokens are charged at 1.25x the prompt rate")
|
||||
func calculatesCacheWriteCost() {
|
||||
let usage = ChatResponse.Usage(
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 1_000_000,
|
||||
cacheCreationInputTokens: 1_000_000
|
||||
)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 3.75)
|
||||
}
|
||||
|
||||
@Test("Zero usage produces zero cost")
|
||||
func zeroUsageIsZeroCost() {
|
||||
let usage = ChatResponse.Usage(promptTokens: 0, completionTokens: 0, totalTokens: 0)
|
||||
let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0)
|
||||
#expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// ContextSelectionServiceTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Suite("ContextSelectionService pure scoring helpers")
|
||||
struct ContextSelectionServiceTests {
|
||||
|
||||
private var service: ContextSelectionService { ContextSelectionService.shared }
|
||||
|
||||
// MARK: - getImportanceScore
|
||||
|
||||
@Test("A short message with no cost or token data scores from length alone")
|
||||
func lowScoreForPlainShortMessage() {
|
||||
let message = Message(role: .user, content: "hi")
|
||||
#expect(service.getImportanceScore(message) < 0.01)
|
||||
}
|
||||
|
||||
@Test("Cost, length, and token factors combine to the exact weighted sum")
|
||||
func combinesWeightedFactors() {
|
||||
// cost 0.005 / 0.01 = 0.5 -> * 0.5 weight = 0.25
|
||||
// 1000 chars / 2000 = 0.5 -> * 0.3 weight = 0.15
|
||||
// 500 tokens / 1000 = 0.5 -> * 0.2 weight = 0.1
|
||||
// total = 0.5
|
||||
let message = Message(role: .assistant, content: String(repeating: "a", count: 1000), tokens: 500, cost: 0.005)
|
||||
#expect(abs(service.getImportanceScore(message) - 0.5) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Score is capped at 1.0 even when every factor maxes out and would otherwise overflow it")
|
||||
func scoreIsCappedAtOne() {
|
||||
let message = Message(
|
||||
role: .assistant,
|
||||
content: String(repeating: "a", count: 5000), // well past the 2000-char max
|
||||
tokens: 5000, // well past the 1000-token max
|
||||
cost: 1.0 // well past the $0.01 max
|
||||
)
|
||||
#expect(service.getImportanceScore(message) == 1.0)
|
||||
}
|
||||
|
||||
// MARK: - estimateTokens
|
||||
|
||||
@Test("Uses the message's actual token count when present")
|
||||
func usesActualTokenCountWhenAvailable() {
|
||||
let messages = [Message(role: .user, content: "irrelevant for this test", tokens: 42)]
|
||||
#expect(service.estimateTokens(messages) == 42)
|
||||
}
|
||||
|
||||
@Test("Falls back to content.count / 4 when tokens is nil")
|
||||
func fallsBackToCharacterEstimate() {
|
||||
let messages = [Message(role: .user, content: String(repeating: "x", count: 40))]
|
||||
#expect(service.estimateTokens(messages) == 10)
|
||||
}
|
||||
|
||||
@Test("Sums estimates across a mix of messages with and without token counts")
|
||||
func sumsAcrossMixedMessages() {
|
||||
let messages = [
|
||||
Message(role: .user, content: "ignored", tokens: 100),
|
||||
Message(role: .assistant, content: String(repeating: "y", count: 20)), // 20/4 = 5
|
||||
]
|
||||
#expect(service.estimateTokens(messages) == 105)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// EmbeddingServiceTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Suite("EmbeddingService serialization")
|
||||
struct EmbeddingServiceTests {
|
||||
|
||||
@Test("Serialize then deserialize round-trips an embedding exactly")
|
||||
func roundTrip() {
|
||||
let original: [Float] = [0.0, 1.0, -1.0, 3.14159, -0.0001, 1_000_000.5]
|
||||
let data = EmbeddingService.shared.serializeEmbedding(original)
|
||||
let restored = EmbeddingService.shared.deserializeEmbedding(data)
|
||||
#expect(restored == original)
|
||||
}
|
||||
|
||||
@Test("Serialized data is exactly 4 bytes per float")
|
||||
func serializedSizeIsFourBytesPerFloat() {
|
||||
let embedding: [Float] = Array(repeating: 0.5, count: 10)
|
||||
let data = EmbeddingService.shared.serializeEmbedding(embedding)
|
||||
#expect(data.count == 40)
|
||||
}
|
||||
|
||||
@Test("Empty embedding serializes to empty data and back")
|
||||
func emptyEmbedding() {
|
||||
let data = EmbeddingService.shared.serializeEmbedding([])
|
||||
#expect(data.isEmpty)
|
||||
#expect(EmbeddingService.shared.deserializeEmbedding(data).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// GitSyncServiceTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Suite("GitSyncService pure helpers")
|
||||
struct GitSyncServiceTests {
|
||||
|
||||
private var service: GitSyncService { GitSyncService.shared }
|
||||
|
||||
// MARK: - convertToSSH
|
||||
|
||||
@Test("Already-SSH URLs pass through unchanged")
|
||||
func convertToSSHAlreadySSH() {
|
||||
#expect(service.convertToSSH("git@github.com:user/repo.git") == "git@github.com:user/repo.git")
|
||||
}
|
||||
|
||||
@Test("HTTPS URL converts to SSH form")
|
||||
func convertToSSHFromHTTPS() {
|
||||
#expect(service.convertToSSH("https://gitlab.pm/rune/oAI-Sync.git") == "git@gitlab.pm:rune/oAI-Sync.git")
|
||||
}
|
||||
|
||||
@Test("HTTP URL converts to SSH form")
|
||||
func convertToSSHFromHTTP() {
|
||||
#expect(service.convertToSSH("http://example.com/user/repo.git") == "git@example.com:user/repo.git")
|
||||
}
|
||||
|
||||
@Test("Malformed URL with no path segment is returned unchanged")
|
||||
func convertToSSHMalformedNoSlash() {
|
||||
#expect(service.convertToSSH("https://onlyhost") == "https://onlyhost")
|
||||
}
|
||||
|
||||
@Test("Unknown scheme is returned unchanged")
|
||||
func convertToSSHUnknownScheme() {
|
||||
#expect(service.convertToSSH("ftp://example.com/repo") == "ftp://example.com/repo")
|
||||
}
|
||||
|
||||
// MARK: - injectCredentials
|
||||
|
||||
@Test("Injects username and password into an HTTPS URL")
|
||||
func injectCredentialsHTTPS() {
|
||||
let result = service.injectCredentials("https://gitlab.pm/rune/repo.git", username: "rune", password: "secret")
|
||||
#expect(result == "https://rune:secret@gitlab.pm/rune/repo.git")
|
||||
}
|
||||
|
||||
@Test("Non-HTTPS URLs are returned unchanged (credentials not injected)")
|
||||
func injectCredentialsNonHTTPS() {
|
||||
let result = service.injectCredentials("git@gitlab.pm:rune/repo.git", username: "rune", password: "secret")
|
||||
#expect(result == "git@gitlab.pm:rune/repo.git")
|
||||
}
|
||||
|
||||
// MARK: - sanitizeFilename
|
||||
|
||||
@Test("Strips filesystem-invalid characters, replacing each with a dash")
|
||||
func sanitizeFilenameStripsInvalidChars() {
|
||||
#expect(service.sanitizeFilename("a/b\\c:d*e?f\"g<h>i|j") == "a-b-c-d-e-f-g-h-i-j")
|
||||
}
|
||||
|
||||
@Test("Leaves an already-valid filename untouched")
|
||||
func sanitizeFilenameValidInput() {
|
||||
#expect(service.sanitizeFilename("My Chat 2026-01-15") == "My Chat 2026-01-15")
|
||||
}
|
||||
|
||||
// MARK: - detectSecretsInText
|
||||
|
||||
@Test("Detects an OpenAI-style API key")
|
||||
func detectSecretsOpenAIKey() {
|
||||
let text = "here's my key: sk-abcdefghijklmnopqrstuvwxyz123456"
|
||||
#expect(service.detectSecretsInText(text).contains("OpenAI Key"))
|
||||
}
|
||||
|
||||
@Test("Detects a GitHub personal access token")
|
||||
func detectSecretsGitHubToken() {
|
||||
let text = "token=ghp_abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
#expect(service.detectSecretsInText(text).contains("Access Token"))
|
||||
}
|
||||
|
||||
@Test("Plain conversational text has no detected secrets")
|
||||
func detectSecretsCleanText() {
|
||||
#expect(service.detectSecretsInText("Just a normal conversation about the weather.").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Does not flag an OpenAI-shaped string one character short of the length threshold")
|
||||
func detectSecretsJustBelowThreshold() {
|
||||
// "sk-" + 31 chars = 34 total, but the pattern requires 32+ chars after "sk-"
|
||||
let shortKey = "sk-" + String(repeating: "a", count: 31)
|
||||
#expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// OllamaProviderTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("OllamaProvider request/response conversion")
|
||||
struct OllamaProviderTests {
|
||||
|
||||
private var provider: OllamaProvider { OllamaProvider() }
|
||||
|
||||
// MARK: - buildRequestBody
|
||||
|
||||
@Test("Builds a basic request body with model, messages, and stream flag")
|
||||
func basicRequestBody() {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
|
||||
let body = provider.buildRequestBody(from: request, stream: true)
|
||||
#expect(body["model"] as? String == "llama3.2")
|
||||
#expect(body["stream"] as? Bool == true)
|
||||
let messages = body["messages"] as? [[String: Any]]
|
||||
#expect(messages?.count == 1)
|
||||
#expect(messages?.first?["role"] as? String == "user")
|
||||
#expect(messages?.first?["content"] as? String == "hi")
|
||||
}
|
||||
|
||||
@Test("System prompt is prepended as its own system-role message")
|
||||
func systemPromptPrepended() {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", systemPrompt: "be concise")
|
||||
let body = provider.buildRequestBody(from: request, stream: false)
|
||||
let messages = body["messages"] as? [[String: Any]]
|
||||
#expect(messages?.count == 2)
|
||||
#expect(messages?.first?["role"] as? String == "system")
|
||||
#expect(messages?.first?["content"] as? String == "be concise")
|
||||
}
|
||||
|
||||
@Test("maxTokens and temperature are nested under options only when present")
|
||||
func optionsOnlyWhenPresent() {
|
||||
let withOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2", maxTokens: 500, temperature: 0.7)
|
||||
let bodyWithOptions = provider.buildRequestBody(from: withOptions, stream: false)
|
||||
let options = bodyWithOptions["options"] as? [String: Any]
|
||||
#expect(options?["num_predict"] as? Int == 500)
|
||||
#expect(options?["temperature"] as? Double == 0.7)
|
||||
|
||||
let withoutOptions = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "llama3.2")
|
||||
let bodyWithoutOptions = provider.buildRequestBody(from: withoutOptions, stream: false)
|
||||
#expect(bodyWithoutOptions["options"] == nil)
|
||||
}
|
||||
|
||||
// MARK: - parseOllamaResponse
|
||||
|
||||
@Test("Parses content and token counts from a well-formed response")
|
||||
func parsesWellFormedResponse() {
|
||||
let json: [String: Any] = [
|
||||
"message": ["content": "the answer"],
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 20
|
||||
]
|
||||
let response = provider.parseOllamaResponse(json, model: "llama3.2")
|
||||
#expect(response.content == "the answer")
|
||||
#expect(response.role == "assistant")
|
||||
#expect(response.finishReason == "stop")
|
||||
#expect(response.usage?.promptTokens == 10)
|
||||
#expect(response.usage?.completionTokens == 20)
|
||||
#expect(response.usage?.totalTokens == 30)
|
||||
}
|
||||
|
||||
@Test("Missing fields default to empty content and zero token counts")
|
||||
func missingFieldsDefaultGracefully() {
|
||||
let response = provider.parseOllamaResponse([:], model: "llama3.2")
|
||||
#expect(response.content == "")
|
||||
#expect(response.usage?.promptTokens == 0)
|
||||
#expect(response.usage?.completionTokens == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// OpenAIProviderTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("OpenAIProvider request/response conversion")
|
||||
struct OpenAIProviderTests {
|
||||
|
||||
private var provider: OpenAIProvider { OpenAIProvider(apiKey: "test-key") }
|
||||
|
||||
// MARK: - buildURLRequest
|
||||
|
||||
@Test("Sets the Authorization header and JSON content type")
|
||||
func setsAuthAndContentTypeHeaders() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
|
||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Bearer test-key")
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == nil)
|
||||
}
|
||||
|
||||
@Test("Streaming requests add an SSE Accept header and stream_options")
|
||||
func streamingAddsAcceptHeaderAndUsageOption() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o")
|
||||
let urlRequest = try provider.buildURLRequest(from: request, stream: true)
|
||||
#expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/event-stream")
|
||||
|
||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||
let streamOptions = body["stream_options"] as? [String: Any]
|
||||
#expect(streamOptions?["include_usage"] as? Bool == true)
|
||||
}
|
||||
|
||||
@Test("o1/o3 reasoning models omit temperature even when one is requested")
|
||||
func reasoningModelsOmitTemperature() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "o1-preview", temperature: 0.7)
|
||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||
#expect(body["temperature"] == nil)
|
||||
}
|
||||
|
||||
@Test("Non-reasoning models include the requested temperature")
|
||||
func nonReasoningModelsIncludeTemperature() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "gpt-4o", temperature: 0.7)
|
||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||
#expect(body["temperature"] as? Double == 0.7)
|
||||
}
|
||||
|
||||
@Test("A message with an image attachment produces multi-part vision-format content")
|
||||
func imageAttachmentProducesMultipartContent() throws {
|
||||
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01]))
|
||||
let message = Message(role: .user, content: "look at this", attachments: [attachment])
|
||||
let request = ChatRequest(messages: [message], model: "gpt-4o")
|
||||
let urlRequest = try provider.buildURLRequest(from: request, stream: false)
|
||||
let body = try JSONSerialization.jsonObject(with: urlRequest.httpBody!) as! [String: Any]
|
||||
let messages = body["messages"] as? [[String: Any]]
|
||||
let content = messages?.first?["content"] as? [[String: Any]]
|
||||
#expect(content?.count == 2)
|
||||
#expect(content?.first?["type"] as? String == "text")
|
||||
#expect(content?.last?["type"] as? String == "image_url")
|
||||
}
|
||||
|
||||
// MARK: - convertToChatResponse
|
||||
|
||||
@Test("Returns an empty-content response when there are no choices, rather than throwing")
|
||||
func convertToChatResponseEmptyChoicesFallback() {
|
||||
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
|
||||
let response = provider.convertToChatResponse(apiResponse)
|
||||
#expect(response.content == "")
|
||||
#expect(response.role == "assistant")
|
||||
}
|
||||
|
||||
// MARK: - fallbackModels
|
||||
|
||||
@Test("Fallback models are non-empty, all support tools, and are sorted by name")
|
||||
func fallbackModelsAreWellFormed() {
|
||||
let models = provider.fallbackModels()
|
||||
#expect(!models.isEmpty)
|
||||
#expect(models.allSatisfy { $0.capabilities.tools == true })
|
||||
#expect(models.allSatisfy { $0.capabilities.online == false })
|
||||
#expect(models.map(\.name) == models.map(\.name).sorted())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// OpenRouterProviderTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("OpenRouterProvider request/response conversion")
|
||||
struct OpenRouterProviderTests {
|
||||
|
||||
private var provider: OpenRouterProvider { OpenRouterProvider(apiKey: "test-key") }
|
||||
|
||||
// MARK: - buildAPIRequest
|
||||
|
||||
@Test("Plain text message with no attachments uses simple string content")
|
||||
func buildRequestPlainText() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hello")], model: "openai/gpt-4o")
|
||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
||||
guard case .string(let text) = apiRequest.messages.first?.content else {
|
||||
Issue.record("Expected .string content for a message with no attachments")
|
||||
return
|
||||
}
|
||||
#expect(text == "hello")
|
||||
}
|
||||
|
||||
@Test("Message with an image attachment uses array content with a base64 data URL")
|
||||
func buildRequestWithImageAttachment() throws {
|
||||
let attachment = FileAttachment(path: "photo.png", type: .image, data: Data([0x01, 0x02]))
|
||||
let message = Message(role: .user, content: "check this out", attachments: [attachment])
|
||||
let request = ChatRequest(messages: [message], model: "openai/gpt-4o")
|
||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
||||
guard case .array(let items) = apiRequest.messages.first?.content else {
|
||||
Issue.record("Expected .array content for a message with attachments")
|
||||
return
|
||||
}
|
||||
#expect(items.count == 2) // text + image
|
||||
}
|
||||
|
||||
@Test("Online mode appends :online suffix, but not for image generation requests")
|
||||
func onlineModeSuffix() throws {
|
||||
let base = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true)
|
||||
let request = try provider.buildAPIRequest(from: base)
|
||||
#expect(request.model == "openai/gpt-4o:online")
|
||||
|
||||
let imageGenRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o", onlineMode: true, imageGeneration: true)
|
||||
let noSuffix = try provider.buildAPIRequest(from: imageGenRequest)
|
||||
#expect(noSuffix.model == "openai/gpt-4o")
|
||||
}
|
||||
|
||||
@Test("Online mode does not double-append :online if the model already has it")
|
||||
func onlineModeNoDoubleSuffix() throws {
|
||||
let request = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o:online", onlineMode: true)
|
||||
let apiRequest = try provider.buildAPIRequest(from: request)
|
||||
#expect(apiRequest.model == "openai/gpt-4o:online")
|
||||
}
|
||||
|
||||
@Test("Anthropic models get an explicit cache_control opt-in; others don't")
|
||||
func cacheControlOnlyForAnthropic() throws {
|
||||
let anthropicRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "anthropic/claude-sonnet-4-5")
|
||||
let anthropicAPI = try provider.buildAPIRequest(from: anthropicRequest)
|
||||
#expect(anthropicAPI.cacheControl?.type == "ephemeral")
|
||||
|
||||
let openAIRequest = ChatRequest(messages: [Message(role: .user, content: "hi")], model: "openai/gpt-4o")
|
||||
let openAIAPI = try provider.buildAPIRequest(from: openAIRequest)
|
||||
#expect(openAIAPI.cacheControl == nil)
|
||||
}
|
||||
|
||||
// MARK: - convertToChatResponse
|
||||
|
||||
@Test("Throws invalidResponse when there are no choices")
|
||||
func convertToChatResponseThrowsOnEmptyChoices() {
|
||||
let apiResponse = OpenRouterChatResponse(id: "x", model: "m", choices: [], usage: nil, created: 0)
|
||||
#expect(throws: (any Error).self) {
|
||||
try provider.convertToChatResponse(apiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - decodeImageOutputs
|
||||
|
||||
@Test("Decodes a base64 data URL into raw Data")
|
||||
func decodeImageOutputsValidDataURL() {
|
||||
let payload = Data([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
let dataURL = "data:image/png;base64,\(payload.base64EncodedString())"
|
||||
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: dataURL))
|
||||
let decoded = provider.decodeImageOutputs([output])
|
||||
#expect(decoded?.first == payload)
|
||||
}
|
||||
|
||||
@Test("Returns nil for an empty outputs array")
|
||||
func decodeImageOutputsEmpty() {
|
||||
#expect(provider.decodeImageOutputs([]) == nil)
|
||||
}
|
||||
|
||||
@Test("Skips a malformed URL with no comma separator")
|
||||
func decodeImageOutputsMalformedURL() {
|
||||
let output = OpenRouterChatResponse.ImageOutput(imageUrl: .init(url: "not-a-data-url"))
|
||||
#expect(provider.decodeImageOutputs([output]) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user