Procházet zdrojové kódy

test: 补充视频制作与资源回归

main
leiyun před 3 týdny
rodič
revize
44e2f7d00d
6 změnil soubory, kde provedl 1180 přidání a 76 odebrání
  1. +3
    -3
      tests/avatar-capabilities-ui.spec.ts
  2. +141
    -2
      tests/prototype-sync-regression.spec.ts
  3. +697
    -0
      tests/tengwangge-all-avatars-video.spec.ts
  4. +287
    -46
      tests/video-ui-lifecycle.spec.ts
  5. +49
    -22
      tests/zipvoice-cloning-ui.spec.ts
  6. +3
    -3
      tests/zipvoice-real-lifecycle.spec.ts

+ 3
- 3
tests/avatar-capabilities-ui.spec.ts Zobrazit soubor

@@ -232,7 +232,7 @@ async function selectCapabilityStatus(page: Page, label: string) {
}

function capabilityCard(page: Page, name: string) {
return page.locator('.capability-card').filter({ hasText: name }).first()
return page.locator('.capability-card, .voice-clone-table .el-table__row').filter({ hasText: name }).first()
}

function dialogField(dialog: Locator, label: string) {
@@ -624,7 +624,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId)

await card.getByRole('button', { name: '试听' }).click()
const audio = card.locator('.inline-media-preview audio')
const audio = card.locator('.inline-media-preview audio, .voice-table-preview audio')
await expect(audio).toBeVisible()
await expect(audio).toHaveAttribute('src', /.+/)
await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0)
@@ -643,7 +643,7 @@ test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编
card = capabilityCard(page, voiceEditedName)
await expect(card).toContainText('讲解播报')
await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示')
const editedAudio = card.locator('.inline-media-preview audio')
const editedAudio = card.locator('.inline-media-preview audio, .voice-table-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)


+ 141
- 2
tests/prototype-sync-regression.spec.ts Zobrazit soubor

@@ -62,7 +62,25 @@ const video = {
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}

async function installMocks(page: Page) {
const longVideoDescription = Array.from({ length: 24 }, (_, index) => (
`第 ${index + 1} 段:检修前需要逐项确认停机、断电、验电、卸压与上锁挂牌,并完整记录复核结果。`
)).join('\n')

const longDetailVideo = {
...video,
id: 'video-long-description',
title: '长简介成片详情回归',
description: longVideoDescription,
}

const shortDetailVideo = {
...video,
id: 'video-short-description',
title: '短简介成片详情回归',
description: '简短的设备培训说明。',
}

async function installMocks(page: Page, videos: Array<typeof video> = [video]) {
const videoQueries: Array<Record<string, string>> = []

await page.addInitScript(() => {
@@ -115,7 +133,7 @@ async function installMocks(page: Page) {
let data: unknown = {}
if (url.pathname.endsWith('/videos')) {
videoQueries.push(Object.fromEntries(url.searchParams.entries()))
data = { items: [video], total: 1, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] }
data = { items: videos, total: videos.length, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] }
} else if (url.pathname.endsWith('/video-folders')) {
data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 }
} else if (url.pathname.endsWith('/realtime-agents')) {
@@ -176,6 +194,127 @@ test('成片筛选在搜索前且排序方向合并到排序项', async ({ page
await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort')
})

test('成片详情固定首尾、中部滚动且简介仅在溢出时展开收起', async ({ page }, testInfo) => {
const consoleErrors: string[] = []
const pageErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.message))
await installMocks(page, [longDetailVideo, shortDetailVideo])
await page.setViewportSize({ width: 1280, height: 640 })
await page.goto('/videos/manage')

const longCard = page.locator('.video-library-card').filter({ hasText: longDetailVideo.title })
await longCard.getByRole('button', { name: '查看详情', exact: true }).click()

const dialog = page.locator('.video-detail-dialog:visible')
const header = dialog.locator(':scope > .el-dialog__header')
const body = dialog.locator(':scope > .el-dialog__body')
const footer = dialog.locator(':scope > .el-dialog__footer')
const player = dialog.locator('.video-detail-player')
const description = dialog.locator('.video-detail-description')
const descriptionText = description.locator('p')
const expandButton = dialog.getByRole('button', { name: '展开简介', exact: true })

await expect(dialog).toBeVisible()
await expect(page.locator('.video-detail-overlay:visible')).toBeVisible()
await expect(header).toBeVisible()
await expect(body.locator('.video-detail-layout')).toBeVisible()
await expect(footer).toBeVisible()
await expect(descriptionText).toHaveText(longVideoDescription)
await expect(expandButton).toBeVisible()
await expect(expandButton).toHaveAttribute('aria-expanded', 'false')

const collapsedDescription = await descriptionText.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(collapsedDescription.scrollHeight).toBeGreaterThan(collapsedDescription.clientHeight)

const scrollState = await body.evaluate((element) => ({
overflowY: getComputedStyle(element).overflowY,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(['auto', 'scroll']).toContain(scrollState.overflowY)
expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight)
await captureScreenshot(page, testInfo, 'video-detail-long-collapsed-fixed-chrome')
const playerBeforeExpand = await player.boundingBox()
expect(playerBeforeExpand).not.toBeNull()

await expandButton.click()
const collapseButton = dialog.getByRole('button', { name: '收起简介', exact: true })
await expect(collapseButton).toBeVisible()
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
const expandedDescription = await descriptionText.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(expandedDescription.clientHeight).toBeGreaterThan(collapsedDescription.clientHeight)
expect(expandedDescription.clientHeight).toBe(expandedDescription.scrollHeight)
const playerAfterExpand = await player.boundingBox()
expect(playerAfterExpand).not.toBeNull()
expect(Math.abs(playerAfterExpand!.y - playerBeforeExpand!.y)).toBeLessThanOrEqual(1)

const [dialogBox, headerBefore, footerBefore] = await Promise.all([
dialog.boundingBox(),
header.boundingBox(),
footer.boundingBox(),
])
expect(dialogBox).not.toBeNull()
expect(headerBefore).not.toBeNull()
expect(footerBefore).not.toBeNull()

await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
const [headerAfter, bodyBox, footerAfter] = await Promise.all([
header.boundingBox(),
body.boundingBox(),
footer.boundingBox(),
])
expect(headerAfter).not.toBeNull()
expect(bodyBox).not.toBeNull()
expect(footerAfter).not.toBeNull()
expect(Math.abs(headerAfter!.y - headerBefore!.y)).toBeLessThanOrEqual(1)
expect(Math.abs(footerAfter!.y - footerBefore!.y)).toBeLessThanOrEqual(1)
expect(headerAfter!.y).toBeGreaterThanOrEqual(dialogBox!.y)
expect(bodyBox!.y).toBeGreaterThanOrEqual(headerAfter!.y + headerAfter!.height - 2)
expect(bodyBox!.y + bodyBox!.height).toBeLessThanOrEqual(footerAfter!.y + 2)
expect(footerAfter!.y + footerAfter!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height)
await collapseButton.scrollIntoViewIfNeeded()
await expect(collapseButton).toBeInViewport()
await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
await captureScreenshot(page, testInfo, 'video-detail-long-expanded-middle-scrolled')

await collapseButton.click()
await expect(dialog.getByRole('button', { name: '展开简介', exact: true })).toHaveAttribute('aria-expanded', 'false')
const recollapsedHeight = await descriptionText.evaluate((element) => element.clientHeight)
expect(recollapsedHeight).toBeLessThanOrEqual(collapsedDescription.clientHeight + 1)

await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
const shortCard = page.locator('.video-library-card').filter({ hasText: shortDetailVideo.title })
await shortCard.getByRole('button', { name: '查看详情', exact: true }).click()
const shortDialog = page.locator('.video-detail-dialog:visible')
const shortDescription = shortDialog.locator('.video-detail-description')
const shortDescriptionText = shortDescription.locator('p')
await expect(shortDialog).toBeVisible()
await expect(shortDescriptionText).toHaveText(shortDetailVideo.description)
await shortDescriptionText.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}))
const shortDescriptionMetrics = await shortDescriptionText.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(shortDescriptionMetrics.scrollHeight).toBeLessThanOrEqual(shortDescriptionMetrics.clientHeight + 1)
await expect(shortDialog.getByRole('button', { name: /^(展开|收起)简介$/ })).toHaveCount(0)
await captureScreenshot(page, testInfo, 'video-detail-short-no-toggle')
expect(consoleErrors, '定向回归不应产生 console.error').toEqual([])
expect(pageErrors, '定向回归不应产生 pageerror').toEqual([])
})

test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => {
await installMocks(page)
await page.goto('/agents/manage')


+ 697
- 0
tests/tengwangge-all-avatars-video.spec.ts Zobrazit soubor

@@ -0,0 +1,697 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'

import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'

import { attachJson, captureScreenshot, expect, test } from './fixtures'
import { authHeaders, envelopeData, loginAsAdmin } from './helpers'

type JsonRecord = Record<string, unknown>
type Headers = Record<string, string>

interface AvatarSnapshot extends JsonRecord {
id: string
name: string
status: string
bundleReady: boolean
engineAuthorized?: boolean | null
sourceType: string
gender: string
viewerUrl: string
voiceCapabilityId: string
}

interface VoiceSnapshot extends JsonRecord {
capabilityId: string
name: string
gender: string
runtimeMode: string
}

interface PersistentResource {
avatarId: string
avatarName: string
title: string
projectId: string
materialFileCode: string
captureJobId: string
videoId: string
videoFileCode: string
}

interface AvatarOutcome {
avatarId: string
avatarName: string
title: string
result: 'RETAINED' | 'FAILED_CLEANED' | 'FAILED_CLEANUP_INCOMPLETE'
projectId?: string
materialFileCode?: string
captureJobId?: string
videoId?: string
videoFileCode?: string
ttsCapabilityId?: string
ttsRuntimeMode?: string
ttsDurationSeconds?: number
ttsRequestVerified?: boolean
captureUploadBytes?: number | null
captureUploadEvidence?: string
ffprobe?: JsonRecord
error?: string
cleanup?: string[]
}

const SCRIPT_PATH = path.resolve('../滕王阁序.MD')
const UPLOAD_MATERIAL_PATH = path.resolve('../ai_person_web/public/brand/dashboard-instructor.webp')
const SCRIPT_TEXT = fs.readFileSync(SCRIPT_PATH, 'utf8').trim()
const SCRIPT_SHA256 = createHash('sha256').update(SCRIPT_TEXT, 'utf8').digest('hex')
const UPLOAD_MATERIAL = fs.readFileSync(UPLOAD_MATERIAL_PATH)
const TERMINAL_JOB_STATUSES = new Set(['succeeded', 'failed', 'cancelled'])
const TARGET_AVATAR_IDS = new Set(
(process.env.E2E_AVATAR_IDS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
)

const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {}
const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
const textValue = (value: unknown) => String(value ?? '').trim()
const lower = (value: unknown) => textValue(value).toLowerCase()

function safeError(value: unknown) {
return (value instanceof Error ? value.message : String(value))
.replace(/([?&](?:access_token|api_key|signature|token|password)=)[^&\s]+/gi, '$1<redacted>')
.replace(/Bearer\s+\S+/gi, 'Bearer <redacted>')
.slice(0, 2_000)
}

async function apiRecord(response: { status(): number; json(): Promise<unknown> }, expected: number | number[] = 200) {
const accepted = Array.isArray(expected) ? expected : [expected]
expect(accepted, `API 返回了非预期 HTTP ${response.status()}`).toContain(response.status())
if (response.status() === 204) return {}
return asRecord(envelopeData(await response.json()))
}

function normalizeAvatar(item: JsonRecord): AvatarSnapshot {
return {
...item,
id: textValue(item.id),
name: textValue(item.name),
status: lower(item.status),
bundleReady: item.bundleReady === true,
engineAuthorized: item.engineAuthorized == null ? null : item.engineAuthorized === true,
sourceType: lower(item.sourceType),
gender: textValue(item.gender),
viewerUrl: textValue(item.viewerUrl),
voiceCapabilityId: textValue(item.voiceCapabilityId || asRecord(item.voice).capabilityId),
}
}

async function listAllAvatars(request: APIRequestContext, headers: Headers) {
const first = await apiRecord(await request.get('/api/v1/avatars', {
headers,
params: { page: '1', pageSize: '200' },
}))
const items = asRecords(first.items ?? first.records)
const pages = Math.max(1, Number(first.pages || Math.ceil(Number(first.total || items.length) / 200)))
for (let page = 2; page <= pages; page += 1) {
const payload = await apiRecord(await request.get('/api/v1/avatars', {
headers,
params: { page: String(page), pageSize: '200' },
}))
items.push(...asRecords(payload.items ?? payload.records))
}
return items.map(normalizeAvatar)
}

async function listVoices(request: APIRequestContext, headers: Headers) {
const payload = await apiRecord(await request.get('/api/v1/tts/voices', { headers }))
expect(payload.ready, '真实 TTS 目录必须处于 ready').toBe(true)
return asRecords(payload.items).map((item): VoiceSnapshot => ({
...item,
capabilityId: textValue(item.capabilityId || item.id),
name: textValue(item.name),
gender: textValue(item.gender),
runtimeMode: textValue(item.runtimeMode),
})).filter((item) => item.capabilityId && item.name)
}

function normalizedGender(value: string): 'female' | 'male' | 'unknown' {
const normalized = value.trim().toLowerCase()
if (normalized === 'female' || normalized.includes('女')) return 'female'
if (normalized === 'male' || normalized.includes('男')) return 'male'
return 'unknown'
}

function matchingGender(avatarGender: string, voiceGender: string) {
const avatar = normalizedGender(avatarGender)
return avatar !== 'unknown' && avatar === normalizedGender(voiceGender)
}

function voiceForAvatar(avatar: AvatarSnapshot, voices: VoiceSnapshot[]) {
const avatarGender = normalizedGender(avatar.gender)
const preferredCapabilityId = avatarGender === 'female'
? 'builtin-voice-female-clear'
: avatarGender === 'male'
? 'builtin-voice-male-gentle'
: ''
return voices.find((voice) => voice.capabilityId === preferredCapabilityId)
|| voices.find((voice) => matchingGender(avatar.gender, voice.gender) && voice.runtimeMode.toUpperCase().includes('VITS'))
|| voices.find((voice) => voice.runtimeMode.toUpperCase().includes('VITS'))
|| voices[0]
}

function compactAvatarName(avatar: AvatarSnapshot) {
return avatar.name
.replace('资深教员', '')
.replace('教员', '')
.replace(/·/g, ' · ')
.replace(/\s+/g, ' ')
.trim()
}

function resourceShortName(avatar: AvatarSnapshot, occurrence: number) {
const stem = avatar.name.normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '').slice(0, 12)
|| avatar.id.slice(-8)
return occurrence > 1 ? `${stem}${occurrence}` : stem
}

async function chooseElSelect(page: Page, label: string, option: string) {
const combobox = page.getByRole('combobox', { name: label, exact: true })
await combobox
.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]')
.locator('.el-select__wrapper')
.click()
const dropdown = page.locator('.el-select-dropdown:visible').last()
await expect(dropdown).toBeVisible()
await dropdown.getByRole('option', { name: option, exact: true }).click()
await expect(dropdown).toBeHidden()
}

async function selectAvatar(
page: Page,
avatar: AvatarSnapshot,
initialAvatars: AvatarSnapshot[],
) {
await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click()
const builtIn = avatar.sourceType === 'built_in'
await page.getByRole('tab', { name: builtIn ? /本地形象/ : /我的形象/ }).click()
const group = initialAvatars.filter((item) => (item.sourceType === 'built_in') === builtIn)
const index = group.findIndex((item) => item.id === avatar.id)
expect(index, `UI 资源列表中找不到数字人 ${avatar.id}`).toBeGreaterThanOrEqual(0)
const cards = page.locator('.studio-avatar-list button')
await expect.poll(() => cards.count(), { timeout: 30_000 }).toBeGreaterThan(index)
const card = cards.nth(index)
await expect(card).toBeVisible()
await expect(card.locator('b')).toHaveText(compactAvatarName(avatar))
await card.click()
await expect(card).toHaveClass(/active/)
expect(await card.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中数字人必须有可见边框').not.toBe('0px')
await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
return card
}

async function projectDetail(request: APIRequestContext, headers: Headers, id: string) {
const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers })
return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
}

async function videoDetail(request: APIRequestContext, headers: Headers, id: string) {
const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers })
return { response, data: response.status() === 200 ? await apiRecord(response) : {} }
}

async function exactListItem(
request: APIRequestContext,
headers: Headers,
endpoint: string,
title: string,
) {
const payload = await apiRecord(await request.get(endpoint, {
headers,
params: { keyword: title, page: '1', pageSize: '100' },
}))
return asRecords(payload.items).find((item) => textValue(item.name ?? item.title) === title) || null
}

async function settleCaptureJob(request: APIRequestContext, headers: Headers, jobId: string, cleanup: string[]) {
const deadline = Date.now() + 20 * 60_000
while (Date.now() < deadline) {
const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
if (response.status() === 404) return
const job = await apiRecord(response)
const status = lower(job.status)
if (TERMINAL_JOB_STATUSES.has(status)) {
cleanup.push(`任务 ${jobId} 已进入不可变终态 ${status},保留审计历史`)
return
}
if (job.canCancel === true) {
const cancelled = await request.post(`/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, { headers })
await apiRecord(cancelled, [200, 409])
cleanup.push(`已请求取消任务 ${jobId}`)
}
await new Promise((resolve) => setTimeout(resolve, 5_000))
}
throw new Error(`任务 ${jobId} 在失败清理等待期内仍未进入终态`)
}

async function deleteVideo(request: APIRequestContext, headers: Headers, id: string, cleanup: string[]) {
const detail = await videoDetail(request, headers, id)
if (detail.response.status() === 404) return
expect(detail.response.status()).toBe(200)
const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, {
headers,
params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
})
await apiRecord(removed, [200, 404])
cleanup.push(`已删除失败迭代成片 ${id}`)
}

async function deleteProjectAndMaterial(
request: APIRequestContext,
headers: Headers,
id: string,
knownMaterialFileCode: string,
cleanup: string[],
) {
const detail = await projectDetail(request, headers, id)
if (detail.response.status() === 404) return
expect(detail.response.status()).toBe(200)
const tracks = asRecords(asRecord(detail.data.timeline).tracks)
const materialFileCodes = new Set([
knownMaterialFileCode,
...tracks.flatMap((track) => asRecords(track.clips)).map((clip) => textValue(clip.fileId)),
].filter(Boolean))
const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, {
headers,
params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) },
})
await apiRecord(removed, [200, 404])
cleanup.push(`已删除失败迭代工程 ${id}`)
for (const fileCode of materialFileCodes) {
const fileRemoved = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers })
await apiRecord(fileRemoved, [200, 404])
cleanup.push(`已删除失败迭代上传素材 ${fileCode}`)
}
}

async function cleanupFailedIteration(
request: APIRequestContext,
headers: Headers,
title: string,
ids: { projectId: string; videoId: string; jobId: string; materialFileCode: string },
) {
const cleanup: string[] = []
const foundJob = ids.jobId
? { id: ids.jobId }
: await exactListItem(request, headers, '/api/v1/jobs', title)
if (foundJob) await settleCaptureJob(request, headers, textValue(foundJob.id), cleanup)
const foundVideo = ids.videoId
? { id: ids.videoId }
: await exactListItem(request, headers, '/api/v1/videos', title)
if (foundVideo) await deleteVideo(request, headers, textValue(foundVideo.id), cleanup)
const foundProject = ids.projectId
? { id: ids.projectId }
: await exactListItem(request, headers, '/api/v1/video-projects', title)
if (foundProject) {
await deleteProjectAndMaterial(
request,
headers,
textValue(foundProject.id),
ids.materialFileCode,
cleanup,
)
}
return cleanup
}

function probeMp4(filename: string) {
const output = execFileSync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration,size,format_name:stream=codec_name,codec_type,width,height',
'-of', 'json',
filename,
], { encoding: 'utf8', windowsHide: true, maxBuffer: 10 * 1024 * 1024 })
const probe = asRecord(JSON.parse(output))
const format = asRecord(probe.format)
const streams = asRecords(probe.streams)
const video = streams.find((stream) => stream.codec_type === 'video') || {}
const audio = streams.find((stream) => stream.codec_type === 'audio') || {}
expect(textValue(format.format_name)).toMatch(/mp4|mov/)
expect(Number(format.duration), '滕王阁序成片时长必须证明完整长文本已录制').toBeGreaterThan(60)
expect(Number(format.size), '归档 MP4 不能是占位文件').toBeGreaterThan(100_000)
expect(textValue(video.codec_name)).toBe('h264')
expect(Number(video.width)).toBe(1920)
expect(Number(video.height)).toBe(1080)
expect(textValue(audio.codec_name)).toBe('aac')
return {
durationSeconds: Number(format.duration),
sizeBytes: Number(format.size),
format: textValue(format.format_name),
videoCodec: textValue(video.codec_name),
audioCodec: textValue(audio.codec_name),
width: Number(video.width),
height: Number(video.height),
}
}

test.describe.configure({ mode: 'serial' })
// 浏览器端 Viewer 录制和最终 H.264/AAC 播放均依赖系统 Chrome 的完整媒体能力。
test.use({ trace: 'off', video: 'off', channel: 'chrome' })

test('每个可用数字人串行生成并保留一条《滕王阁序》真实成片', async ({ page, request }, testInfo: TestInfo) => {
test.setTimeout(4 * 60 * 60_000)
expect(SCRIPT_TEXT.length, '《滕王阁序》测试文本不能为空').toBeGreaterThan(500)
expect(SCRIPT_TEXT.length, '口播文案必须满足页面 3000 字上限').toBeLessThanOrEqual(3_000)
expect(Math.ceil(SCRIPT_TEXT.length / 6), '口播文案必须满足单条约 5 分钟上限').toBeLessThanOrEqual(300)
expect(UPLOAD_MATERIAL.length, '上传素材必须是真实图片而非空占位').toBeGreaterThan(50_000)

const headers = await authHeaders(request)
const initialAvatars = await listAllAvatars(request, headers)
const allAvailableAvatars = initialAvatars.filter((avatar) => (
avatar.status === 'ready'
&& avatar.bundleReady
&& avatar.engineAuthorized !== false
&& Boolean(avatar.viewerUrl)
))
const availableAvatars = TARGET_AVATAR_IDS.size
? allAvailableAvatars.filter((avatar) => TARGET_AVATAR_IDS.has(avatar.id))
: allAvailableAvatars
const voices = await listVoices(request, headers)
if (TARGET_AVATAR_IDS.size) {
expect(
new Set(availableAvatars.map((avatar) => avatar.id)),
'目标数字人过滤必须精确命中所有 ID',
).toEqual(TARGET_AVATAR_IDS)
}
expect(availableAvatars.length, '至少需要一个 READY、已制包且有 Viewer 的可用数字人').toBeGreaterThan(0)
expect(voices.length, '至少需要一个真实可用 TTS 音色').toBeGreaterThan(0)

const runStamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const nameOccurrences = new Map<string, number>()
const persistent: PersistentResource[] = []
const outcomes: AvatarOutcome[] = []
const failures: string[] = []

await attachJson(testInfo, 'available-avatar-snapshot', {
capturedAt: new Date().toISOString(),
script: { sha256: SCRIPT_SHA256, characters: SCRIPT_TEXT.length, estimatedSeconds: Math.ceil(SCRIPT_TEXT.length / 6) },
totalAvatarRecords: initialAvatars.length,
allAvailableAvatarIds: allAvailableAvatars.map((avatar) => avatar.id),
allAvailableAvatarNames: allAvailableAvatars.map((avatar) => avatar.name),
selectedAvatarIds: availableAvatars.map((avatar) => avatar.id),
selectedAvatarNames: availableAvatars.map((avatar) => avatar.name),
retentionPolicy: '成功工程、成片、任务及工程引用素材均作为预期持久数据保留;仅失败迭代清理可删除临时资源。',
})

await page.setViewportSize({ width: 1920, height: 1080 })
await loginAsAdmin(page)

for (const [index, avatar] of availableAvatars.entries()) {
const baseShortName = resourceShortName(avatar, 1)
const occurrence = (nameOccurrences.get(baseShortName) || 0) + 1
nameOccurrences.set(baseShortName, occurrence)
const shortName = resourceShortName(avatar, occurrence)
const title = `滕王阁序验证-${shortName}-${runStamp}`
const materialName = `滕王阁序素材-${shortName}-${runStamp}.webp`
const selectedVoice = voiceForAvatar(avatar, voices)
expect(selectedVoice, `数字人 ${avatar.id} 必须可匹配真实 TTS 音色`).toBeTruthy()
const avatarGender = normalizedGender(avatar.gender)
if (avatarGender === 'female') {
expect(selectedVoice!.capabilityId, '女性数字人必须优选清晰女声').toBe('builtin-voice-female-clear')
} else if (avatarGender === 'male') {
expect(selectedVoice!.capabilityId, '男性数字人必须优选温和男声').toBe('builtin-voice-male-gentle')
}
expect(title).not.toMatch(/APE2E/i)

const ids = { projectId: '', videoId: '', jobId: '', materialFileCode: '' }
let videoFileCode = ''
let ttsResponse: Response | null = null
let ttsDurationSeconds = 0
let captureResponse: Response | null = null
let captureResponseHandler: ((response: Response) => void) | null = null
let iterationError: unknown

try {
await page.goto('/videos/create?mode=offline')
await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 45_000 })
await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()

await selectAvatar(page, avatar, initialAvatars)
const railRadii = await page.locator('.editor-tool-rail').evaluate((element) => {
const style = getComputedStyle(element)
return { topLeft: style.borderTopLeftRadius, bottomLeft: style.borderBottomLeftRadius }
})
expect(railRadii, '工具 rail 左上、左下必须保持直角').toEqual({ topLeft: '0px', bottomLeft: '0px' })
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-avatar-selected-${shortName}`)

await page.getByLabel('项目名称').fill(title)
await page.locator('.script-field textarea').fill(SCRIPT_TEXT)

await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
const backgroundCard = page.locator('.scene-background-card').first()
await expect(backgroundCard).toBeVisible({ timeout: 30_000 })
const backgroundCapabilityId = textValue(await backgroundCard.getAttribute('data-capability-id'))
expect(backgroundCapabilityId, '必须选择真实 SCENE 能力资产作为背景').not.toBe('')
await expect(backgroundCard.locator('img, video').first()).toHaveAttribute('src', /.+/)
await backgroundCard.click()
await expect(backgroundCard).toHaveClass(/active/)
await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:')

await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
await page.getByRole('button', { name: /添加正文/ }).click()
await page.getByLabel('显示文字').fill('滕王阁序')
await expect(page.locator('.stage-overlay[data-overlay-kind="body"]')).toContainText('滕王阁序')

await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
await page.getByRole('button', { name: /检查标记/ }).click()
await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible()

await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
await page.locator('.media-library input[type="file"]').setInputFiles({
name: materialName,
mimeType: 'image/webp',
buffer: UPLOAD_MATERIAL,
})
await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()
await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0)

await chooseElSelect(
page,
'语音能力资产',
`${selectedVoice!.name}(${selectedVoice!.runtimeMode.toUpperCase().includes('ZIPVOICE') ? '克隆' : '内置'})`,
)
await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
const selectedVoiceCard = page.locator('.editor-resource-list article.active')
await expect(selectedVoiceCard).toBeVisible()
expect(await selectedVoiceCard.evaluate((element) => getComputedStyle(element).borderTopWidth), '选中音色必须有可见边框').not.toBe('0px')
const voicePreviewIcon = selectedVoiceCard.locator('.offline-voice-preview')
await expect(voicePreviewIcon).toBeVisible()
await expect(voicePreviewIcon.locator('svg')).toHaveCount(1)
expect((await voicePreviewIcon.innerText()).trim(), '音频试听操作只能显示图标').toBe('')

const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true })
if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') {
await subtitleSwitch
.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]')
.click()
}
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-configured-${shortName}`)

await page.getByRole('button', { name: '保存工程', exact: true }).click()
await expect(page.getByText('工程与五轨时间线已保存').last()).toBeVisible({ timeout: 90_000 })
await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
ids.projectId = new URL(page.url()).searchParams.get('project') || ''
expect(ids.projectId).not.toBe('')

let persistedProject = await projectDetail(request, headers, ids.projectId)
expect(persistedProject.response.status()).toBe(200)
expect(persistedProject.data.name).toBe(title)
expect(persistedProject.data.scriptText).toBe(SCRIPT_TEXT)
expect(persistedProject.data.avatarId).toBe(avatar.id)
expect(persistedProject.data.backgroundType).toBe('asset')
expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId)
expect(persistedProject.data.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
expect(persistedProject.data.subtitleEnabled).toBe(true)
const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
expect(tracks.map((track) => textValue(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE'])
const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
ids.materialFileCode = textValue(mediaClips.find((clip) => clip.type === 'MEDIA')?.fileId)
expect(ids.materialFileCode, '上传素材必须进入 MEDIA 轨道').not.toBe('')
const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips)
expect(elementClips.some((clip) => clip.type === 'BODY' && clip.text === '滕王阁序')).toBe(true)
expect(elementClips.some((clip) => clip.type === 'CHECK')).toBe(true)
const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
expect(audioClip.capabilityId).toBe(selectedVoice!.capabilityId)
expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips).map((clip) => textValue(clip.text)).join('')).toBe(SCRIPT_TEXT)
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-saved-${shortName}`)

captureResponseHandler = (response: Response) => {
const requestUrl = new URL(response.url())
if (response.request().method() !== 'POST') return
if (requestUrl.pathname === '/api/v1/tts') ttsResponse = response
if (requestUrl.pathname === '/api/v1/captures') captureResponse = response
}
page.on('response', captureResponseHandler)

await page.getByRole('button', { name: '生成视频', exact: true }).click()
await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 })
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-recording-${shortName}`)

await expect.poll(() => ttsResponse?.status() || 0, {
message: '页面必须调用真实 /tts',
timeout: 180_000,
}).toBe(200)
const ttsRequestPayload = ttsResponse!.request().postDataJSON() as JsonRecord
expect(ttsRequestPayload.text).toBe(SCRIPT_TEXT)
expect(ttsRequestPayload.voiceCapabilityId).toBe(selectedVoice!.capabilityId)
const speech = asRecord(envelopeData(await ttsResponse!.json()))
expect(textValue(speech.audioUrl), '真实 TTS 必须返回受保护 WAV 地址').not.toBe('')
expect(textValue(speech.mimeType)).toBe('audio/wav')
ttsDurationSeconds = Number(speech.duration)
expect(ttsDurationSeconds, '真实 TTS 音频必须覆盖长文本').toBeGreaterThan(60)

await expect.poll(() => captureResponse?.status() || 0, {
message: 'Viewer 浏览器录制必须上传 /captures',
timeout: 35 * 60_000,
}).toBe(202)

const succeeded = page.locator('.editor-render-progress.is-succeeded')
await expect(succeeded).toContainText('视频生成完成', { timeout: 45 * 60_000 })
await expect(succeeded).toContainText('100%')
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-rendered-${shortName}`)

persistedProject = await projectDetail(request, headers, ids.projectId)
expect(persistedProject.response.status()).toBe(200)
ids.jobId = textValue(persistedProject.data.lastJobId)
const projectVideoId = textValue(persistedProject.data.lastVideoId)
expect(ids.jobId, 'UI 成功后工程必须公开 lastJobId').not.toBe('')
expect(projectVideoId, 'UI 成功后工程必须公开 lastVideoId').not.toBe('')
const job = await apiRecord(await request.get(`/api/v1/jobs/${encodeURIComponent(ids.jobId)}`, { headers }))
expect(lower(job.status)).toBe('succeeded')
expect(lower(job.kind)).toBe('capture_transcode')
expect(job.title).toBe(title)
expect(Number(job.progress)).toBe(100)
expect(textValue(asRecord(job.output).mimeType)).toBe('video/mp4')

await succeeded.getByRole('button', { name: '查看作品' }).click()
await expect(page).toHaveURL(/\/videos\/manage\?video=/)
ids.videoId = new URL(page.url()).searchParams.get('video') || ''
expect(ids.videoId).toBe(projectVideoId)
let detail = page.locator('.video-detail-overlay')
await expect(detail).toBeVisible({ timeout: 45_000 })
await expect(detail).toContainText(title)
await detail.getByRole('button', { name: '关闭' }).click()

await page.getByPlaceholder('搜索标题、简介或标签').fill(title)
await page.getByRole('button', { name: '搜索', exact: true }).click()
const card = page.locator('.video-library-card').filter({ hasText: title })
await expect(card).toBeVisible({ timeout: 30_000 })
await card.getByRole('button', { name: '查看详情', exact: true }).click()
detail = page.locator('.video-detail-overlay')
await expect(detail).toContainText(title)
const video = detail.locator('video')
await expect(video).toHaveAttribute('src', /.+/)
await detail.getByRole('button', { name: '播放视频', exact: true }).click()
await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true)
await detail.getByRole('button', { name: '暂停视频', exact: true }).click()

const downloadPromise = page.waitForEvent('download', { timeout: 120_000 })
await detail.getByRole('button', { name: '下载视频', exact: true }).click()
const download = await downloadPromise
const downloadedPath = await download.path()
expect(downloadedPath, '必须能从成片管理下载正式 MP4').not.toBeNull()
const ffprobe = probeMp4(downloadedPath!)

const persistedVideo = await videoDetail(request, headers, ids.videoId)
expect(persistedVideo.response.status()).toBe(200)
expect(persistedVideo.data.title).toBe(title)
expect(persistedVideo.data.avatarId).toBe(avatar.id)
expect(lower(persistedVideo.data.status)).toBe('ready')
videoFileCode = textValue(persistedVideo.data.videoFileCode)
expect(videoFileCode).not.toBe('')
expect(Number(persistedVideo.data.size)).toBeGreaterThan(100_000)
persistedProject = await projectDetail(request, headers, ids.projectId)
expect(persistedProject.data.lastJobId).toBe(ids.jobId)
expect(persistedProject.data.lastVideoId).toBe(ids.videoId)
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-manage-${shortName}`)

await page.goto(`/jobs?jobId=${encodeURIComponent(ids.jobId)}`)
const jobDialog = page.locator('.el-dialog:visible').last()
await expect(jobDialog).toBeVisible({ timeout: 30_000 })
await expect(jobDialog).toContainText(ids.jobId)
await expect(jobDialog).toContainText('100%')
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-job-${shortName}`)

const retained: PersistentResource = {
avatarId: avatar.id,
avatarName: avatar.name,
title,
projectId: ids.projectId,
materialFileCode: ids.materialFileCode,
captureJobId: ids.jobId,
videoId: ids.videoId,
videoFileCode,
}
persistent.push(retained)
outcomes.push({
...retained,
result: 'RETAINED',
ttsCapabilityId: selectedVoice!.capabilityId,
ttsRuntimeMode: selectedVoice!.runtimeMode,
ttsDurationSeconds,
ttsRequestVerified: true,
captureUploadBytes: null,
captureUploadEvidence: 'Browser multipart internals intentionally not inspected; HTTP 202 durable capture job + succeeded FFmpeg job + FFprobe MP4',
ffprobe,
})
await attachJson(testInfo, `expected-persistent-ids-avatar-${String(index + 1).padStart(2, '0')}`, retained)
console.log(`[TENGWANGGE_RETAINED] avatar=${avatar.name} stage=成片管理已核验 videoId=${ids.videoId}`)
} catch (error) {
iterationError = error
const message = safeError(error)
failures.push(`${avatar.name}:${message}`)
await captureScreenshot(page, testInfo, `${String(index + 1).padStart(2, '0')}-failed-${shortName}`).catch(() => undefined)
const failedOutcome: AvatarOutcome = {
avatarId: avatar.id,
avatarName: avatar.name,
title,
result: 'FAILED_CLEANED',
projectId: ids.projectId || undefined,
materialFileCode: ids.materialFileCode || undefined,
captureJobId: ids.jobId || undefined,
videoId: ids.videoId || undefined,
error: message,
}
try {
failedOutcome.cleanup = await cleanupFailedIteration(request, headers, title, ids)
} catch (cleanupError) {
failedOutcome.result = 'FAILED_CLEANUP_INCOMPLETE'
failedOutcome.cleanup = [safeError(cleanupError)]
failures.push(`${avatar.name} 清理:${safeError(cleanupError)}`)
}
outcomes.push(failedOutcome)
} finally {
if (captureResponseHandler) page.off('response', captureResponseHandler)
// 成功资源是用户要求的正式可见结果;这里刻意不执行任何成功清理。
if (iterationError) await page.goto('/overview').catch(() => undefined)
}
}

await attachJson(testInfo, 'expected-persistent-ids', {
policy: 'intentional-retention',
count: persistent.length,
resources: persistent,
})
await attachJson(testInfo, 'all-avatar-video-outcomes', outcomes)

expect(persistent.length, '每个初始可用数字人都必须留下独立工程、任务、素材和成片').toBe(availableAvatars.length)
expect(new Set(persistent.map((item) => item.projectId)).size).toBe(persistent.length)
expect(new Set(persistent.map((item) => item.captureJobId)).size).toBe(persistent.length)
expect(new Set(persistent.map((item) => item.videoId)).size).toBe(persistent.length)
expect(failures, failures.join('\n')).toEqual([])
})

+ 287
- 46
tests/video-ui-lifecycle.spec.ts Zobrazit soubor

@@ -1,4 +1,4 @@
import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test'
import type { APIRequestContext, Page, TestInfo } from '@playwright/test'

import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures'
import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin } from './helpers'
@@ -111,17 +111,30 @@ async function cleanupProject(request: APIRequestContext, headers: Headers, id:
}
}

async function selectFirstEnabledOption(select: Locator, preferredLabel: RegExp) {
const options = await select.locator('option').evaluateAll((nodes) => nodes.map((node) => ({
value: (node as HTMLOptionElement).value,
async function chooseElSelect(page: Page, label: string, option: string) {
const combobox = page.getByRole('combobox', { name: label, exact: true })
await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click()
const dropdown = page.locator('.el-select-dropdown:visible').last()
await expect(dropdown).toBeVisible()
await dropdown.getByRole('option', { name: option, exact: true }).click()
await expect(dropdown).toBeHidden()
}

async function chooseFirstElSelectOption(page: Page, label: string, preferred: RegExp) {
const combobox = page.getByRole('combobox', { name: label, exact: true })
await combobox.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]').locator('.el-select__wrapper').click()
const dropdown = page.locator('.el-select-dropdown:visible').last()
await expect(dropdown).toBeVisible()
const options = await dropdown.getByRole('option').evaluateAll((nodes) => nodes.map((node) => ({
label: (node.textContent || '').trim(),
disabled: (node as HTMLOptionElement).disabled,
disabled: node.classList.contains('is-disabled') || node.getAttribute('aria-disabled') === 'true',
})))
const selected = options.find((item) => !item.disabled && item.value && preferredLabel.test(item.label))
|| options.find((item) => !item.disabled && item.value)
expect(selected, '页面必须加载至少一个可选的真实语音能力').toBeTruthy()
await select.selectOption(selected!.value)
return selected!
const selected = options.find((item) => !item.disabled && preferred.test(item.label))
|| options.find((item) => !item.disabled)
expect(selected, `“${label}”必须存在至少一个可选项`).toBeTruthy()
await dropdown.getByRole('option', { name: selected!.label, exact: true }).click()
await expect(dropdown).toBeHidden()
return selected!.label
}

async function expectPublicShareRejected(page: Page, url: string) {
@@ -143,7 +156,7 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
const editedVideoName = `${runPrefix}-液压安全成片`
const folderName = `${runPrefix}-视频测试文件夹`
const renamedFolderName = `${runPrefix}-安全培训成片`
const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。'
const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。操作结束后复核隔离状态,确认现场安全。'
const materialName = `${runPrefix}-检修要点.png`
const sharePassword = 'Ape2eVideo#2026'
const rotatedPassword = 'Ape2eRotate#2026'
@@ -153,12 +166,76 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
let primaryError: unknown

try {
await page.setViewportSize({ width: 1440, height: 1000 })
await loginAsAdmin(page)
await page.goto('/videos/create?mode=offline')
await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible()
await expect(page.locator('.studio-avatar-list button:not([disabled])').first()).toBeVisible()
const firstAvatarCard = page.locator('.studio-avatar-list button:not([disabled])').first()
await expect(firstAvatarCard).toBeVisible()
await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 })
await captureScreenshot(page, testInfo, '01-video-create-nonempty-assets')
await expect(page.getByRole('tab', { name: /本地形象/ })).toHaveAttribute('aria-selected', 'true')
await expect(page.locator('.library-tabs .el-button')).toHaveCount(0)
await expect(page.getByRole('tab', { name: '文本', exact: true })).toHaveAttribute('aria-selected', 'true')
await expect(page.locator('.inspector-tabs .el-button')).toHaveCount(0)
expect(await page.getByLabel('项目名称').evaluate((element) => {
const style = getComputedStyle(element)
return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth]
}), 'Element Plus 输入框内部不能再绘制第二层边框').toEqual(['0px', '0px', '0px', '0px'])
const avatarCardBox = await firstAvatarCard.boundingBox()
const avatarImageBox = await firstAvatarCard.locator('img').boundingBox()
expect(avatarCardBox, '数字人卡片不能折叠').not.toBeNull()
expect(avatarCardBox!.height, '数字人卡片必须完整展示图片和名称').toBeGreaterThan(150)
expect(avatarImageBox, '数字人卡片必须显示完整封面区').not.toBeNull()
expect(avatarImageBox!.height).toBeGreaterThanOrEqual(120)
await captureScreenshot(page, testInfo, '01-video-create-avatar-library-layout')

await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
const layoutBackgroundCard = page.locator('.scene-background-card').first()
await expect(layoutBackgroundCard).toBeVisible()
const layoutBackgroundBox = await layoutBackgroundCard.boundingBox()
const layoutBackgroundMediaBox = await layoutBackgroundCard.locator('img, video').first().boundingBox()
expect(layoutBackgroundBox, '背景资源卡片不能折叠').not.toBeNull()
expect(layoutBackgroundBox!.height).toBeGreaterThan(105)
expect(layoutBackgroundMediaBox, '背景资源必须完整显示缩略图').not.toBeNull()
expect(layoutBackgroundMediaBox!.height).toBeGreaterThanOrEqual(80)
await captureScreenshot(page, testInfo, '01-background-library-layout')

await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
const layoutVoiceRow = page.locator('.editor-resource-list article').first()
await expect(layoutVoiceRow).toBeVisible()
expect((await layoutVoiceRow.boundingBox())!.height).toBeGreaterThanOrEqual(60)
const selectedVoiceRow = page.locator('.editor-resource-list article.active')
await expect(selectedVoiceRow).toHaveCount(1)
expect(await selectedVoiceRow.evaluate((element) => getComputedStyle(element).borderTopColor))
.not.toBe('rgba(0, 0, 0, 0)')
const voicePreviewIcon = layoutVoiceRow.getByRole('button', { name: /试听声音|停止试听/ })
await expect(voicePreviewIcon).toHaveText('')
await expect(voicePreviewIcon.locator('svg')).toHaveCount(1)
expect(await voicePreviewIcon.evaluate((element) => ({
border: getComputedStyle(element).borderTopWidth,
background: getComputedStyle(element).backgroundColor,
}))).toEqual({ border: '0px', background: 'rgba(0, 0, 0, 0)' })
expect(await page.locator('.editor-tool-rail button').first().evaluate((element) => getComputedStyle(element).borderTopLeftRadius))
.toBe('0px')

await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
const layoutTextButton = page.locator('.text-assets button').first()
await expect(layoutTextButton).toBeVisible()
expect((await layoutTextButton.boundingBox())!.height).toBeGreaterThanOrEqual(60)

await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
const layoutElementButton = page.locator('.element-library button').first()
await expect(layoutElementButton).toBeVisible()
expect((await layoutElementButton.boundingBox())!.height).toBeGreaterThanOrEqual(68)

await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
const layoutUploadButton = page.locator('.media-library .upload-zone')
await expect(layoutUploadButton).toBeVisible()
expect((await layoutUploadButton.boundingBox())!.height).toBeGreaterThanOrEqual(100)

await page.locator('.editor-tool-rail button').filter({ hasText: '数字人' }).click()
await expect(firstAvatarCard).toBeVisible()
await page.setViewportSize({ width: 1920, height: 1080 })

await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click()
const avatar = page.locator('.studio-avatar-list button:not([disabled])').first()
@@ -173,10 +250,75 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
await expect(voiceCard.locator('input[type="radio"]')).toBeChecked()

await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click()
await page.getByRole('button', { name: '实训教室', exact: true }).click()
const backgroundCard = page.locator('.scene-background-card').first()
await expect(backgroundCard).toBeVisible()
const backgroundCapabilityId = await backgroundCard.getAttribute('data-capability-id')
expect(backgroundCapabilityId).toBeTruthy()
await backgroundCard.click()
await expect(page.locator('.stage-status-bar .stage-background-context')).toContainText('背景:')
await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0)
await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click()
await page.getByRole('button', { name: /检查标记/ }).click()
await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible()
const checkOverlay = page.locator('.stage-overlay[data-overlay-kind="check"]')
await expect(checkOverlay).toBeVisible()
await expect(checkOverlay.locator('.overlay-copy')).toHaveCount(0)
expect(await checkOverlay.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
const initialCheckBox = await checkOverlay.boundingBox()
expect(initialCheckBox).not.toBeNull()
await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2, initialCheckBox!.y + initialCheckBox!.height / 2)
await page.mouse.down()
await page.mouse.move(initialCheckBox!.x + initialCheckBox!.width / 2 - 34, initialCheckBox!.y + initialCheckBox!.height / 2 - 22, { steps: 5 })
await page.mouse.up()
const movedCheckBox = await checkOverlay.boundingBox()
expect(movedCheckBox!.x).toBeLessThan(initialCheckBox!.x - 20)
expect(movedCheckBox!.y).toBeLessThan(initialCheckBox!.y - 10)
const resizeHandle = checkOverlay.getByRole('button', { name: '缩放已完成检查' })
const resizeBox = await resizeHandle.boundingBox()
expect(resizeBox).not.toBeNull()
await page.mouse.move(resizeBox!.x + resizeBox!.width / 2, resizeBox!.y + resizeBox!.height / 2)
await page.mouse.down()
await page.mouse.move(resizeBox!.x + resizeBox!.width / 2 + 30, resizeBox!.y + resizeBox!.height / 2 + 26, { steps: 5 })
await page.mouse.up()
const resizedCheckBox = await checkOverlay.boundingBox()
expect(resizedCheckBox!.width).toBeGreaterThan(movedCheckBox!.width + 18)
expect(resizedCheckBox!.height).toBeGreaterThan(movedCheckBox!.height + 14)
const positionedResizeBox = await resizeHandle.boundingBox()
expect(Math.abs(positionedResizeBox!.x + positionedResizeBox!.width / 2 - (resizedCheckBox!.x + resizedCheckBox!.width))).toBeLessThanOrEqual(16)
expect(Math.abs(positionedResizeBox!.y + positionedResizeBox!.height / 2 - (resizedCheckBox!.y + resizedCheckBox!.height))).toBeLessThanOrEqual(16)
const numberControl = page.locator('.overlay-size-fields .el-input-number').first()
const numberBox = await numberControl.boundingBox()
const increaseBox = await numberControl.locator('.el-input-number__increase').boundingBox()
const decreaseBox = await numberControl.locator('.el-input-number__decrease').boundingBox()
expect(numberBox).not.toBeNull()
expect(increaseBox).not.toBeNull()
expect(decreaseBox).not.toBeNull()
expect(Math.abs(numberBox!.x + numberBox!.width - (increaseBox!.x + increaseBox!.width))).toBeLessThanOrEqual(2)
expect(Math.abs(numberBox!.x + numberBox!.width - (decreaseBox!.x + decreaseBox!.width))).toBeLessThanOrEqual(2)
expect(decreaseBox!.y - (increaseBox!.y + increaseBox!.height)).toBeLessThanOrEqual(2)
const inspectorBox = await page.locator('.editor-inspector').boundingBox()
const stageBox = await page.locator('.editor-canvas-row .studio-stage').boundingBox()
expect(inspectorBox).not.toBeNull()
expect(stageBox).not.toBeNull()
expect(Math.abs(inspectorBox!.height - stageBox!.height), '右侧编辑区不能撑高中间预览区').toBeLessThanOrEqual(2)
const inspectorScrollState = await page.locator('.editor-inspector-scroll').evaluate((element) => ({
overflowY: getComputedStyle(element).overflowY,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(inspectorScrollState).toMatchObject({ overflowY: 'auto' })
expect(inspectorScrollState.scrollHeight).toBeGreaterThan(inspectorScrollState.clientHeight)
await captureScreenshot(page, testInfo, '02-stage-element-moved-resized-without-label-or-background')
await page.getByLabel('开始(秒)').fill('0.5')
await page.getByLabel('结束(秒)').fill('4.5')

await page.locator('.editor-tool-rail button').filter({ hasText: '文本' }).click()
await page.getByRole('button', { name: /添加正文/ }).click()
const bodyOverlay = page.locator('.stage-overlay[data-overlay-kind="body"]')
await expect(bodyOverlay).toBeVisible()
await chooseElSelect(page, '元素文字字体', '楷体')
await chooseElSelect(page, '元素文字字号', '40')
await expect(bodyOverlay).toHaveCSS('font-family', /KaiTi/i)
await expect(bodyOverlay).toHaveCSS('font-size', '18px')

await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
await page.locator('.media-library input[type="file"]').setInputFiles({
@@ -186,21 +328,78 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
})
await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible()

await page.getByRole('button', { name: '文本', exact: true }).last().click()
await expect(page.getByText('待保存素材', { exact: true })).toHaveCount(0)
const stageMedia = page.locator('.stage-media-picture-in-picture')
const initialMediaBox = await stageMedia.boundingBox()
expect(initialMediaBox).not.toBeNull()
await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2, initialMediaBox!.y + initialMediaBox!.height / 2)
await page.mouse.down()
await page.mouse.move(initialMediaBox!.x + initialMediaBox!.width / 2 - 32, initialMediaBox!.y + initialMediaBox!.height / 2 + 24, { steps: 5 })
await page.mouse.up()
const movedMediaBox = await stageMedia.boundingBox()
expect(movedMediaBox!.x).toBeLessThan(initialMediaBox!.x - 18)
expect(movedMediaBox!.y).toBeGreaterThan(initialMediaBox!.y + 12)
const mediaResize = stageMedia.getByRole('button', { name: `缩放${materialName}` })
const mediaRemove = stageMedia.getByRole('button', { name: `移除${materialName}` })
const mediaRemoveBox = await mediaRemove.boundingBox()
const initialMediaResizeBox = await mediaResize.boundingBox()
expect(mediaRemoveBox).not.toBeNull()
expect(initialMediaResizeBox).not.toBeNull()
expect(Math.abs(mediaRemoveBox!.width - mediaRemoveBox!.height), '素材移除按钮必须保持正方形').toBeLessThanOrEqual(1)
expect(Math.abs(initialMediaResizeBox!.width - initialMediaResizeBox!.height), '素材缩放控制器必须保持正方形').toBeLessThanOrEqual(1)
await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2)
await page.mouse.down()
await page.mouse.move(initialMediaResizeBox!.x + initialMediaResizeBox!.width / 2 + 28, initialMediaResizeBox!.y + initialMediaResizeBox!.height / 2 + 20, { steps: 5 })
await page.mouse.up()
const resizedMediaBox = await stageMedia.boundingBox()
expect(resizedMediaBox!.width).toBeGreaterThan(movedMediaBox!.width + 14)
expect(resizedMediaBox!.height).toBeGreaterThan(movedMediaBox!.height + 10)

await page.getByRole('tab', { name: '文本', exact: true }).click()
await page.getByLabel('项目名称').fill(projectName)
await page.locator('.script-field textarea').fill(scriptText)
const capabilitySelect = page.getByLabel('语音能力资产')
const selectedCapability = await selectFirstEnabledOption(capabilitySelect, /演示实训播报语音/)
await page.getByLabel('字幕', { exact: true }).check()
await page.getByRole('button', { name: '字幕样式', exact: true }).click()
await page.getByLabel('字体').selectOption('Source Han Sans SC')
await page.getByLabel('字号').selectOption('48')
await page.getByLabel('字幕位置').selectOption('center')
const scriptInput = page.locator('.script-field textarea')
await scriptInput.fill('时间轴长内容验证。'.repeat(250))
const timelineScroll = page.getByRole('region', { name: '可横向滚动的时间轴轨道' })
await expect(timelineScroll).toBeVisible()
const initialTimelineSize = await timelineScroll.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}))
expect(initialTimelineSize.scrollWidth).toBeGreaterThan(initialTimelineSize.clientWidth * 1.25)
await page.getByRole('button', { name: '放大时间轴' }).click()
await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth)).toBeGreaterThan(initialTimelineSize.scrollWidth)
await timelineScroll.hover()
await page.keyboard.down('Shift')
await page.mouse.wheel(0, 700)
await page.keyboard.up('Shift')
await expect.poll(() => timelineScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0)
const timelineZoomSlider = page.getByRole('slider', { name: '调整时间轴比例尺' })
await expect(timelineZoomSlider).toBeVisible()
await expect(timelineZoomSlider).toHaveAttribute('aria-valuemin', '5')
await expect(timelineZoomSlider).toHaveAttribute('aria-valuemax', '1000')
await captureScreenshot(page, testInfo, '02a-timeline-zoom-and-horizontal-scroll')
await page.getByRole('button', { name: '适配', exact: true }).click()
await expect.poll(() => timelineScroll.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(2)
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2)
await scriptInput.fill(scriptText)
const voiceCatalogBeforeSave = await apiData(await request.get('/api/v1/tts/voices', { headers }))
const availableVoices = asRecords(voiceCatalogBeforeSave.items).filter((voice) => String(voice.capabilityId ?? '') && String(voice.name ?? ''))
const selectedVoiceLabel = await chooseFirstElSelectOption(page, '语音能力资产', /演示实训播报语音/)
const selectedVoiceName = selectedVoiceLabel.replace(/((?:克隆|内置))$/, '')
const selectedCapability = availableVoices.find((voice) => String(voice.name ?? '') === selectedVoiceName)
expect(selectedCapability, '页面必须加载至少一个可选的真实语音能力').toBeTruthy()
const subtitleSwitch = page.getByRole('switch', { name: '字幕', exact: true })
if (await subtitleSwitch.getAttribute('aria-checked') !== 'true') {
await subtitleSwitch.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " el-switch ")][1]').click()
}
await page.getByRole('tab', { name: '字幕样式', exact: true }).click()
await chooseElSelect(page, '字体', '思源黑体')
await chooseElSelect(page, '字号', '48')
await chooseElSelect(page, '字幕位置', '画面中部')
await captureScreenshot(page, testInfo, '02-video-project-ui-configured')

await page.getByRole('button', { name: '保存工程', exact: true }).click()
await expect(page.getByText('工程与四轨时间线已保存').last()).toBeVisible({ timeout: 60_000 })
await expect(page.getByText('工程与轨时间线已保存').last()).toBeVisible({ timeout: 60_000 })
await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull()
projectId = new URL(page.url()).searchParams.get('project') || ''
expect(projectId).not.toBe('')
@@ -210,7 +409,8 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
expect(persistedProject.data.name).toBe(projectName)
expect(persistedProject.data.scriptText).toBe(scriptText)
expect(persistedProject.data.orientation).toBe('landscape')
expect(persistedProject.data.backgroundType).toBe('training')
expect(persistedProject.data.backgroundType).toBe('asset')
expect(persistedProject.data.backgroundCapabilityId).toBe(backgroundCapabilityId)
expect(persistedProject.data.subtitleEnabled).toBe(true)
expect(persistedProject.data.subtitleFont).toBe('Source Han Sans SC')
expect(persistedProject.data.subtitleFontSize).toBe(48)
@@ -218,30 +418,72 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
expect(String(persistedProject.data.avatarId ?? '')).not.toBe('')
expect(String(persistedProject.data.voiceCapabilityId ?? '')).not.toBe('')
const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks)
expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'ELEMENT', 'AUDIO', 'SUBTITLE'])
const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips)
expect(mediaClips.some((clip) => clip.type === 'MEDIA' && String(clip.fileId ?? ''))).toBe(true)
expect(mediaClips.some((clip) => clip.type === 'CHECK')).toBe(true)
const elementClips = asRecords(tracks.find((track) => track.code === 'ELEMENT')?.clips)
const checkClip = elementClips.find((clip) => clip.type === 'CHECK') || {}
expect(checkClip.startMs).toBe(500)
expect(checkClip.endMs).toBe(4500)
expect(Number(checkClip.x)).toBeLessThan(0.75)
expect(Number(checkClip.y)).toBeLessThan(0.18)
expect(Number(checkClip.width)).toBeGreaterThan(0.15)
expect(Number(checkClip.height)).toBeGreaterThan(0.15)
const bodyClip = elementClips.find((clip) => clip.type === 'BODY') || {}
expect(asRecord(bodyClip.style).fontFamily).toBe('KaiTi')
expect(asRecord(bodyClip.style).fontSize).toBe(40)
const mediaClip = mediaClips.find((clip) => clip.type === 'MEDIA') || {}
expect(Number(mediaClip.x)).toBeLessThan(0.64)
expect(Number(mediaClip.y)).toBeGreaterThan(0.05)
expect(Number(mediaClip.width)).toBeGreaterThan(0.32)
expect(Number(mediaClip.height)).toBeGreaterThan(0.18)
const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {}
expect(asRecord(audioClip.style).speaker).toBe(2)
expect(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? ''))
expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)[0]?.text).toBe(scriptText)
activities.push({ module: '视频制作', action: '页面保存横屏工程、真实形象音色、素材、元素、字幕和四轨时间线', result: 'PASS', resourceId: projectId })
await captureScreenshot(page, testInfo, '03-video-project-saved-four-tracks')
const voiceCatalog = await apiData(await request.get('/api/v1/tts/voices', { headers }))
const persistedVoice = asRecords(voiceCatalog.items).find((voice) => (
String(voice.capabilityId ?? '') === String(persistedProject.data.voiceCapabilityId ?? '')
))
expect(persistedVoice, '工程音轨引用的音色必须仍在统一 TTS 目录中').toBeTruthy()
expect(asRecord(audioClip.style).speaker ?? null).toBe(persistedVoice!.speaker ?? null)
const subtitleClips = asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)
expect(subtitleClips.length).toBeGreaterThan(1)
expect(subtitleClips.map((clip) => String(clip.text || '')).join('')).toBe(scriptText)
activities.push({ module: '视频制作', action: '页面保存资源背景、真实形象音色、素材、可定时元素、分段字幕和五轨时间线', result: 'PASS', resourceId: projectId })
await captureScreenshot(page, testInfo, '03-video-project-saved-five-tracks')

await page.reload()
await expect(page.getByText('工程已保存', { exact: true })).toBeVisible({ timeout: 30_000 })
await expect(page.getByText('已保存', { exact: true })).toBeVisible({ timeout: 30_000 })
await expect(page.getByLabel('项目名称')).toHaveValue(projectName)
await expect(page.locator('.script-field textarea')).toHaveValue(scriptText)
await expect(page.getByRole('button', { name: /横屏(1920 × 1080)/ })).toHaveClass(/active/)
await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click()
await expect(page.locator('.uploaded-media-card')).toContainText(materialName)
await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible()
await expect(page.getByLabel('语音能力资产')).toHaveValue(selectedCapability.value)
const timelineSlider = page.getByRole('slider', { name: '拖动时间轴播放头' })
await timelineSlider.focus()
await timelineSlider.press('Home')
for (let step = 0; step < 20; step += 1) await timelineSlider.press('ArrowRight')
await expect(page.locator('.stage-overlay[data-overlay-kind="check"]')).toBeVisible()
const reloadedVoiceSelect = page.getByRole('combobox', { name: '语音能力资产', exact: true })
.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " el-select ")][1]')
await expect(reloadedVoiceSelect.locator('.el-select__selected-item:not(.is-hidden)')).toContainText(String(selectedCapability!.name))
await expect(page.locator('.timeline-clip.media')).toContainText(materialName)
await expect(page.locator('.timeline-clip.subtitle')).toContainText(scriptText)
await expect(page.locator('.stage-frame .stage-demo-background > span')).toHaveCount(0)
const timelinePlayButton = page.getByRole('button', { name: '播放', exact: true })
await expect(timelinePlayButton.locator('svg')).toHaveCount(1)
await expect.poll(async () => (
(await page.locator('.timeline-clip.subtitle').allTextContents()).join('')
)).toBe(scriptText)
await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click()
await expect(page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).locator('input[type="radio"]')).toBeChecked()
await expect(page.locator(`.editor-resource-list input[type="radio"][value="${String(selectedCapability!.capabilityId)}"]`)).toBeChecked()
const previewSpeechRequest = page.waitForRequest((request) => (
request.method() === 'POST' && new URL(request.url()).pathname === '/api/v1/tts'
))
await page.getByRole('button', { name: '预览播放', exact: true }).click()
const previewSpeechPayload = (await previewSpeechRequest).postDataJSON() as Record<string, unknown>
expect(previewSpeechPayload.text).toBe(scriptText)
await expect(page.getByRole('button', { name: '停止预览', exact: true })).toBeVisible({ timeout: 120_000 })
await page.getByRole('button', { name: '停止预览', exact: true }).click()
await expect(page.getByRole('button', { name: '预览播放', exact: true })).toBeVisible()
activities.push({ module: '视频制作', action: '刷新 project 深链并核验全部配置持久化', result: 'PASS', resourceId: projectId })
await captureScreenshot(page, testInfo, '04-video-project-deeplink-persisted')

@@ -285,7 +527,7 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded')

await detail.getByRole('button', { name: '关闭' }).click()
await page.locator('.folder-create-button').click()
await page.getByRole('button', { name: '新建文件夹', exact: true }).click()
let dialog = page.locator('.el-dialog:visible').last()
await dialog.getByLabel('文件夹名称').fill(folderName)
const createFolderResponsePromise = page.waitForResponse((response) => (
@@ -318,11 +560,10 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',

await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName)
await page.getByRole('button', { name: '搜索', exact: true }).click()
await page.getByLabel('分类').selectOption({ label: '口播视频' })
await page.getByLabel('可见范围').selectOption('private')
await page.getByLabel('归属').selectOption('mine')
await page.getByLabel('排序').selectOption('title')
await page.locator('.sort-direction').click()
await chooseElSelect(page, '视频分类', '口播视频')
await chooseElSelect(page, '可见范围', '仅自己')
await chooseElSelect(page, '视频归属', '我创建的')
await chooseElSelect(page, '排序方式', '标题升序')
let card = page.locator('.video-library-card').filter({ hasText: projectName })
await expect(card).toBeVisible()
activities.push({ module: '成片管理', action: '页面重命名文件夹并组合搜索、分类、可见范围、归属与排序', result: 'PASS', resourceId: folderId })
@@ -409,8 +650,8 @@ test('视频制作与成片管理全部通过页面提交并真实生成成片',
await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible()
folderId = ''
await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName)
await page.getByLabel('分类').selectOption('')
await page.getByLabel('可见范围').selectOption('public')
await chooseElSelect(page, '视频分类', '全部分类')
await chooseElSelect(page, '可见范围', '内网分享链接')
await page.getByRole('button', { name: '搜索', exact: true }).click()
card = page.locator('.video-library-card').filter({ hasText: editedVideoName })
await expect(card).toBeVisible()


+ 49
- 22
tests/zipvoice-cloning-ui.spec.ts Zobrazit soubor

@@ -1,4 +1,4 @@
import type { Route } from '@playwright/test'
import type { Page, Route } from '@playwright/test'

import { captureScreenshot, expect, test } from './fixtures'
import { loginAsAdmin } from './helpers'
@@ -19,6 +19,11 @@ const fulfillJson = (route: Route, data: unknown, status = 200) => route.fulfill
body: JSON.stringify(envelope(data)),
})

const voiceRow = (page: Page, name: string) => page
.locator('.voice-clone-table .el-table__row')
.filter({ hasText: name })
.first()

const baseCapability = {
dataVersion: 1,
capabilityType: 'VOICE_CLONE',
@@ -36,7 +41,7 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象
test.setTimeout(120_000)
await loginAsAdmin(page)

const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord } = {}
const requests: { create?: JsonRecord; retry?: JsonRecord; speech?: JsonRecord; avatarUpdate?: JsonRecord; builtIn?: string | null } = {}
let capabilities = [
{
...baseCapability,
@@ -188,7 +193,11 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象
return fulfillJson(route, created, 201)
}
if (url.pathname.endsWith('/capabilities') && request.method() === 'GET') {
return fulfillJson(route, { items: capabilities, total: capabilities.length, page: 1, pageSize: 9, pages: 1 })
requests.builtIn = url.searchParams.get('builtIn')
const visible = requests.builtIn == null
? capabilities
: capabilities.filter((item) => item.builtIn === (requests.builtIn === 'true'))
return fulfillJson(route, { items: visible, total: visible.length, page: 1, pageSize: 10, pages: 1 })
}
return route.fallback()
})
@@ -218,29 +227,47 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象

await page.goto('/assets/voice-clones')
await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible()
await expect(page.locator('.capability-card').filter({ hasText: '历史参考样音' })).toContainText('待开始克隆')
await expect(page.locator('.capability-card').filter({ hasText: '排队中的男教员' })).toContainText('42%')
await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('参考音频有效人声时长不足')
await expect(page.locator('.capability-card').filter({ hasText: '可用克隆男声' })).toContainText('克隆音色可用')
const builtinCard = page.locator('.capability-card').filter({ hasText: '温和男声' })
await expect(builtinCard).toContainText('平台内置')
await expect(builtinCard).toContainText('VITS')
await expect(builtinCard.getByRole('button', { name: '试听', exact: true })).toBeVisible()
await expect(builtinCard.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0)
await expect(builtinCard.getByRole('button', { name: '停用', exact: true })).toHaveCount(0)
await expect(builtinCard.getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
await expect(voiceRow(page, '历史参考样音')).toContainText('待开始克隆')
await expect(voiceRow(page, '排队中的男教员')).toContainText('42%')
const failedInitialRow = voiceRow(page, '待重试音色')
await expect(failedInitialRow).not.toContainText('参考音频有效人声时长不足')
await expect(voiceRow(page, '可用克隆男声')).toContainText('克隆音色可用')
const builtinRow = voiceRow(page, '温和男声')
await expect(builtinRow).toContainText('平台内置')
await expect(builtinRow).toContainText('VITS')
await expect(builtinRow.getByRole('button', { name: '试听', exact: true })).toBeVisible()
await expect(builtinRow.getByRole('button', { name: '编辑', exact: true })).toHaveCount(0)
await expect(builtinRow.getByRole('button', { name: '停用', exact: true })).toHaveCount(0)
await expect(builtinRow.getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
await failedInitialRow.getByRole('button', { name: '详情', exact: true }).click()
const failedDetailDialog = page.locator('.model-detail-dialog:visible').last()
await expect(failedDetailDialog).toContainText('参考音频有效人声时长不足')
await failedDetailDialog.getByRole('button', { name: '关闭', exact: true }).click()
const readyDetailRow = voiceRow(page, '可用克隆男声')
await readyDetailRow.getByRole('button', { name: '详情', exact: true }).click()
const detailDialog = page.locator('.model-detail-dialog:visible').last()
await expect(detailDialog.locator('.el-dialog__title')).toHaveText('音色详情')
await expect(detailDialog).toContainText('可用克隆男声')
await expect(detailDialog).toContainText('ZipVoice')
await detailDialog.getByRole('button', { name: '关闭', exact: true }).click()
await captureScreenshot(page, testInfo, '01-zipvoice-async-states')

const failedCard = page.locator('.capability-card').filter({ hasText: '待重试音色' })
await failedCard.getByRole('button', { name: '重新克隆' }).click()
await expect(failedCard.locator('.el-button.is-loading')).toBeVisible()
await page.locator('.voice-ownership-filter').click()
await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '自定义音色' }).click()
await page.getByRole('button', { name: '搜索', exact: true }).click()
await expect.poll(() => requests.builtIn).toBe('false')
await expect(voiceRow(page, '温和男声')).toHaveCount(0)

const failedRow = voiceRow(page, '待重试音色')
await failedRow.getByRole('button', { name: '重新克隆' }).click()
await expect(failedRow.locator('.el-button.is-loading')).toBeVisible()
await expect.poll(() => requests.retry?.dataVersion).toBe(1)
await expect(page.locator('.capability-card').filter({ hasText: '待重试音色' })).toContainText('排队中')
await expect(voiceRow(page, '待重试音色')).toContainText('排队中')

const readyCard = page.locator('.capability-card').filter({ hasText: '可用克隆男声' })
await readyCard.getByRole('button', { name: '试听', exact: true }).click()
const readyRow = voiceRow(page, '可用克隆男声')
await readyRow.getByRole('button', { name: '试听', exact: true }).click()
await expect.poll(() => requests.speech?.voiceCapabilityId).toBe('clone-ready')
await expect(readyCard.locator('.inline-media-preview audio')).toBeVisible()
await expect(readyRow.locator('.voice-table-preview audio')).toBeVisible()

await page.getByRole('button', { name: /新建克隆音色/ }).click()
const dialog = page.locator('.admin-form-dialog:visible').last()
@@ -280,7 +307,7 @@ test('ZipVoice 受控 UI:克隆契约、异步状态、重试与数字形象
mediaFileCode: 'file-controlled',
consentConfirmed: true,
})
await expect(page.locator('.capability-card').filter({ hasText: '受控 ZipVoice 男教员' })).toContainText('排队中')
await expect(voiceRow(page, '受控 ZipVoice 男教员')).toContainText('排队中')

await page.goto('/assets/avatars')
await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible()


+ 3
- 3
tests/zipvoice-real-lifecycle.spec.ts Zobrazit soubor

@@ -633,9 +633,9 @@ test('ZipVoice 真实生命周期:服务端异步克隆、跨浏览器续作
const voiceSearch = verificationPage.getByPlaceholder('搜索名称或说明')
await voiceSearch.fill(capabilityName)
await verificationPage.getByRole('button', { name: '搜索', exact: true }).click()
const readyCard = verificationPage.locator('.capability-card').filter({ hasText: capabilityName }).first()
await expect(readyCard).toBeVisible({ timeout: 30_000 })
await expect(readyCard).toContainText('克隆音色可用')
const readyRow = verificationPage.locator('.voice-clone-table .el-table__row').filter({ hasText: capabilityName }).first()
await expect(readyRow).toBeVisible({ timeout: 30_000 })
await expect(readyRow).toContainText('克隆音色可用')
await captureScreenshot(verificationPage, testInfo, '02-zipvoice-real-ready-card-new-context')
addActivity(activities, '声音克隆页面', '新浏览器上下文展示 READY 克隆音色', 'PASS', {
resourceId: references.capabilityId,


Načítá se…
Zrušit
Uložit