import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage' vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({ isMac: true, isWindows: false, runtimePlatform: 'darwin', supportsPersonalWechatSend: true })) const getSettings = vi.fn() const saveSettings = vi.fn() const listVoices = vi.fn() const openApiKeys = vi.fn() const checkVoiceEncodingEnvironment = vi.fn() const installPilk = vi.fn() const openPythonDownload = vi.fn() const openFfmpegDownload = vi.fn() const response = { success: true, settings: { provider: 'fish-audio' as const, hasApiKey: true, hasStoredApiKey: true, hasEnvironmentApiKey: false, keySource: 'secure-storage' as const, encryptionAvailable: true, selectedVoiceId: 'fish-warm-female', outputFormat: 'mp3' as const, model: 's2.1-pro-free' as const, phase: 'ready' as const }, voices: [ { id: 'fish-warm-female', name: '暖阳女声', description: '自然温和,适合日常对话', tags: ['女声', '自然', '普通话'], languages: ['中文'], source: 'fish-audio' as const }, { id: 'fish-calm-male', name: '沉稳男声', description: '低沉清晰,适合知识说明', tags: ['男声', '沉稳', '普通话'], languages: ['中文'], source: 'fish-audio' as const } ] } describe('TextToSpeechPage', () => { beforeEach(() => { getSettings.mockReset().mockResolvedValue(response) listVoices.mockReset().mockResolvedValue({ success: true, items: response.voices, total: response.voices.length, pageNumber: 1, pageSize: 24, hasMore: false }) saveSettings.mockReset().mockImplementation(async (request) => ({ ...response, settings: { ...response.settings, hasApiKey: Boolean(request.apiKey), selectedVoiceId: request.selectedVoiceId || response.settings.selectedVoiceId, model: request.model || response.settings.model } })) openApiKeys.mockReset().mockResolvedValue({ success: true }) checkVoiceEncodingEnvironment.mockReset() installPilk.mockReset() openPythonDownload.mockReset().mockResolvedValue({ success: true }) openFfmpegDownload.mockReset().mockResolvedValue({ success: true }) Object.defineProperty(window, 'api', { configurable: true, value: { getTextToSpeechSettings: getSettings, saveTextToSpeechSettings: saveSettings, listTextToSpeechVoices: listVoices, openFishAudioApiKeys: openApiKeys, checkPersonalWechatVoiceEncodingEnvironment: checkVoiceEncodingEnvironment, installPersonalWechatPilk: installPilk, openPersonalWechatVoicePythonDownload: openPythonDownload, openPersonalWechatVoiceFfmpegDownload: openFfmpegDownload } }) }) it('searches, selects and securely saves an API key with a voice', async () => { const onNotice = vi.fn() render() expect(await screen.findAllByText('暖阳女声')).not.toHaveLength(0) fireEvent.change(screen.getByPlaceholderText('按音色名称搜索'), { target: { value: '沉稳' } }) fireEvent.click(screen.getByRole('button', { name: '搜索' })) await waitFor(() => expect(listVoices).toHaveBeenCalledWith({ pageNumber: 1, pageSize: 24, title: '沉稳', tags: [] }) ) fireEvent.click(screen.getByRole('radio', { name: /沉稳男声/ })) fireEvent.change(screen.getByPlaceholderText('已安全保存;输入新 Key 可替换'), { target: { value: 'fish-fixture-key' } }) fireEvent.click(screen.getByRole('button', { name: '保存 Key' })) await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ apiKey: 'fish-fixture-key' }) ) expect(onNotice).toHaveBeenCalledWith('API Key 已安全保存') }) it('opens the official Fish Audio API key page through the main process', async () => { render() await screen.findByText('API 设置') fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' })) await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1)) }) it('keeps model selection and API key actions on their existing settings callbacks', async () => { const user = userEvent.setup() render() const apiKeyInput = await screen.findByPlaceholderText('已安全保存;输入新 Key 可替换') expect(apiKeyInput).toHaveAttribute('type', 'password') await user.click(screen.getByRole('button', { name: '显示' })) expect(apiKeyInput).toHaveAttribute('type', 'text') await user.click(screen.getByRole('combobox', { name: '合成模型' })) await user.click(screen.getByRole('option', { name: 's2.1-pro', exact: true })) await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ model: 's2.1-pro' })) await user.click(screen.getByRole('button', { name: '清除应用内 Key' })) await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true })) }) it('shows the macOS voice encoding environment entry', async () => { render() expect(await screen.findByText('API 设置')).toBeVisible() expect(screen.getByText('语音编码环境')).toBeVisible() expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument() }) it('checks the actual encoding environment and offers pilk repair', async () => { const onNotice = vi.fn() const incompleteEnvironment = { state: 'incomplete' as const, ready: false, runtimeReady: true, python: { ready: true, executable: '/usr/local/bin/python3', version: '3.12.11' }, pilk: { ready: false, error: '未安装' }, ffmpeg: { ready: true, executable: '/runtime/ffmpeg', version: '6.1' }, encoder: 'silk' as const, message: '语音编码环境不完整,语音将回退到内置 SILK 编码' } const readyEnvironment = { ...incompleteEnvironment, state: 'ready' as const, ready: true, pilk: { ready: true, version: '0.2.4', path: '/user/pilk/__init__.py' }, encoder: 'pilk' as const, message: '语音编码环境正常,可以使用 pilk 编码' } checkVoiceEncodingEnvironment.mockResolvedValue(incompleteEnvironment) installPilk.mockResolvedValue({ success: true, environment: readyEnvironment }) render() fireEvent.click(await screen.findByRole('button', { name: '检查编码环境' })) expect(await screen.findByText('⚠ 编码环境不完整')).toBeVisible() expect(screen.getByRole('button', { name: '安装 pilk' })).toBeEnabled() fireEvent.click(screen.getByRole('button', { name: '安装 pilk' })) await waitFor(() => expect(installPilk).toHaveBeenCalledTimes(1)) expect(onNotice).toHaveBeenCalledWith('语音编码环境已修复。') expect(await screen.findByText('✓ 编码环境正常')).toBeVisible() }) })