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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user