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 type Headers = Record 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 } 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 { 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, ) { 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, ) { 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() 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([]) })