Files
oai-swift/oAITests/ChatViewModelPureLogicTests.swift
T
rune 3414e37e24 Add per-conversation notes.md
Gives each conversation an opt-in, persistent memory file the model reads
automatically every turn and writes to on its own initiative via a fenced
```update-notes``` block in its reply — no per-write approval, matching the
Confab-as-CLAUDE.md-for-itself concept Rune wanted. /notes on|off|show,
files live in ~/Library/Application Support/oAI/notes/, embedded ID header
for future Git Sync compatibility. Adds DB migration v12.
2026-08-04 07:58:47 +02:00

160 lines
6.4 KiB
Swift

//
// ChatViewModelPureLogicTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
@testable import Confab
@Suite("ChatViewModel pure static helpers")
struct ChatViewModelPureLogicTests {
// 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: - draftFingerprint
@Test("Identical message content produces the same fingerprint")
func draftFingerprintStableForSameContent() {
let messages = [
Message(role: .user, content: "Hello there"),
Message(role: .assistant, content: "Hi! How can I help?")
]
#expect(ChatViewModel.draftFingerprint(for: messages) == ChatViewModel.draftFingerprint(for: messages))
}
@Test("Changed message content produces a different fingerprint")
func draftFingerprintChangesWithContent() {
let before = [Message(role: .user, content: "Hello there")]
let after = [Message(role: .user, content: "Hello there"), Message(role: .assistant, content: "Hi!")]
#expect(ChatViewModel.draftFingerprint(for: before) != ChatViewModel.draftFingerprint(for: after))
}
@Test("Empty message list has a stable fingerprint")
func draftFingerprintEmptyIsStable() {
#expect(ChatViewModel.draftFingerprint(for: []) == ChatViewModel.draftFingerprint(for: []))
}
// MARK: - buildNotesSection
@Test("Nil body produces no notes section at all")
func buildNotesSectionNilBodyIsEmpty() {
#expect(ChatViewModel.buildNotesSection(body: nil) == "")
}
@Test("Empty body still produces a section, with a placeholder for the notes content")
func buildNotesSectionEmptyBodyShowsPlaceholder() {
let section = ChatViewModel.buildNotesSection(body: "")
#expect(section.contains("## Conversation Notes"))
#expect(section.contains("nothing saved yet"))
}
@Test("Non-empty body is included verbatim")
func buildNotesSectionIncludesBody() {
let section = ChatViewModel.buildNotesSection(body: "User prefers dark roast coffee.")
#expect(section.contains("User prefers dark roast coffee."))
}
// MARK: - extractNotesUpdate
@Test("No fenced block leaves content untouched and returns no notes body")
func extractNotesUpdateNoBlock() {
let (display, body) = ChatViewModel.extractNotesUpdate(from: "Just a normal reply.")
#expect(display == "Just a normal reply.")
#expect(body == nil)
}
@Test("A fenced update-notes block is stripped from the display text and its body extracted")
func extractNotesUpdateStripsBlock() {
let content = "Sure, noted!\n\n```update-notes\nUser prefers dark roast coffee.\n```"
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Sure, noted!")
#expect(body == "User prefers dark roast coffee.")
}
@Test("Text surrounding the fenced block on both sides is preserved")
func extractNotesUpdatePreservesSurroundingText() {
let content = "Before text.\n```update-notes\nRemember this.\n```\nAfter text."
let (display, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(display == "Before text.\n\nAfter text.")
#expect(body == "Remember this.")
}
@Test("A block with the language tag but an empty body extracts an empty string, not nil")
func extractNotesUpdateEmptyBody() {
let content = "```update-notes\n```"
let (_, body) = ChatViewModel.extractNotesUpdate(from: content)
#expect(body == "")
}
}