AXERA-TECH/lite_webui
17
1import { afterEach, describe, expect, it, vi } from 'vitest';2import { App } from '../src/components/app.js';3import { store } from '../src/store.js';4 5function setViewport(width) {6 Object.defineProperty(window, 'innerWidth', {7 configurable: true,8 writable: true,9 value: width,10 });11}12 13function mountApp() {14 const root = document.createElement('div');15 document.body.appendChild(root);16 const app = new App(root);17 app._render();18 app._syncSidebarLayout();19 app._loadCurrentConversation();20 app.settingsModal.render();21 app._updateContextInfo();22 return { app, root };23}24 25afterEach(() => {26 document.body.innerHTML = '';27 vi.restoreAllMocks();28});29 30describe('App sidebar layout', () => {31 it('keeps the sidebar visible on desktop when selecting another conversation', () => {32 setViewport(1280);33 const first = store.createConversation('model-a');34 const second = store.createConversation('model-a');35 store.setCurrentConversationId(first.id);36 37 const { app, root } = mountApp();38 const sidebar = root.querySelector('#sidebar');39 40 expect(sidebar.className).not.toContain('-translate-x-full');41 42 app._selectConversation(second.id);43 44 expect(sidebar.className).not.toContain('-translate-x-full');45 expect(sidebar.className).not.toContain('fixed');46 expect(root.querySelector('#new-chat-btn')).not.toBeNull();47 });48 49 it('does not hide the sidebar on desktop after creating multiple new chats', () => {50 setViewport(1280);51 const { app, root } = mountApp();52 53 app._newChat();54 app._newChat();55 56 const sidebar = root.querySelector('#sidebar');57 expect(sidebar.className).not.toContain('-translate-x-full');58 expect(root.querySelectorAll('#conv-list [role="option"]').length).toBe(2);59 });60 61 it('uses off-canvas sidebar behaviour only on mobile widths', () => {62 setViewport(640);63 const first = store.createConversation('model-a');64 const second = store.createConversation('model-a');65 store.setCurrentConversationId(first.id);66 67 const { app, root } = mountApp();68 const sidebar = root.querySelector('#sidebar');69 70 expect(sidebar.className).toContain('-translate-x-full');71 expect(sidebar.className).toContain('fixed');72 73 app._toggleMobileSidebar();74 expect(sidebar.className).toContain('translate-x-0');75 expect(sidebar.className).not.toContain('-translate-x-full');76 77 app._selectConversation(second.id);78 expect(sidebar.className).toContain('-translate-x-full');79 });80});81 82describe('App model state', () => {83 it('uses the globally selected model instead of per-conversation model', () => {84 setViewport(1280);85 store.saveSettings({ baseUrl: 'http://a.local' });86 store.saveAvailableModels('http://a.local', ['model-a']);87 store.setCurrentModel('http://a.local', 'model-a');88 89 const first = store.createConversation('legacy-model');90 store.setCurrentConversationId(first.id);91 92 const { app } = mountApp();93 94 expect(app.modelPicker.getModel()).toBe('model-a');95 expect(app.inputBar._currentModel).toBe('model-a');96 });97 98 it('clears the current model after models:changed when current URL no longer provides it', () => {99 setViewport(1280);100 store.saveSettings({ baseUrl: 'http://a.local' });101 store.saveAvailableModels('http://a.local', ['model-a']);102 store.setCurrentModel('http://a.local', 'model-a');103 104 const { app } = mountApp();105 store.saveAvailableModels('http://a.local', ['model-b']);106 document.dispatchEvent(new CustomEvent('models:changed'));107 108 expect(app.modelPicker.getModel()).toBe('');109 expect(app.inputBar._currentModel).toBe('');110 });111});112 113describe('App audio workflows', () => {114 it('uploads audio and returns transcription text', async () => {115 setViewport(1280);116 store.saveSettings({ baseUrl: 'http://a.local' });117 store.saveAvailableModels('http://a.local', ['audio-model']);118 store.setCurrentModel('http://a.local', 'audio-model');119 store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });120 121 globalThis.fetch = vi.fn(async (url) => {122 if (String(url).includes('/v1/audio/transcriptions')) {123 return { ok: true, json: async () => ({ text: '会议录音转写文本' }) };124 }125 throw new Error(`Unexpected fetch: ${url}`);126 });127 128 const { app } = mountApp();129 const file = new File(['audio'], 'meeting.wav', { type: 'audio/wav' });130 await app._handleSend('', null, null, { file, mode: 'transcribe' });131 132 const conv = store.getCurrentConversation();133 expect(conv.messages.at(-1).content).toBe('会议录音转写文本');134 });135 136 it('uploads audio and can return translated text via instruction', async () => {137 setViewport(1280);138 store.saveSettings({ baseUrl: 'http://a.local' });139 store.saveAvailableModels('http://a.local', ['audio-model']);140 store.setCurrentModel('http://a.local', 'audio-model');141 store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });142 143 const sseChunks = [144 'data: {"choices":[{"delta":{"content":"Translated meeting notes"}}]}\n\n',145 'data: [DONE]\n\n',146 ];147 let idx = 0;148 const reader = {149 read: vi.fn(async () =>150 idx >= sseChunks.length151 ? { done: true }152 : { done: false, value: new TextEncoder().encode(sseChunks[idx++]) }153 ),154 };155 156 globalThis.fetch = vi.fn(async (url) => {157 if (String(url).includes('/v1/audio/transcriptions')) {158 return { ok: true, json: async () => ({ text: 'Bonjour tout le monde' }) };159 }160 if (String(url).includes('/v1/chat/completions')) {161 return { ok: true, body: { getReader: () => reader } };162 }163 throw new Error(`Unexpected fetch: ${url}`);164 });165 166 const { app } = mountApp();167 const file = new File(['audio'], 'speech.m4a', { type: 'audio/mp4' });168 await app._handleSend('Translate this audio to English', null, null, { file });169 170 const conv = store.getCurrentConversation();171 expect(conv.messages.at(-1).content).toBe('Translated meeting notes');172 });173 174 it('uploads audio with an instruction and returns processed text', async () => {175 setViewport(1280);176 store.saveSettings({ baseUrl: 'http://a.local' });177 store.saveAvailableModels('http://a.local', ['audio-model']);178 store.setCurrentModel('http://a.local', 'audio-model');179 store.saveModelCapabilities({ 'audio-model': { text: true, image: false, audio: true } });180 181 const sseChunks = [182 'data: {"choices":[{"delta":{"content":"待办事项:整理纪要"}}]}\n\n',183 'data: [DONE]\n\n',184 ];185 let idx = 0;186 const reader = {187 read: vi.fn(async () =>188 idx >= sseChunks.length189 ? { done: true }190 : { done: false, value: new TextEncoder().encode(sseChunks[idx++]) }191 ),192 };193 194 let capturedChatBody = '';195 globalThis.fetch = vi.fn(async (url, opts) => {196 if (String(url).includes('/v1/audio/transcriptions')) {197 return { ok: true, json: async () => ({ text: '原始会议录音文本' }) };198 }199 if (String(url).includes('/v1/chat/completions')) {200 capturedChatBody = opts?.body || '';201 return { ok: true, body: { getReader: () => reader } };202 }203 throw new Error(`Unexpected fetch: ${url}`);204 });205 206 const { app } = mountApp();207 const file = new File(['audio'], 'call.wav', { type: 'audio/wav' });208 await app._handleSend('提取会议待办事项', null, null, { file });209 210 const conv = store.getCurrentConversation();211 expect(conv.messages.at(-1).content).toBe('待办事项:整理纪要');212 expect(capturedChatBody).toContain('提取会议待办事项');213 expect(capturedChatBody).toContain('原始会议录音文本');214 });215});216 