1549 lines
77 KiB
TypeScript
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)
|
|
})
|
|
})
|