// // 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) } @Test("apple- prefixed model is inferred as Apple on-device") func infersAppleOnDevice() { #expect(ChatViewModel.inferProvider(from: "apple-on-device") == .appleOnDevice) } // 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) } // MARK: - shouldAutoSave private func autoSaveEligible( syncEnabled: Bool = true, syncAutoSave: Bool = true, syncConfigured: Bool = true, isCloned: Bool = true, chatMessageCount: Int = 10, minMessages: Int = 4, lastSavedConversationId: String? = nil, currentConversationHash: String = "hash-a" ) -> Bool { ChatViewModel.shouldAutoSave( syncEnabled: syncEnabled, syncAutoSave: syncAutoSave, syncConfigured: syncConfigured, isCloned: isCloned, chatMessageCount: chatMessageCount, minMessages: minMessages, lastSavedConversationId: lastSavedConversationId, currentConversationHash: currentConversationHash ) } @Test("Eligible when every criterion is satisfied") func autoSaveEligibleWhenAllCriteriaMet() { #expect(autoSaveEligible()) } @Test("Not eligible when sync or auto-save is disabled") func autoSaveIneligibleWhenDisabled() { #expect(!autoSaveEligible(syncEnabled: false)) #expect(!autoSaveEligible(syncAutoSave: false)) } @Test("Not eligible when sync isn't configured or the repo isn't cloned") func autoSaveIneligibleWhenNotConfiguredOrCloned() { #expect(!autoSaveEligible(syncConfigured: false)) #expect(!autoSaveEligible(isCloned: false)) } @Test("Not eligible below the minimum message count") func autoSaveIneligibleBelowMinimumMessages() { #expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4)) } @Test("Not eligible if this exact conversation was already auto-saved") func autoSaveIneligibleWhenAlreadySaved() { #expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a")) } @Test("Eligible when the last saved hash differs from the current conversation") func autoSaveEligibleWhenConversationChanged() { #expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b")) } }