feat: 统一手动发送入口为文字转语音

- 移除普通文本、图片和本地语音的手动发送入口
- 保留文字转语音的生成、试听和发送能力
This commit is contained in:
Wxw-Gu
2026-09-04 15:05:04 +08:00
parent 517e650942
commit 454f449c41
19 changed files with 478 additions and 801 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ describe('chat menus', () => {
expect(searchInput).toHaveFocus()
await user.type(searchInput, '测试')
expect(onContentFilterChange).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: '发送消息' })).toBeDisabled()
expect(screen.getByRole('button', { name: '文字转语音' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: '关闭搜索' }))
expect(onContentFilterChange).toHaveBeenLastCalledWith('')
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,12 +13,9 @@ vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
const getStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const downloadRuntime = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const rebind = vi.fn()
const selectImage = vi.fn()
const selectVoice = vi.fn()
const sendMessage = vi.fn()
const sendGeneratedTtsVoice = vi.fn()
const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const synthesizeTextToSpeech = vi.fn()
@@ -32,6 +29,7 @@ const contact = {
md5: 'fixture-md5',
type: 'group' as const
}
const readyStatus = {
state: 'online' as const,
platform: 'darwin',
@@ -59,6 +57,7 @@ const readyStatus = {
canSendVoice: true,
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
@@ -80,32 +79,19 @@ function renderDialog(
}
async function startComposer(): Promise<void> {
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
fireEvent.click(detect)
const start = await screen.findByRole('button', { name: '开始发送' })
fireEvent.click(start)
await screen.findByRole('textbox', { name: '语音文字' })
}
describe('PersonalWechatSendDialog', () => {
beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
rebind.mockReset().mockResolvedValue(readyStatus)
selectImage
.mockReset()
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.png', name: 'test.png' })
selectVoice
.mockReset()
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.silk', name: 'test.silk' })
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
sendGeneratedTtsVoice.mockReset().mockResolvedValue({
action: { status: 'sent' },
status: readyStatus
})
getTextToSpeechSettings.mockReset().mockResolvedValue({
success: true,
settings: {
@@ -121,16 +107,7 @@ describe('PersonalWechatSendDialog', () => {
})
listTextToSpeechVoices.mockReset().mockResolvedValue({
success: true,
items: [
{
id: 'fish-warm-female',
name: '暖阳女声',
description: '自然温和',
tags: ['女声'],
languages: ['中文'],
source: 'fish-audio'
}
],
items: [{ id: 'fish-warm-female', name: '暖阳女声' }],
total: 1,
pageNumber: 1,
pageSize: 24,
@@ -149,12 +126,9 @@ describe('PersonalWechatSendDialog', () => {
value: {
getPersonalWechatSenderStatus: getStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
downloadPersonalWechatRuntime: downloadRuntime,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
rebindPersonalWechatSender: rebind,
selectPersonalWechatImage: selectImage,
selectPersonalWechatVoice: selectVoice,
sendPersonalWechatMessage: sendMessage,
sendGeneratedTtsVoice,
getTextToSpeechSettings,
listTextToSpeechVoices,
synthesizeTextToSpeech,
@@ -165,257 +139,39 @@ describe('PersonalWechatSendDialog', () => {
})
})
it('shows a user-facing four-step setup and keeps diagnostics collapsed', async () => {
getRuntimeStatus
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
.mockResolvedValue(readyRuntime)
getStatus.mockResolvedValue({
...readyStatus,
state: 'stopped',
runtimeReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '尚未绑定当前微信'
})
renderDialog()
expect(await screen.findByText('准备 OneBot 运行时')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载运行时' })).toBeEnabled()
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('能力检测')).toBeInTheDocument()
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
expect(screen.getByRole('note')).toHaveTextContent(
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
)
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeInTheDocument()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
expect(screen.queryByText('PID 4668')).not.toBeVisible()
fireEvent.click(screen.getByText('高级诊断'))
expect(screen.getByText('PID 4668')).toBeInTheDocument()
})
it('sends text through the existing message API and echoes it in the chat', async () => {
it('opens the TTS composer immediately when voice capability is ready', async () => {
renderDialog()
await startComposer()
await userEvent
.setup()
.type(screen.getByRole('textbox', { name: '消息内容' }), '你好 TraceMemo')
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'text',
to: 'fixture-room@chatroom',
text: '你好 TraceMemo',
isGroup: true
})
)
expect(
screen.getByLabelText('消息列表').querySelector('.personal-wechat-message-bubble')
).toHaveTextContent('你好 TraceMemo')
expect(screen.getByRole('dialog')).toHaveTextContent('文字转语音')
expect(screen.queryByText('验证消息能力')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '生成语音' })).toBeDisabled()
})
it('supports local image and voice selection', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
expect(await screen.findByText('test.png')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'image',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.png',
isGroup: true
})
)
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
expect(await screen.findByText('test.silk')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
to: 'fixture-room@chatroom',
filePath: '/Users/fixture/test.silk',
isGroup: true
})
)
})
it('sends the same generated voice through the comparison path', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
fireEvent.change(screen.getByRole('textbox', { name: '语音文字' }), {
target: { value: '入口2语音测试' }
})
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '空白语音?重新处理' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
to: 'fixture-room@chatroom',
filePath: '/tmp/generated.mp3',
isGroup: true,
voiceSendMode: 'legacy'
})
)
})
it('uses the existing runtime, binding and detection IPC actions', async () => {
getRuntimeStatus
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
.mockResolvedValue(readyRuntime)
getStatus
.mockResolvedValueOnce({
...readyStatus,
state: 'stopped',
runtimeReady: false,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
.mockResolvedValue({
...readyStatus,
state: 'stopped',
runtimeReady: true,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
renderDialog()
await screen.findByRole('button', { name: '下载运行时' })
fireEvent.click(screen.getByRole('button', { name: '下载运行时' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
})
it('shows a stable bound state after binding', async () => {
getRuntimeStatus.mockResolvedValue(readyRuntime)
getStatus
.mockResolvedValueOnce({
...readyStatus,
state: 'stopped',
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false
})
.mockResolvedValue(readyStatus)
renderDialog()
await screen.findByRole('button', { name: '绑定微信' })
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
expect(await screen.findByText('✓ 微信已绑定')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '绑定微信' })).not.toBeInTheDocument()
})
it('treats image and voice readiness as one media capability', async () => {
getStatus.mockResolvedValue({
...readyStatus,
canSendImage: false,
canSendVoice: true
})
renderDialog()
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
expect(screen.queryByText('语音消息')).not.toBeInTheDocument()
})
it('keeps re-detection available and explains when no new messages are found', async () => {
getRuntimeStatus.mockResolvedValue(readyRuntime)
getStatus.mockResolvedValue({
...readyStatus,
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '等待消息初始化'
})
renderDialog()
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
expect(detect).toBeEnabled()
fireEvent.click(detect)
expect(
await screen.findByText(
'暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。'
)
).toBeInTheDocument()
expect(screen.getAllByText('未检测').length).toBe(2)
})
it('keeps the dialog and controls stable while sending', async () => {
it('generates, previews and sends a voice through the semantic TTS IPC', async () => {
const user = userEvent.setup()
sendMessage.mockImplementation(() => new Promise(() => undefined))
renderDialog()
await startComposer()
await screen.findByRole('textbox', { name: '消息内容' })
await user.type(screen.getByRole('textbox', { name: '消息内容' }), '发送中')
await user.click(screen.getByRole('button', { name: '发送消息' }))
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
await user.type(screen.getByRole('textbox', { name: '语音文字' }), '你好 TraceMemo')
await user.click(screen.getByRole('button', { name: '生成语音' }))
expect(await screen.findByText('语音已生成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '试听' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: '发送到微信' }))
await waitFor(() =>
expect(sendGeneratedTtsVoice).toHaveBeenCalledWith({
to: 'fixture-room@chatroom',
isGroup: true,
filePath: '/tmp/generated.mp3'
})
)
expect(screen.getByLabelText('消息列表')).toHaveTextContent('你好 TraceMemo')
})
it('shows and copies the latest redacted voice diagnostic JSON', async () => {
getPersonalWechatVoiceDiagnostic.mockResolvedValue({
request_id: 'request-1',
voice_id: 'request-1',
phase: 'completed',
encoder_name: 'go-silk',
encoder_version: 'wechat_chatter-v0.0.18',
input_bytes: 35107,
normalized_input_bytes: 69804,
pcm_size: 69760,
sample_rate: 16000,
channels: 1,
input_duration_ms: 2180,
upload_result: '0',
upload_data_len: 4380,
silk_duration_ms: 2180,
send_result: '1'
})
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
const diagnosticDialog = await screen.findByRole('dialog', { name: '语音发送诊断' })
expect(diagnosticDialog).toHaveTextContent('"encoder_name": "go-silk"')
expect(diagnosticDialog).not.toHaveTextContent('aesKey')
fireEvent.click(screen.getByRole('button', { name: '复制诊断 JSON' }))
await waitFor(() => expect(copyText).toHaveBeenCalledWith(expect.stringContaining('request-1')))
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
})
it('shows an empty state when no voice diagnostic exists', async () => {
renderDialog()
await startComposer()
fireEvent.click(screen.getByRole('button', { name: '语音发送诊断' }))
expect(await screen.findByText('暂无诊断信息')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '复制诊断 JSON' })).toBeDisabled()
it('keeps the setup guide voice-only when voice capability is unavailable', async () => {
getStatus.mockResolvedValue({ ...readyStatus, canSend: false, canSendVoice: false })
renderDialog({ onOpenPersonalWechatSettings: vi.fn() })
expect(await screen.findByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('语音消息')).toBeInTheDocument()
expect(screen.queryByText('文字消息')).not.toBeInTheDocument()
expect(screen.queryByText('图片和语音消息')).not.toBeInTheDocument()
})
})
@@ -160,11 +160,11 @@ describe('PersonalWechatSendPage on Windows', () => {
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '个人微信发送' })).toBeVisible()
expect(screen.getByRole('region', { name: '发送消息' })).toBeVisible()
expect(screen.getByRole('heading', { name: '文字转语音' })).toBeVisible()
expect(screen.getByRole('region', { name: '文字转语音' })).toBeVisible()
})
it('opens the Windows send dialog in text mode by default', async () => {
it('opens the Windows send dialog in TTS mode by default', async () => {
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
render(
@@ -180,11 +180,8 @@ describe('PersonalWechatSendPage on Windows', () => {
/>
)
expect(await screen.findByRole('radio', { name: '文字' })).toHaveAttribute(
'data-state',
'checked'
)
expect(screen.getByRole('textbox', { name: '消息内容' })).toBeVisible()
expect(await screen.findByRole('textbox', { name: '语音文字' })).toBeVisible()
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
})
it('keeps the Windows voice page focused on speech settings', async () => {