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

271 lines
11 KiB
TypeScript

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<typeof fetch>().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<typeof useAdvancedProduction>
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<HTMLElement>('.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<typeof useProduction>
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 })
})
})