feat: 同步精简接口并统一表单校验

This commit is contained in:
GJ
2026-09-10 22:34:47 +08:00
parent 82d7bde631
commit 2eea0ea72e
66 changed files with 1611 additions and 1100 deletions
@@ -50,6 +50,8 @@ describe('镜头生产数据契约', () => {
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('演员身份未锁定')
@@ -135,6 +137,7 @@ describe('镜头生产数据契约', () => {
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
})
@@ -0,0 +1,168 @@
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<typeof fetch>().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<typeof fetch>().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<typeof fetch>().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)
})
})
+22 -18
View File
@@ -10,8 +10,8 @@ import {
qualityKey,
qualitySession,
qualityTargets,
savedVideoValidation,
videoRepairInfo,
sessionVideoValidation,
isVisualValidation,
validQualityInput
} from '@/features/production/quality'
import type { KeyframeReadiness } from '@/features/production/types'
@@ -125,8 +125,7 @@ function server() {
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
videoUrl: null,
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
videoUrl: null
})
}
else if (path.endsWith('/repair')) data = repair
@@ -136,13 +135,14 @@ function server() {
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
isPrimary: validation.passed,
videoUrl: video.videoUrl,
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
allowedTexts: []
}
else data = keyframeResult
} else if (path.endsWith(`/projects/${projectId}`)) data = project
else if (path.endsWith('/checkpoints')) data = []
else if (path.includes('/readiness')) data = readiness
else if (path.endsWith('/keyframes')) data = [keyframe]
else if (path.endsWith('/videos')) data = [video]
@@ -437,9 +437,10 @@ describe('质量面板渐进展示', () => {
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
expect(savedVideoValidation('{broken')).toBeNull()
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
expect(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')).toBeNull()
expect(isVisualValidation(data.validation)).toBe(true)
expect(isVisualValidation('{broken')).toBe(false)
expect(isVisualValidation({ passed: true })).toBe(false)
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
@@ -454,7 +455,9 @@ describe('视频修复候选与复检晋升', () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
data.validation.passed = false
await data.service.run('validate', input())
data.fetcher.mockClear()
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
@@ -465,21 +468,23 @@ describe('视频修复候选与复检晋升', () => {
})
})
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
it('修复不接受零次上限,后端次数限制错误原样保留且不重试', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.rawJson = JSON.stringify({
videoValidation: { ...data.validation, passed: false },
repair: { attempt: 2 }
})
data.validation.passed = false
await data.service.run('validate', input())
data.fetcher.mockClear()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
vi.spyOn(qualityApi, 'repairVideo').mockRejectedValueOnce(new Error('修复次数上限已达到'))
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(qualityApi.repairVideo).toHaveBeenCalledTimes(1)
expect(data.service.session.value.error).toContain('次数上限')
expect(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')?.passed).toBe(false)
})
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
data.video.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
@@ -499,8 +504,7 @@ describe('视频修复候选与复检晋升', () => {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
disabled: false,
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
disabled: false
}
})
await flushPromises()