import type { Route } from '@playwright/test' import { captureScreenshot, expect, test } from './fixtures' import { loginAsAdmin } from './helpers' type JsonRecord = Record 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) })