You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

641 rivejä
34 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { execFileSync } from 'node:child_process'
  4. import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test'
  5. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  6. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  7. test.describe.configure({ mode: 'serial' })
  8. test.use({ trace: 'off', video: 'off' })
  9. type JsonRecord = Record<string, unknown>
  10. type Headers = Record<string, string>
  11. type CapabilityType = 'VOICE_CLONE' | 'TTS' | 'SCENE'
  12. interface Activity {
  13. module: string
  14. action: string
  15. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  16. httpStatus?: number
  17. resourceId?: string
  18. detail?: string
  19. }
  20. interface AvatarPreferences {
  21. defaultAvatarId: string
  22. builtInVisible: boolean
  23. dataVersion: number
  24. }
  25. interface ResponseLike {
  26. status(): number
  27. json(): Promise<unknown>
  28. }
  29. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  30. ? value as JsonRecord
  31. : {}
  32. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  33. async function responseData(response: ResponseLike, expected: number | number[], label: string) {
  34. const statuses = Array.isArray(expected) ? expected : [expected]
  35. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  36. return asRecord(envelopeData(await response.json()))
  37. }
  38. function addPass(
  39. activities: Activity[],
  40. module: string,
  41. action: string,
  42. response?: ResponseLike,
  43. resourceId?: string,
  44. detail?: string,
  45. ) {
  46. activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail })
  47. }
  48. async function attachLifecycleReport(
  49. testInfo: TestInfo,
  50. title: string,
  51. activities: Activity[],
  52. cleanupErrors: string[],
  53. ) {
  54. await attachJson(testInfo, `${title}-生命周期与清理`, {
  55. runId,
  56. runPrefix,
  57. title,
  58. activities,
  59. cleanupComplete: cleanupErrors.length === 0,
  60. cleanupErrors,
  61. security: '报告不记录管理员密码、访问令牌或媒体签名地址。',
  62. })
  63. }
  64. async function getAvatarPreferences(request: APIRequestContext, headers: Headers): Promise<AvatarPreferences> {
  65. const response = await request.get('/api/v1/avatar-preferences', { headers })
  66. const value = await responseData(response, 200, '读取数字人平台偏好')
  67. return {
  68. defaultAvatarId: String(value.defaultAvatarId ?? ''),
  69. builtInVisible: value.builtInVisible !== false,
  70. dataVersion: Number(value.dataVersion ?? 0),
  71. }
  72. }
  73. async function restoreAvatarPreferences(
  74. request: APIRequestContext,
  75. headers: Headers,
  76. snapshot: AvatarPreferences,
  77. ) {
  78. const current = await getAvatarPreferences(request, headers)
  79. const response = await request.put('/api/v1/avatar-preferences', {
  80. headers,
  81. data: {
  82. defaultAvatarId: snapshot.defaultAvatarId,
  83. builtInVisible: snapshot.builtInVisible,
  84. dataVersion: current.dataVersion,
  85. },
  86. })
  87. await responseData(response, 200, '恢复数字人平台偏好快照')
  88. return response
  89. }
  90. async function listAvatars(request: APIRequestContext, headers: Headers, keyword = '') {
  91. const response = await request.get('/api/v1/avatars', {
  92. headers,
  93. params: { keyword, page: '1', pageSize: '200' },
  94. })
  95. const page = await responseData(response, 200, '读取数字形象列表')
  96. return { response, items: asRecords(page.items ?? page.records) }
  97. }
  98. async function cleanupAvatarsByPrefix(
  99. request: APIRequestContext,
  100. headers: Headers,
  101. activities: Activity[],
  102. cleanupErrors: string[],
  103. ownedNames: Set<string>,
  104. ) {
  105. try {
  106. const { items } = await listAvatars(request, headers, runPrefix)
  107. for (const item of items) {
  108. const id = String(item.id ?? '')
  109. if (!id || !ownedNames.has(String(item.name ?? ''))) continue
  110. const detailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers })
  111. if (detailResponse.status() === 404) continue
  112. const detail = await responseData(detailResponse, 200, '数字人清理前刷新版本')
  113. const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(id)}`, {
  114. headers,
  115. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  116. timeout: 120_000,
  117. })
  118. if (![200, 404].includes(removed.status())) throw new Error(`数字人 ${id} 删除失败:HTTP ${removed.status()}`)
  119. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  120. }
  121. } catch (error) {
  122. const message = `数字人兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  123. cleanupErrors.push(message)
  124. activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  125. }
  126. }
  127. async function listCapabilities(
  128. request: APIRequestContext,
  129. headers: Headers,
  130. capabilityType: CapabilityType,
  131. keyword = '',
  132. ) {
  133. const response = await request.get('/api/v1/capabilities', {
  134. headers,
  135. params: { capabilityType, keyword, page: '1', pageSize: '200' },
  136. })
  137. const page = await responseData(response, 200, `读取 ${capabilityType} 能力列表`)
  138. return { response, items: asRecords(page.items ?? page.records) }
  139. }
  140. async function cleanupCapabilitiesByPrefix(
  141. request: APIRequestContext,
  142. headers: Headers,
  143. activities: Activity[],
  144. cleanupErrors: string[],
  145. knownFileCodes: Set<string>,
  146. ) {
  147. for (const capabilityType of ['VOICE_CLONE', 'TTS', 'SCENE'] as const) {
  148. try {
  149. const { items } = await listCapabilities(request, headers, capabilityType, runPrefix)
  150. for (const item of items) {
  151. const id = String(item.id ?? '')
  152. if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue
  153. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers })
  154. if (detailResponse.status() === 404) continue
  155. const detail = await responseData(detailResponse, 200, '能力清理前刷新版本')
  156. const mediaFileCode = String(detail.mediaFileCode ?? '')
  157. if (mediaFileCode) knownFileCodes.add(mediaFileCode)
  158. const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, {
  159. headers,
  160. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  161. })
  162. if (![200, 404].includes(removed.status())) throw new Error(`能力 ${id} 删除失败:HTTP ${removed.status()}`)
  163. activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  164. }
  165. } catch (error) {
  166. const message = `${capabilityType} 兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  167. cleanupErrors.push(message)
  168. activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  169. }
  170. }
  171. for (const fileCode of knownFileCodes) {
  172. try {
  173. const removed = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
  174. if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
  175. activities.push({ module: '能力媒体', action: '解除引用后清理上传文件', result: 'CLEANED', httpStatus: removed.status(), resourceId: fileCode })
  176. } catch (error) {
  177. const message = `能力媒体 ${fileCode} 清理异常:${error instanceof Error ? error.message : String(error)}`
  178. cleanupErrors.push(message)
  179. activities.push({ module: '能力媒体', action: '清理上传文件', result: 'CLEANUP_FAILED', resourceId: fileCode, detail: message })
  180. }
  181. }
  182. }
  183. function avatarCard(page: Page, name: string) {
  184. return page.locator('.avatar-card').filter({ hasText: name }).first()
  185. }
  186. function capabilityCard(page: Page, name: string) {
  187. return page.locator('.capability-card').filter({ hasText: name }).first()
  188. }
  189. function dialogField(dialog: Locator, label: string) {
  190. return dialog.locator('.capability-form > label').filter({ hasText: label }).first()
  191. }
  192. async function waitForToast(page: Page, text: string | RegExp) {
  193. await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible()
  194. }
  195. async function deleteCapabilityThroughUi(page: Page, name: string) {
  196. const card = capabilityCard(page, name)
  197. await card.getByRole('button', { name: '删除' }).click()
  198. const box = page.locator('.el-message-box:visible').last()
  199. await expect(box).toBeVisible()
  200. await box.getByRole('button', { name: /确定|确认/ }).click()
  201. await waitForToast(page, '已删除')
  202. await expect(capabilityCard(page, name)).toHaveCount(0)
  203. }
  204. function createVoiceFixture(sourceVideo: string, outputPath: string) {
  205. fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  206. execFileSync(process.env.FFMPEG_BINARY?.trim() || 'ffmpeg', [
  207. '-y', '-hide_banner', '-loglevel', 'error', '-i', sourceVideo,
  208. '-vn', '-t', '12', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', outputPath,
  209. ], { stdio: 'pipe', timeout: 60_000, windowsHide: true })
  210. expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000)
  211. }
  212. test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request }, testInfo) => {
  213. test.setTimeout(480_000)
  214. const headers = await authHeaders(request)
  215. const activities: Activity[] = []
  216. const cleanupErrors: string[] = []
  217. const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  218. const originalName = `${runPrefix}-UI本人视频形象`.slice(0, 80)
  219. const editedName = `${runPrefix}-UI液压维修教员`.slice(0, 80)
  220. let preferenceSnapshot: AvatarPreferences | null = null
  221. let primaryError: unknown
  222. expect(fs.existsSync(sourceVideo), `原型本人视频不存在:${sourceVideo}`).toBeTruthy()
  223. try {
  224. const upstreamHealth = await request.get('/human/health')
  225. expect(upstreamHealth.status(), '数字人生成上游健康检查必须成功').toBe(200)
  226. addPass(activities, '数字形象', '真实上游健康检查', upstreamHealth)
  227. preferenceSnapshot = await getAvatarPreferences(request, headers)
  228. activities.push({ module: '数字形象偏好', action: '保存默认形象与内置显示快照', result: 'PASS' })
  229. await loginAsAdmin(page)
  230. await page.goto('/assets/avatars')
  231. await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
  232. const builtInToggle = page.locator('.avatar-demo-toggle')
  233. const builtInSwitch = builtInToggle.getByRole('switch')
  234. const builtInSwitchControl = builtInToggle.locator('.el-switch')
  235. if (!(await builtInSwitch.isChecked())) {
  236. await builtInSwitchControl.click()
  237. await waitForToast(page, '已显示内置示例形象')
  238. }
  239. const { items: visibleAvatars } = await listAvatars(request, headers)
  240. const previewAvatar = visibleAvatars.find((item) => [
  241. 'a1000000000000000000000000000001',
  242. 'a1000000000000000000000000000002',
  243. 'a1000000000000000000000000000003',
  244. 'a1000000000000000000000000000004',
  245. ].includes(String(item.id ?? '')))
  246. expect(previewAvatar, '平台应保留至少一个可播放口播样片的内置形象').toBeTruthy()
  247. const previewName = String(previewAvatar?.name ?? '')
  248. const nameSearch = page.getByPlaceholder('搜索数字人名称')
  249. await nameSearch.fill(previewName)
  250. await expect(avatarCard(page, previewName)).toBeVisible()
  251. const previewGender = String(previewAvatar?.gender ?? '')
  252. if (previewGender === '男' || previewGender === '女') {
  253. await page.locator('.avatar-gender-filter select').selectOption(previewGender)
  254. await expect(avatarCard(page, previewName)).toBeVisible()
  255. }
  256. await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter')
  257. addPass(activities, '数字形象', '名称与性别组合筛选')
  258. await avatarCard(page, previewName).getByRole('button', { name: '预览口播' }).click()
  259. const previewDialog = page.locator('.avatar-preview-dialog:visible').last()
  260. const previewVideo = previewDialog.locator('video')
  261. await expect(previewVideo).toBeVisible()
  262. await expect(previewVideo).toHaveAttribute('src', /avatar-previews\/.+\.mp4/)
  263. const previewSource = String(await previewVideo.getAttribute('src'))
  264. const previewPoster = String(await previewVideo.getAttribute('poster'))
  265. const sourceResponse = await request.get(previewSource)
  266. expect(sourceResponse.status()).toBe(200)
  267. expect(sourceResponse.headers()['content-type']).toContain('video/mp4')
  268. const posterResponse = await request.get(previewPoster)
  269. expect(posterResponse.status()).toBe(200)
  270. expect(posterResponse.headers()['content-type']).toMatch(/^image\//)
  271. await captureScreenshot(page, testInfo, '02-builtin-avatar-real-video-preview')
  272. addPass(activities, '数字形象', '内置口播真实视频与封面预览', sourceResponse, String(previewAvatar?.id ?? ''), '已校验真实 MP4/封面响应;Playwright Chromium 的 H.264 解码能力不作为服务端文件可用性判据')
  273. await previewDialog.locator('.el-dialog__headerbtn').click()
  274. await nameSearch.fill('')
  275. await page.locator('.avatar-gender-filter select').selectOption('')
  276. await page.getByRole('button', { name: '创建数字人' }).click()
  277. const createDialog = page.locator('.avatar-create-dialog:visible').last()
  278. await expect(createDialog).toBeVisible()
  279. await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo)
  280. await createDialog.locator('.avatar-create-fields input').fill(originalName)
  281. await createDialog.locator('details.avatar-create-advanced summary').click()
  282. await createDialog.locator('select').selectOption('off')
  283. await captureScreenshot(page, testInfo, '03-avatar-real-video-ready-to-submit')
  284. await createDialog.getByRole('button', { name: '开始创建' }).click()
  285. await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 240_000 })
  286. await captureScreenshot(page, testInfo, '04-avatar-upstream-job-succeeded')
  287. await createDialog.getByRole('button', { name: '关闭', exact: true }).click()
  288. await nameSearch.fill(originalName)
  289. const createdCard = avatarCard(page, originalName)
  290. await expect(createdCard).toBeVisible()
  291. await expect(createdCard).toContainText('可预览')
  292. const { response: createdListResponse, items: createdItems } = await listAvatars(request, headers, originalName)
  293. const createdAvatar = createdItems.find((item) => String(item.name ?? '') === originalName)
  294. const avatarId = String(createdAvatar?.id ?? '')
  295. expect(avatarId).not.toBe('')
  296. expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready')
  297. addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId)
  298. await createdCard.getByRole('button', { name: '编辑' }).click()
  299. const editDialog = page.locator('.admin-form-dialog:visible').last()
  300. await editDialog.getByLabel('数字人名称').fill(editedName)
  301. await editDialog.getByLabel('角色').fill('装备维修教员')
  302. await editDialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select').click()
  303. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click()
  304. await editDialog.getByLabel('专业方向').fill('液压与电气检修实训')
  305. const visibilityField = editDialog.locator('.avatar-visibility-field')
  306. const visibilitySwitch = visibilityField.getByRole('switch')
  307. if (await visibilitySwitch.isChecked()) await visibilityField.locator('.el-switch').click()
  308. await captureScreenshot(page, testInfo, '05-avatar-profile-and-visibility-edit')
  309. await editDialog.getByRole('button', { name: '保存修改' }).click()
  310. await waitForToast(page, '数字人信息已更新')
  311. await nameSearch.fill(editedName)
  312. const editedCard = avatarCard(page, editedName)
  313. await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训')
  314. await expect(editedCard).toContainText('平台共享')
  315. const detailAfterEdit = await responseData(
  316. await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }),
  317. 200,
  318. '刷新验证数字人页面编辑',
  319. )
  320. expect(detailAfterEdit).toMatchObject({
  321. name: editedName,
  322. role: '装备维修教员',
  323. specialty: '液压与电气检修实训',
  324. gender: '男',
  325. visibility: 'internal',
  326. })
  327. addPass(activities, '数字形象', '页面编辑并刷新持久化', undefined, avatarId)
  328. await editedCard.getByRole('button', { name: '停用' }).click()
  329. const disableBox = page.locator('.el-message-box:visible').last()
  330. await expect(disableBox).toContainText('停用后不会出现在业务选择器中')
  331. await disableBox.getByRole('button', { name: '确认停用' }).click()
  332. await waitForToast(page, '数字人已停用')
  333. await expect(avatarCard(page, editedName)).toContainText('已停用')
  334. await captureScreenshot(page, testInfo, '06-avatar-disabled-through-ui')
  335. await avatarCard(page, editedName).getByRole('button', { name: '启用' }).click()
  336. await waitForToast(page, '数字人已启用')
  337. await expect(avatarCard(page, editedName)).toContainText('可预览')
  338. addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId)
  339. await avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' }).click()
  340. await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`))
  341. await expect(avatarCard(page, editedName)).toContainText('默认数字人')
  342. const defaultPreferences = await getAvatarPreferences(request, headers)
  343. expect(defaultPreferences.defaultAvatarId).toBe(avatarId)
  344. addPass(activities, '数字形象偏好', '页面设为平台默认数字人', undefined, avatarId)
  345. await nameSearch.fill('')
  346. const currentSwitchState = await builtInSwitch.isChecked()
  347. await builtInSwitchControl.click()
  348. await waitForToast(page, currentSwitchState ? '已隐藏内置示例形象' : '已显示内置示例形象')
  349. expect(await builtInSwitch.isChecked()).toBe(!currentSwitchState)
  350. await captureScreenshot(page, testInfo, '07-builtin-avatar-visibility-toggled')
  351. addPass(activities, '数字形象偏好', '页面切换内置示例显示配置')
  352. const restoredResponse = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
  353. addPass(activities, '数字形象偏好', '按测试前快照恢复默认形象与内置显示', restoredResponse)
  354. preferenceSnapshot = null
  355. await page.reload()
  356. await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
  357. await page.getByPlaceholder('搜索数字人名称').fill(editedName)
  358. await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' })).toBeEnabled()
  359. await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click()
  360. const deleteBox = page.locator('.el-message-box:visible').last()
  361. await expect(deleteBox).toContainText(`确定删除“${editedName}”吗`)
  362. await deleteBox.getByRole('button', { name: '确认删除' }).click()
  363. await waitForToast(page, '数字人已删除')
  364. await expect(avatarCard(page, editedName)).toHaveCount(0)
  365. const absent = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  366. expect(absent.status()).toBe(404)
  367. await captureScreenshot(page, testInfo, '08-avatar-deleted-and-filter-empty')
  368. addPass(activities, '数字形象', '页面删除且正式 API 不可再读取', absent, avatarId)
  369. } catch (error) {
  370. primaryError = error
  371. activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  372. } finally {
  373. if (preferenceSnapshot) {
  374. try {
  375. const restored = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
  376. activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() })
  377. } catch (error) {
  378. const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}`
  379. cleanupErrors.push(message)
  380. activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message })
  381. }
  382. }
  383. await cleanupAvatarsByPrefix(request, headers, activities, cleanupErrors, new Set([originalName, editedName]))
  384. await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors)
  385. }
  386. if (primaryError) throw primaryError
  387. expect(cleanupErrors, '数字形象测试数据与平台偏好必须完整恢复').toEqual([])
  388. })
  389. test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编辑、筛选、预览、停启和删除闭环', async ({ page, request }, testInfo) => {
  390. test.setTimeout(360_000)
  391. const headers = await authHeaders(request)
  392. const activities: Activity[] = []
  393. const cleanupErrors: string[] = []
  394. const knownFileCodes = new Set<string>()
  395. const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  396. const sceneImage = path.resolve('../ai_person/public/brand/DA_bg.jpg')
  397. const voiceFixture = testInfo.outputPath(`${runPrefix}-real-voice.wav`)
  398. let primaryError: unknown
  399. expect(fs.existsSync(sourceVideo)).toBeTruthy()
  400. expect(fs.existsSync(sceneImage)).toBeTruthy()
  401. createVoiceFixture(sourceVideo, voiceFixture)
  402. const voiceName = `${runPrefix}-维修教员克隆音色`.slice(0, 50)
  403. const voiceEditedName = `${runPrefix}-维修讲解克隆音色`.slice(0, 50)
  404. const ttsName = `${runPrefix}-本地标准中文TTS`.slice(0, 50)
  405. const ttsEditedName = `${runPrefix}-本地自然口播TTS`.slice(0, 50)
  406. const sceneName = `${runPrefix}-数字车间背景`.slice(0, 50)
  407. const sceneEditedName = `${runPrefix}-数字车间实训背景`.slice(0, 50)
  408. try {
  409. await loginAsAdmin(page)
  410. await page.goto('/assets/voice-clones')
  411. await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
  412. await page.getByRole('button', { name: /新建克隆音色/ }).click()
  413. let dialog = page.locator('.admin-form-dialog:visible').last()
  414. await dialogField(dialog, '名称').locator('input').fill(voiceName)
  415. await dialogField(dialog, '分类').locator('select').selectOption('男声教员')
  416. await dialogField(dialog, '说明').locator('textarea').fill('装备维修实训问答与安全口播音色')
  417. await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles(voiceFixture)
  418. await expect(dialog.locator('.voice-capture-panel audio')).toBeVisible()
  419. await captureScreenshot(page, testInfo, '09-voice-clone-real-audio-before-save')
  420. const voiceUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
  421. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  422. const voiceUpload = await voiceUploadPromise
  423. const voiceFile = await responseData(voiceUpload, 201, '上传真实克隆音色样本')
  424. knownFileCodes.add(String(voiceFile.id ?? ''))
  425. await waitForToast(page, '配置已创建')
  426. let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明')
  427. await search.fill(voiceName)
  428. let card = capabilityCard(page, voiceName)
  429. await expect(card).toBeVisible()
  430. const { response: voiceListResponse, items: voiceItems } = await listCapabilities(request, headers, 'VOICE_CLONE', voiceName)
  431. const voiceId = String(voiceItems.find((item) => String(item.name ?? '') === voiceName)?.id ?? '')
  432. expect(voiceId).not.toBe('')
  433. addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId)
  434. await card.getByRole('button', { name: '试听' }).click()
  435. const audio = card.locator('.inline-media-preview audio')
  436. await expect(audio).toBeVisible()
  437. await expect(audio).toHaveAttribute('src', /.+/)
  438. await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
  439. await captureScreenshot(page, testInfo, '10-voice-clone-signed-audio-preview')
  440. addPass(activities, '声音克隆', '短期签名音频真实试听', undefined, voiceId)
  441. await card.getByRole('button', { name: '编辑' }).click()
  442. dialog = page.locator('.admin-form-dialog:visible').last()
  443. await dialogField(dialog, '名称').locator('input').fill(voiceEditedName)
  444. await dialogField(dialog, '分类').locator('select').selectOption('讲解播报')
  445. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于维修工序讲解与风险提示')
  446. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  447. await waitForToast(page, '配置已更新')
  448. await expect(dialog).toBeHidden()
  449. await search.fill(voiceEditedName)
  450. card = capabilityCard(page, voiceEditedName)
  451. await expect(card).toContainText('讲解播报')
  452. await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示')
  453. const editedAudio = card.locator('.inline-media-preview audio')
  454. if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click()
  455. await expect(editedAudio).toBeVisible()
  456. await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
  457. addPass(activities, '声音克隆', '页面编辑并刷新持久化', undefined, voiceId)
  458. await card.getByRole('button', { name: '停用' }).click()
  459. await waitForToast(page, '已停用')
  460. await page.getByLabel('状态筛选').selectOption('disabled')
  461. await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用')
  462. await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter')
  463. await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click()
  464. await waitForToast(page, '已启用')
  465. await page.getByLabel('状态筛选').selectOption('all')
  466. addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId)
  467. await deleteCapabilityThroughUi(page, voiceEditedName)
  468. addPass(activities, '声音克隆', '页面删除', undefined, voiceId)
  469. await page.goto('/assets/voice-models')
  470. await expect(page.getByRole('heading', { name: '语音模型' })).toBeVisible()
  471. await page.getByRole('button', { name: /新增语音模型/ }).click()
  472. dialog = page.locator('.admin-form-dialog:visible').last()
  473. await dialogField(dialog, '名称').locator('input').fill(ttsName)
  474. await dialogField(dialog, '分类').locator('select').selectOption('标准中文')
  475. await dialogField(dialog, '说明').locator('textarea').fill('本地实时问答标准中文播报')
  476. await dialog.locator('details.capability-form-technical summary').click()
  477. await dialog.locator('details.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-tts-standard-zh`)
  478. await captureScreenshot(page, testInfo, '12-tts-local-reference-before-save')
  479. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  480. await waitForToast(page, '配置已创建')
  481. search = page.getByPlaceholder('搜索语音模型名称、分类或说明')
  482. await search.fill(ttsName)
  483. card = capabilityCard(page, ttsName)
  484. await expect(card).toBeVisible()
  485. const { response: ttsListResponse, items: ttsItems } = await listCapabilities(request, headers, 'TTS', ttsName)
  486. const ttsId = String(ttsItems.find((item) => String(item.name ?? '') === ttsName)?.id ?? '')
  487. expect(ttsId).not.toBe('')
  488. addPass(activities, 'TTS', '页面创建正式本地能力引用', ttsListResponse, ttsId)
  489. await card.locator('details.capability-technical-info summary').click()
  490. await card.getByRole('button', { name: '校验本地配置' }).click()
  491. await waitForToast(page, /配置可用|校验通过/)
  492. await card.getByRole('button', { name: '预览' }).click()
  493. await waitForToast(page, new RegExp(`${ttsName}适合`))
  494. await card.getByRole('button', { name: '编辑' }).click()
  495. dialog = page.locator('.admin-form-dialog:visible').last()
  496. await dialogField(dialog, '名称').locator('input').fill(ttsEditedName)
  497. await dialogField(dialog, '分类').locator('select').selectOption('自然口播')
  498. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:面向数字教员自然口播')
  499. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  500. await waitForToast(page, '配置已更新')
  501. await expect(dialog).toBeHidden()
  502. await search.fill(ttsEditedName)
  503. card = capabilityCard(page, ttsEditedName)
  504. await expect(card).toContainText('自然口播')
  505. await card.getByRole('button', { name: '停用' }).click()
  506. await waitForToast(page, '已停用')
  507. await card.getByRole('button', { name: '启用' }).click()
  508. await waitForToast(page, '已启用')
  509. await captureScreenshot(page, testInfo, '13-tts-edited-validated-and-enabled')
  510. addPass(activities, 'TTS', '校验、预览、编辑、停用与启用', undefined, ttsId)
  511. await deleteCapabilityThroughUi(page, ttsEditedName)
  512. addPass(activities, 'TTS', '页面删除', undefined, ttsId)
  513. await page.goto('/assets/scene-materials')
  514. await expect(page.getByRole('heading', { name: '场景素材' })).toBeVisible()
  515. await page.getByRole('tab', { name: '背景图片' }).click()
  516. await page.getByRole('button', { name: /新增场景素材/ }).click()
  517. dialog = page.locator('.admin-form-dialog:visible').last()
  518. await dialogField(dialog, '名称').locator('input').fill(sceneName)
  519. await dialogField(dialog, '分类').locator('select').selectOption('背景图片')
  520. await dialogField(dialog, '说明').locator('textarea').fill('数字车间设备维修实训背景')
  521. await dialog.locator('.file-field input[type="file"]').setInputFiles(sceneImage)
  522. const formPreview = dialog.locator('.scene-form-preview')
  523. await expect(formPreview).toBeVisible()
  524. await expect.poll(() => formPreview.locator('.scene-form-foreground').evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  525. await captureScreenshot(page, testInfo, '14-scene-real-image-upload-preview')
  526. const sceneUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
  527. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  528. const sceneUpload = await sceneUploadPromise
  529. const sceneFile = await responseData(sceneUpload, 201, '上传真实场景图片')
  530. knownFileCodes.add(String(sceneFile.id ?? ''))
  531. await waitForToast(page, '配置已创建')
  532. search = page.getByPlaceholder('搜索场景素材名称、分类或说明')
  533. await search.fill(sceneName)
  534. card = capabilityCard(page, sceneName)
  535. await expect(card).toBeVisible()
  536. const { response: sceneListResponse, items: sceneItems } = await listCapabilities(request, headers, 'SCENE', sceneName)
  537. const sceneId = String(sceneItems.find((item) => String(item.name ?? '') === sceneName)?.id ?? '')
  538. expect(sceneId).not.toBe('')
  539. addPass(activities, '场景素材', '页面上传真实图片并创建', sceneListResponse, sceneId)
  540. await card.getByRole('button', { name: '预览' }).click()
  541. const scenePreview = card.locator('.inline-media-preview.scene-preview img')
  542. await expect(scenePreview).toBeVisible()
  543. await expect.poll(() => scenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  544. await captureScreenshot(page, testInfo, '15-scene-signed-image-list-preview')
  545. await card.getByRole('button', { name: '编辑' }).click()
  546. dialog = page.locator('.admin-form-dialog:visible').last()
  547. await dialogField(dialog, '名称').locator('input').fill(sceneEditedName)
  548. await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于液压与电气联合检修实训')
  549. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  550. await waitForToast(page, '配置已更新')
  551. await expect(dialog).toBeHidden()
  552. await search.fill(sceneEditedName)
  553. card = capabilityCard(page, sceneEditedName)
  554. await expect(card).toContainText('已编辑:用于液压与电气联合检修实训')
  555. const editedScenePreview = card.locator('.inline-media-preview.scene-preview img')
  556. if (!(await editedScenePreview.count())) await card.getByRole('button', { name: '预览' }).click()
  557. await expect(editedScenePreview).toBeVisible()
  558. await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
  559. await card.getByRole('button', { name: '停用' }).click()
  560. await waitForToast(page, '已停用')
  561. await page.getByLabel('状态筛选').selectOption('disabled')
  562. await expect(capabilityCard(page, sceneEditedName)).toBeVisible()
  563. await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered')
  564. await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click()
  565. await waitForToast(page, '已启用')
  566. await page.getByLabel('状态筛选').selectOption('all')
  567. addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId)
  568. await deleteCapabilityThroughUi(page, sceneEditedName)
  569. addPass(activities, '场景素材', '页面删除', undefined, sceneId)
  570. } catch (error) {
  571. primaryError = error
  572. activities.push({ module: '能力资产 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  573. } finally {
  574. fs.rmSync(voiceFixture, { force: true })
  575. await cleanupCapabilitiesByPrefix(request, headers, activities, cleanupErrors, knownFileCodes)
  576. await attachLifecycleReport(testInfo, '能力资产 UI', activities, cleanupErrors)
  577. }
  578. if (primaryError) throw primaryError
  579. expect(cleanupErrors, '能力资产及上传媒体必须完整清理').toEqual([])
  580. })