Phase 1: pure-logic tests requiring zero production code changes
27 tests across 4 files, all targeting code that was already testable the moment the target existed -- no visibility bumps, no refactors: - GitignoreParserTests: MCPService.GitignoreParser's glob-to-regex matching (wildcards, **, directory anchors, negation, comments). - OpenRouterModelsTests: the string-vs-content-block-array decoders on both the request side (APIMessage.MessageContent/ContentItem) and response side (Choice.MessageContent, StreamChoice.Delta), including image extraction from content blocks. - AIProviderTests: ChatResponse/Usage decoding, with an explicit regression guard that Usage.rawCostUSD always decodes to nil (it's only ever set programmatically, never from API JSON). - MessageCodableTests: confirms Message's transient fields (isStreaming, isStarred, generatedImages, toolCalls, thinkingContent) don't survive an encode/decode round-trip, and documents that its custom == deliberately ignores role/timestamp/ attachments/modelId -- a real but non-obvious behavior worth locking in with a test. Removed the placeholder oAITests.swift example test now that there's real coverage. Phase 1 of the test-suite rollout plan (peaceful-baking-kurzweil).
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// AIProviderTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("ChatResponse / Usage decoding")
|
||||
struct AIProviderTests {
|
||||
|
||||
@Test("Usage decodes token counts and cache fields from snake_case keys")
|
||||
func usageDecodesTokenCounts() throws {
|
||||
let json = """
|
||||
{
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"total_tokens": 150,
|
||||
"cache_creation_input_tokens": 20,
|
||||
"cache_read_input_tokens": 10
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
||||
#expect(usage.promptTokens == 100)
|
||||
#expect(usage.completionTokens == 50)
|
||||
#expect(usage.totalTokens == 150)
|
||||
#expect(usage.cacheCreationInputTokens == 20)
|
||||
#expect(usage.cacheReadInputTokens == 10)
|
||||
}
|
||||
|
||||
@Test("Usage.rawCostUSD is always nil after decoding, even if a matching key were present")
|
||||
func usageRawCostNeverDecoded() throws {
|
||||
// rawCostUSD has no CodingKeys entry at all -- it's only ever set via the
|
||||
// memberwise init, never from API JSON. Confirm decode always yields nil,
|
||||
// which is exactly the "$0.0000 cost" regression this guards against.
|
||||
let json = """
|
||||
{
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
"raw_cost_u_s_d": 5.0
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
||||
#expect(usage.rawCostUSD == nil)
|
||||
}
|
||||
|
||||
@Test("Usage cache fields are nil when absent from the response")
|
||||
func usageMissingCacheFields() throws {
|
||||
let json = #"{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}"#.data(using: .utf8)!
|
||||
let usage = try JSONDecoder().decode(ChatResponse.Usage.self, from: json)
|
||||
#expect(usage.cacheCreationInputTokens == nil)
|
||||
#expect(usage.cacheReadInputTokens == nil)
|
||||
}
|
||||
|
||||
@Test("ChatResponse decodes core fields and forces toolCalls/generatedImages to nil")
|
||||
func chatResponseDecodesCoreFieldsOnly() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "resp-1",
|
||||
"model": "test-model",
|
||||
"content": "hello there",
|
||||
"role": "assistant",
|
||||
"finishReason": "stop",
|
||||
"created": 1700000000
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let response = try JSONDecoder().decode(ChatResponse.self, from: json)
|
||||
#expect(response.id == "resp-1")
|
||||
#expect(response.model == "test-model")
|
||||
#expect(response.content == "hello there")
|
||||
#expect(response.role == "assistant")
|
||||
#expect(response.finishReason == "stop")
|
||||
// Not part of CodingKeys -- always nil straight out of decode.
|
||||
#expect(response.toolCalls == nil)
|
||||
#expect(response.generatedImages == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// GitignoreParserTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
@testable import oAI
|
||||
|
||||
@Suite("GitignoreParser")
|
||||
struct GitignoreParserTests {
|
||||
|
||||
private func parser(for content: String) -> GitignoreParser {
|
||||
var parser = GitignoreParser(rootPath: "/tmp/fake-root")
|
||||
parser.parseContent(content)
|
||||
return parser
|
||||
}
|
||||
|
||||
@Test("Simple extension wildcard matches at any depth")
|
||||
func extensionWildcard() {
|
||||
let p = parser(for: "*.log")
|
||||
#expect(p.isIgnored("debug.log"))
|
||||
#expect(p.isIgnored("nested/debug.log"))
|
||||
#expect(!p.isIgnored("debug.txt"))
|
||||
}
|
||||
|
||||
@Test("Directory pattern matches the directory and everything under it")
|
||||
func directoryPattern() {
|
||||
let p = parser(for: "build/")
|
||||
#expect(p.isIgnored("build"))
|
||||
#expect(p.isIgnored("build/output.txt"))
|
||||
#expect(p.isIgnored("foo/build/output.txt"))
|
||||
#expect(!p.isIgnored("rebuild"))
|
||||
}
|
||||
|
||||
@Test("Path-anchored pattern only matches that exact relative path, not nested occurrences")
|
||||
func pathAnchoredWildcard() {
|
||||
let p = parser(for: "src/*.tmp")
|
||||
#expect(p.isIgnored("src/foo.tmp"))
|
||||
#expect(!p.isIgnored("other/src/foo.tmp"))
|
||||
#expect(!p.isIgnored("src/sub/foo.tmp"))
|
||||
}
|
||||
|
||||
@Test("Double-star matches across any number of intermediate directories")
|
||||
func doubleStarPattern() {
|
||||
let p = parser(for: "**/logs")
|
||||
#expect(p.isIgnored("logs"))
|
||||
#expect(p.isIgnored("a/logs"))
|
||||
#expect(p.isIgnored("a/b/logs"))
|
||||
#expect(p.isIgnored("a/logs/file.txt"))
|
||||
#expect(!p.isIgnored("logsfile"))
|
||||
}
|
||||
|
||||
@Test("Negation un-ignores a path matched by an earlier rule")
|
||||
func negation() {
|
||||
let p = parser(for: "*.log\n!important.log")
|
||||
#expect(p.isIgnored("debug.log"))
|
||||
#expect(!p.isIgnored("important.log"))
|
||||
}
|
||||
|
||||
@Test("Comments and blank lines are skipped, not treated as patterns")
|
||||
func commentsAndBlankLines() {
|
||||
let p = parser(for: "# a comment\n\n*.log\n \n")
|
||||
#expect(p.isIgnored("x.log"))
|
||||
#expect(!p.isIgnored("# a comment"))
|
||||
}
|
||||
|
||||
@Test("Leading slash still matches (root-anchoring is not enforced by this implementation)")
|
||||
func leadingSlashPattern() {
|
||||
// Documents actual current behavior: the leading "/" is stripped without
|
||||
// truly restricting the match to the root level, unlike real git.
|
||||
let p = parser(for: "/dist")
|
||||
#expect(p.isIgnored("dist"))
|
||||
#expect(p.isIgnored("dist/file.txt"))
|
||||
}
|
||||
|
||||
@Test("No rules means nothing is ignored")
|
||||
func noRules() {
|
||||
let p = parser(for: "")
|
||||
#expect(!p.isIgnored("anything.txt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// MessageCodableTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("Message Codable + Equatable")
|
||||
struct MessageCodableTests {
|
||||
|
||||
@Test("Transient fields don't survive an encode/decode round-trip")
|
||||
func transientFieldsDoNotPersist() throws {
|
||||
var message = Message(role: .assistant, content: "hi", isStreaming: true, generatedImages: [Data([0x01])])
|
||||
message.isStarred = true
|
||||
message.toolCalls = [ToolCallDetail(name: "test_tool", input: "{}", result: nil)]
|
||||
message.thinkingContent = "reasoning..."
|
||||
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(Message.self, from: data)
|
||||
|
||||
#expect(decoded.content == "hi")
|
||||
#expect(decoded.isStreaming == false)
|
||||
#expect(decoded.isStarred == false)
|
||||
#expect(decoded.generatedImages == nil)
|
||||
#expect(decoded.toolCalls == nil)
|
||||
#expect(decoded.thinkingContent == nil)
|
||||
}
|
||||
|
||||
@Test("Persisted fields survive an encode/decode round-trip")
|
||||
func persistedFieldsSurvive() throws {
|
||||
let message = Message(
|
||||
role: .user,
|
||||
content: "what's the weather",
|
||||
tokens: 42,
|
||||
cost: 0.0012,
|
||||
responseTime: 1.5,
|
||||
wasInterrupted: true,
|
||||
modelId: "claude-sonnet-5"
|
||||
)
|
||||
let data = try JSONEncoder().encode(message)
|
||||
let decoded = try JSONDecoder().decode(Message.self, from: data)
|
||||
|
||||
#expect(decoded.id == message.id)
|
||||
#expect(decoded.role == message.role)
|
||||
#expect(decoded.content == message.content)
|
||||
#expect(decoded.tokens == message.tokens)
|
||||
#expect(decoded.cost == message.cost)
|
||||
#expect(decoded.responseTime == message.responseTime)
|
||||
#expect(decoded.wasInterrupted == message.wasInterrupted)
|
||||
#expect(decoded.modelId == message.modelId)
|
||||
}
|
||||
|
||||
@Test("Custom == ignores role, timestamp, attachments, and modelId")
|
||||
func equalityIgnoresSomeFields() {
|
||||
let base = Message(id: UUID(), role: .user, content: "same", timestamp: Date())
|
||||
let differentRoleAndModel = Message(
|
||||
id: base.id,
|
||||
role: .assistant,
|
||||
content: "same",
|
||||
timestamp: Date(timeIntervalSince1970: 0),
|
||||
modelId: "some-other-model"
|
||||
)
|
||||
// Documents actual current behavior: these fields are intentionally
|
||||
// excluded from Message's custom == -- surprising if you don't know it.
|
||||
#expect(base == differentRoleAndModel)
|
||||
}
|
||||
|
||||
@Test("Custom == does distinguish on content")
|
||||
func equalityDistinguishesContent() {
|
||||
let a = Message(id: UUID(), role: .user, content: "one")
|
||||
let b = Message(id: a.id, role: .user, content: "two")
|
||||
#expect(a != b)
|
||||
}
|
||||
|
||||
@Test("FileAttachment.typeFromExtension recognizes common types")
|
||||
func attachmentTypeFromExtension() {
|
||||
#expect(FileAttachment.typeFromExtension("photo.PNG") == .image)
|
||||
#expect(FileAttachment.typeFromExtension("photo.jpg") == .image)
|
||||
#expect(FileAttachment.typeFromExtension("doc.pdf") == .pdf)
|
||||
#expect(FileAttachment.typeFromExtension("notes.md") == .text)
|
||||
#expect(FileAttachment.typeFromExtension("noextension") == .text)
|
||||
}
|
||||
|
||||
@Test("FileAttachment.mimeType matches the detected extension")
|
||||
func attachmentMimeType() {
|
||||
let attachment = FileAttachment(path: "image.webp", type: .image, data: nil)
|
||||
#expect(attachment.mimeType == "image/webp")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// OpenRouterModelsTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import oAI
|
||||
|
||||
@Suite("OpenRouterModels decoding")
|
||||
struct OpenRouterModelsTests {
|
||||
|
||||
// MARK: - Request-side: APIMessage.MessageContent (string-or-array union)
|
||||
|
||||
@Test("Request message content decodes a plain string")
|
||||
func requestContentPlainString() throws {
|
||||
let json = #"{"role":"user","content":"hello"}"#.data(using: .utf8)!
|
||||
let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json)
|
||||
guard case .string(let text) = message.content else {
|
||||
Issue.record("Expected .string case")
|
||||
return
|
||||
}
|
||||
#expect(text == "hello")
|
||||
}
|
||||
|
||||
@Test("Request message content decodes an array of content items")
|
||||
func requestContentArray() throws {
|
||||
let json = #"{"role":"user","content":[{"type":"text","text":"hi"}]}"#.data(using: .utf8)!
|
||||
let message = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.self, from: json)
|
||||
guard case .array(let items) = message.content else {
|
||||
Issue.record("Expected .array case")
|
||||
return
|
||||
}
|
||||
#expect(items.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Request-side: ContentItem (text / image union, with string fallback)
|
||||
|
||||
@Test("ContentItem decodes a text block")
|
||||
func contentItemText() throws {
|
||||
let json = #"{"type":"text","text":"hi there"}"#.data(using: .utf8)!
|
||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
||||
guard case .text(let text) = item else {
|
||||
Issue.record("Expected .text case")
|
||||
return
|
||||
}
|
||||
#expect(text == "hi there")
|
||||
}
|
||||
|
||||
@Test("ContentItem decodes an image_url block")
|
||||
func contentItemImage() throws {
|
||||
let json = #"{"type":"image_url","image_url":{"url":"https://example.com/x.png"}}"#.data(using: .utf8)!
|
||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
||||
guard case .image(let image) = item else {
|
||||
Issue.record("Expected .image case")
|
||||
return
|
||||
}
|
||||
#expect(image.imageUrl.url == "https://example.com/x.png")
|
||||
}
|
||||
|
||||
@Test("ContentItem falls back to .text for a bare string")
|
||||
func contentItemBareStringFallback() throws {
|
||||
let json = #""just text""#.data(using: .utf8)!
|
||||
let item = try JSONDecoder().decode(OpenRouterChatRequest.APIMessage.ContentItem.self, from: json)
|
||||
guard case .text(let text) = item else {
|
||||
Issue.record("Expected .text fallback case")
|
||||
return
|
||||
}
|
||||
#expect(text == "just text")
|
||||
}
|
||||
|
||||
// MARK: - Response-side: Choice.MessageContent (plain string vs content-block array)
|
||||
|
||||
@Test("Response message content decodes a plain string with no image blocks")
|
||||
func responseContentPlainString() throws {
|
||||
let json = #"{"role":"assistant","content":"the answer"}"#.data(using: .utf8)!
|
||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
||||
#expect(content.content == "the answer")
|
||||
#expect(content.contentBlockImages.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Response message content extracts text and images from a content-block array")
|
||||
func responseContentBlockArrayWithImage() throws {
|
||||
let json = """
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "here you go"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/out.png"}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
||||
#expect(content.content == "here you go")
|
||||
#expect(content.contentBlockImages.count == 1)
|
||||
#expect(content.contentBlockImages.first?.imageUrl.url == "https://example.com/out.png")
|
||||
}
|
||||
|
||||
@Test("Response message content with no content field at all decodes to nil content, no images")
|
||||
func responseContentMissing() throws {
|
||||
let json = #"{"role":"assistant"}"#.data(using: .utf8)!
|
||||
let content = try JSONDecoder().decode(OpenRouterChatResponse.Choice.MessageContent.self, from: json)
|
||||
#expect(content.content == nil)
|
||||
#expect(content.contentBlockImages.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Streaming: StreamChoice.Delta (same string-or-block-array shape, streamed)
|
||||
|
||||
@Test("Stream delta decodes a plain string content chunk")
|
||||
func streamDeltaPlainString() throws {
|
||||
let json = #"{"role":"assistant","content":"partial"}"#.data(using: .utf8)!
|
||||
let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json)
|
||||
#expect(delta.content == "partial")
|
||||
#expect(delta.contentBlockImages.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Stream delta extracts images from a content-block array")
|
||||
func streamDeltaContentBlockArrayWithImage() throws {
|
||||
let json = """
|
||||
{
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/stream.png"}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let delta = try JSONDecoder().decode(OpenRouterStreamChunk.StreamChoice.Delta.self, from: json)
|
||||
#expect(delta.contentBlockImages.count == 1)
|
||||
#expect(delta.contentBlockImages.first?.imageUrl.url == "https://example.com/stream.png")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// oAITests.swift
|
||||
// oAITests
|
||||
//
|
||||
// Created by Rune Olsen on 23/07/2026.
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
struct oAITests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
// Swift Testing Documentation
|
||||
// https://developer.apple.com/documentation/testing
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user