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 '@/features/production/components/KeyframeDialog.vue' import { productionApi } from '@/features/production/api' import { isActiveVideo, issueLabel, primaryKeyframe, primaryVideo, productionStatusLabel, validOptionalSize } from '@/features/production/model' import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures' let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() }) /** 从 Naive 弹窗中查找精确按钮。 */ 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('stale_prompt')).toBe('视频提示词需要更新') expect(issueLabel('provider_input_recovery')).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.generateQualityVideos('project/1', { concurrency: 3, force: true, limit: 2, maxRepairAttempts: 2 }) 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-quality') expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ concurrency: 3, force: true, limit: 2, maxRepairAttempts: 2 }) }) 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([[{ 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() await flushPromises() expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }]) }) it('首帧生成前可预览后端最终选图规划和 Prompt', async () => { const fetcher = vi.fn().mockResolvedValue( new Response( JSON.stringify({ data: { shotId: 'shot-1', episodeNo: 1, beatNo: 1, shotNo: 1, provider: 'seedream', model: 'image-model', generationConfig: {}, references: [], referencePlan: { provider: 'seedream', maxReferenceImages: 4, totalCandidates: 2, strategy: 'subject-coverage', selected: [ { subjectRef: '@CH0001', module: 'character', role: 'identity-anchor', imageId: 'anchor-1' } ], omitted: [ { subjectRef: '@SC0001', module: 'scene', role: 'form-primary', imageId: 'scene-1' } ] }, providerPrompt: '最终首帧 Prompt' } }) ) ) vi.stubGlobal('fetch', fetcher) wrapper = mount(KeyframeDialog, { attachTo: document.body, props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false } }) await flushPromises() button('预览实际生成输入').click() await flushPromises() expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframe/preview') expect(document.body.textContent).toContain('已选 1 / 2 张') expect(document.body.textContent).toContain('省略 1 张') expect(document.body.textContent).toContain('最终首帧 Prompt') expect(wrapper.emitted('generate')).toBeUndefined() }) })