From 1f4a2caf670aac16ce0ca9637228438b1037e68e Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 13:37:49 +0200 Subject: [PATCH] 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). --- oAITests/AIProviderTests.swift | 81 ++++++++++++++++ oAITests/GitignoreParserTests.swift | 83 +++++++++++++++++ oAITests/MessageCodableTests.swift | 93 +++++++++++++++++++ oAITests/OpenRouterModelsTests.swift | 132 +++++++++++++++++++++++++++ oAITests/oAITests.swift | 18 ---- 5 files changed, 389 insertions(+), 18 deletions(-) create mode 100644 oAITests/AIProviderTests.swift create mode 100644 oAITests/GitignoreParserTests.swift create mode 100644 oAITests/MessageCodableTests.swift create mode 100644 oAITests/OpenRouterModelsTests.swift delete mode 100644 oAITests/oAITests.swift diff --git a/oAITests/AIProviderTests.swift b/oAITests/AIProviderTests.swift new file mode 100644 index 0000000..65e391e --- /dev/null +++ b/oAITests/AIProviderTests.swift @@ -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) + } +} diff --git a/oAITests/GitignoreParserTests.swift b/oAITests/GitignoreParserTests.swift new file mode 100644 index 0000000..ac77942 --- /dev/null +++ b/oAITests/GitignoreParserTests.swift @@ -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")) + } +} diff --git a/oAITests/MessageCodableTests.swift b/oAITests/MessageCodableTests.swift new file mode 100644 index 0000000..5bfb14b --- /dev/null +++ b/oAITests/MessageCodableTests.swift @@ -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") + } +} diff --git a/oAITests/OpenRouterModelsTests.swift b/oAITests/OpenRouterModelsTests.swift new file mode 100644 index 0000000..460d7df --- /dev/null +++ b/oAITests/OpenRouterModelsTests.swift @@ -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") + } +} diff --git a/oAITests/oAITests.swift b/oAITests/oAITests.swift deleted file mode 100644 index c99a30f..0000000 --- a/oAITests/oAITests.swift +++ /dev/null @@ -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 - } - -}