Files
oai-swift/oAITests/ChatViewModelPureLogicTests.swift
T
rune a053d4a983 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).
2026-07-23 14:11:50 +02:00

107 lines
4.1 KiB
Swift

//
// 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)
}
}