|
- import fs from 'node:fs'
- import path from 'node:path'
- import { execFileSync } from 'node:child_process'
-
- import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
- import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
-
- test.describe.configure({ mode: 'serial' })
- test.use({ trace: 'off', video: 'off' })
-
- type JsonRecord = Record<string, unknown>
- type Headers = Record<string, string>
- type CapabilityType = 'VOICE_CLONE' | 'TTS' | 'SCENE'
-
- interface Activity {
- module: string
- action: string
- result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
- httpStatus?: number
- resourceId?: string
- detail?: string
- }
-
- interface AvatarPreferences {
- defaultAvatarId: string
- builtInVisible: boolean
- dataVersion: number
- }
-
- interface ResponseLike {
- status(): number
- json(): Promise<unknown>
- }
-
- const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
- ? value as JsonRecord
- : {}
-
- const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
-
- async function responseData(response: ResponseLike, expected: number | number[], label: string) {
- const statuses = Array.isArray(expected) ? expected : [expected]
- expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
- return asRecord(envelopeData(await response.json()))
- }
-
- function addPass(
- activities: Activity[],
- module: string,
- action: string,
- response?: ResponseLike,
- resourceId?: string,
- detail?: string,
- ) {
- activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail })
- }
-
- async function attachLifecycleReport(
- testInfo: TestInfo,
- title: string,
- activities: Activity[],
- cleanupErrors: string[],
- ) {
- await attachJson(testInfo, `${title}-生命周期与清理`, {
- runId,
- runPrefix,
- title,
- activities,
- cleanupComplete: cleanupErrors.length === 0,
- cleanupErrors,
- security: '报告不记录管理员密码、访问令牌或媒体签名地址。',
- })
- }
-
- async function getAvatarPreferences(request: APIRequestContext, headers: Headers): Promise<AvatarPreferences> {
- const response = await request.get('/api/v1/avatar-preferences', { headers })
- const value = await responseData(response, 200, '读取数字人平台偏好')
- return {
- defaultAvatarId: String(value.defaultAvatarId ?? ''),
- builtInVisible: value.builtInVisible !== false,
- dataVersion: Number(value.dataVersion ?? 0),
- }
- }
-
- async function restoreAvatarPreferences(
- request: APIRequestContext,
- headers: Headers,
- snapshot: AvatarPreferences,
- ) {
- const current = await getAvatarPreferences(request, headers)
- const response = await request.put('/api/v1/avatar-preferences', {
- headers,
- data: {
- defaultAvatarId: snapshot.defaultAvatarId,
- builtInVisible: snapshot.builtInVisible,
- dataVersion: current.dataVersion,
- },
- })
- await responseData(response, 200, '恢复数字人平台偏好快照')
- return response
- }
-
- async function listAvatars(request: APIRequestContext, headers: Headers, keyword = '') {
- const response = await request.get('/api/v1/avatars', {
- headers,
- params: { keyword, page: '1', pageSize: '200' },
- })
- const page = await responseData(response, 200, '读取数字形象列表')
- return { response, items: asRecords(page.items ?? page.records) }
- }
-
- async function cleanupAvatarsByPrefix(
- request: APIRequestContext,
- headers: Headers,
- activities: Activity[],
- cleanupErrors: string[],
- ownedNames: Set<string>,
- ) {
- try {
- const { items } = await listAvatars(request, headers, runPrefix)
- for (const item of items) {
- const id = String(item.id ?? '')
- if (!id || !ownedNames.has(String(item.name ?? ''))) continue
- const detailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers })
- if (detailResponse.status() === 404) continue
- const detail = await responseData(detailResponse, 200, '数字人清理前刷新版本')
- const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(id)}`, {
- headers,
- params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
- timeout: 120_000,
- })
- if (![200, 404].includes(removed.status())) throw new Error(`数字人 ${id} 删除失败:HTTP ${removed.status()}`)
- activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
- }
- } catch (error) {
- const message = `数字人兜底清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
- }
- }
-
- async function listCapabilities(
- request: APIRequestContext,
- headers: Headers,
- capabilityType: CapabilityType,
- keyword = '',
- ) {
- const response = await request.get('/api/v1/capabilities', {
- headers,
- params: { capabilityType, keyword, page: '1', pageSize: '200' },
- })
- const page = await responseData(response, 200, `读取 ${capabilityType} 能力列表`)
- return { response, items: asRecords(page.items ?? page.records) }
- }
-
- async function cleanupCapabilitiesByPrefix(
- request: APIRequestContext,
- headers: Headers,
- activities: Activity[],
- cleanupErrors: string[],
- knownFileCodes: Set<string>,
- ) {
- for (const capabilityType of ['VOICE_CLONE', 'TTS', 'SCENE'] as const) {
- try {
- const { items } = await listCapabilities(request, headers, capabilityType, runPrefix)
- for (const item of items) {
- const id = String(item.id ?? '')
- if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue
- const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers })
- if (detailResponse.status() === 404) continue
- const detail = await responseData(detailResponse, 200, '能力清理前刷新版本')
- const mediaFileCode = String(detail.mediaFileCode ?? '')
- if (mediaFileCode) knownFileCodes.add(mediaFileCode)
- const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, {
- headers,
- params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
- })
- if (![200, 404].includes(removed.status())) throw new Error(`能力 ${id} 删除失败:HTTP ${removed.status()}`)
- activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
- }
- } catch (error) {
- const message = `${capabilityType} 兜底清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
- }
- }
-
- for (const fileCode of knownFileCodes) {
- try {
- const removed = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
- if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`)
- activities.push({ module: '能力媒体', action: '解除引用后清理上传文件', result: 'CLEANED', httpStatus: removed.status(), resourceId: fileCode })
- } catch (error) {
- const message = `能力媒体 ${fileCode} 清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '能力媒体', action: '清理上传文件', result: 'CLEANUP_FAILED', resourceId: fileCode, detail: message })
- }
- }
- }
-
- function avatarCard(page: Page, name: string) {
- return page.locator('.avatar-card').filter({ hasText: name }).first()
- }
-
- function capabilityCard(page: Page, name: string) {
- return page.locator('.capability-card').filter({ hasText: name }).first()
- }
-
- function dialogField(dialog: Locator, label: string) {
- return dialog.locator('.capability-form > label').filter({ hasText: label }).first()
- }
-
- async function waitForToast(page: Page, text: string | RegExp) {
- await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible()
- }
-
- async function deleteCapabilityThroughUi(page: Page, name: string) {
- const card = capabilityCard(page, name)
- await card.getByRole('button', { name: '删除' }).click()
- const box = page.locator('.el-message-box:visible').last()
- await expect(box).toBeVisible()
- await box.getByRole('button', { name: /确定|确认/ }).click()
- await waitForToast(page, '已删除')
- await expect(capabilityCard(page, name)).toHaveCount(0)
- }
-
- function createVoiceFixture(sourceVideo: string, outputPath: string) {
- fs.mkdirSync(path.dirname(outputPath), { recursive: true })
- execFileSync(process.env.FFMPEG_BINARY?.trim() || 'ffmpeg', [
- '-y', '-hide_banner', '-loglevel', 'error', '-i', sourceVideo,
- '-vn', '-t', '12', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', outputPath,
- ], { stdio: 'pipe', timeout: 60_000, windowsHide: true })
- expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000)
- }
-
- test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request }, testInfo) => {
- test.setTimeout(480_000)
- const headers = await authHeaders(request)
- const activities: Activity[] = []
- const cleanupErrors: string[] = []
- const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
- const originalName = `${runPrefix}-UI本人视频形象`.slice(0, 80)
- const editedName = `${runPrefix}-UI液压维修教员`.slice(0, 80)
- let preferenceSnapshot: AvatarPreferences | null = null
- let primaryError: unknown
-
- expect(fs.existsSync(sourceVideo), `原型本人视频不存在:${sourceVideo}`).toBeTruthy()
-
- try {
- const upstreamHealth = await request.get('/human/health')
- expect(upstreamHealth.status(), '数字人生成上游健康检查必须成功').toBe(200)
- addPass(activities, '数字形象', '真实上游健康检查', upstreamHealth)
-
- preferenceSnapshot = await getAvatarPreferences(request, headers)
- activities.push({ module: '数字形象偏好', action: '保存默认形象与内置显示快照', result: 'PASS' })
-
- await loginAsAdmin(page)
- await page.goto('/assets/avatars')
- await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
-
- const builtInToggle = page.locator('.avatar-demo-toggle')
- const builtInSwitch = builtInToggle.getByRole('switch')
- const builtInSwitchControl = builtInToggle.locator('.el-switch')
- if (!(await builtInSwitch.isChecked())) {
- await builtInSwitchControl.click()
- await waitForToast(page, '已显示内置示例形象')
- }
-
- const { items: visibleAvatars } = await listAvatars(request, headers)
- const previewAvatar = visibleAvatars.find((item) => [
- 'a1000000000000000000000000000001',
- 'a1000000000000000000000000000002',
- 'a1000000000000000000000000000003',
- 'a1000000000000000000000000000004',
- ].includes(String(item.id ?? '')))
- expect(previewAvatar, '平台应保留至少一个可播放口播样片的内置形象').toBeTruthy()
-
- const previewName = String(previewAvatar?.name ?? '')
- const nameSearch = page.getByPlaceholder('搜索数字人名称')
- await nameSearch.fill(previewName)
- await expect(avatarCard(page, previewName)).toBeVisible()
- const previewGender = String(previewAvatar?.gender ?? '')
- if (previewGender === '男' || previewGender === '女') {
- await page.locator('.avatar-gender-filter select').selectOption(previewGender)
- await expect(avatarCard(page, previewName)).toBeVisible()
- }
- await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter')
- addPass(activities, '数字形象', '名称与性别组合筛选')
-
- await avatarCard(page, previewName).getByRole('button', { name: '预览口播' }).click()
- const previewDialog = page.locator('.avatar-preview-dialog:visible').last()
- const previewVideo = previewDialog.locator('video')
- await expect(previewVideo).toBeVisible()
- await expect(previewVideo).toHaveAttribute('src', /avatar-previews\/.+\.mp4/)
- const previewSource = String(await previewVideo.getAttribute('src'))
- const previewPoster = String(await previewVideo.getAttribute('poster'))
- const sourceResponse = await request.get(previewSource)
- expect(sourceResponse.status()).toBe(200)
- expect(sourceResponse.headers()['content-type']).toContain('video/mp4')
- const posterResponse = await request.get(previewPoster)
- expect(posterResponse.status()).toBe(200)
- expect(posterResponse.headers()['content-type']).toMatch(/^image\//)
- await captureScreenshot(page, testInfo, '02-builtin-avatar-real-video-preview')
- addPass(activities, '数字形象', '内置口播真实视频与封面预览', sourceResponse, String(previewAvatar?.id ?? ''), '已校验真实 MP4/封面响应;Playwright Chromium 的 H.264 解码能力不作为服务端文件可用性判据')
- await previewDialog.locator('.el-dialog__headerbtn').click()
-
- await nameSearch.fill('')
- await page.locator('.avatar-gender-filter select').selectOption('')
- await page.getByRole('button', { name: '创建数字人' }).click()
- const createDialog = page.locator('.avatar-create-dialog:visible').last()
- await expect(createDialog).toBeVisible()
- await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo)
- await createDialog.locator('.avatar-create-fields input').fill(originalName)
- await createDialog.locator('details.avatar-create-advanced summary').click()
- await createDialog.locator('select').selectOption('off')
- await captureScreenshot(page, testInfo, '03-avatar-real-video-ready-to-submit')
- await createDialog.getByRole('button', { name: '开始创建' }).click()
- await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 240_000 })
- await captureScreenshot(page, testInfo, '04-avatar-upstream-job-succeeded')
- await createDialog.getByRole('button', { name: '关闭', exact: true }).click()
-
- await nameSearch.fill(originalName)
- const createdCard = avatarCard(page, originalName)
- await expect(createdCard).toBeVisible()
- await expect(createdCard).toContainText('可预览')
- const { response: createdListResponse, items: createdItems } = await listAvatars(request, headers, originalName)
- const createdAvatar = createdItems.find((item) => String(item.name ?? '') === originalName)
- const avatarId = String(createdAvatar?.id ?? '')
- expect(avatarId).not.toBe('')
- expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready')
- addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId)
-
- await createdCard.getByRole('button', { name: '编辑' }).click()
- const editDialog = page.locator('.admin-form-dialog:visible').last()
- await editDialog.getByLabel('数字人名称').fill(editedName)
- await editDialog.getByLabel('角色').fill('装备维修教员')
- await editDialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select').click()
- await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click()
- await editDialog.getByLabel('专业方向').fill('液压与电气检修实训')
- const visibilityField = editDialog.locator('.avatar-visibility-field')
- const visibilitySwitch = visibilityField.getByRole('switch')
- if (await visibilitySwitch.isChecked()) await visibilityField.locator('.el-switch').click()
- await captureScreenshot(page, testInfo, '05-avatar-profile-and-visibility-edit')
- await editDialog.getByRole('button', { name: '保存修改' }).click()
- await waitForToast(page, '数字人信息已更新')
-
- await nameSearch.fill(editedName)
- const editedCard = avatarCard(page, editedName)
- await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训')
- await expect(editedCard).toContainText('平台共享')
- const detailAfterEdit = await responseData(
- await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }),
- 200,
- '刷新验证数字人页面编辑',
- )
- expect(detailAfterEdit).toMatchObject({
- name: editedName,
- role: '装备维修教员',
- specialty: '液压与电气检修实训',
- gender: '男',
- visibility: 'internal',
- })
- addPass(activities, '数字形象', '页面编辑并刷新持久化', undefined, avatarId)
-
- await editedCard.getByRole('button', { name: '停用' }).click()
- const disableBox = page.locator('.el-message-box:visible').last()
- await expect(disableBox).toContainText('停用后不会出现在业务选择器中')
- await disableBox.getByRole('button', { name: '确认停用' }).click()
- await waitForToast(page, '数字人已停用')
- await expect(avatarCard(page, editedName)).toContainText('已停用')
- await captureScreenshot(page, testInfo, '06-avatar-disabled-through-ui')
- await avatarCard(page, editedName).getByRole('button', { name: '启用' }).click()
- await waitForToast(page, '数字人已启用')
- await expect(avatarCard(page, editedName)).toContainText('可预览')
- addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId)
-
- await avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' }).click()
- await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`))
- await expect(avatarCard(page, editedName)).toContainText('默认数字人')
- const defaultPreferences = await getAvatarPreferences(request, headers)
- expect(defaultPreferences.defaultAvatarId).toBe(avatarId)
- addPass(activities, '数字形象偏好', '页面设为平台默认数字人', undefined, avatarId)
-
- await nameSearch.fill('')
- const currentSwitchState = await builtInSwitch.isChecked()
- await builtInSwitchControl.click()
- await waitForToast(page, currentSwitchState ? '已隐藏内置示例形象' : '已显示内置示例形象')
- expect(await builtInSwitch.isChecked()).toBe(!currentSwitchState)
- await captureScreenshot(page, testInfo, '07-builtin-avatar-visibility-toggled')
- addPass(activities, '数字形象偏好', '页面切换内置示例显示配置')
-
- const restoredResponse = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
- addPass(activities, '数字形象偏好', '按测试前快照恢复默认形象与内置显示', restoredResponse)
- preferenceSnapshot = null
- await page.reload()
- await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()
- await page.getByPlaceholder('搜索数字人名称').fill(editedName)
- await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' })).toBeEnabled()
-
- await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click()
- const deleteBox = page.locator('.el-message-box:visible').last()
- await expect(deleteBox).toContainText(`确定删除“${editedName}”吗`)
- await deleteBox.getByRole('button', { name: '确认删除' }).click()
- await waitForToast(page, '数字人已删除')
- await expect(avatarCard(page, editedName)).toHaveCount(0)
- const absent = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
- expect(absent.status()).toBe(404)
- await captureScreenshot(page, testInfo, '08-avatar-deleted-and-filter-empty')
- addPass(activities, '数字形象', '页面删除且正式 API 不可再读取', absent, avatarId)
- } catch (error) {
- primaryError = error
- activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
- } finally {
- if (preferenceSnapshot) {
- try {
- const restored = await restoreAvatarPreferences(request, headers, preferenceSnapshot)
- activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() })
- } catch (error) {
- const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message })
- }
- }
- await cleanupAvatarsByPrefix(request, headers, activities, cleanupErrors, new Set([originalName, editedName]))
- await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors)
- }
-
- if (primaryError) throw primaryError
- expect(cleanupErrors, '数字形象测试数据与平台偏好必须完整恢复').toEqual([])
- })
-
- test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编辑、筛选、预览、停启和删除闭环', async ({ page, request }, testInfo) => {
- test.setTimeout(360_000)
- const headers = await authHeaders(request)
- const activities: Activity[] = []
- const cleanupErrors: string[] = []
- const knownFileCodes = new Set<string>()
- const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
- const sceneImage = path.resolve('../ai_person/public/brand/DA_bg.jpg')
- const voiceFixture = testInfo.outputPath(`${runPrefix}-real-voice.wav`)
- let primaryError: unknown
-
- expect(fs.existsSync(sourceVideo)).toBeTruthy()
- expect(fs.existsSync(sceneImage)).toBeTruthy()
- createVoiceFixture(sourceVideo, voiceFixture)
-
- const voiceName = `${runPrefix}-维修教员克隆音色`.slice(0, 50)
- const voiceEditedName = `${runPrefix}-维修讲解克隆音色`.slice(0, 50)
- const ttsName = `${runPrefix}-本地标准中文TTS`.slice(0, 50)
- const ttsEditedName = `${runPrefix}-本地自然口播TTS`.slice(0, 50)
- const sceneName = `${runPrefix}-数字车间背景`.slice(0, 50)
- const sceneEditedName = `${runPrefix}-数字车间实训背景`.slice(0, 50)
-
- try {
- await loginAsAdmin(page)
-
- await page.goto('/assets/voice-clones')
- await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
- await page.getByRole('button', { name: /新建克隆音色/ }).click()
- let dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(voiceName)
- await dialogField(dialog, '分类').locator('select').selectOption('男声教员')
- await dialogField(dialog, '说明').locator('textarea').fill('装备维修实训问答与安全口播音色')
- await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles(voiceFixture)
- await expect(dialog.locator('.voice-capture-panel audio')).toBeVisible()
- await captureScreenshot(page, testInfo, '09-voice-clone-real-audio-before-save')
- const voiceUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- const voiceUpload = await voiceUploadPromise
- const voiceFile = await responseData(voiceUpload, 201, '上传真实克隆音色样本')
- knownFileCodes.add(String(voiceFile.id ?? ''))
- await waitForToast(page, '配置已创建')
-
- let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明')
- await search.fill(voiceName)
- let card = capabilityCard(page, voiceName)
- await expect(card).toBeVisible()
- const { response: voiceListResponse, items: voiceItems } = await listCapabilities(request, headers, 'VOICE_CLONE', voiceName)
- const voiceId = String(voiceItems.find((item) => String(item.name ?? '') === voiceName)?.id ?? '')
- expect(voiceId).not.toBe('')
- addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId)
-
- await card.getByRole('button', { name: '试听' }).click()
- const audio = card.locator('.inline-media-preview audio')
- await expect(audio).toBeVisible()
- await expect(audio).toHaveAttribute('src', /.+/)
- await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
- await captureScreenshot(page, testInfo, '10-voice-clone-signed-audio-preview')
- addPass(activities, '声音克隆', '短期签名音频真实试听', undefined, voiceId)
-
- await card.getByRole('button', { name: '编辑' }).click()
- dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(voiceEditedName)
- await dialogField(dialog, '分类').locator('select').selectOption('讲解播报')
- await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于维修工序讲解与风险提示')
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- await waitForToast(page, '配置已更新')
- await expect(dialog).toBeHidden()
- await search.fill(voiceEditedName)
- card = capabilityCard(page, voiceEditedName)
- await expect(card).toContainText('讲解播报')
- await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示')
- const editedAudio = card.locator('.inline-media-preview audio')
- if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click()
- await expect(editedAudio).toBeVisible()
- await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
- addPass(activities, '声音克隆', '页面编辑并刷新持久化', undefined, voiceId)
-
- await card.getByRole('button', { name: '停用' }).click()
- await waitForToast(page, '已停用')
- await page.getByLabel('状态筛选').selectOption('disabled')
- await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用')
- await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter')
- await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click()
- await waitForToast(page, '已启用')
- await page.getByLabel('状态筛选').selectOption('all')
- addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId)
- await deleteCapabilityThroughUi(page, voiceEditedName)
- addPass(activities, '声音克隆', '页面删除', undefined, voiceId)
-
- await page.goto('/assets/voice-models')
- await expect(page.getByRole('heading', { name: '语音模型' })).toBeVisible()
- await page.getByRole('button', { name: /新增语音模型/ }).click()
- dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(ttsName)
- await dialogField(dialog, '分类').locator('select').selectOption('标准中文')
- await dialogField(dialog, '说明').locator('textarea').fill('本地实时问答标准中文播报')
- await dialog.locator('details.capability-form-technical summary').click()
- await dialog.locator('details.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-tts-standard-zh`)
- await captureScreenshot(page, testInfo, '12-tts-local-reference-before-save')
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- await waitForToast(page, '配置已创建')
-
- search = page.getByPlaceholder('搜索语音模型名称、分类或说明')
- await search.fill(ttsName)
- card = capabilityCard(page, ttsName)
- await expect(card).toBeVisible()
- const { response: ttsListResponse, items: ttsItems } = await listCapabilities(request, headers, 'TTS', ttsName)
- const ttsId = String(ttsItems.find((item) => String(item.name ?? '') === ttsName)?.id ?? '')
- expect(ttsId).not.toBe('')
- addPass(activities, 'TTS', '页面创建正式本地能力引用', ttsListResponse, ttsId)
-
- await card.locator('details.capability-technical-info summary').click()
- await card.getByRole('button', { name: '校验本地配置' }).click()
- await waitForToast(page, /配置可用|校验通过/)
- await card.getByRole('button', { name: '预览' }).click()
- await waitForToast(page, new RegExp(`${ttsName}适合`))
- await card.getByRole('button', { name: '编辑' }).click()
- dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(ttsEditedName)
- await dialogField(dialog, '分类').locator('select').selectOption('自然口播')
- await dialogField(dialog, '说明').locator('textarea').fill('已编辑:面向数字教员自然口播')
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- await waitForToast(page, '配置已更新')
- await expect(dialog).toBeHidden()
- await search.fill(ttsEditedName)
- card = capabilityCard(page, ttsEditedName)
- await expect(card).toContainText('自然口播')
- await card.getByRole('button', { name: '停用' }).click()
- await waitForToast(page, '已停用')
- await card.getByRole('button', { name: '启用' }).click()
- await waitForToast(page, '已启用')
- await captureScreenshot(page, testInfo, '13-tts-edited-validated-and-enabled')
- addPass(activities, 'TTS', '校验、预览、编辑、停用与启用', undefined, ttsId)
- await deleteCapabilityThroughUi(page, ttsEditedName)
- addPass(activities, 'TTS', '页面删除', undefined, ttsId)
-
- await page.goto('/assets/scene-materials')
- await expect(page.getByRole('heading', { name: '场景素材' })).toBeVisible()
- await page.getByRole('tab', { name: '背景图片' }).click()
- await page.getByRole('button', { name: /新增场景素材/ }).click()
- dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(sceneName)
- await dialogField(dialog, '分类').locator('select').selectOption('背景图片')
- await dialogField(dialog, '说明').locator('textarea').fill('数字车间设备维修实训背景')
- await dialog.locator('.file-field input[type="file"]').setInputFiles(sceneImage)
- const formPreview = dialog.locator('.scene-form-preview')
- await expect(formPreview).toBeVisible()
- await expect.poll(() => formPreview.locator('.scene-form-foreground').evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
- await captureScreenshot(page, testInfo, '14-scene-real-image-upload-preview')
- const sceneUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url()))
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- const sceneUpload = await sceneUploadPromise
- const sceneFile = await responseData(sceneUpload, 201, '上传真实场景图片')
- knownFileCodes.add(String(sceneFile.id ?? ''))
- await waitForToast(page, '配置已创建')
-
- search = page.getByPlaceholder('搜索场景素材名称、分类或说明')
- await search.fill(sceneName)
- card = capabilityCard(page, sceneName)
- await expect(card).toBeVisible()
- const { response: sceneListResponse, items: sceneItems } = await listCapabilities(request, headers, 'SCENE', sceneName)
- const sceneId = String(sceneItems.find((item) => String(item.name ?? '') === sceneName)?.id ?? '')
- expect(sceneId).not.toBe('')
- addPass(activities, '场景素材', '页面上传真实图片并创建', sceneListResponse, sceneId)
-
- await card.getByRole('button', { name: '预览' }).click()
- const scenePreview = card.locator('.inline-media-preview.scene-preview img')
- await expect(scenePreview).toBeVisible()
- await expect.poll(() => scenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
- await captureScreenshot(page, testInfo, '15-scene-signed-image-list-preview')
- await card.getByRole('button', { name: '编辑' }).click()
- dialog = page.locator('.admin-form-dialog:visible').last()
- await dialogField(dialog, '名称').locator('input').fill(sceneEditedName)
- await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于液压与电气联合检修实训')
- await dialog.getByRole('button', { name: '保存', exact: true }).click()
- await waitForToast(page, '配置已更新')
- await expect(dialog).toBeHidden()
- await search.fill(sceneEditedName)
- card = capabilityCard(page, sceneEditedName)
- await expect(card).toContainText('已编辑:用于液压与电气联合检修实训')
- const editedScenePreview = card.locator('.inline-media-preview.scene-preview img')
- if (!(await editedScenePreview.count())) await card.getByRole('button', { name: '预览' }).click()
- await expect(editedScenePreview).toBeVisible()
- await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
- await card.getByRole('button', { name: '停用' }).click()
- await waitForToast(page, '已停用')
- await page.getByLabel('状态筛选').selectOption('disabled')
- await expect(capabilityCard(page, sceneEditedName)).toBeVisible()
- await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered')
- await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click()
- await waitForToast(page, '已启用')
- await page.getByLabel('状态筛选').selectOption('all')
- addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId)
- await deleteCapabilityThroughUi(page, sceneEditedName)
- addPass(activities, '场景素材', '页面删除', undefined, sceneId)
- } catch (error) {
- primaryError = error
- activities.push({ module: '能力资产 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
- } finally {
- fs.rmSync(voiceFixture, { force: true })
- await cleanupCapabilitiesByPrefix(request, headers, activities, cleanupErrors, knownFileCodes)
- await attachLifecycleReport(testInfo, '能力资产 UI', activities, cleanupErrors)
- }
-
- if (primaryError) throw primaryError
- expect(cleanupErrors, '能力资产及上传媒体必须完整清理').toEqual([])
- })
|