import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { mediaAssetUrl } from '../../lib/assets' import KeyframeDialog from './components/KeyframeDialog.vue' import { productionApi } from './api' import { isActiveVideo, issueLabel, primaryKeyframe, primaryVideo, productionStatusLabel, validOptionalSize } from './model' import { keyframeFixture, videoFixture } from './testing/fixtures' let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() }) /** 从 Reka Portal 中查找精确按钮。 */ function button(label: string): HTMLButtonElement { const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label) if (!item) throw new Error(`缺少按钮 ${label}`) return item } describe('镜头生产数据契约', () => { it('可选尺寸必须成对留空或填写正整数', () => { expect(validOptionalSize('', '')).toBe(true) expect(validOptionalSize(1920, 1080)).toBe(true) expect(validOptionalSize(1920, '')).toBe(false) expect(validOptionalSize('', 1080)).toBe(false) expect(validOptionalSize(0, 1080)).toBe(false) expect(validOptionalSize(10.5, 1080)).toBe(false) }) it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => { expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1') expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined() expect(primaryVideo([videoFixture()])?.id).toBe('video-1') expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined() for (const status of ['pending', 'queued', 'running'] as const) expect(isActiveVideo(videoFixture({ status }))).toBe(true) expect(isActiveVideo(videoFixture())).toBe(false) }) it('就绪问题和异步任务状态提供中文标签', () => { expect(issueLabel('missing_keyframe')).toBe('缺少主首帧') expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整') expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版') expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定') expect(productionStatusLabel('in_progress')).toBe('任务进行中') expect(productionStatusLabel('unknown')).toBe('unknown') }) it('视频地址与图片使用相同的安全协议限制', () => { expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4') expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4') expect(mediaAssetUrl('javascript:alert(1)')).toBeNull() expect(mediaAssetUrl('/storage/../admin')).toBeNull() }) it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => { const fetcher = vi.fn().mockImplementation(async url => { const path = String(url) if (path.includes('/readiness')) return new Response( JSON.stringify({ data: { total: 1, ready: 1, skipped: 0, inProgress: 0, blocked: 0, missingPrompt: 0, missingKeyframe: 0, missingReference: 0, items: [] } }) ) return new Response( JSON.stringify({ data: { total: 1, targetCount: 1, created: 1, skipped: 0, readiness: {}, failed: 0, failures: [] } }) ) }) vi.stubGlobal('fetch', fetcher) await productionApi.videoReadiness('project/1', true) await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true }) expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true') expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate') expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ provider: 'seedance', concurrency: 3, force: true }) }) it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => { wrapper = mount(KeyframeDialog, { attachTo: document.body, props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false } }) await flushPromises() expect(button('确认生成首帧').disabled).toBe(true) document.querySelector('#confirm-keyframe-cost')!.click() await flushPromises() button('确认生成首帧').click() await flushPromises() expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]]) await wrapper.setProps({ open: false }) await flushPromises() await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] }) await flushPromises() document.querySelector('#confirm-keyframe-cost')!.click() await flushPromises() button('确认生成首帧').click() expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }]) }) })