diff --git a/tests/avatar-capabilities-ui.spec.ts b/tests/avatar-capabilities-ui.spec.ts index a402e06..a8b4a61 100644 --- a/tests/avatar-capabilities-ui.spec.ts +++ b/tests/avatar-capabilities-ui.spec.ts @@ -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) diff --git a/tests/prototype-sync-regression.spec.ts b/tests/prototype-sync-regression.spec.ts index 233f4e2..40060fd 100644 --- a/tests/prototype-sync-regression.spec.ts +++ b/tests/prototype-sync-regression.spec.ts @@ -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 = [video]) { const videoQueries: Array> = [] 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((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') diff --git a/tests/tengwangge-all-avatars-video.spec.ts b/tests/tengwangge-all-avatars-video.spec.ts new file mode 100644 index 0000000..67895a2 --- /dev/null +++ b/tests/tengwangge-all-avatars-video.spec.ts @@ -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 +type Headers = Record + +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') + .replace(/Bearer\s+\S+/gi, 'Bearer ') + .slice(0, 2_000) +} + +async function apiRecord(response: { status(): number; json(): Promise }, 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() + 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([]) +}) diff --git a/tests/video-ui-lifecycle.spec.ts b/tests/video-ui-lifecycle.spec.ts index 1c9abbb..f4fa579 100644 --- a/tests/video-ui-lifecycle.spec.ts +++ b/tests/video-ui-lifecycle.spec.ts @@ -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 + 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() diff --git a/tests/zipvoice-cloning-ui.spec.ts b/tests/zipvoice-cloning-ui.spec.ts index d71c29a..3966fe3 100644 --- a/tests/zipvoice-cloning-ui.spec.ts +++ b/tests/zipvoice-cloning-ui.spec.ts @@ -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() diff --git a/tests/zipvoice-real-lifecycle.spec.ts b/tests/zipvoice-real-lifecycle.spec.ts index 092faec..9b27567 100644 --- a/tests/zipvoice-real-lifecycle.spec.ts +++ b/tests/zipvoice-real-lifecycle.spec.ts @@ -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,