25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

302 satır
14 KiB

  1. import type { Route } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. import { loginAsAdmin } from './helpers'
  4. type JsonRecord = Record<string, unknown>
  5. const envelope = (data: unknown) => ({
  6. code: 0,
  7. message: '成功',
  8. data,
  9. timestamp: Date.now(),
  10. requestId: 'ape2e-zipvoice-controlled',
  11. })
  12. const fulfillJson = (route: Route, data: unknown, status = 200) => route.fulfill({
  13. status,
  14. contentType: 'application/json',
  15. body: JSON.stringify(envelope(data)),
  16. })
  17. const baseCapability = {
  18. dataVersion: 1,
  19. capabilityType: 'VOICE_CLONE',
  20. type: 'SHERPA_ZIPVOICE',
  21. description: '受控测试音色',
  22. reference: '参考录音.wav',
  23. referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。',
  24. gender: 'MALE',
  25. enabled: true,
  26. builtIn: false,
  27. updatedAt: '2026-08-22T10:00:00+08:00',
  28. }
  29. test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象默认音色', async ({ page }, testInfo) => {
  30. test.setTimeout(120_000)
  31. await loginAsAdmin(page)
  32. const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord } = {}
  33. let capabilities = [
  34. {
  35. ...baseCapability,
  36. id: 'builtin-voice-male-gentle',
  37. name: '温和男声',
  38. type: '平台内置音色',
  39. builtIn: true,
  40. voiceRuntimeMode: 'TTS_SPEAKER',
  41. validationStatus: 'AVAILABLE',
  42. status: 'ENABLED',
  43. mediaUrl: '',
  44. referenceText: null,
  45. },
  46. {
  47. ...baseCapability,
  48. id: 'clone-sample-only',
  49. name: '历史参考样音',
  50. voiceRuntimeMode: 'SAMPLE_ONLY',
  51. validationStatus: 'UNTESTED',
  52. status: 'ENABLED',
  53. mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
  54. referenceText: null,
  55. gender: 'UNSPECIFIED',
  56. },
  57. {
  58. ...baseCapability,
  59. id: 'clone-processing',
  60. name: '排队中的男教员',
  61. voiceRuntimeMode: 'ZIPVOICE_PROCESSING',
  62. cloneStatus: 'PROCESSING',
  63. cloneProgress: 42,
  64. currentJobId: 'JOB-ZIPVOICE-42',
  65. validationStatus: 'UNTESTED',
  66. status: 'PROCESSING',
  67. },
  68. {
  69. ...baseCapability,
  70. id: 'clone-failed',
  71. name: '待重试音色',
  72. voiceRuntimeMode: 'ZIPVOICE_FAILED',
  73. cloneStatus: 'FAILED',
  74. currentJobId: 'JOB-ZIPVOICE-FAILED',
  75. validationStatus: 'UNAVAILABLE',
  76. status: 'FAILED',
  77. errorMessage: '参考音频有效人声时长不足',
  78. },
  79. {
  80. ...baseCapability,
  81. id: 'clone-ready',
  82. name: '可用克隆男声',
  83. voiceRuntimeMode: 'ZIPVOICE_READY',
  84. cloneStatus: 'READY',
  85. cloneProgress: 100,
  86. validationStatus: 'AVAILABLE',
  87. status: 'ENABLED',
  88. previewUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
  89. mediaUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
  90. },
  91. ]
  92. const ttsVoices = [
  93. { id: 'male-gentle', capabilityId: 'builtin-voice-male-gentle', name: '温和男声', speaker: 1, gender: 'male', language: 'zh-CN', runtimeMode: 'VITS' },
  94. { id: 'clone-ready', capabilityId: 'clone-ready', name: '可用克隆男声', speaker: null, gender: 'male', language: 'zh-CN', runtimeMode: 'ZIPVOICE' },
  95. ]
  96. const avatar = {
  97. id: 'avatar-controlled',
  98. dataVersion: 3,
  99. name: '受控维修教员',
  100. status: 'ready',
  101. bundleReady: true,
  102. owner: '系统管理员',
  103. ownerId: '1',
  104. ownerName: '系统管理员',
  105. sourceType: 'user',
  106. role: '维修教员',
  107. specialty: '装备检修',
  108. gender: '男',
  109. visibility: 'private',
  110. voiceCapabilityId: 'builtin-voice-male-gentle',
  111. voice: { capabilityId: 'builtin-voice-male-gentle', speaker: 1, name: '温和男声', speed: 0.8 },
  112. coverUrl: '/avatar-previews/mechanical-male.png',
  113. previewUrl: '/avatar-previews/mechanical-male.mp4',
  114. viewerUrl: '/human/viewer/avatar-controlled',
  115. updatedAt: '2026-08-22T10:00:00+08:00',
  116. }
  117. await page.route(/\/api\/v1\/tts(?:\/|\?|$)/, async (route) => {
  118. const url = new URL(route.request().url())
  119. if (route.request().method() === 'GET' && url.pathname.endsWith('/tts/voices')) {
  120. return fulfillJson(route, { ready: true, engine: 'sherpa-onnx', device: 'cpu', items: ttsVoices, error: null, missing: [] })
  121. }
  122. if (route.request().method() === 'POST' && url.pathname.endsWith('/tts')) {
  123. requests.speech = route.request().postDataJSON() as JsonRecord
  124. return fulfillJson(route, {
  125. status: 'succeeded', id: 'speech-controlled', audioUrl: 'data:audio/wav;base64,UklGRgAAAABXQVZF',
  126. mimeType: 'audio/wav', sampleRate: 24000, duration: 1, speaker: null,
  127. voiceCapabilityId: requests.speech.voiceCapabilityId, runtimeMode: 'ZIPVOICE', speed: 0.9,
  128. })
  129. }
  130. return route.fallback()
  131. })
  132. await page.route(/\/api\/v1\/files(?:\/|\?|$)/, async (route) => {
  133. const url = new URL(route.request().url())
  134. if (route.request().method() === 'POST' && url.pathname.endsWith('/files/signed-urls')) {
  135. 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' }] })
  136. }
  137. if (route.request().method() === 'POST' && url.pathname.endsWith('/files')) {
  138. 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)
  139. }
  140. return route.fallback()
  141. })
  142. await page.route(/\/api\/v1\/capabilities(?:\/|\?|$)/, async (route) => {
  143. const request = route.request()
  144. const url = new URL(request.url())
  145. const retryMatch = url.pathname.match(/\/capabilities\/([^/]+)\/voice-clone\/retry$/)
  146. if (retryMatch && request.method() === 'POST') {
  147. requests.retry = request.postDataJSON() as JsonRecord
  148. await new Promise((resolve) => setTimeout(resolve, 350))
  149. capabilities = capabilities.map((item) => item.id === retryMatch[1]
  150. ? { ...item, dataVersion: 2, voiceRuntimeMode: 'ZIPVOICE_PROCESSING', cloneStatus: 'QUEUED', cloneProgress: 0, status: 'PROCESSING', errorMessage: '' }
  151. : item)
  152. return fulfillJson(route, capabilities.find((item) => item.id === retryMatch[1]))
  153. }
  154. const detailMatch = url.pathname.match(/\/capabilities\/([^/]+)$/)
  155. if (detailMatch && request.method() === 'GET') {
  156. return fulfillJson(route, capabilities.find((item) => item.id === detailMatch[1]))
  157. }
  158. if (url.pathname.endsWith('/capabilities') && request.method() === 'POST') {
  159. requests.create = request.postDataJSON() as JsonRecord
  160. const created = {
  161. ...baseCapability,
  162. ...requests.create,
  163. id: 'clone-new-processing',
  164. dataVersion: 1,
  165. name: String(requests.create.name || ''),
  166. mediaFileCode: 'file-controlled',
  167. mediaUrl: '',
  168. voiceRuntimeMode: 'ZIPVOICE_PROCESSING',
  169. cloneStatus: 'QUEUED',
  170. cloneProgress: 0,
  171. currentJobId: 'JOB-ZIPVOICE-NEW',
  172. validationStatus: 'UNTESTED',
  173. status: 'PROCESSING',
  174. }
  175. capabilities = [created, ...capabilities]
  176. return fulfillJson(route, created, 201)
  177. }
  178. if (url.pathname.endsWith('/capabilities') && request.method() === 'GET') {
  179. return fulfillJson(route, { items: capabilities, total: capabilities.length, page: 1, pageSize: 9, pages: 1 })
  180. }
  181. return route.fallback()
  182. })
  183. await page.route(/\/api\/v1\/avatars(?:\/|\?|$)/, async (route) => {
  184. const request = route.request()
  185. const url = new URL(request.url())
  186. if (request.method() === 'GET' && url.pathname.endsWith('/avatars')) {
  187. return fulfillJson(route, { items: [avatar], total: 1, page: 1, pageSize: 9, pages: 1 })
  188. }
  189. if (request.method() === 'PUT' && url.pathname.endsWith(`/avatars/${avatar.id}`)) {
  190. requests.avatarUpdate = request.postDataJSON() as JsonRecord
  191. Object.assign(avatar, {
  192. ...requests.avatarUpdate,
  193. dataVersion: 4,
  194. voiceCapabilityId: requests.avatarUpdate.voiceCapabilityId,
  195. voice: { capabilityId: requests.avatarUpdate.voiceCapabilityId, speaker: null, name: '可用克隆男声', speed: 0.8 },
  196. })
  197. return fulfillJson(route, avatar)
  198. }
  199. return route.fallback()
  200. })
  201. await page.route(/\/api\/v1\/avatar-preferences(?:\?|$)/, (route) => fulfillJson(route, {
  202. defaultAvatarId: '', builtInVisible: true, dataVersion: 1,
  203. }))
  204. await page.goto('/assets/voice-clones')
  205. await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
  206. await expect(page.locator('.capability-card').filter({ hasText: '历史参考样音' })).toContainText('待开始克隆')
  207. await expect(page.locator('.capability-card').filter({ hasText: '排队中的男教员' })).toContainText('42%')
  208. await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('参考音频有效人声时长不足')
  209. await expect(page.locator('.capability-card').filter({ hasText: '可用克隆男声' })).toContainText('克隆音色可用')
  210. const builtinCard = page.locator('.capability-card').filter({ hasText: '温和男声' })
  211. await expect(builtinCard).toContainText('平台内置')
  212. await expect(builtinCard).toContainText('VITS')
  213. await expect(builtinCard.getByRole('button', { name: '试听', exact: true })).toBeVisible()
  214. await expect(builtinCard.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0)
  215. await expect(builtinCard.getByRole('button', { name: '停用', exact: true })).toHaveCount(0)
  216. await expect(builtinCard.getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
  217. await captureScreenshot(page, testInfo, '01-zipvoice-async-states')
  218. const failedCard = page.locator('.capability-card').filter({ hasText: '待重试音色' })
  219. await failedCard.getByRole('button', { name: '重新克隆' }).click()
  220. await expect(failedCard.locator('.el-button.is-loading')).toBeVisible()
  221. await expect.poll(() => requests.retry?.dataVersion).toBe(1)
  222. await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('排队中')
  223. const readyCard = page.locator('.capability-card').filter({ hasText: '可用克隆男声' })
  224. await readyCard.getByRole('button', { name: '试听', exact: true }).click()
  225. await expect.poll(() => requests.speech?.voiceCapabilityId).toBe('clone-ready')
  226. await expect(readyCard.locator('.inline-media-preview audio')).toBeVisible()
  227. await page.getByRole('button', { name: /新建克隆音色/ }).click()
  228. const dialog = page.locator('.admin-form-dialog:visible').last()
  229. const genderField = dialog.locator('.voice-gender-field')
  230. const engineField = dialog.locator('.voice-engine-field')
  231. await expect(engineField).toContainText('Sherpa-ONNX ZipVoice')
  232. await expect(engineField).toContainText('CPU')
  233. const [genderBox, engineBox] = await Promise.all([genderField.boundingBox(), engineField.boundingBox()])
  234. expect(genderBox).not.toBeNull()
  235. expect(engineBox).not.toBeNull()
  236. expect(engineBox!.x).toBeGreaterThan(genderBox!.x)
  237. expect(genderBox!.x + genderBox!.width).toBeLessThanOrEqual(engineBox!.x)
  238. await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill('受控 ZipVoice 男教员')
  239. await dialog.locator('.capability-form > label').filter({ hasText: '性别' }).locator('.el-select').click()
  240. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男声' }).click()
  241. await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill('用于装备维修逐步讲解')
  242. await dialog.locator('.reference-transcript textarea').fill('进入检修区域前,请确认设备已停机、断电、卸压。')
  243. await dialog.locator('.el-dialog__body').evaluate((element) => { element.scrollTop = 0 })
  244. await captureScreenshot(page, testInfo, '02-zipvoice-required-fields')
  245. await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles({
  246. name: 'reference.wav',
  247. mimeType: 'audio/wav',
  248. buffer: Buffer.from('RIFF-controlled-wave'),
  249. })
  250. await expect(dialog.locator('.voice-consent .el-checkbox__label')).toHaveCSS('white-space', 'nowrap')
  251. await dialog.locator('.voice-consent').click()
  252. await captureScreenshot(page, testInfo, '03-zipvoice-source-and-consent')
  253. await dialog.getByRole('button', { name: '提交克隆任务' }).click()
  254. await expect(dialog.getByRole('button', { name: '提交克隆任务' })).toHaveClass(/is-loading/)
  255. await expect(dialog).toBeHidden()
  256. expect(requests.create).toMatchObject({
  257. capabilityType: 'VOICE_CLONE',
  258. type: 'SHERPA_ZIPVOICE',
  259. name: '受控 ZipVoice 男教员',
  260. gender: 'MALE',
  261. referenceText: '进入检修区域前,请确认设备已停机、断电、卸压。',
  262. mediaFileCode: 'file-controlled',
  263. consentConfirmed: true,
  264. })
  265. await expect(page.locator('.capability-card').filter({ hasText: '受控 ZipVoice 男教员' })).toContainText('排队中')
  266. await page.goto('/assets/avatars')
  267. await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
  268. const avatarCard = page.locator('.avatar-card').filter({ hasText: avatar.name })
  269. await avatarCard.getByRole('button', { name: '编辑' }).click()
  270. const avatarDialog = page.locator('.admin-form-dialog:visible').last()
  271. const voiceSelect = avatarDialog.locator('.el-form-item').filter({ hasText: '默认音色' }).locator('.el-select')
  272. await voiceSelect.click()
  273. const dropdown = page.locator('.el-select-dropdown:visible').last()
  274. await expect(dropdown).toContainText('温和男声(内置)')
  275. await expect(dropdown).toContainText('可用克隆男声(克隆)')
  276. await expect(dropdown).not.toContainText('排队中的男教员')
  277. await dropdown.locator('.el-select-dropdown__item').filter({ hasText: '可用克隆男声' }).click()
  278. await captureScreenshot(page, testInfo, '04-avatar-default-ready-voice-only')
  279. await avatarDialog.getByRole('button', { name: '保存修改' }).click()
  280. await expect.poll(() => requests.avatarUpdate?.voiceCapabilityId).toBe('clone-ready')
  281. expect(requests.avatarUpdate?.voiceSpeed).toBe(0.8)
  282. })