Files
short-drama-agent-front/tests/features/workflows/pages.test.ts
T

1549 lines
77 KiB
TypeScript

import { computed, ref } from 'vue'
import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
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 '@/features/projects/components/CreateProjectDialog.vue'
import BreakdownPage from '@/features/breakdown/BreakdownPage.vue'
import CreateDramaPage from '@/features/create-drama/CreateDramaPage.vue'
import ProjectsPage from '@/features/projects/ProjectsPage.vue'
import { projectContextKey, type useProjectData } from '@/features/projects/context'
import type { ProjectDetail } from '@/features/projects/types'
import type { Checkpoint } from '@/features/workflows/types'
import StoryboardPage from '@/features/storyboard/StoryboardPage.vue'
import ShotTools from '@/features/storyboard/components/ShotTools.vue'
import {
designedShot,
directionsResult,
episodeShots,
storyboardCheckpoint,
visualStatesResult
} from '@/features/storyboard/testing/fixtures'
import { getStoryboardSession } from '@/features/storyboard/model'
import { getOperation } from '@/features/workflows/operations'
import { readAllStyles } from '@/testing/styles'
import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
import { getImageSession } from '@/features/subject-images/model'
import VisualStylePage from '@/features/visual-style/VisualStylePage.vue'
import SubjectIdentityPage from '@/features/subject-identity/SubjectIdentityPage.vue'
import { styleFixture, styleImageFixture } from '@/features/visual-style/testing/fixtures'
import { identityFixture, identityImageFixture } from '@/features/subject-identity/testing/fixtures'
import { getIdentitySession } from '@/features/subject-identity/model'
import { drawerPanel, expandSections, selectControl, selectMenu } from '@/testing/naive'
import ProductionPage from '@/features/production/ProductionPage.vue'
import { getProductionSession } from '@/features/production/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()
if (page === 'identity') await expandSections()
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: '当代' }
}
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 的 Naive 弹窗按钮。 */
function button(label: string): HTMLButtonElement {
const element = [...document.querySelectorAll('button')].find(
item => item.textContent?.trim() === label || item.getAttribute('aria-label') === label
)
if (!element) throw new Error('找不到按钮:' + label)
return element
}
/** 走真实确认弹窗,包括后端任务停止的显式确认。 */
async function confirmGeneration(label: string) {
button(label).click()
await flushPromises()
const acknowledgement = document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')
expect(button('确认' + label).disabled).toBe(true)
acknowledgement!.click()
await flushPromises()
button('确认' + label).click()
await flushPromises()
}
/** 挂载分镜页面并复用项目上下文 fixture。 */
async 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 } }
})
await flushPromises()
await expandSections()
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()
await expandSections()
}
afterEach(() => {
getProductionSession(fixture.id).receipt = null
getProductionSession(fixture.id).pipelineReceipt = null
getIdentitySession(fixture.id).receipt = null
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {}, regeneratedEpisodes: {} })
getImageSession(fixture.id).receipt = null
getImageSession(fixture.id).promptReceipt = null
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
})
describe('内容级滚动下的生产反馈', () => {
it('生产目录和资产详情由两个独立 NScrollbar 接管,切镜头不会新增生产请求', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/storyboard-directions')) return jsonResponse(directionsResult(fixture.id))
if (path.includes('/readiness') || path.endsWith('/videos/status'))
return jsonResponse({
total: 2,
ready: 0,
skipped: 0,
blocked: 0,
completed: 0,
queued: 0,
pending: 0,
running: 0,
failed: 0,
cancelled: 0,
notStarted: 2,
items: []
})
return jsonResponse([])
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ProductionPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
const directory = wrapper.get('.production-shot-list')
const detail = wrapper.get('.production-detail')
expect(directory.getComponent(NScrollbar).props('xScrollable')).toBe(true)
expect(detail.get('.panel-scroll').classes()).toContain('n-scrollbar')
expect(directory.get('.n-scrollbar-container').element).not.toBe(detail.get('.n-scrollbar-container').element)
const items = directory.findAll('.production-shot-link')
expect(items).toHaveLength(2)
expect(directory.findAll('.beat-directory-group')).toHaveLength(2)
expect(directory.findAll('.directory-group-count').map(item => item.text())).toEqual(['1 镜', '1 镜'])
expect(items[0]!.get('.directory-shot-number').text()).toBe('镜头 01')
expect(directory.get('.directory-heading').text()).toContain('生产镜头')
expect(directory.get('.directory-heading').element.closest('.n-scrollbar-container')).toBeNull()
expect(items[0]!.find('.directory-item-title').exists()).toBe(true)
expect(items[0]!.find('.directory-item-meta .directory-status').exists()).toBe(true)
await items[1]!.trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-current')).toBe('true')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('默认折叠总览时查询错误仍显示在内容区外层', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断')))
wrapper = mount(ProductionPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
const alert = wrapper.get('.workspace-split [role="alert"]')
expect(alert.element.closest('.workspace-tools-drawer')).toBeNull()
expect(alert.text()).toContain('无法连接后端')
})
it('回执返回时自动展开总览,部分失败诊断不会留在折叠区内', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('离线测试')))
wrapper = mount(ProductionPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
await flushPromises()
getProductionSession(fixture.id).receipt = {
kind: 'retry',
title: '重试失败视频任务',
result: {
totalFailed: 1,
retryable: 1,
skipped: 0,
retried: 0,
failed: 1,
failures: [{ shotId: 'shot-1', error: '供应商不可用' }]
}
}
await flushPromises()
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
expect(drawerPanel().find('.n-collapse-item--active').exists()).toBe(true)
expect(drawerPanel().text()).toContain('供应商不可用')
})
})
describe('视觉风格与主体身份工作区', () => {
it.each([
{ locked: null, label: '尚未创建', action: 'AI 生成风格', disabled: false },
{ locked: false, label: '未锁定', action: 'AI 重新生成风格', disabled: false },
{ locked: true, label: '已锁定', action: 'AI 重新生成风格', disabled: true }
])('视觉风格 $label 使用独立状态说明与等高操作按钮,保留锁定限制', async ({ locked, label, action, disabled }) => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () => jsonResponse(locked === null ? null : styleFixture({ isLocked: locked })))
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
const status = wrapper!.get('.visual-style-status')
expect(status.attributes('role')).toBe('status')
expect(status.get('strong').text()).toBe(label)
expect(status.find('button').exists()).toBe(false)
expect(wrapper!.find('.visual-style-toolbar .n-tag').exists()).toBe(false)
expect(wrapper!.get('.visual-style-actions').findAll('button')).toHaveLength(2)
expect(button(action).disabled).toBe(disabled)
expect(button(action).style.getPropertyValue('--n-height')).toBe('34px')
expect(button('刷新风格').style.getPropertyValue('--n-height')).toBe('34px')
expect(fetcher.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
})
it('主体目录显示已确认母版和默认占位,点缩略图只切换主体,不重复读取所有角色图库', async () => {
const first = formFixture()
const forms = [
first,
...[2, 3].map(index => ({
...first,
id: `form-${index}`,
subjectId: `subject-${index}`,
images: [],
subject: { ...first.subject, id: `subject-${index}`, ref: `@CH000${index}`, name: `角色${index}` }
}))
]
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse(forms)
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 3,
ready: 2,
missingIdentity: 0,
missingAnchor: 1,
candidatePending: 0,
unlocked: 0,
locked: 2,
items: forms.map((form, index) => ({
subjectId: form.subjectId,
subjectRef: form.subject.ref,
subjectName: form.subject.name,
status: index === 2 ? 'missing_anchor' : 'ready',
isLocked: index !== 2,
anchorImageId: index === 2 ? undefined : `anchor-${index}`,
anchorImageUrl: index === 2 ? undefined : `/storage/anchor-${index}.png`,
candidateImages: [{ id: 'candidate', imageUrl: '/storage/candidate.png' }]
}))
})
const second = path.includes('/subject-2/')
if (path.endsWith('/identity/images'))
return jsonResponse([
identityImageFixture({
identityId: second ? 'identity-2' : 'identity-db-1',
imageUrl: `/storage/anchor-${second ? 1 : 0}.png`
})
])
return jsonResponse(
identityFixture({
subjectId: second ? 'subject-2' : first.subjectId,
id: second ? 'identity-2' : 'identity-db-1'
})
)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
const items = wrapper!.findAll('.identity-subject-item')
expect(items).toHaveLength(3)
expect(items[0]!.get('.identity-thumbnail img').attributes('src')).toContain('/storage/anchor-0.png')
expect(items[1]!.get('.identity-thumbnail img').attributes('src')).toContain('/storage/anchor-1.png')
expect(items[2]!.get('.identity-thumbnail [role="img"]').attributes('aria-label')).toContain('暂无母版')
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/identity/images'))).toHaveLength(1)
await items[1]!.get('.identity-thumbnail img').trigger('click')
await flushPromises()
expect(items[1]!.attributes('aria-pressed')).toBe('true')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
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')
const sidebar = wrapper!.get('.identity-directory')
expect(sidebar.get('.directory-filters').element.closest('.n-scrollbar-container')).toBeNull()
expect(sidebar.get('.identity-subject-list').findAll('.directory-item')).toHaveLength(2)
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
await flushPromises()
await wrapper!.get('[aria-label="刷新主体目录"]').trigger('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').trigger('click')
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)')
button('登记参考图').click()
await flushPromises()
expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('请填写有效的')
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
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<HTMLElement>('.app-dialog [role="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 expandSections()
expect(wrapper!.text()).toContain('默认采用中国人物选角基线')
expect(wrapper!.text()).toContain('人物风格与选角背景')
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
button('保存视觉风格').click()
await flushPromises()
expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('JSON 字符串数组')
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
await wrapper!.get('#style-lock').trigger('click')
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({ viewType: 'front' })
})
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(drawerPanel().text()).toContain('本次存在部分失败')
expect(drawerPanel().text()).toContain('含锁定 1')
expect(drawerPanel().text()).toContain('文本模型超时')
})
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
let selected = false
const form = formFixture()
form.subject.module = 'scene'
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([form])
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', 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('当前身份母版')
const anchorImage = wrapper!
.findAllComponents(NImage)
.find(image => image.props('alt')?.endsWith('当前身份母版'))!
expect(anchorImage.props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
})
it('选角进度卡片分层显示长姓名、编号和状态,点击仍按正式 ID 切换主体', async () => {
const longName = '来自记忆当铺的另一条时间线中尚未丢失记忆的林默'
const items = [
{
subjectId: 'subject-db-1',
subjectRef: '@CH0001',
subjectName: longName,
status: 'ready',
isLocked: true
},
{
subjectId: 'subject-db-2',
subjectRef: '@CH0002',
subjectName: '老周',
status: 'candidate_pending',
isLocked: false
}
]
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 2,
ready: 1,
missingIdentity: 0,
missingAnchor: 0,
candidatePending: 1,
unlocked: 0,
locked: 1,
items
})
if (path.endsWith('/identity/images')) return jsonResponse([])
const second = path.includes('/subject-db-2/')
return jsonResponse(
identityFixture({
subjectId: second ? 'subject-db-2' : 'subject-db-1',
description: second ? '老周的稳定身份' : '林默的稳定身份'
})
)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(drawerPanel().get('.identity-tools-intro').text()).toContain('为同一个人物、场景或道具固定稳定特征')
const cards = drawerPanel().get('[aria-label="角色选角进度"]').findAll('.casting-item')
expect(cards).toHaveLength(2)
expect(cards[0]!.get('.casting-item-name').text()).toBe(longName)
expect(cards[0]!.get('.casting-item-ref').text()).toBe('@CH0001')
expect(cards[0]!.get('.casting-item-status').text()).toBe('选角已完成')
expect(cards[0]!.find('.truncate').exists()).toBe(false)
expect(cards[0]!.attributes('aria-pressed')).toBe('true')
expect(cards[1]!.get('.casting-item-status').text()).toBe('等待确认演员')
await cards[1]!.trigger('click')
await flushPromises()
expect(cards[0]!.attributes('aria-pressed')).toBe('false')
expect(cards[1]!.attributes('aria-pressed')).toBe('true')
expect(cards[1]!.classes()).toContain('selected')
expect(wrapper!.get<HTMLTextAreaElement>('#identity-description').element.value).toBe('老周的稳定身份')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-2/identity'))).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('Character 确认选角原子切换 Anchor 并锁定 Identity,不调用普通母版接口', async () => {
let locked = false
let selected = false
const candidate = identityImageFixture({ id: 'casting-1', isAnchor: false })
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 1,
ready: locked ? 1 : 0,
missingIdentity: 0,
missingAnchor: 0,
candidatePending: locked ? 0 : 1,
unlocked: 0,
locked: locked ? 1 : 0,
items: [
{
subjectId: 'subject-db-1',
subjectRef: '@CH0001',
subjectName: '林知夏',
status: locked ? 'ready' : 'candidate_pending',
identityId: 'identity-db-1',
candidateImages: locked ? undefined : [{ id: 'casting-1', imageUrl: candidate.imageUrl }],
anchorImageId: locked ? 'casting-1' : undefined,
anchorImageUrl: locked ? candidate.imageUrl : undefined,
isLocked: locked
}
]
})
if (path.endsWith('/identity/images')) return jsonResponse([{ ...candidate, isAnchor: selected }])
if (init?.method === 'PUT' && path.endsWith('/casting')) {
locked = true
selected = true
return jsonResponse({
identityId: candidate.identityId,
subjectId: 'subject-db-1',
isLocked: true,
anchor: { ...candidate, isAnchor: true }
})
}
return jsonResponse(identityFixture({ isLocked: locked }))
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(wrapper!.text()).toContain('等待确认演员')
button('确认选角并锁定').click()
await flushPromises()
button('确认演员选择').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
expect(wrapper!.text()).toContain('选角状态:选角已完成')
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 person = formFixture()
const scene = {
...formFixture(),
id: 'scene-form',
images: [],
subject: { ...person.subject, id: 'scene-subject', ref: '@SC0001', name: '旧书店', module: 'scene' }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse([person, scene]))
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
const filters = wrapper!.get('.form-image-filters')
expect(wrapper!.get('.form-image-toolbar').classes()).not.toContain('has-impact-picker')
expect(filters.element.parentElement).toBe(wrapper!.get('.form-image-toolbar').element)
expect(wrapper!.findAll('.form-image-filters')).toHaveLength(1)
expect(filters.get('.filter-result-count').text()).toBe('2 个形态')
expect(filters.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
selectControl(wrapper!, 'aria-label', '筛选图片状态').vm.$emit('update:value', 'missing')
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').text()).toContain('旧书店')
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'character')
await flushPromises()
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'all')
await filters.get('input[aria-label="搜索形态图片"]').setValue('旧书店')
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
expect(selectControl(wrapper!, 'aria-label', '筛选图片状态').props('options')).not.toContainEqual({
label: '身份过期',
value: 'stale'
})
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('拆解更多菜单位于 Tabs suffix,空快照时导出仍禁用', async () => {
wrapper = mount(BreakdownPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
const toolbar = wrapper.get('.result-toolbar')
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
expect(toolbar.find(':scope > button').exists()).toBe(false)
expect(toolbar.get('.n-tabs-nav__suffix button').attributes('aria-label')).toBe('拆解更多操作')
await toolbar.get('.n-tabs-nav__suffix button').trigger('click')
await flushPromises()
const exportItem = [...document.querySelectorAll('.n-dropdown-option-body')].find(
item => item.textContent?.trim() === '导出 JSON'
)!
expect(exportItem.className).toContain('disabled')
})
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'],
['/api/projects/page-test-project/keyframes/readiness?force=false', 'GET'],
['/api/projects/page-test-project/videos/readiness?force=false', 'GET']
])
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态')
expect(wrapper!.text()).toContain('没有匹配的形态')
button('清除筛选').click()
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
})
it('形态卡片铺满并打开原生图片预览,历史管理仍从独立按钮进入', async () => {
const form = formFixture()
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async url =>
jsonResponse(String(url).endsWith('/subject-forms') ? [form] : form.images)
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
const card = wrapper!.get('.form-image-card')
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
await card.get('img').trigger('click')
await flushPromises()
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toContain(
'/storage/subjects/project/form/image.png'
)
expect(document.querySelector('.image-history')).toBeNull()
expect(fetcher).toHaveBeenCalledTimes(3)
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
await flushPromises()
button('查看图片与记录').click()
await flushPromises()
expect(document.querySelector('.image-history')).not.toBeNull()
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subject-forms/form-db-1/images'))).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
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('.app-dialog 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({ 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(drawerPanel().get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
expect(drawerPanel().text()).toContain('服务暂不可用')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ concurrency: 2, force: false })
await drawerPanel().get('.n-collapse [role="checkbox"]').trigger('click')
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('分镜参考图使用 cover 和原生预览,查看图片不触发生成功能', async () => {
const shot = designedShot()
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
jsonResponse({
shotId: shot.shotId,
references: [
{
shotSubjectId: 'binding-db',
subjectId: 'subject-db',
subjectRef: '@CH0001',
subjectName: '林知夏',
module: 'character',
subjectFormId: 'form-db',
subjectFormName: '日常',
imageId: 'image-db',
imageUrl: '/storage/reference.png'
}
],
missing: []
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ShotTools, { attachTo: document.body, props: { projectId: fixture.id, shot, disabled: false } })
button('读取参考图').click()
await flushPromises()
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false })
const image = wrapper.get('.reference-card img')
expect(image.element.parentElement?.closest('a')).toBeNull()
await image.trigger('click')
await flushPromises()
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toBe(image.attributes('src'))
expect(fetcher.mock.calls).toHaveLength(1)
expect(fetcher.mock.calls.every(([, init]) => !init?.method || init.method === 'GET')).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)
await mountStoryboard()
await flushPromises()
await confirmGeneration('补齐项目导演设计')
expect(button('补齐项目导演设计').disabled).toBe(true)
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
expect(getOperation(fixture.id).pending).toBe(true)
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
await flushPromises()
wrapper!.unmount()
wrapper = undefined
finish(
new Response(
`{"data":{"projectId":"${fixture.id}","episodeCount":2,"completedEpisodes":1,"skippedEpisodes":1,"failedEpisodes":0,"shotCount":4,"directionCount":4,"coverage":1,"episodes":[]}}`
)
)
await flushPromises()
expect(getStoryboardSession(fixture.id).receipt).toMatchObject({
kind: 'batch',
title: '项目导演设计'
})
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({ concurrency: 2, force: false })
})
it('分镜只提供正式项目生成,逐集失败回执保留零次修复参数', 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: 1,
completedEpisodes: 0,
skippedEpisodes: 0,
failedEpisodes: 1,
shotCount: 2,
visualStateCount: 0,
coverage: 0,
episodes: [
{
episodeNo: 1,
status: 'failed',
shotCount: 2,
visualStateCount: 0,
repairAttempts: 0,
error: '缺少主体状态'
}
]
}
})
)
const result = String(url).includes('storyboard-directions')
? directionsResult(fixture.id, 1, true)
: visualStatesResult(fixture.id)
return new Response(JSON.stringify({ data: result }))
})
vi.stubGlobal('fetch', fetcher)
await mountStoryboard()
await flushPromises()
expect(wrapper!.text()).not.toContain('生成本集导演设计')
expect(wrapper!.text()).not.toContain('生成本集即时状态')
expect(button('补齐项目导演设计').disabled).toBe(false)
expect(button('补齐项目即时状态').disabled).toBe(false)
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
await drawerPanel().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({
concurrency: 2,
force: false,
maxRepairAttempts: 0
})
expect(drawerPanel().text()).toContain('本次存在失败')
expect(drawerPanel().text()).toContain('缺少主体状态')
})
it('重生成当前集镜头后刷新正式设计,并用接口回执替换过期 Breakdown 文本', async () => {
const regenerated = episodeShots()
regenerated.beatShots[0]!.shots[0]!.title = '重生成后的开场镜头'
regenerated.beatShots[0]!.shots[0]!.description = '新的镜头正文'
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'POST')
return jsonResponse({
projectId: fixture.id,
episodeNo: 1,
episodeShot: regenerated,
storyboardShotSummary: {
episodeCount: 1,
beatCount: 2,
shotCount: 2,
totalDurationSeconds: 10,
subjectRefCount: 2,
subjectBindingCount: 2,
bindingCoverage: 1
},
storyboardShotValidation: { valid: true, issues: [] }
})
return jsonResponse(
String(url).includes('storyboard-directions')
? directionsResult(fixture.id, 1, false)
: visualStatesResult(fixture.id)
)
})
vi.stubGlobal('fetch', fetcher)
await mountStoryboard()
await confirmGeneration('重生成第 1 集镜头')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/projects/page-test-project/storyboard-shots/regenerate')
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1 })
expect(wrapper!.text()).toContain('重生成后的开场镜头')
expect(wrapper!.text()).toContain('新的镜头正文')
expect(drawerPanel().text()).toContain('本集新镜头已保存')
expect(drawerPanel().text()).toContain('2 个镜头')
expect(fetcher.mock.calls.filter(([, init]) => !init?.method || init.method === 'GET')).toHaveLength(4)
})
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)
await mountStoryboard()
await flushPromises()
await confirmGeneration('补齐项目导演设计')
expect(drawerPanel().get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
expect(drawerPanel().text()).toContain('模型校验失败')
await drawerPanel().get('[role="checkbox"]').trigger('click')
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)
await mountStoryboard()
await flushPromises()
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 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)
await 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: {
shotId: 'shot-db-1-1',
updatedAt: '2026-09-10T00:00:00Z',
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.getComponent(NRadioGroup).props('size')).toBe('medium')
const refresh = wrapper.get('[aria-label="刷新项目"]')
expect(refresh.classes()).toContain('icon-button')
expect(refresh.find('.n-button__icon svg').exists()).toBe(true)
expect(refresh.text()).toBe('')
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 } }
})
await expandSections()
button('预览分组').click()
await flushPromises()
expect(button('开始拆解').disabled).toBe(false)
await drawerPanel().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('窄屏隐藏进入拆解,选择器需压过工具栏文字按钮', () => {
const css = readAllStyles()
expect(css).toMatch(
/@media \(max-width: 800px\)[\s\S]*?\.tabs-toolbar-actions \.script-next-link\s*\{[^}]*display:\s*none/
)
})
it('切换剧本标签保留阅读容器和位置,执行记录按需展开', async () => {
wrapper = mount(CreateDramaPage, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
})
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
expect(suffix.findAll('button').map(item => item.attributes('aria-label'))).toEqual(['剧本更多操作'])
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`)
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
reader.scrollTop = 120
expect(wrapper.find('.history-panel').exists()).toBe(false)
await wrapper
.findAll('.n-tabs-tab')
.find(item => item.text() === '角色设定')!
.trigger('click')
expect(wrapper.get('.episode-reader').isVisible()).toBe(false)
await wrapper
.findAll('.n-tabs-tab')
.find(item => item.text().includes('剧集正文'))!
.trigger('click')
expect(wrapper.get('.episode-reader').isVisible()).toBe(true)
expect(wrapper.get('.reader-scroll .n-scrollbar-container').element).toBe(reader)
expect(reader.scrollTop).toBe(120)
await selectMenu('剧本更多操作', '执行记录')
expect(wrapper.find('.history-panel').exists()).toBe(true)
expect(wrapper.get('.content-with-history').classes()).not.toContain('history-hidden')
await selectMenu('剧本更多操作', '收起执行记录')
expect(wrapper.find('.history-panel').exists()).toBe(false)
})
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)
})
})