345 lines
14 KiB
TypeScript
345 lines
14 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: false,
|
|
dispatchCompleted: true,
|
|
needsManualReview: false,
|
|
stopReason: '',
|
|
errors: ['一个提示词未生成'],
|
|
keyframeQualityResult: {
|
|
total: 1,
|
|
passed: 1,
|
|
skipped: 0,
|
|
blocked: 0,
|
|
repairFailed: 0,
|
|
failed: 0,
|
|
stalePrimaryKeyframe: 0
|
|
},
|
|
videoGenerationResult: { total: 1, created: 1, skipped: 0, inProgress: 0, blocked: 0, failed: 0 }
|
|
}
|
|
const stage = () => ({ total: 1, planned: 1, skipped: 0, upstreamResolvable: 0, manualBlocked: 0 })
|
|
const plan = {
|
|
projectId: 'capability-test',
|
|
completed: false,
|
|
stage: 'subject_images',
|
|
hasManualBlockers: false,
|
|
manualBlocked: 0,
|
|
subjectImages: stage(),
|
|
keyframes: stage(),
|
|
videoPrompts: stage(),
|
|
videos: stage(),
|
|
details: {
|
|
subjectImages: [
|
|
{
|
|
subjectFormId: form.id,
|
|
subjectRef: 'C1',
|
|
subjectName: '角色',
|
|
formName: '基础形态',
|
|
module: 'character',
|
|
status: 'ready',
|
|
primaryImageId: null,
|
|
primaryImageStale: false,
|
|
issues: [] as { code: string; reason: string }[]
|
|
}
|
|
],
|
|
keyframes: readiness.items,
|
|
videoPrompts: readiness.items,
|
|
videos: readiness.items
|
|
}
|
|
}
|
|
const status = {
|
|
projectId: 'capability-test',
|
|
completed: false,
|
|
stage: 'subject_images',
|
|
subjectImages: { total: 1, current: 0, missing: 1, stale: 0, complete: false, items: [] },
|
|
keyframes: readiness,
|
|
videos: readiness
|
|
}
|
|
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.endsWith('/production/plan')) data = plan
|
|
else if (path.endsWith('/production/status')) data = status
|
|
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,
|
|
plan,
|
|
status,
|
|
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('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交空参数', 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).endsWith('/production/plan'))).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.inProgress = 1
|
|
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()
|
|
form.images = []
|
|
readiness.items[0]!.primaryKeyframeStale = true
|
|
readiness.items[0]!.issues = [{ code: 'missing_reference', reason: '先补参考图' }]
|
|
expect((await checkPipeline('capability-test')).issues).toEqual([])
|
|
context.data.value!.project.status = 'need_review'
|
|
readiness.inProgress = 1
|
|
const result = await checkPipeline('capability-test')
|
|
expect(result.issues.join()).toContain('剧本尚未完成')
|
|
expect(result.issues.join()).toContain('活动视频任务')
|
|
})
|
|
it('人工阻塞保留后端原因,同时允许其他就绪形态先生成', async () => {
|
|
const { service, plan, posts } = await advanced()
|
|
plan.hasManualBlockers = true
|
|
plan.manualBlocked = 1
|
|
plan.keyframes.manualBlocked = 1
|
|
plan.details.keyframes[0]!.issues = [{ code: 'missing_identity_anchor', reason: '缺少身份母版' }]
|
|
await service.preflight()
|
|
expect(service.check.value?.issues).toEqual([])
|
|
expect(service.check.value?.plan.details.keyframes[0]?.issues[0]?.reason).toBe('缺少身份母版')
|
|
await service.start()
|
|
expect(posts()).toHaveLength(1)
|
|
})
|
|
it('没有可执行任务或真实资产已全部完成时不再启动', async () => {
|
|
const { plan, status } = server()
|
|
for (const stage of [plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos]) stage.planned = 0
|
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('没有可执行')
|
|
status.completed = true
|
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('制作已完成')
|
|
})
|
|
it('计划项目不匹配或镜头集合改变时拒绝采用旧计划', async () => {
|
|
const { plan } = server()
|
|
plan.details.videoPrompts = []
|
|
expect((await checkPipeline('capability-test')).issues.join()).toContain('资产列表不一致')
|
|
plan.projectId = 'other'
|
|
await expect(checkPipeline('capability-test')).rejects.toThrow('其他项目')
|
|
})
|
|
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')
|
|
expect(document.body.textContent).toContain('任务已分发,等待视频完成')
|
|
expect(document.body.textContent).toContain('校验通过 1')
|
|
expect(document.body.textContent).not.toContain('不会生成首帧')
|
|
})
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|