import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CodingComposer, type CodingComposerProps } from '@/pages/Chat/CodingComposer'; 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 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?.(); } } function composerProps(overrides: Partial = {}): CodingComposerProps { return { value: '', editable: true, canSend: false, preparing: false, recovering: false, runStatus: 'idle', mode: 'prompt', queue: { items: [] }, error: null, recoverableError: false, acceptedCount: 0, attachments: [], submitting: false, placeholder: '随心输入', voiceScopeKey: 'conversation-1', onChange: vi.fn(), onModeChange: vi.fn(), onSubmit: vi.fn(), onRecover: vi.fn(), onAddFiles: vi.fn(), onRemoveAttachment: vi.fn(), ...overrides, }; } describe('Coding Composer voice input', () => { beforeEach(() => { 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('removes the project-write hint and keeps the idle model control transparent', () => { const conversation = { id: 'conversation-1', agentId: 'agent-1', title: 'Composer UI', archivedAt: null, unread: false, createdAt: '2026-08-28T00:00:00.000Z', updatedAt: '2026-08-28T00:00:00.000Z', model: null, modelResolution: 'required' as const, }; render( undefined), })} />); expect(screen.queryByText('项目内可写')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '语音输入' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /模型与思考设置/ })) .toHaveClass('bg-transparent'); expect(screen.getByRole('button', { name: /模型与思考设置/ })) .not.toHaveClass('bg-surface-subtle/75'); }); it('records, transcribes, and appends speech to the current draft', async () => { const stopTrack = vi.fn(); const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }], }); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia }, }); Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: MockMediaRecorder, }); const onChange = vi.fn(); render(); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); await screen.findByRole('button', { name: '停止语音输入' }); fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); await waitFor(() => expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledWith({ accessToken: 'voice-access-token', audioBase64: 'voice-base64', fileName: 'voice.wav', mimeType: 'audio/wav', language: 'zh', })); expect(onChange).toHaveBeenCalledWith('现有需求\n补充单元测试'); expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); expect(stopTrack).toHaveBeenCalledOnce(); await screen.findByRole('button', { name: '语音输入' }); }); it('stops an active recording when the draft scope changes', 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 { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: '语音输入' })); await screen.findByRole('button', { name: '停止语音输入' }); rerender(); await waitFor(() => expect(stopTrack).toHaveBeenCalledOnce()); expect(MockMediaRecorder.latest?.state).toBe('inactive'); expect(voiceApi.transcribeWorksSpeech).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled(); }); });