chore(integration): snapshot local main worktree
This commit is contained in:
206
tests/unit/coding-composer-voice.test.tsx
Normal file
206
tests/unit/coding-composer-voice.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user