对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。 整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。 补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。 未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
1005 lines
48 KiB
TypeScript
1005 lines
48 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import CreateProjectDialog from '../projects/components/CreateProjectDialog.vue'
|
|
import BreakdownPage from '../breakdown/BreakdownPage.vue'
|
|
import CreateDramaPage from '../create-drama/CreateDramaPage.vue'
|
|
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'
|
|
import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue'
|
|
import { formFixture, imageFixture } from '../subject-images/testing/fixtures'
|
|
import { getImageSession } from '../subject-images/model'
|
|
import VisualStylePage from '../visual-style/VisualStylePage.vue'
|
|
import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
|
|
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
|
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
|
import { getIdentitySession } from '../subject-identity/model'
|
|
|
|
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
|
|
async function mountAssets(page: 'style' | 'identity', query = '') {
|
|
const component = page === 'style' ? VisualStylePage : SubjectIdentityPage
|
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/assets', component }] })
|
|
await router.push('/assets' + query)
|
|
const provided = context()
|
|
wrapper = mount(component, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
|
|
})
|
|
await flushPromises()
|
|
return provided
|
|
}
|
|
|
|
/** 统一构造独立响应,避免复用已消费的 Response body。 */
|
|
function jsonResponse(data: unknown) {
|
|
return new Response(JSON.stringify({ data }))
|
|
}
|
|
|
|
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
|
|
const fixture: ProjectDetail = {
|
|
id: 'page-test-project',
|
|
title: '雨夜来信',
|
|
topic: '一封信改变了两个人的命运',
|
|
style: '都市悬疑',
|
|
status: 'completed',
|
|
createdAt: '2026-08-27T00:00:00Z',
|
|
updatedAt: '2026-08-27T00:00:00Z',
|
|
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\n第一场:旧书店。' }],
|
|
characters: [{ id: 'character', name: '林知夏' }],
|
|
world: { era: '当代' },
|
|
reviews: [],
|
|
tasks: []
|
|
}
|
|
let wrapper: VueWrapper | undefined
|
|
|
|
/** 使用真实上下文形状,不绕过页面内的异步操作与按钮守卫。 */
|
|
function context(): ReturnType<typeof useProjectData> {
|
|
const data = ref({ project: fixture, checkpoints: [] as Checkpoint[] })
|
|
return {
|
|
data,
|
|
project: computed(() => data.value.project),
|
|
checkpoints: computed(() => data.value.checkpoints),
|
|
loading: ref(false),
|
|
error: ref(''),
|
|
updatedAt: ref(''),
|
|
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
|
|
}
|
|
}
|
|
|
|
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
|
|
function button(label: string): HTMLButtonElement {
|
|
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
|
if (!element) throw new Error('找不到按钮:' + label)
|
|
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
|
|
}
|
|
|
|
/** 形态图库使用真实路由解析来自主体列表的筛选参数。 */
|
|
async function mountSubjectImages() {
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [{ path: '/projects/:projectId/:workspace', component: SubjectImagesPage }]
|
|
})
|
|
await router.push(`/projects/${fixture.id}/subject-images`)
|
|
wrapper = mount(SubjectImagesPage, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
|
|
})
|
|
await flushPromises()
|
|
}
|
|
|
|
afterEach(() => {
|
|
getIdentitySession(fixture.id).receipt = null
|
|
wrapper?.unmount()
|
|
wrapper = undefined
|
|
document.body.innerHTML = ''
|
|
vi.unstubAllGlobals()
|
|
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
|
|
getImageSession(fixture.id).receipt = null
|
|
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
|
})
|
|
|
|
describe('视觉风格与主体身份工作区', () => {
|
|
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
|
const form = formFixture()
|
|
const second = {
|
|
...form,
|
|
id: 'form-2',
|
|
subjectId: 'subject-2',
|
|
images: [],
|
|
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
|
}
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
return jsonResponse(null)
|
|
})
|
|
)
|
|
await mountAssets('identity', '?subjectId=subject-db-1')
|
|
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
|
await flushPromises()
|
|
button('刷新主体目录').click()
|
|
await flushPromises()
|
|
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
|
|
})
|
|
|
|
it('身份文本保存发送正式主体 ID 与锁定状态,草稿切换需确认', async () => {
|
|
let identity = identityFixture()
|
|
const form = formFixture()
|
|
const second = {
|
|
...form,
|
|
id: 'form-2',
|
|
subjectId: 'subject-2',
|
|
images: [],
|
|
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
|
}
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
|
if (String(url).includes('/subject-2/')) return jsonResponse(null)
|
|
if (init?.method === 'PUT') identity = { ...identity, ...JSON.parse(init.body as string) }
|
|
return jsonResponse(identity)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
await wrapper!.get('#identity-description').setValue('人工稳定身份')
|
|
await wrapper!.get('#identity-lock').setValue(true)
|
|
button('保存主体身份').click()
|
|
await flushPromises()
|
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
|
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
|
|
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
|
|
expect(button('AI 重新生成身份').disabled).toBe(true)
|
|
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
|
|
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
|
expect(wrapper!.text()).toContain('切换会丢弃草稿')
|
|
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('新的未保存草稿')
|
|
button('放弃草稿并切换').click()
|
|
await flushPromises()
|
|
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('')
|
|
})
|
|
|
|
it('风格图拒绝危险 URL,登记只传已有地址及分类排序', async () => {
|
|
const image = styleImageFixture()
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async (_url, init) => jsonResponse(init?.method === 'POST' ? image : styleFixture()))
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('style')
|
|
await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)')
|
|
expect(button('登记参考图').disabled).toBe(true)
|
|
await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png')
|
|
button('登记参考图').click()
|
|
await flushPromises()
|
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
expect(post[0]).toBe('/api/projects/page-test-project/visual-style/images')
|
|
expect(JSON.parse(post[1]!.body as string)).toEqual({
|
|
imageUrl: '/storage/style.png',
|
|
category: 'overall',
|
|
source: 'upload',
|
|
enabled: true,
|
|
sortOrder: 0
|
|
})
|
|
expect((wrapper!.get('[aria-label="风格图片地址"]').element as HTMLInputElement).value).toBe('')
|
|
})
|
|
|
|
it('AI 确认取消后重新打开必须再次确认,不能复用上次勾选', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(styleFixture()))
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('style')
|
|
button('AI 重新生成风格').click()
|
|
await flushPromises()
|
|
document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')!.click()
|
|
await flushPromises()
|
|
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
|
button('取消').click()
|
|
await flushPromises()
|
|
button('AI 重新生成风格').click()
|
|
await flushPromises()
|
|
expect(button('确认AI 重新生成风格').disabled).toBe(true)
|
|
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
|
})
|
|
it('视觉风格首次查询为空时可人工保存,JSON 校验和锁定字段准确传递', async () => {
|
|
let style: ReturnType<typeof styleFixture> | null = null
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
|
if (init?.method === 'PUT') style = styleFixture(JSON.parse(init.body as string))
|
|
return jsonResponse(style)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('style')
|
|
expect(wrapper!.text()).toContain('尚未创建')
|
|
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
|
expect(button('保存视觉风格').disabled).toBe(true)
|
|
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
|
await wrapper!.get('#style-lock').setValue(true)
|
|
await wrapper!.get('#style-name').setValue('雨夜风格')
|
|
button('保存视觉风格').click()
|
|
await flushPromises()
|
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
|
expect(put[0]).toBe('/api/projects/page-test-project/visual-style')
|
|
expect(JSON.parse(put[1]!.body as string)).toMatchObject({
|
|
name: '雨夜风格',
|
|
isLocked: true,
|
|
hardConstraints: ['真人写实']
|
|
})
|
|
expect(button('AI 重新生成风格').disabled).toBe(true)
|
|
expect(button('保存视觉风格').disabled).toBe(false)
|
|
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
|
})
|
|
|
|
it('刷新风格不覆盖未保存草稿,草稿存在时禁止 AI 重生成', async () => {
|
|
let style = styleFixture()
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(style))
|
|
)
|
|
await mountAssets('style')
|
|
await wrapper!.get('#style-prompt').setValue('人工草稿')
|
|
style = styleFixture({ prompt: '后台新内容' })
|
|
button('刷新风格').click()
|
|
await flushPromises()
|
|
expect((wrapper!.get('#style-prompt').element as HTMLTextAreaElement).value).toBe('人工草稿')
|
|
expect(button('AI 重新生成风格').disabled).toBe(true)
|
|
})
|
|
|
|
it('风格接口 404 显示错误而不是当作空风格,禁止写入', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn<typeof fetch>().mockResolvedValue(new Response('{"message":"路由不存在"}', { status: 404 }))
|
|
)
|
|
await mountAssets('style')
|
|
expect(wrapper!.text()).toContain('路由不存在')
|
|
expect(button('AI 生成风格').disabled).toBe(true)
|
|
expect(wrapper!.find('#style-prompt').exists()).toBe(false)
|
|
})
|
|
|
|
it('风格图启停用 PUT,移除经确认用 DELETE,均不调用生图接口', async () => {
|
|
let image = styleImageFixture()
|
|
let removed = false
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (init?.method === 'PUT') {
|
|
image = { ...image, enabled: false }
|
|
return jsonResponse(image)
|
|
}
|
|
if (init?.method === 'DELETE') {
|
|
removed = true
|
|
return jsonResponse(image)
|
|
}
|
|
expect(String(url)).toContain('/visual-style')
|
|
return jsonResponse(styleFixture({ images: removed ? [] : [image] }))
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('style')
|
|
button('停用').click()
|
|
await flushPromises()
|
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
|
expect(put[0]).toBe('/api/projects/page-test-project/visual-style/images/style-image-1/enabled')
|
|
expect(JSON.parse(put[1]!.body as string)).toEqual({ enabled: false })
|
|
button('移除').click()
|
|
await flushPromises()
|
|
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
|
|
button('确认移除').click()
|
|
await flushPromises()
|
|
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'DELETE')?.[0]).toBe(
|
|
'/api/projects/page-test-project/visual-style/images/style-image-1'
|
|
)
|
|
expect(wrapper!.text()).toContain('尚无风格参考图')
|
|
})
|
|
|
|
it('主体目录按正式 Subject ID 去重,无 Identity 时不请求会报错的图片接口', async () => {
|
|
const form = formFixture()
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
if (String(url).endsWith('/subject-forms'))
|
|
return jsonResponse([form, { ...form, id: 'form-db-2', images: [], name: '雨夜造型' }])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
return jsonResponse(null)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
expect(wrapper!.findAll('.identity-subject-item')).toHaveLength(1)
|
|
expect(wrapper!.text()).toContain('2 个形态')
|
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
|
|
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
|
|
expect(button('生成身份参考图').disabled).toBe(true)
|
|
expect(button('AI 生成身份').disabled).toBe(false)
|
|
})
|
|
|
|
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
|
|
if (init?.method === 'POST') return jsonResponse(identityImageFixture())
|
|
if (String(url).endsWith('/identity/images')) return jsonResponse([identityImageFixture()])
|
|
return jsonResponse(identityFixture({ isLocked: true }))
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
expect(button('AI 重新生成身份').disabled).toBe(true)
|
|
expect(button('保存主体身份').disabled).toBe(false)
|
|
button('生成身份参考图').click()
|
|
await flushPromises()
|
|
expect(button('确认生成身份图').disabled).toBe(true)
|
|
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
|
await flushPromises()
|
|
button('确认生成身份图').click()
|
|
await flushPromises()
|
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'primary' })
|
|
})
|
|
|
|
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(null)
|
|
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
|
return jsonResponse(identityFixture())
|
|
})
|
|
)
|
|
await mountAssets('identity')
|
|
expect(button('AI 重新生成身份').disabled).toBe(true)
|
|
expect(button('生成身份参考图').disabled).toBe(true)
|
|
expect(button('保存主体身份').disabled).toBe(false)
|
|
})
|
|
|
|
it('身份批量生成区分锁定跳过和部分失败,不将 HTTP 200 当作全部成功', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (init?.method === 'POST')
|
|
return jsonResponse({
|
|
total: 3,
|
|
targetCount: 2,
|
|
generated: 1,
|
|
skipped: 1,
|
|
skippedLocked: 1,
|
|
failed: 1,
|
|
failures: [{ subjectId: 'failed-db', subjectRef: '@CH0003', error: '文本模型超时' }]
|
|
})
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
return jsonResponse(null)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
await confirmGeneration('补齐项目身份文本')
|
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
|
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
|
|
expect(wrapper!.text()).toContain('部分主体生成失败')
|
|
expect(wrapper!.text()).toContain('含锁定 1')
|
|
expect(wrapper!.text()).toContain('文本模型超时')
|
|
})
|
|
|
|
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
|
|
let selected = false
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
if (init?.method === 'PUT') {
|
|
selected = true
|
|
return jsonResponse(identityImageFixture({ id: 'candidate' }))
|
|
}
|
|
if (String(url).endsWith('/identity/images'))
|
|
return jsonResponse([identityImageFixture({ id: 'candidate', enabled: selected, isAnchor: selected })])
|
|
return jsonResponse(identityFixture())
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
button('设为身份母版').click()
|
|
await flushPromises()
|
|
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
|
|
button('确认切换身份母版').click()
|
|
await flushPromises()
|
|
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
|
|
'/api/subjects/subject-db-1/identity/images/candidate/anchor'
|
|
)
|
|
expect(wrapper!.text()).toContain('当前身份母版')
|
|
})
|
|
|
|
it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => {
|
|
let finish!: (value: Response) => void
|
|
const form = formFixture()
|
|
const second = {
|
|
...form,
|
|
id: 'form-2',
|
|
subjectId: 'subject-2',
|
|
images: [],
|
|
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
|
|
}
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
const path = String(url)
|
|
if (path.endsWith('/subject-forms')) return jsonResponse([form, second])
|
|
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
if (path.endsWith('/subject-db-1/identity'))
|
|
return new Promise(resolve => {
|
|
finish = resolve
|
|
})
|
|
if (path.endsWith('/identity/images')) return jsonResponse([])
|
|
return jsonResponse(
|
|
identityFixture({ id: 'identity-2', subjectId: 'subject-2', description: '陆川的稳定身份' })
|
|
)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountAssets('identity')
|
|
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
|
|
await flushPromises()
|
|
expect(
|
|
fetcher.mock.calls.find(([url]) => String(url).endsWith('/subject-db-1/identity'))?.[1]?.signal?.aborted
|
|
).toBe(true)
|
|
finish(jsonResponse(identityFixture()))
|
|
await flushPromises()
|
|
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('陆川的稳定身份')
|
|
})
|
|
|
|
it('生成失败不自动重发,后端正在拆解时禁止身份写操作', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
|
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
|
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
|
|
if (String(url).endsWith('/identity/images')) return jsonResponse([])
|
|
if (init?.method === 'POST') throw new Error('connection lost')
|
|
return jsonResponse(identityFixture())
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
const provided = await mountAssets('identity')
|
|
await confirmGeneration('AI 重新生成身份')
|
|
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
|
|
expect(getOperation(fixture.id).error).toContain('无法连接后端')
|
|
provided.data.value!.project = { ...fixture, status: 'generating' }
|
|
await flushPromises()
|
|
expect(button('保存主体身份').disabled).toBe(true)
|
|
expect(button('生成身份参考图').disabled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('工作台页面交互', () => {
|
|
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () => new Response(JSON.stringify({ data: [formFixture()] })))
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountSubjectImages()
|
|
expect(wrapper!.get('.form-image-card img').attributes('src')).toContain(
|
|
'/storage/subjects/project/form/image.png'
|
|
)
|
|
expect(wrapper!.text()).toContain('林知夏')
|
|
expect(wrapper!.text()).toContain('主参考图')
|
|
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
|
|
['/api/projects/page-test-project/subject-forms', 'GET']
|
|
])
|
|
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态')
|
|
expect(wrapper!.text()).toContain('没有匹配的形态')
|
|
button('清除筛选').click()
|
|
await flushPromises()
|
|
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
|
})
|
|
|
|
it('形态单图确认后发送正式 ID 并刷新候选图,不会调用 production/start', async () => {
|
|
const form = formFixture()
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
|
if (init?.method === 'POST') {
|
|
const candidate = imageFixture({ id: 'new-candidate', isPrimary: false, imageUrl: '/storage/new.png' })
|
|
form.images.unshift(candidate)
|
|
return new Response(JSON.stringify({ data: candidate }))
|
|
}
|
|
return new Response(JSON.stringify({ data: [form] }))
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountSubjectImages()
|
|
button('再生成一张').click()
|
|
await flushPromises()
|
|
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
|
await flushPromises()
|
|
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
await flushPromises()
|
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
|
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', setPrimary: false })
|
|
expect(wrapper!.text()).toContain('2 条记录')
|
|
expect(wrapper!.get('.form-image-card img').attributes('src')).not.toContain('/storage/new.png')
|
|
expect(fetcher.mock.calls.some(([url]) => String(url).includes('production/start'))).toBe(false)
|
|
})
|
|
|
|
it('批量生图保留部分失败回执,默认跳过有主图项,筛选不会成为批量参数', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
|
async (_url, init) =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data:
|
|
init?.method === 'POST'
|
|
? {
|
|
total: 2,
|
|
targetCount: 1,
|
|
generated: 0,
|
|
skipped: 1,
|
|
failed: 1,
|
|
failures: [{ subjectFormId: 'form-db-2', error: '服务暂不可用' }]
|
|
}
|
|
: [formFixture()]
|
|
})
|
|
)
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountSubjectImages()
|
|
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('@CH0001')
|
|
await confirmGeneration('补齐项目主参考图')
|
|
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
|
|
expect(wrapper!.text()).toContain('服务暂不可用')
|
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
|
|
await wrapper!.get('.panel details input[type="checkbox"]').setValue(true)
|
|
await confirmGeneration('为全项目新增候选图')
|
|
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
|
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
|
|
})
|
|
|
|
it('后端持久化生成中或查询失败时暂停生图,旧图库仍可查看', async () => {
|
|
let fail = false
|
|
const form = formFixture()
|
|
form.images.unshift(imageFixture({ id: 'running', isPrimary: false, status: 'generating', imageUrl: null }))
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockImplementation(async () =>
|
|
fail
|
|
? new Response('{"message":"数据库异常"}', { status: 500 })
|
|
: new Response(JSON.stringify({ data: [form] }))
|
|
)
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await mountSubjectImages()
|
|
expect(button('再生成一张').disabled).toBe(true)
|
|
expect(wrapper!.text()).toContain('数据库中仍有排队或生成中的图片')
|
|
fail = true
|
|
button('刷新图库').click()
|
|
await flushPromises()
|
|
expect(wrapper!.text()).toContain('数据库异常')
|
|
expect(wrapper!.find('.form-image-card img').exists()).toBe(true)
|
|
expect(button('补齐项目主参考图').disabled).toBe(true)
|
|
})
|
|
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',
|
|
vi.fn<typeof fetch>().mockImplementation(async () => new Response(JSON.stringify({ data: [fixture] })))
|
|
)
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [{ path: '/', component: ProjectsPage }]
|
|
})
|
|
await router.push('/')
|
|
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('雨夜来信')
|
|
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
|
|
expect(wrapper.text()).toContain('没有匹配的剧本')
|
|
button('清除筛选').click()
|
|
await flushPromises()
|
|
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
|
})
|
|
|
|
it('新建弹窗提交真实参数并返回 202 的项目 ID', async () => {
|
|
const fetcher = vi
|
|
.fn<typeof fetch>()
|
|
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
|
|
vi.stubGlobal('fetch', fetcher)
|
|
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
|
|
button('新建剧本').click()
|
|
await flushPromises()
|
|
const topic = document.querySelector<HTMLTextAreaElement>('#topic')!
|
|
topic.value = ' 雨夜来信 '
|
|
topic.dispatchEvent(new Event('input', { bubbles: true }))
|
|
const count = document.querySelector<HTMLInputElement>('#episode-count')!
|
|
count.value = '6'
|
|
count.dispatchEvent(new Event('input', { bubbles: true }))
|
|
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
await flushPromises()
|
|
expect(wrapper.emitted('created')).toEqual([['created']])
|
|
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({
|
|
topic: '雨夜来信',
|
|
style: '爽文反转',
|
|
episodeCount: 6
|
|
})
|
|
})
|
|
|
|
it('修改每组集数会废弃预览,重新预览并确认后才能启动', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
if (String(url).includes('breakdown-preview'))
|
|
return new Response(
|
|
'{"data":{"episodeCount":1,"groupCount":1,"estimatedTaskCount":3,"groups":[],"tasks":[],"modules":["character","scene","prop"],"groupSize":2}}'
|
|
)
|
|
return new Response('{"data":{"workflowExecution":{"status":"completed"}}}')
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
const provided = context()
|
|
wrapper = mount(BreakdownPage, {
|
|
attachTo: document.body,
|
|
global: { provide: { [projectContextKey as symbol]: provided } }
|
|
})
|
|
button('预览分组').click()
|
|
await flushPromises()
|
|
expect(button('开始拆解').disabled).toBe(false)
|
|
await wrapper.get('#group-size').setValue('2')
|
|
expect(document.body.textContent).not.toContain('开始拆解')
|
|
button('预览分组').click()
|
|
await flushPromises()
|
|
button('开始拆解').click()
|
|
await flushPromises()
|
|
button('确认开始拆解').click()
|
|
await flushPromises()
|
|
const post = fetcher.mock.calls.find(call => call[1]?.method === 'POST')
|
|
expect(post?.[0]).toBe('/api/projects/page-test-project/breakdown/start')
|
|
expect(JSON.parse(post![1]!.body as string)).toEqual({ groupSize: 2, modules: ['character', 'scene', 'prop'] })
|
|
expect(provided.refresh).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
|
|
wrapper = mount(CreateDramaPage, {
|
|
attachTo: document.body,
|
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
|
})
|
|
expect(wrapper.find('.script-body').text()).toContain('<script>不要执行模型内容</script>')
|
|
expect(wrapper.find('.script-body script').exists()).toBe(false)
|
|
})
|
|
})
|