207 lines
6.6 KiB
TypeScript
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();
|
|
});
|
|
});
|