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
|
// MARK: - Serialization
|
||||||
|
|
||||||
/// Serialize embedding to binary data (4 bytes per float, little-endian)
|
/// 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)
|
var data = Data(capacity: embedding.count * 4)
|
||||||
for value in embedding {
|
for value in embedding {
|
||||||
var littleEndian = value.bitPattern.littleEndian
|
var littleEndian = value.bitPattern.littleEndian
|
||||||
@@ -360,7 +360,7 @@ final class EmbeddingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deserialize embedding from binary data
|
/// Deserialize embedding from binary data
|
||||||
func deserializeEmbedding(_ data: Data) -> [Float] {
|
nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||||
var embedding: [Float] = []
|
var embedding: [Float] = []
|
||||||
embedding.reserveCapacity(data.count / 4)
|
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) }
|
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")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
// 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
|
/// 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 lowercased = text.lowercased()
|
||||||
let goodbyePhrases = [
|
let goodbyePhrases = [
|
||||||
"bye", "goodbye", "bye bye", "good bye",
|
"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
|
/// pricing when present: cache writes cost 1.25x the base input rate, cache
|
||||||
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
|
/// reads cost 0.1x. `usage.promptTokens` is already the uncached remainder —
|
||||||
/// it does not need cache tokens subtracted from it.
|
/// 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 inputCost = Double(usage.promptTokens) * pricing.prompt / 1_000_000
|
||||||
let cacheReadCost = Double(usage.cacheReadInputTokens ?? 0) * pricing.prompt * 0.1 / 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
|
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