feat: 接入分镜导演设计与即时视觉状态工作台
增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。 补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
@@ -9,6 +9,16 @@ import ProjectsPage from '../projects/ProjectsPage.vue'
|
||||
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||
import type { ProjectDetail } from '../projects/types'
|
||||
import type { Checkpoint } from './types'
|
||||
import StoryboardPage from '../storyboard/StoryboardPage.vue'
|
||||
import ShotTools from '../storyboard/components/ShotTools.vue'
|
||||
import {
|
||||
designedShot,
|
||||
directionsResult,
|
||||
storyboardCheckpoint,
|
||||
visualStatesResult
|
||||
} from '../storyboard/testing/fixtures'
|
||||
import { getStoryboardSession } from '../storyboard/model'
|
||||
import { getOperation } from './operations'
|
||||
|
||||
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
|
||||
const fixture: ProjectDetail = {
|
||||
@@ -48,14 +58,365 @@ function button(label: string): HTMLButtonElement {
|
||||
return element
|
||||
}
|
||||
|
||||
/** 走真实确认弹窗,包括后端任务停止的显式确认。 */
|
||||
async function confirmGeneration(label: string) {
|
||||
button(label).click()
|
||||
await flushPromises()
|
||||
const acknowledgement = document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')
|
||||
expect(button('确认' + label).disabled).toBe(true)
|
||||
acknowledgement!.click()
|
||||
await flushPromises()
|
||||
button('确认' + label).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 挂载分镜页面并复用项目上下文 fixture。 */
|
||||
function mountStoryboard() {
|
||||
const provided = context()
|
||||
provided.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||
wrapper = mount(StoryboardPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||
})
|
||||
return provided
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('工作台页面交互', () => {
|
||||
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
|
||||
const shot = designedShot()
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
shotId: shot.shotId,
|
||||
references: [
|
||||
{
|
||||
shotSubjectId: 'binding-db',
|
||||
subjectId: 'subject-db',
|
||||
subjectRef: '@CH0001',
|
||||
subjectName: '林知夏',
|
||||
module: 'character',
|
||||
subjectFormId: 'form-db',
|
||||
subjectFormName: '日常',
|
||||
imageId: 'image-db',
|
||||
imageUrl: 'javascript:alert(1)'
|
||||
}
|
||||
],
|
||||
missing: []
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
projectId: fixture.id,
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
shotId: shot.shotId,
|
||||
shotNo: 1,
|
||||
title: '来信',
|
||||
description: '真实镜头',
|
||||
visualFocus: '信封',
|
||||
durationSeconds: 5,
|
||||
subjects: [{ subjectId: 'subject-db', subjectFormId: 'form-db' }],
|
||||
direction: shot.direction,
|
||||
environment: {
|
||||
timeOfDay: '夜晚',
|
||||
weather: '小雨',
|
||||
atmosphere: '安静',
|
||||
transientState: '地面积水'
|
||||
},
|
||||
continuityNote: '信封在右手'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, { attachTo: document.body, props: { projectId: fixture.id, shot, disabled: false } })
|
||||
button('读取参考图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('图片不可用')
|
||||
expect(wrapper.find('.reference-card a').exists()).toBe(false)
|
||||
expect(wrapper.find('.reference-card img').exists()).toBe(false)
|
||||
button('读取生成规格').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('pre').text()).toContain('form-db')
|
||||
expect(wrapper.get('pre').text()).toContain('地面积水')
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/generation-spec')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
|
||||
})
|
||||
|
||||
it('生成途中切换剧集并离开页面,不改变提交目标,也不丢失项目回执', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const episodeNo = Number(new URL(String(url), 'http://localhost').searchParams.get('episodeNo'))
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, episodeNo)
|
||||
: visualStatesResult(fixture.id, episodeNo)
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('生成本集导演设计')
|
||||
expect(button('补齐项目导演设计').disabled).toBe(true)
|
||||
await wrapper!.get('select').setValue('2')
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
wrapper = undefined
|
||||
finish(
|
||||
new Response(
|
||||
'{"data":{"episodeNo":1,"direction":{"episodeNo":1,"beatDirections":[]},"validation":{"valid":true,"issues":[]}}}'
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(getStoryboardSession(fixture.id).receipt).toMatchObject({
|
||||
kind: 'direction',
|
||||
episodeNo: 1,
|
||||
title: '第 1 集导演设计'
|
||||
})
|
||||
expect(getOperation(fixture.id).pending).toBe(false)
|
||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(JSON.parse(posts[0]![1]!.body as string)).toEqual({ episodeNo: 1, persist: true })
|
||||
})
|
||||
|
||||
it('分镜必须先补齐导演设计,校验失败显示未保存,修复次数 0 不被默认值覆盖', async () => {
|
||||
let complete = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
episodeNo: 1,
|
||||
visualState: {},
|
||||
validation: {
|
||||
valid: false,
|
||||
issues: [{ episodeNo: 1, beatNo: 1, shotNo: 1, message: '缺少主体状态' }]
|
||||
},
|
||||
repaired: false,
|
||||
repairAttempts: 0,
|
||||
remainingIssues: [],
|
||||
persisted: false
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, 1, complete)
|
||||
: visualStatesResult(fixture.id)
|
||||
return new Response(JSON.stringify({ data: result }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||
expect(button('生成本集即时状态').disabled).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
|
||||
complete = true
|
||||
button('刷新分镜').click()
|
||||
await flushPromises()
|
||||
expect(button('生成本集即时状态').disabled).toBe(false)
|
||||
await wrapper!.get('#storyboard-repairs').setValue('0')
|
||||
await confirmGeneration('生成本集即时状态')
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1, persist: true, maxRepairAttempts: 0 })
|
||||
expect(wrapper!.text()).toContain('校验失败,未保存本次生成结果')
|
||||
expect(wrapper!.text()).toContain('缺少主体状态')
|
||||
})
|
||||
|
||||
it('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
projectId: fixture.id,
|
||||
episodeCount: 2,
|
||||
completedEpisodes: 0,
|
||||
skippedEpisodes: 1,
|
||||
failedEpisodes: 1,
|
||||
shotCount: 4,
|
||||
directionCount: 2,
|
||||
coverage: 0.5,
|
||||
episodes: [
|
||||
{ episodeNo: 1, status: 'skipped', shotCount: 2, directionCount: 2 },
|
||||
{
|
||||
episodeNo: 2,
|
||||
status: 'failed',
|
||||
shotCount: 2,
|
||||
directionCount: 0,
|
||||
error: '模型校验失败'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('补齐项目导演设计')
|
||||
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
|
||||
expect(wrapper!.text()).toContain('模型校验失败')
|
||||
await wrapper!.get('input[type="checkbox"]').setValue(true)
|
||||
await confirmGeneration('重生成全部导演设计')
|
||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
|
||||
{ concurrency: 2, force: false },
|
||||
{ concurrency: 2, force: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('切换剧集取消旧查询,迟到响应不会覆盖新剧集,也不自动提交生成', async () => {
|
||||
const oldRequests: { finish: (response: Response) => void; url: string; signal: AbortSignal }[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
|
||||
const address = String(url)
|
||||
if (address.endsWith('episodeNo=1'))
|
||||
return new Promise(resolve =>
|
||||
oldRequests.push({ finish: resolve, url: address, signal: init!.signal as AbortSignal })
|
||||
)
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: address.includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, 2)
|
||||
: visualStatesResult(fixture.id, 2)
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await wrapper!.get('select[aria-label="选择分镜剧集"]').setValue('2')
|
||||
await flushPromises()
|
||||
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
|
||||
for (const request of oldRequests)
|
||||
request.finish(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: request.url.includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('shot-db-2-1')
|
||||
expect(wrapper!.text()).not.toContain('shot-db-1-1')
|
||||
expect(fetcher.mock.calls).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('查询失败保留已读取镜头并禁用生成,刷新恢复后才允许操作', async () => {
|
||||
let fail = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
if (fail) return new Response('{"message":"数据库暂不可用"}', { status: 503 })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
fail = true
|
||||
button('刷新分镜').click()
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('shot-db-1-1')
|
||||
expect(wrapper!.text()).toContain('数据库暂不可用')
|
||||
expect(button('生成本集导演设计').disabled).toBe(true)
|
||||
fail = false
|
||||
button('重试查询').click()
|
||||
await flushPromises()
|
||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('镜头工具按需 GET,切换镜头会废弃旧参考图,未完成设计不能编译规格', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
|
||||
})
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
button('读取参考图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/references')
|
||||
await wrapper.setProps({ shot: { ...designedShot('shot-db-1-2'), visualState: null } })
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
finish(
|
||||
new Response(
|
||||
'{"data":{"shotId":"shot-db-1-1","references":[],"missing":[{"subjectId":"x","subjectName":"旧镜头人物","reason":"缺图"}]}}'
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('旧镜头人物')
|
||||
expect(button('读取生成规格').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('提示词必须确认才 POST,使用正式 Shot ID,返回内容以纯文本显示', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: 'shot-db-1-1',
|
||||
videoPrompt: '<script>模型内容</script>',
|
||||
negativePrompt: '模糊',
|
||||
status: 'prompt_ready'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
|
||||
})
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await confirmGeneration('读取/生成提示词')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/video-prompt')
|
||||
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({ force: false })
|
||||
expect(wrapper.text()).toContain('<script>模型内容</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
})
|
||||
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
Reference in New Issue
Block a user