Add teacher microphone and per-question model selection
This commit is contained in:
1 parent
cd1ba41e21
commit
78cfde6734
20 files changed
+517
-37
No files matched your search
@@ -0,0 +1,130 @@
|
||||
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<typeof import('@/lib/works-square')>(),
|
||||
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(<TeacherChatPanel projectId="p" sourceId="source-one" />);
|
||||
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(<TeacherChatPanel projectId="p" sourceId="source-two" />);
|
||||
await waitFor(() => expect(stopTrack).toHaveBeenCalledTimes(2));
|
||||
expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledTimes(1);
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user