Files
makelore/tests/unit/coding-composer-voice.test.tsx

207 lines
6.6 KiB
TypeScript

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<typeof import('@/lib/works-square')>(),
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> = {}): 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(<CodingComposer
{...composerProps({
conversation,
onRefreshRuntime: vi.fn(async () => 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(<CodingComposer {...composerProps({ value: '现有需求', onChange })} />);
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(<CodingComposer {...composerProps()} />);
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
await screen.findByRole('button', { name: '停止语音输入' });
rerender(<CodingComposer {...composerProps({ voiceScopeKey: 'conversation-2' })} />);
await waitFor(() => expect(stopTrack).toHaveBeenCalledOnce());
expect(MockMediaRecorder.latest?.state).toBe('inactive');
expect(voiceApi.transcribeWorksSpeech).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled();
});
});