import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel'; import { useAuthStore } from '@/stores/auth'; const voiceApi = vi.hoisted(() => ({ blobToBase64: vi.fn(), convertAudioBlobTo16kMonoWav: vi.fn(), transcribeWorksSpeech: vi.fn(), })); vi.mock('@/lib/voice-recording', () => ({ blobToBase64: (...args: unknown[]) => voiceApi.blobToBase64(...args), convertAudioBlobTo16kMonoWav: (...args: unknown[]) => ( voiceApi.convertAudioBlobTo16kMonoWav(...args) ), })); vi.mock('@/lib/works-square', async (importOriginal) => ({ ...await importOriginal(), transcribeWorksSpeech: (...args: unknown[]) => voiceApi.transcribeWorksSpeech(...args), })); const api = vi.hoisted(() => ({ send: vi.fn() })); vi.mock('@/lib/coding-teacher', () => ({ teacherTopicsPath: () => '/teacher', legacyTopicBase: (base: string) => base, teacherApi: { config: async () => ({ enabled: true, definition: { schema_version: 1, teacher_id: 'teacher', name: '老师', description: '', avatar_id: 'avatar-01', welcome_message: '欢迎', suggested_questions: [], system_prompt: '你是老师', skills: [], model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } } }), catalog: async () => ({ items: [] }), list: async () => ({ items: [], lastSelectedTopicId: null }), send: api.send, }, })); const initialAuthState = useAuthStore.getState(); const initialMediaRecorder = window.MediaRecorder; const initialMediaDevices = navigator.mediaDevices; class MockMediaRecorder { static latest: MockMediaRecorder | null = null; static isTypeSupported(): boolean { return true; } ondataavailable: ((event: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; state: RecordingState = 'inactive'; mimeType = 'audio/webm'; constructor() { MockMediaRecorder.latest = this; } start(): void { this.state = 'recording'; } stop(): void { this.state = 'inactive'; this.ondataavailable?.({ data: new Blob(['voice bytes'], { type: 'audio/webm' }) }); this.onstop?.(); } } describe('Teacher composer voice input', () => { beforeEach(() => { localStorage.clear(); MockMediaRecorder.latest = null; voiceApi.convertAudioBlobTo16kMonoWav.mockResolvedValue( new Blob(['wav bytes'], { type: 'audio/wav' }), ); voiceApi.blobToBase64.mockResolvedValue('voice-base64'); voiceApi.transcribeWorksSpeech.mockResolvedValue({ text: '补充单元测试', model: 'gpt-4o-mini-transcribe', }); useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'voice-access-token', tokenType: 'Bearer', expiresAt: Date.now() + 60_000, lastActiveAt: Date.now(), canRefresh: true, legacyRefreshToken: null, user: { username: 'coder', userId: 'coder-1', tenantId: null, deptId: null, authorities: [], }, }); }); afterEach(() => { useAuthStore.setState(initialAuthState); Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: initialMediaRecorder, }); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: initialMediaDevices, }); }); it('transcribes into the latest teacher draft without sending, then cancels on source change', async () => { const stopTrack = vi.fn(); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }) } }); Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: MockMediaRecorder }); const view = render(); const input = await screen.findByRole('textbox', { name: '向智能体提问' }); await waitFor(() => expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled()); fireEvent.change(input, { target: { value: '原来的草稿' } }); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); await screen.findByRole('button', { name: '停止语音输入' }); fireEvent.change(input, { target: { value: '录音时继续写' } }); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(input).toHaveValue('录音时继续写\n补充单元测试')); expect(api.send).not.toHaveBeenCalled(); expect(stopTrack).toHaveBeenCalledOnce(); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); await screen.findByRole('button', { name: '停止语音输入' }); view.rerender(); await waitFor(() => expect(stopTrack).toHaveBeenCalledTimes(2)); expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledTimes(1); expect(api.send).not.toHaveBeenCalled(); }); });