import { flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import QualityResult from '@/features/production/components/QualityResult.vue' import { expandSections } from '@/testing/naive' import ShotProductionAssets from '@/features/production/components/ShotProductionAssets.vue' import { primaryKeyframe } from '@/features/production/model' import { qualityKey, qualitySession, sessionVideoValidation } from '@/features/production/quality' import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures' import { storyboardApi } from '@/features/storyboard/api' import { hasRunningWorkflow } from '@/features/workflows/selectors' import type { DesignedShot } from '@/features/storyboard/types' import type { VideoValidation } from '@/features/production/quality.types' import type { Checkpoint } from '@/features/workflows/types' const shot: DesignedShot = { shotId: 'shot-1', shotNo: 1, beatNo: 1, title: '测试镜头', description: '', visualFocus: '', direction: null, visualState: null } const validation: VideoValidation = { shotId: 'shot-1', videoId: 'video-1', videoUrl: '/storage/videos/video-1.mp4', isPrimary: false, passed: true, summary: '通过', subjects: [], subjectCountConsistent: true, unauthorizedText: { detected: false, texts: [] }, issues: [], sampleFrames: [], allowedTexts: [], validatedAt: '2026-09-10T00:00:00Z' } afterEach(() => { vi.unstubAllGlobals() }) /** 构造带完整执行字段的后端 checkpoint。 */ const record = (workflowName: string, day: number, status: 'running' | 'completed'): Checkpoint => ({ checkpointId: `${workflowName}-${day}`, workflowName, createdAt: `2026-09-0${day}T00:00:00Z`, state: { workflowExecution: { status, executionId: `${workflowName}-${day}`, startedAt: `2026-09-0${day}T00:00:00Z` } } }) describe('精简公开资产回归', () => { it('无内部字段的视频可播放,未知质量不得直接晋升;真实校验结果按项目隔离', async () => { const projectId = 'public-video-contract' const video = videoFixture({ isPrimary: false }) expect(video).not.toHaveProperty('rawJson') const fetcher = vi.fn().mockImplementation( async url => new Response( JSON.stringify({ data: String(url).endsWith('/videos') ? [video] : [] }) ) ) vi.stubGlobal('fetch', fetcher) const wrapper = mount(ShotProductionAssets, { props: { projectId, shot, disabled: false } }) try { await flushPromises() expect(wrapper.find('video').exists()).toBe(true) expect(wrapper.text()).not.toContain('自动校验中') expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false) const session = qualitySession( qualityKey(projectId, { kind: 'video', shotId: shot.shotId, assetId: video.id, title: '' }) ) session.videoValidation = validation await flushPromises() expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(true) expect(sessionVideoValidation('other-project', shot.shotId, video.id)).toBeNull() session.videoValidation = { ...validation, passed: false } await flushPromises() expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false) expect(fetcher.mock.calls.every(([, options]) => options?.method === 'GET')).toBe(true) } finally { wrapper.unmount() } }) it('模型输入变体可以预览,但不能设为业务主首帧', async () => { const variant = keyframeFixture({ source: 'provider_variant', isPrimary: false }) expect(primaryKeyframe([{ ...variant, isPrimary: true }])).toBeUndefined() vi.stubGlobal( 'fetch', vi.fn().mockImplementation( async url => new Response( JSON.stringify({ data: String(url).endsWith('/keyframes') ? [variant] : [] }) ) ) ) const wrapper = mount(ShotProductionAssets, { props: { projectId: 'public-variant-contract', shot, disabled: false } }) try { await flushPromises() expect(wrapper.text()).toContain('模型输入变体') expect(wrapper.findAll('button').some(button => button.text() === '设为主首帧')).toBe(false) } finally { wrapper.unmount() } }) it('提示词返回使用 shotId,保留正文、状态和更新时间', async () => { const result = { shotId: 'shot/db', videoPrompt: '镜头正文', negativePrompt: null, status: 'prompt_ready', updatedAt: '2026-09-10T00:00:00Z' } const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: result }))) vi.stubGlobal('fetch', fetcher) expect(await storyboardApi.generatePrompt('shot/db', false)).toEqual(result) expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot%2Fdb/video-prompt') expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ force: false }) }) it('视频专项检查区分通过、失败和未执行,并保留后端摘要', async () => { const wrapper = mount(QualityResult, { attachTo: document.body, props: { receipt: { kind: 'video', promoted: false, result: { ...validation, passed: false, motionRealism: { enabled: true, passed: false, summary: '运动发生跳变' }, spatialInteraction: { enabled: true, passed: true, summary: '空间关系一致' }, physicalStructure: { enabled: false, reason: 'missing_prop_subject' } } } } }) try { await expandSections() expect(wrapper.text()).toContain('运动真实性:未通过') expect(wrapper.text()).toContain('运动发生跳变') expect(wrapper.text()).toContain('空间交互:通过') expect(wrapper.text()).toContain('物理结构:未执行') } finally { wrapper.unmount() } }) it('项目不再提供任务数组时,从每条工作流最新 checkpoint 判断运行状态', () => { expect(hasRunningWorkflow([])).toBe(false) expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('breakdown', 2, 'completed')])).toBe(false) expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('other', 2, 'completed')])).toBe(true) }) })