fix: harden AI design voice lifecycle
This commit is contained in:
@@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
import { WorksSquareApiError } from '@/lib/works-square';
|
||||
import { ImageCanvas } from '@/pages/ImageCanvas';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
|
||||
@@ -991,6 +992,127 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the Works Speech token once after a 401 and keeps the refreshed result', async () => {
|
||||
installMockVoiceWavTranscoder();
|
||||
const getValidAccessToken = vi.fn().mockResolvedValue('stale-token');
|
||||
const refreshSession = vi.fn().mockResolvedValue('fresh-token');
|
||||
transcribeWorksSpeechMock
|
||||
.mockRejectedValueOnce(new WorksSquareApiError('token expired', 401))
|
||||
.mockResolvedValueOnce({ text: '刷新后的语音内容', model: 'gpt-4o-mini-transcribe' });
|
||||
const stopTrack = vi.fn();
|
||||
const getUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
});
|
||||
class MockMediaRecorder {
|
||||
static isTypeSupported(): boolean { return true; }
|
||||
ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
state: RecordingState = 'inactive';
|
||||
mimeType = 'audio/webm';
|
||||
start(): void { this.state = 'recording'; }
|
||||
stop(): void {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable?.({ data: new Blob(['audio bytes'], { type: 'audio/webm' }) });
|
||||
this.onstop?.();
|
||||
}
|
||||
}
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
Object.defineProperty(window, 'MediaRecorder', {
|
||||
configurable: true,
|
||||
value: MockMediaRecorder,
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
accessToken: 'stale-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
getValidAccessToken,
|
||||
refreshSession,
|
||||
user: {
|
||||
username: 'designer',
|
||||
userId: '42',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
const composer = screen.getByLabelText('设计需求');
|
||||
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
|
||||
await screen.findByRole('button', { name: '停止语音输入' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止语音输入' }));
|
||||
|
||||
await waitFor(() => expect(transcribeWorksSpeechMock).toHaveBeenCalledTimes(2));
|
||||
expect(transcribeWorksSpeechMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ accessToken: 'stale-token' }),
|
||||
);
|
||||
expect(transcribeWorksSpeechMock.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ accessToken: 'fresh-token' }),
|
||||
);
|
||||
expect(refreshSession).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(composer).toHaveValue('刷新后的语音内容'));
|
||||
expect(stopTrack).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('releases the acquired stream when MediaRecorder construction fails', async () => {
|
||||
const toastError = vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id');
|
||||
const stopTrack = vi.fn();
|
||||
const getUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
});
|
||||
class FailingMediaRecorder {
|
||||
static isTypeSupported(): boolean { return true; }
|
||||
constructor() { throw new Error('recorder construction failed'); }
|
||||
}
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
Object.defineProperty(window, 'MediaRecorder', {
|
||||
configurable: true,
|
||||
value: FailingMediaRecorder,
|
||||
});
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
lastActiveAt: Date.now(),
|
||||
canRefresh: true,
|
||||
legacyRefreshToken: null,
|
||||
user: {
|
||||
username: 'designer',
|
||||
userId: '42',
|
||||
tenantId: null,
|
||||
deptId: null,
|
||||
authorities: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||
await screen.findByTestId('image-workspace-conversation');
|
||||
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
|
||||
|
||||
await waitFor(() => expect(getUserMedia).toHaveBeenCalledWith({ audio: true }));
|
||||
await waitFor(() => expect(stopTrack).toHaveBeenCalledOnce());
|
||||
expect(toastError).toHaveBeenCalledWith('无法开始录音', {
|
||||
description: 'recorder construction failed',
|
||||
});
|
||||
toastError.mockRestore();
|
||||
});
|
||||
|
||||
it('discards a late transcription after Conversation switch', async () => {
|
||||
installMockVoiceWavTranscoder();
|
||||
const transcription = deferred<{ text: string; model: string }>();
|
||||
|
||||
Reference in New Issue
Block a user