| import { afterEach, describe, expect, it, vi } from 'vitest'; |
| import { App } from '../src/components/app.js'; |
| import { store } from '../src/store.js'; |
|
|
| function setViewport(width) { |
| Object.defineProperty(window, 'innerWidth', { |
| configurable: true, |
| writable: true, |
| value: width, |
| }); |
| } |
|
|
| function mountApp() { |
| const root = document.createElement('div'); |
| document.body.appendChild(root); |
| const app = new App(root); |
| app._render(); |
| app._syncSidebarLayout(); |
| app._loadCurrentConversation(); |
| app.settingsModal.render(); |
| app._updateContextInfo(); |
| return { app, root }; |
| } |
|
|
| afterEach(() => { |
| document.body.innerHTML = ''; |
| vi.restoreAllMocks(); |
| }); |
|
|
| describe('App sidebar layout', () => { |
| it('keeps the sidebar visible on desktop when selecting another conversation', () => { |
| setViewport(1280); |
| const first = store.createConversation('model-a'); |
| const second = store.createConversation('model-a'); |
| store.setCurrentConversationId(first.id); |
|
|
| const { app, root } = mountApp(); |
| const sidebar = root.querySelector('#sidebar'); |
|
|
| expect(sidebar.className).not.toContain('-translate-x-full'); |
|
|
| app._selectConversation(second.id); |
|
|
| expect(sidebar.className).not.toContain('-translate-x-full'); |
| expect(sidebar.className).not.toContain('fixed'); |
| expect(root.querySelector('#new-chat-btn')).not.toBeNull(); |
| }); |
|
|
| it('does not hide the sidebar on desktop after creating multiple new chats', () => { |
| setViewport(1280); |
| const { app, root } = mountApp(); |
|
|
| app._newChat(); |
| app._newChat(); |
|
|
| const sidebar = root.querySelector('#sidebar'); |
| expect(sidebar.className).not.toContain('-translate-x-full'); |
| expect(root.querySelectorAll('#conv-list [role="option"]').length).toBe(2); |
| }); |
|
|
| it('uses off-canvas sidebar behaviour only on mobile widths', () => { |
| setViewport(640); |
| const first = store.createConversation('model-a'); |
| const second = store.createConversation('model-a'); |
| store.setCurrentConversationId(first.id); |
|
|
| const { app, root } = mountApp(); |
| const sidebar = root.querySelector('#sidebar'); |
|
|
| expect(sidebar.className).toContain('-translate-x-full'); |
| expect(sidebar.className).toContain('fixed'); |
|
|
| app._toggleMobileSidebar(); |
| expect(sidebar.className).toContain('translate-x-0'); |
| expect(sidebar.className).not.toContain('-translate-x-full'); |
|
|
| app._selectConversation(second.id); |
| expect(sidebar.className).toContain('-translate-x-full'); |
| }); |
| }); |
|
|
| describe('App model state', () => { |
| it('uses the globally selected model instead of per-conversation model', () => { |
| setViewport(1280); |
| store.saveSettings({ baseUrl: 'http://a.local' }); |
| store.saveAvailableModels('http://a.local', ['model-a']); |
| store.setCurrentModel('http://a.local', 'model-a'); |
|
|
| const first = store.createConversation('legacy-model'); |
| store.setCurrentConversationId(first.id); |
|
|
| const { app } = mountApp(); |
|
|
| expect(app.modelPicker.getModel()).toBe('model-a'); |
| expect(app.inputBar._currentModel).toBe('model-a'); |
| }); |
|
|
| it('clears the current model after models:changed when current URL no longer provides it', () => { |
| setViewport(1280); |
| store.saveSettings({ baseUrl: 'http://a.local' }); |
| store.saveAvailableModels('http://a.local', ['model-a']); |
| store.setCurrentModel('http://a.local', 'model-a'); |
|
|
| const { app } = mountApp(); |
| store.saveAvailableModels('http://a.local', ['model-b']); |
| document.dispatchEvent(new CustomEvent('models:changed')); |
|
|
| expect(app.modelPicker.getModel()).toBe(''); |
| expect(app.inputBar._currentModel).toBe(''); |
| }); |
| }); |
|
|
| describe('App audio workflows', () => { |
| it('uploads audio and returns transcription text', async () => { |
| setViewport(1280); |
| store.saveSettings({ baseUrl: 'http://a.local' }); |
| store.saveAvailableModels('http://a.local', ['audio-model']); |
| store.setCurrentModel('http://a.local', 'audio-model'); |
| store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } }); |
|
|
| globalThis.fetch = vi.fn(async (url) => { |
| if (String(url).includes('/v1/audio/transcriptions')) { |
| return { ok: true, json: async () => ({ text: '会议录音转写文本' }) }; |
| } |
| throw new Error(`Unexpected fetch: ${url}`); |
| }); |
|
|
| const { app } = mountApp(); |
| const file = new File(['audio'], 'meeting.wav', { type: 'audio/wav' }); |
| await app._handleSend('', null, null, { file, mode: 'transcribe' }); |
|
|
| const conv = store.getCurrentConversation(); |
| expect(conv.messages.at(-1).content).toBe('会议录音转写文本'); |
| }); |
|
|
| it('uploads audio and can return translated text via instruction', async () => { |
| setViewport(1280); |
| store.saveSettings({ baseUrl: 'http://a.local' }); |
| store.saveAvailableModels('http://a.local', ['audio-model']); |
| store.setCurrentModel('http://a.local', 'audio-model'); |
| store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } }); |
|
|
| const sseChunks = [ |
| 'data: {"choices":[{"delta":{"content":"Translated meeting notes"}}]}\n\n', |
| 'data: [DONE]\n\n', |
| ]; |
| let idx = 0; |
| const reader = { |
| read: vi.fn(async () => |
| idx >= sseChunks.length |
| ? { done: true } |
| : { done: false, value: new TextEncoder().encode(sseChunks[idx++]) } |
| ), |
| }; |
|
|
| globalThis.fetch = vi.fn(async (url) => { |
| if (String(url).includes('/v1/audio/transcriptions')) { |
| return { ok: true, json: async () => ({ text: 'Bonjour tout le monde' }) }; |
| } |
| if (String(url).includes('/v1/chat/completions')) { |
| return { ok: true, body: { getReader: () => reader } }; |
| } |
| throw new Error(`Unexpected fetch: ${url}`); |
| }); |
|
|
| const { app } = mountApp(); |
| const file = new File(['audio'], 'speech.m4a', { type: 'audio/mp4' }); |
| await app._handleSend('Translate this audio to English', null, null, { file }); |
|
|
| const conv = store.getCurrentConversation(); |
| expect(conv.messages.at(-1).content).toBe('Translated meeting notes'); |
| }); |
|
|
| it('uploads audio with an instruction and returns processed text', async () => { |
| setViewport(1280); |
| store.saveSettings({ baseUrl: 'http://a.local' }); |
| store.saveAvailableModels('http://a.local', ['audio-model']); |
| store.setCurrentModel('http://a.local', 'audio-model'); |
| store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } }); |
|
|
| const sseChunks = [ |
| 'data: {"choices":[{"delta":{"content":"待办事项:整理纪要"}}]}\n\n', |
| 'data: [DONE]\n\n', |
| ]; |
| let idx = 0; |
| const reader = { |
| read: vi.fn(async () => |
| idx >= sseChunks.length |
| ? { done: true } |
| : { done: false, value: new TextEncoder().encode(sseChunks[idx++]) } |
| ), |
| }; |
|
|
| let capturedChatBody = ''; |
| globalThis.fetch = vi.fn(async (url, opts) => { |
| if (String(url).includes('/v1/audio/transcriptions')) { |
| return { ok: true, json: async () => ({ text: '原始会议录音文本' }) }; |
| } |
| if (String(url).includes('/v1/chat/completions')) { |
| capturedChatBody = opts?.body || ''; |
| return { ok: true, body: { getReader: () => reader } }; |
| } |
| throw new Error(`Unexpected fetch: ${url}`); |
| }); |
|
|
| const { app } = mountApp(); |
| const file = new File(['audio'], 'call.wav', { type: 'audio/wav' }); |
| await app._handleSend('提取会议待办事项', null, null, { file }); |
|
|
| const conv = store.getCurrentConversation(); |
| expect(conv.messages.at(-1).content).toBe('待办事项:整理纪要'); |
| expect(capturedChatBody).toContain('提取会议待办事项'); |
| expect(capturedChatBody).toContain('原始会议录音文本'); |
| }); |
| }); |
|
|