test(app): add unit tests for useAI composable
16 tests covering provider selection, context injection, sendMessage streaming, error handling, stop generation, and web search integration. Mocks fetch/SSE and IDB for isolated testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
72f5656e02
commit
cb3a50a920
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock web search before importing useAI
|
||||
vi.mock('@/composables/useWebSearch', () => ({
|
||||
searchWeb: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
// Mock mock data to avoid importing large fixture files
|
||||
vi.mock('@/mocks/films', () => ({
|
||||
mockFilms: [
|
||||
{ id: 'f1', title: 'Test Film', year: 2020, director: 'Test Dir', genres: ['Drama'], rating: 8, sources: [{ type: 'stream' }] },
|
||||
],
|
||||
}))
|
||||
vi.mock('@/mocks/songs', () => ({
|
||||
mockSongs: [
|
||||
{ id: 's1', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', year: 2020, genres: ['Rock'], sources: [{ type: 'jamendo' }] },
|
||||
],
|
||||
}))
|
||||
vi.mock('@/mocks/podcasts', () => ({
|
||||
mockPodcasts: [
|
||||
{ id: 'p1', title: 'Test Podcast', host: 'Test Host', year: 2020, genres: ['Tech'], sources: [{ type: 'rss' }] },
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock idb-storage to avoid IndexedDB access in tests
|
||||
vi.mock('@/utils/idb-storage', () => ({
|
||||
saveConversation: vi.fn().mockResolvedValue(undefined),
|
||||
loadAllConversations: vi.fn().mockResolvedValue(new Map()),
|
||||
deleteConversation: vi.fn().mockResolvedValue(undefined),
|
||||
isIDBAvailable: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
import { useAI } from '@/composables/useAI'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { searchWeb } from '@/composables/useWebSearch'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
// Helper to create a mock SSE readable stream
|
||||
function createSSEStream(events: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < events.length) {
|
||||
controller.enqueue(encoder.encode(events[index]))
|
||||
index++
|
||||
} else {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mockClaudeResponse(events: string[]) {
|
||||
return {
|
||||
ok: true,
|
||||
body: createSSEStream(events),
|
||||
text: () => Promise.resolve(''),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAI', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
// Reset provider state (module-level ref) back to claude
|
||||
const { setProvider } = useAI()
|
||||
setProvider('claude')
|
||||
// Re-mock searchWeb
|
||||
vi.mocked(searchWeb).mockResolvedValue([])
|
||||
// Default fetch mock (catches loadServerChats and any stray calls)
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('provider selection', () => {
|
||||
it('defaults to claude provider', () => {
|
||||
const { activeProvider } = useAI()
|
||||
expect(activeProvider.value).toBe('claude')
|
||||
})
|
||||
|
||||
it('switches provider via setProvider', () => {
|
||||
const { setProvider, activeProvider, activeModel } = useAI()
|
||||
setProvider('openrouter')
|
||||
expect(activeProvider.value).toBe('openrouter')
|
||||
expect(activeModel.value).toBe('meta-llama/llama-4-maverick')
|
||||
})
|
||||
|
||||
it('switches to mock provider', () => {
|
||||
const { setProvider, activeProvider, activeModel } = useAI()
|
||||
setProvider('mock')
|
||||
expect(activeProvider.value).toBe('mock')
|
||||
expect(activeModel.value).toBe('echo')
|
||||
})
|
||||
|
||||
it('lists available providers with models', () => {
|
||||
const { availableProviders } = useAI()
|
||||
expect(availableProviders.value.length).toBe(3)
|
||||
const ids = availableProviders.value.map(p => p.id)
|
||||
expect(ids).toContain('claude')
|
||||
expect(ids).toContain('openrouter')
|
||||
expect(ids).toContain('mock')
|
||||
})
|
||||
|
||||
it('sets model directly via setModel', () => {
|
||||
const { setModel, activeModel } = useAI()
|
||||
setModel('claude-sonnet-4')
|
||||
expect(activeModel.value).toBe('claude-sonnet-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('context injection', () => {
|
||||
it('includes film library in system prompt', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
|
||||
)
|
||||
globalThis.fetch = fetchSpy
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('hello')
|
||||
|
||||
const claudeCall = fetchSpy.mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
|
||||
)
|
||||
expect(claudeCall).toBeDefined()
|
||||
const body = JSON.parse(claudeCall![1].body as string)
|
||||
expect(body.system).toContain('Test Film')
|
||||
expect(body.system).toContain("user's film library")
|
||||
})
|
||||
|
||||
it('includes song library in system prompt', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
|
||||
)
|
||||
globalThis.fetch = fetchSpy
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('hello')
|
||||
|
||||
const claudeCall = fetchSpy.mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
|
||||
)
|
||||
const body = JSON.parse(claudeCall![1].body as string)
|
||||
expect(body.system).toContain('Test Song')
|
||||
expect(body.system).toContain("user's song library")
|
||||
})
|
||||
|
||||
it('includes content tag format instructions', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n'])
|
||||
)
|
||||
globalThis.fetch = fetchSpy
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('hello')
|
||||
|
||||
const claudeCall = fetchSpy.mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
|
||||
)
|
||||
const body = JSON.parse(claudeCall![1].body as string)
|
||||
expect(body.system).toContain('[[film_ext:')
|
||||
expect(body.system).toContain('[[song_ext:')
|
||||
expect(body.system).toContain('[[book_ext:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('adds user message to store', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"ok"}}\n\n'])
|
||||
)
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('test message')
|
||||
|
||||
const userMsg = chatStore.messages.find(m => m.role === 'user')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(userMsg!.content).toBe('test message')
|
||||
})
|
||||
|
||||
it('creates assistant message placeholder', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hi"}}\n\n'])
|
||||
)
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('test')
|
||||
|
||||
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
|
||||
expect(assistantMsg).toBeDefined()
|
||||
expect(assistantMsg!.content).toContain('hi')
|
||||
})
|
||||
|
||||
it('sets isStreaming to true during stream and false after', async () => {
|
||||
const streamingStates: boolean[] = []
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation(() => {
|
||||
streamingStates.push(useChatStore().isStreaming)
|
||||
return Promise.resolve(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"hello"}}\n\n'])
|
||||
)
|
||||
})
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
await sendMessage('hi')
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
// During the fetch call, isStreaming should have been true
|
||||
expect(streamingStates[0]).toBe(true)
|
||||
})
|
||||
|
||||
it('handles stream errors gracefully', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve('Internal Server Error'),
|
||||
})
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage, setProvider } = useAI()
|
||||
setProvider('claude') // Ensure claude provider
|
||||
|
||||
await sendMessage('test')
|
||||
|
||||
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
|
||||
expect(assistantMsg).toBeDefined()
|
||||
expect(assistantMsg!.content).toContain('⚠')
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('handles connection errors gracefully', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage, setProvider } = useAI()
|
||||
setProvider('claude') // Ensure claude provider
|
||||
|
||||
await sendMessage('test')
|
||||
|
||||
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
|
||||
expect(assistantMsg).toBeDefined()
|
||||
expect(assistantMsg!.content).toContain('Connection error')
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('uses mock provider when set to mock', async () => {
|
||||
const { sendMessage, setProvider } = useAI()
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
|
||||
setProvider('mock')
|
||||
await sendMessage('echo this')
|
||||
|
||||
const assistantMsg = chatStore.messages.find(m => m.role === 'assistant')
|
||||
expect(assistantMsg).toBeDefined()
|
||||
expect(assistantMsg!.content).toContain('echo this')
|
||||
expect(assistantMsg!.content).toContain('echo mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopGeneration', () => {
|
||||
it('aborts active stream and sets isStreaming to false', async () => {
|
||||
// Create a fetch that returns a stream which rejects on abort
|
||||
globalThis.fetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Send first chunk so readSSE enters its loop
|
||||
const encoder = new TextEncoder()
|
||||
controller.enqueue(encoder.encode('data: {"type":"content_block_delta","delta":{"text":"h"}}\n\n'))
|
||||
// When aborted, close the stream
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
try { controller.close() } catch { /* already closed */ }
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve(''),
|
||||
})
|
||||
})
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = false
|
||||
const { sendMessage, stopGeneration, setProvider } = useAI()
|
||||
setProvider('claude')
|
||||
|
||||
const sendPromise = sendMessage('test')
|
||||
|
||||
// Wait for fetch to start and first chunk to process
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
expect(chatStore.isStreaming).toBe(true)
|
||||
|
||||
stopGeneration()
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
|
||||
await sendPromise
|
||||
})
|
||||
})
|
||||
|
||||
describe('web search integration', () => {
|
||||
it('injects web results into system prompt when enabled', async () => {
|
||||
const mockResults = [
|
||||
{ title: 'Result 1', url: 'https://example.com', content: 'Some content' },
|
||||
]
|
||||
vi.mocked(searchWeb).mockResolvedValue(mockResults)
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
mockClaudeResponse(['data: {"type":"content_block_delta","delta":{"text":"answer"}}\n\n'])
|
||||
)
|
||||
globalThis.fetch = fetchSpy
|
||||
|
||||
const chatStore = useChatStore()
|
||||
chatStore.webSearchEnabled = true
|
||||
const { sendMessage } = useAI()
|
||||
|
||||
await sendMessage('latest bitcoin news')
|
||||
|
||||
// Find the Claude API call (not any dev-chats call)
|
||||
const claudeCall = fetchSpy.mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string)?.toString().includes('/claude/')
|
||||
)
|
||||
expect(claudeCall).toBeDefined()
|
||||
const body = JSON.parse(claudeCall![1].body as string)
|
||||
expect(body.system).toContain('Web search results')
|
||||
expect(body.system).toContain('Result 1')
|
||||
expect(body.webSearch).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user