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().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 { return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch } } /** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */ function validationButton() { return [...document.querySelectorAll('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 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="额外允许的画面文字"]')).not.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('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) }) })