|
- import type { Route } from '@playwright/test'
-
- import { captureScreenshot, expect, test } from './fixtures'
- import { loginAsAdmin } from './helpers'
-
- type JsonRecord = Record<string, unknown>
-
- const envelope = (data: unknown) => ({
- code: 0,
- message: '成功',
- data,
- timestamp: Date.now(),
- requestId: 'ape2e-zipvoice-controlled',
- })
-
- const fulfillJson = (route: Route, data: unknown, status = 200) => route.fulfill({
- status,
- contentType: 'application/json',
- body: JSON.stringify(envelope(data)),
- })
-
- const baseCapability = {
- dataVersion: 1,
- capabilityType: 'VOICE_CLONE',
- type: 'SHERPA_ZIPVOICE',
- description: '受控测试音色',
- reference: '参考录音.wav',
- referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。',
- gender: 'MALE',
- enabled: true,
- builtIn: false,
- updatedAt: '2026-08-22T10:00:00+08:00',
- }
-
- test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象默认音色', async ({ page }, testInfo) => {
- test.setTimeout(120_000)
- await loginAsAdmin(page)
-
- const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord } = {}
- let capabilities = [
- {
- ...baseCapability,
- id: 'builtin-voice-male-gentle',
- name: '温和男声',
- type: '平台内置音色',
- builtIn: true,
- voiceRuntimeMode: 'TTS_SPEAKER',
- validationStatus: 'AVAILABLE',
- status: 'ENABLED',
- mediaUrl: '',
- referenceText: null,
- },
- {
- ...baseCapability,
- id: 'clone-sample-only',
- name: '历史参考样音',
- voiceRuntimeMode: 'SAMPLE_ONLY',
- validationStatus: 'UNTESTED',
- status: 'ENABLED',
- mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
- referenceText: null,
- gender: 'UNSPECIFIED',
- },
- {
- ...baseCapability,
- id: 'clone-processing',
- name: '排队中的男教员',
- voiceRuntimeMode: 'ZIPVOICE_PROCESSING',
- cloneStatus: 'PROCESSING',
- cloneProgress: 42,
- currentJobId: 'JOB-ZIPVOICE-42',
- validationStatus: 'UNTESTED',
- status: 'PROCESSING',
- },
- {
- ...baseCapability,
- id: 'clone-failed',
- name: '待重试音色',
- voiceRuntimeMode: 'ZIPVOICE_FAILED',
- cloneStatus: 'FAILED',
- currentJobId: 'JOB-ZIPVOICE-FAILED',
- validationStatus: 'UNAVAILABLE',
- status: 'FAILED',
- errorMessage: '参考音频有效人声时长不足',
- },
- {
- ...baseCapability,
- id: 'clone-ready',
- name: '可用克隆男声',
- voiceRuntimeMode: 'ZIPVOICE_READY',
- cloneStatus: 'READY',
- cloneProgress: 100,
- validationStatus: 'AVAILABLE',
- status: 'ENABLED',
- previewUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
- mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
- },
- ]
-
- const ttsVoices = [
- { id: 'male-gentle', capabilityId: 'builtin-voice-male-gentle', name: '温和男声', speaker: 1, gender: 'male', language: 'zh-CN', runtimeMode: 'VITS' },
- { id: 'clone-ready', capabilityId: 'clone-ready', name: '可用克隆男声', speaker: null, gender: 'male', language: 'zh-CN', runtimeMode: 'ZIPVOICE' },
- ]
-
- const avatar = {
- id: 'avatar-controlled',
- dataVersion: 3,
- name: '受控维修教员',
- status: 'ready',
- bundleReady: true,
- owner: '系统管理员',
- ownerId: '1',
- ownerName: '系统管理员',
- sourceType: 'user',
- role: '维修教员',
- specialty: '装备检修',
- gender: '男',
- visibility: 'private',
- voiceCapabilityId: 'builtin-voice-male-gentle',
- voice: { capabilityId: 'builtin-voice-male-gentle', speaker: 1, name: '温和男声', speed: 0.8 },
- coverUrl: '/avatar-previews/mechanical-male.png',
- previewUrl: '/avatar-previews/mechanical-male.mp4',
- viewerUrl: '/human/viewer/avatar-controlled',
- updatedAt: '2026-08-22T10:00:00+08:00',
- }
-
- await page.route(/\/api\/v1\/tts(?:\/|\?|$)/, async (route) => {
- const url = new URL(route.request().url())
- if (route.request().method() === 'GET' && url.pathname.endsWith('/tts/voices')) {
- return fulfillJson(route, { ready: true, engine: 'sherpa-onnx', device: 'cpu', items: ttsVoices, error: null, missing: [] })
- }
- if (route.request().method() === 'POST' && url.pathname.endsWith('/tts')) {
- requests.speech = route.request().postDataJSON() as JsonRecord
- return fulfillJson(route, {
- status: 'succeeded', id: 'speech-controlled', audioUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
- mimeType: 'audio/wav', sampleRate: 24000, duration: 1, speaker: null,
- voiceCapabilityId: requests.speech.voiceCapabilityId, runtimeMode: 'ZIPVOICE', speed: 0.9,
- })
- }
- return route.fallback()
- })
-
- await page.route(/\/api\/v1\/files(?:\/|\?|$)/, async (route) => {
- const url = new URL(route.request().url())
- if (route.request().method() === 'POST' && url.pathname.endsWith('/files/signed-urls')) {
- return fulfillJson(route, { items: [{ id: 'file-controlled', fileCode: 'file-controlled', url: '/api/v1/files/file-controlled/content?expires=1999999999&signature=controlled', expiresAt: 1999999999, mediaType: 'audio/wav' }] })
- }
- if (route.request().method() === 'POST' && url.pathname.endsWith('/files')) {
- return fulfillJson(route, { id: 'file-controlled', databaseId: '91', originalName: 'reference.wav', mediaType: 'audio/wav', purposeCode: 'CAPABILITY_SOURCE', size: 1024, sha256: 'controlled', status: 'READY', downloadUrl: '' }, 201)
- }
- return route.fallback()
- })
-
- await page.route(/\/api\/v1\/capabilities(?:\/|\?|$)/, async (route) => {
- const request = route.request()
- const url = new URL(request.url())
- const retryMatch = url.pathname.match(/\/capabilities\/([^/]+)\/voice-clone\/retry$/)
- if (retryMatch && request.method() === 'POST') {
- requests.retry = request.postDataJSON() as JsonRecord
- await new Promise((resolve) => setTimeout(resolve, 350))
- capabilities = capabilities.map((item) => item.id === retryMatch[1]
- ? { ...item, dataVersion: 2, voiceRuntimeMode: 'ZIPVOICE_PROCESSING', cloneStatus: 'QUEUED', cloneProgress: 0, status: 'PROCESSING', errorMessage: '' }
- : item)
- return fulfillJson(route, capabilities.find((item) => item.id === retryMatch[1]))
- }
- const detailMatch = url.pathname.match(/\/capabilities\/([^/]+)$/)
- if (detailMatch && request.method() === 'GET') {
- return fulfillJson(route, capabilities.find((item) => item.id === detailMatch[1]))
- }
- if (url.pathname.endsWith('/capabilities') && request.method() === 'POST') {
- requests.create = request.postDataJSON() as JsonRecord
- const created = {
- ...baseCapability,
- ...requests.create,
- id: 'clone-new-processing',
- dataVersion: 1,
- name: String(requests.create.name || ''),
- mediaFileCode: 'file-controlled',
- mediaUrl: '',
- voiceRuntimeMode: 'ZIPVOICE_PROCESSING',
- cloneStatus: 'QUEUED',
- cloneProgress: 0,
- currentJobId: 'JOB-ZIPVOICE-NEW',
- validationStatus: 'UNTESTED',
- status: 'PROCESSING',
- }
- capabilities = [created, ...capabilities]
- return fulfillJson(route, created, 201)
- }
- if (url.pathname.endsWith('/capabilities') && request.method() === 'GET') {
- return fulfillJson(route, { items: capabilities, total: capabilities.length, page: 1, pageSize: 9, pages: 1 })
- }
- return route.fallback()
- })
-
- await page.route(/\/api\/v1\/avatars(?:\/|\?|$)/, async (route) => {
- const request = route.request()
- const url = new URL(request.url())
- if (request.method() === 'GET' && url.pathname.endsWith('/avatars')) {
- return fulfillJson(route, { items: [avatar], total: 1, page: 1, pageSize: 9, pages: 1 })
- }
- if (request.method() === 'PUT' && url.pathname.endsWith(`/avatars/${avatar.id}`)) {
- requests.avatarUpdate = request.postDataJSON() as JsonRecord
- Object.assign(avatar, {
- ...requests.avatarUpdate,
- dataVersion: 4,
- voiceCapabilityId: requests.avatarUpdate.voiceCapabilityId,
- voice: { capabilityId: requests.avatarUpdate.voiceCapabilityId, speaker: null, name: '可用克隆男声', speed: 0.8 },
- })
- return fulfillJson(route, avatar)
- }
- return route.fallback()
- })
-
- await page.route(/\/api\/v1\/avatar-preferences(?:\?|$)/, (route) => fulfillJson(route, {
- defaultAvatarId: '', builtInVisible: true, dataVersion: 1,
- }))
-
- await page.goto('/assets/voice-clones')
- await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
- await expect(page.locator('.capability-card').filter({ hasText: '历史参考样音' })).toContainText('待开始克隆')
- await expect(page.locator('.capability-card').filter({ hasText: '排队中的男教员' })).toContainText('42%')
- await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('参考音频有效人声时长不足')
- await expect(page.locator('.capability-card').filter({ hasText: '可用克隆男声' })).toContainText('克隆音色可用')
- const builtinCard = page.locator('.capability-card').filter({ hasText: '温和男声' })
- await expect(builtinCard).toContainText('平台内置')
- await expect(builtinCard).toContainText('VITS')
- await expect(builtinCard.getByRole('button', { name: '试听', exact: true })).toBeVisible()
- await expect(builtinCard.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0)
- await expect(builtinCard.getByRole('button', { name: '停用', exact: true })).toHaveCount(0)
- await expect(builtinCard.getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
- await captureScreenshot(page, testInfo, '01-zipvoice-async-states')
-
- const failedCard = page.locator('.capability-card').filter({ hasText: '待重试音色' })
- await failedCard.getByRole('button', { name: '重新克隆' }).click()
- await expect(failedCard.locator('.el-button.is-loading')).toBeVisible()
- await expect.poll(() => requests.retry?.dataVersion).toBe(1)
- await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('排队中')
-
- const readyCard = page.locator('.capability-card').filter({ hasText: '可用克隆男声' })
- await readyCard.getByRole('button', { name: '试听', exact: true }).click()
- await expect.poll(() => requests.speech?.voiceCapabilityId).toBe('clone-ready')
- await expect(readyCard.locator('.inline-media-preview audio')).toBeVisible()
-
- await page.getByRole('button', { name: /新建克隆音色/ }).click()
- const dialog = page.locator('.admin-form-dialog:visible').last()
- await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill('受控 ZipVoice 男教员')
- await dialog.locator('.capability-form > label').filter({ hasText: '性别' }).locator('.el-select').click()
- await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男声' }).click()
- await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill('用于装备维修逐步讲解')
- await dialog.locator('.reference-transcript textarea').fill('进入检修区域前,请确认设备已停机、断电、卸压。')
- await dialog.locator('.el-dialog__body').evaluate((element) => { element.scrollTop = 0 })
- await captureScreenshot(page, testInfo, '02-zipvoice-required-fields')
- await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles({
- name: 'reference.wav',
- mimeType: 'audio/wav',
- buffer: Buffer.from('RIFF-controlled-wave'),
- })
- await dialog.locator('.voice-consent').click()
- await captureScreenshot(page, testInfo, '03-zipvoice-source-and-consent')
- await dialog.getByRole('button', { name: '提交克隆任务' }).click()
- await expect(dialog.getByRole('button', { name: '提交克隆任务' })).toHaveClass(/is-loading/)
- await expect(dialog).toBeHidden()
- expect(requests.create).toMatchObject({
- capabilityType: 'VOICE_CLONE',
- type: 'SHERPA_ZIPVOICE',
- name: '受控 ZipVoice 男教员',
- gender: 'MALE',
- referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。',
- mediaFileCode: 'file-controlled',
- consentConfirmed: true,
- })
- await expect(page.locator('.capability-card').filter({ hasText: '受控 ZipVoice 男教员' })).toContainText('排队中')
-
- await page.goto('/assets/avatars')
- await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
- const avatarCard = page.locator('.avatar-card').filter({ hasText: avatar.name })
- await avatarCard.getByRole('button', { name: '编辑' }).click()
- const avatarDialog = page.locator('.admin-form-dialog:visible').last()
- const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select')
- await voiceSelect.click()
- const dropdown = page.locator('.el-select-dropdown:visible').last()
- await expect(dropdown).toContainText('温和男声(内置)')
- await expect(dropdown).toContainText('可用克隆男声(克隆)')
- await expect(dropdown).not.toContainText('排队中的男教员')
- await dropdown.locator('.el-select-dropdown__item').filter({ hasText: '可用克隆男声' }).click()
- await captureScreenshot(page, testInfo, '04-avatar-default-ready-voice-only')
- await avatarDialog.getByRole('button', { name: '保存修改' }).click()
- await expect.poll(() => requests.avatarUpdate?.voiceCapabilityId).toBe('clone-ready')
- expect(requests.avatarUpdate?.voiceSpeed).toBe(0.8)
- })
|