Files
short-drama-agent-front/tests/features/production/quality.test.ts
T

520 lines
23 KiB
TypeScript

import { defineComponent, reactive, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
import { testProjectContext } from '@/testing/project-context'
import { productionApi } from '@/features/production/api'
import { qualityApi } from '@/features/production/quality-api'
import {
allowedTextLines,
qualityKey,
qualitySession,
qualityTargets,
sessionVideoValidation,
isVisualValidation,
validQualityInput
} from '@/features/production/quality'
import type { KeyframeReadiness } from '@/features/production/types'
import type { QualityInput, QualityTarget } from '@/features/production/quality.types'
import { useQuality } from '@/features/production/useQuality'
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
import QualityDialog from '@/features/production/components/QualityDialog.vue'
import QualityResult from '@/features/production/components/QualityResult.vue'
import ModelCapabilities from '@/features/production/components/ModelCapabilities.vue'
let wrapper: VueWrapper | undefined
let sequence = 0
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
function server() {
const projectId = `quality-test-${sequence++}`
const project = testProjectContext(projectId).project.value!
const validation = {
passed: true,
summary: '身份与造型一致',
subjects: [],
subjectCountConsistent: true,
unauthorizedText: { detected: false, texts: [] },
issues: []
}
const keyframe = keyframeFixture()
const video = videoFixture()
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingVisualStyle: 0,
missingIdentity: 0,
missingIdentityAnchor: 0,
identityUnlocked: 0,
invalidGenerationSpec: 0,
missingReference: 0,
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
} as KeyframeReadiness
const capability = {
provider: 'qwen-image',
referenceCount: 3,
maxReferenceImages: 3,
valid: true,
message: '参考图超限'
}
const keyframeResult = {
...validation,
shotId: 'shot-1',
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
isPrimary: true,
imageUrl: keyframe.imageUrl!
}
const attempt = {
attempt: 0,
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
passed: true,
issues: [],
validationDurationMs: 10
}
const repair = {
shotId: 'shot-1',
initialKeyframeId: 'keyframe-1',
finalKeyframeId: 'keyframe-1',
passed: true,
repaired: false,
primaryChanged: false,
repairAttempts: 0,
maxRepairAttempts: 1,
attempts: [attempt]
}
const batch = {
total: 1,
ready: 1,
selected: 1,
targetCount: 1,
passed: 1,
repairFailed: 0,
failed: 0,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingReference: 0,
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
const path = String(url)
let data: unknown
if (options?.method === 'POST') {
if (path.endsWith('/generate-quality')) data = batch
else if (path.includes('/videos/') && path.endsWith('/repair'))
data = {
shotId: 'shot-1',
sourceVideoId: 'video-1',
repairAttempt: 1,
maxRepairAttempts: 2,
repairInstructions: ['修复人物漂移'],
allowedTexts: [],
candidate: videoFixture({
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
videoUrl: null
})
}
else if (path.endsWith('/repair')) data = repair
else if (path.includes('/videos/'))
data = {
...validation,
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
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]
else if (path.endsWith('/keyframe-provider-capability')) data = capability
else if (path.endsWith('/image-providers/capabilities'))
data = [
{
provider: 'qwen-image',
active: true,
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
}
]
else throw new Error('意外接口:' + path)
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
projectId,
project,
validation,
keyframe,
video,
readiness,
capability,
keyframeResult,
repair,
batch,
fetcher,
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
}
}
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
function input(patch: Partial<QualityInput> = {}): QualityInput {
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
}
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
function validationButton() {
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始视觉校验'
)!
}
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
const data = server()
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
const visible = ref(true)
const changed = vi.fn<() => void>()
let service!: ReturnType<typeof useQuality>
wrapper = mount(
defineComponent({
setup() {
service = useQuality(props, () => visible.value, changed)
return () => null
}
})
)
return { ...data, props, visible, changed, service }
}
describe('视觉质量契约与付费边界', () => {
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
const data = setup()
expect(data.fetcher).not.toHaveBeenCalled()
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
})
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
const data = setup()
data.project.status = status
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('剧本未完成')
})
it('目标不属于项目时不查询目标素材,不生成', async () => {
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
})
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
const data = setup()
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
...data.keyframeResult,
passed: undefined
} as never)
await data.service.run('validate', input())
expect(data.service.session.value.receipt).toBeNull()
expect(data.service.session.value.error).toContain('不完整')
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
})
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
const data = setup()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toContain('/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
})
it('模型参考图能力不足时阻止修复与质量批次', async () => {
const data = setup()
data.capability.valid = false
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('参考图超限')
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
})
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.durationSeconds = null
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
data.video.durationSeconds = 5
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video',
result: { sampleFrames: [{ timeSeconds: 2 }] }
})
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
})
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
Object.assign(data.batch, {
passed: 0,
repairFailed: 1,
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
})
await data.service.run('batch', input({ width: 1536, height: 1024 }))
expect(data.posts()).toHaveLength(1)
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
const receipt = data.service.session.value.receipt!
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('仍需处理')
expect(wrapper.text()).toContain('人物不一致')
await wrapper
.findAll('button')
.find(button => button.text() === '定位镜头')!
.trigger('click')
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
})
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
data.readiness.stalePrimaryKeyframe = 1
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('切换剧集')
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
})
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
const data = setup()
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
await data.service.run('repair', input(values))
expect(data.fetcher).not.toHaveBeenCalled()
data.keyframe.status = 'generating'
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
data.props.disabled = true
const count = data.fetcher.mock.calls.length
await data.service.run('validate', input())
expect(data.fetcher).toHaveBeenCalledTimes(count)
})
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
const data = setup()
let finishCheck!: (value: KeyframeReadiness) => void
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
() =>
new Promise(resolve => {
finishCheck = resolve
})
)
const pendingCheck = data.service.run('validate', input())
await flushPromises()
data.visible.value = false
finishCheck(data.readiness)
await pendingCheck
expect(data.posts()).toHaveLength(0)
data.visible.value = true
let finish!: (value: typeof data.keyframeResult) => void
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
const originalKey = data.service.key.value
const pending = data.service.run('validate', input())
await flushPromises()
await data.service.run('validate', input())
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
data.props.projectId = 'different-project'
finish(data.keyframeResult)
await pending
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
expect(data.service.session.value.receipt).toBeNull()
})
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
const data = server()
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
await qualityApi.validateVideo('shot/a', 'video/b', [])
expect(data.posts().map(([url]) => url)).toEqual([
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
])
})
})
describe('质量面板渐进展示', () => {
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
open: true,
disabled: false
}
})
await flushPromises()
expect(validationButton().disabled).toBe(true)
expect(document.body.textContent).toContain('更多参数与模型限制')
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
expect(data.fetcher).not.toHaveBeenCalled()
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
expect(validationButton().disabled).toBe(false)
validationButton().click()
await flushPromises()
expect(data.posts()).toHaveLength(1)
expect(document.body.textContent).toContain('视觉校验通过')
})
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
open: true,
disabled: false
}
})
await flushPromises()
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
await flushPromises()
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始质量生成'
)!
expect(submit.disabled).toBe(true)
expect(data.posts()).toHaveLength(0)
})
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
const data = server()
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
expect(data.fetcher).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('qwen-image · 当前启用')
expect(wrapper.text()).toContain('最多 3 张参考图')
expect(data.posts()).toHaveLength(0)
})
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
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(
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
)
expect(data.fetcher).not.toHaveBeenCalled()
})
})
describe('视频修复候选与复检晋升', () => {
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
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.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')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video-repair',
result: { candidate: { status: 'queued', isPrimary: false } }
})
})
it('修复不接受零次上限,后端次数限制错误原样保留且不重试', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.validation.passed = false
await data.service.run('validate', input())
data.fetcher.mockClear()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
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.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
expect(data.posts()).toHaveLength(1)
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('后端已将其设为主视频')
})
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
disabled: false
}
})
await flushPromises()
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
await flushPromises()
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
expect(document.body.textContent).not.toContain('次生图')
expect(document.body.textContent).toContain('修复链次数上限')
expect(data.posts()).toHaveLength(0)
})
})