import { defineComponent } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectContextKey } from '@/features/projects/context' import { testProjectContext } from '@/testing/project-context' import { getOperation } from '@/features/workflows/operations' import { formFixture } from '@/features/subject-images/testing/fixtures' import { directionsResult } from '@/features/storyboard/testing/fixtures' import { checkPipeline, useAdvancedProduction } from '@/features/production/useAdvancedProduction' import { useProduction } from '@/features/production/useProduction' import { getProductionSession } from '@/features/production/model' import AdvancedProduction from '@/features/production/components/AdvancedProduction.vue' import { expandSections } from '@/testing/naive' let wrapper: VueWrapper | undefined /** 从实际挂载的确认弹窗查找操作按钮。 */ function button(label: string) { return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)! } afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() for (const id of ['capability-test', 'new-project']) { Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' }) Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null }) } }) /** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */ function server() { const context = testProjectContext() const form = formFixture('capability-test') const readiness = { total: 1, ready: 1, skipped: 0, blocked: 0, inProgress: 0, stalePrimaryKeyframe: 0, items: [ { shotId: 'shot-1', shotNo: 1, episodeNo: 1, beatNo: 1, status: 'ready', issues: [] as { code: string; reason: string }[], primaryKeyframeId: 'keyframe-1', primaryKeyframeStale: false } ] } const receipt = { projectId: 'capability-test', completed: true, needsManualReview: false, stopReason: '', errors: ['一个提示词未生成'], videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 } } const fetcher = vi.fn().mockImplementation(async (url, init) => { const path = String(url) let data: unknown = context.project.value if (path.endsWith('/checkpoints')) data = context.checkpoints.value else if (path.endsWith('/subject-forms')) data = [form] else if (path.includes('/readiness')) data = readiness else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test') else if (path.endsWith('/videos/status')) data = { total: 1, completed: 0, queued: 0, running: 0, pending: 0, failed: 0, cancelled: 0, notStarted: 1, items: [] } else if (init?.method === 'POST') data = path.endsWith('/production/start') ? receipt : { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] } return new Response(JSON.stringify({ data })) }) vi.stubGlobal('fetch', fetcher) return { context, form, readiness, receipt, fetcher, posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST') } } async function advanced() { const data = server() let service!: ReturnType wrapper = mount( defineComponent({ setup() { service = useAdvancedProduction() return () => null } }), { global: { provide: { [projectContextKey as symbol]: data.context } } } ) await flushPromises() return { ...data, service } } describe('高级串联生产的安全边界', () => { it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => { const { service, fetcher, posts } = await advanced() expect(fetcher).not.toHaveBeenCalled() await service.start() expect(posts()).toHaveLength(0) await service.preflight() expect(service.check.value?.issues).toEqual([]) expect(posts()).toHaveLength(0) await service.start() expect(posts()).toHaveLength(1) expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start') expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({}) expect( fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true')) ).toHaveLength(2) expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成']) expect(service.check.value).toBeNull() }) it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => { const { service, readiness, posts } = await advanced() await service.preflight() readiness.items[0]!.primaryKeyframeStale = true await service.start() expect(posts()).toHaveLength(0) expect(service.check.value?.issues.join()).toContain('有效主首帧') expect(getOperation('capability-test').error).toContain('条件变化') }) it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => { const { context, form, readiness } = server() context.data.value!.project.status = 'need_review' form.images = [] readiness.inProgress = 1 const result = await checkPipeline('capability-test') expect(result.issues.join()).toContain('剧本尚未完成') expect(result.issues.join()).toContain('所有形态主图') expect(result.issues.join()).toContain('活动视频任务') }) it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => { const { readiness } = server() readiness.items[0]!.status = 'skipped' readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }] const result = await checkPipeline('capability-test') expect(result.issues.join()).toContain('视频前置检查未通过') }) it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => { const { service, context, posts } = await advanced() await service.preflight() getOperation('capability-test').pending = true await service.start() expect(posts()).toHaveLength(0) getOperation('capability-test').pending = false context.data.value!.project.status = 'failed' await service.start() expect(posts()).toHaveLength(0) context.data.value!.project.id = 'new-project' await flushPromises() expect(service.check.value).toBeNull() }) it('已提交的长请求只写回原项目的回执,不污染新项目', async () => { const { service, context, fetcher, receipt } = await advanced() await service.preflight() const original = fetcher.getMockImplementation()! let finish!: (value: Response) => void fetcher.mockImplementation((url, init) => String(url).endsWith('/production/start') ? new Promise(resolve => { finish = resolve }) : original(url, init) ) const pending = service.start() await flushPromises() context.data.value!.project.id = 'new-project' await flushPromises() finish(new Response(JSON.stringify({ data: receipt }))) await pending expect(service.session.value.pipelineReceipt).toBeNull() expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test') }) it('回执项目不匹配时不发布成功回执', async () => { const { service, receipt } = await advanced() await service.preflight() receipt.projectId = 'wrong' await service.start() expect(service.session.value.pipelineReceipt).toBeNull() expect(getOperation('capability-test').error).toContain('回执项目不匹配') }) it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => { const { context, receipt, posts } = server() wrapper = mount(AdvancedProduction, { attachTo: document.body, global: { provide: { [projectContextKey as symbol]: context } } }) await flushPromises() await expandSections() expect(button('启动串联生产').disabled).toBe(true) button('检查串联生产条件').click() await flushPromises() button('启动串联生产').click() await flushPromises() expect(button('确认启动串联生产').disabled).toBe(true) expect(posts()).toHaveLength(0) document.querySelector('.app-dialog [role="checkbox"]')!.click() await flushPromises() button('确认启动串联生产').click() await flushPromises() expect(posts()).toHaveLength(1) expect(document.body.textContent).toContain('流程返回不等于成片完成') expect(document.body.textContent).toContain(receipt.errors[0]) expect(document.body.textContent).toContain('已提交 1') }) }) describe('首帧批量参数', () => { it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => { const { context, posts } = server() let service!: ReturnType wrapper = mount( defineComponent({ setup() { service = useProduction() return () => null } }), { global: { provide: { [projectContextKey as symbol]: context } } } ) await flushPromises() service.keyframeLimit.value = 2 service.keyframeWidth.value = 2048 await service.run('keyframes') expect(posts()).toHaveLength(0) service.keyframeHeight.value = 2048 await service.run('keyframes') expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 2, force: false, episodeNo: 1, limit: 2, width: 2048, height: 2048 }) service.keyframeLimit.value = -1 await service.run('keyframes') expect(posts()).toHaveLength(1) await service.run('prompts') expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false }) service.keyframeLimit.value = '' service.keyframeWidth.value = '' service.keyframeHeight.value = '' service.force.value = true await flushPromises() await service.run('keyframes') expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true }) }) })