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, 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: '\n第一场:旧书店。' }], characters: [{ id: 'character', name: '林知夏' }], world: { era: '当代' }, reviews: [], tasks: [] } let wrapper: VueWrapper | undefined /** 使用真实上下文形状,不绕过页面内的异步操作与按钮守卫。 */ function context(): ReturnType { 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>().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('.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: {} }) 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().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().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().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, 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() .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().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().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().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() .mockImplementation(async (_url, init) => jsonResponse(init?.method === 'POST' ? image : styleFixture())) vi.stubGlobal('fetch', fetcher) await mountAssets('style') await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)') expect(button('登记参考图').disabled).toBe(true) await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png') button('登记参考图').click() await flushPromises() const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')! expect(post[0]).toBe('/api/projects/page-test-project/visual-style/images') expect(JSON.parse(post[1]!.body as string)).toEqual({ imageUrl: '/storage/style.png', category: 'overall', source: 'upload', enabled: true, sortOrder: 0 }) expect((wrapper!.get('[aria-label="风格图片地址"]').element as HTMLInputElement).value).toBe('') }) it('AI 确认取消后重新打开必须再次确认,不能复用上次勾选', async () => { const fetcher = vi.fn().mockImplementation(async () => jsonResponse(styleFixture())) vi.stubGlobal('fetch', fetcher) await mountAssets('style') button('AI 重新生成风格').click() await flushPromises() document.querySelector('.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 | null = null const fetcher = vi.fn().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}') expect(button('保存视觉风格').disabled).toBe(true) 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().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().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().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().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().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('#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().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().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().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', enabled: selected, isAnchor: selected })]) return jsonResponse(identityFixture()) }) vi.stubGlobal('fetch', fetcher) await mountAssets('identity') button('设为身份母版').click() await flushPromises() expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false) button('确认切换身份母版').click() await flushPromises() expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe( '/api/subjects/subject-db-1/identity/images/candidate/anchor' ) expect(wrapper!.text()).toContain('当前身份母版') 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().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('#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', enabled: false, isAnchor: false }) const fetcher = vi.fn().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, enabled: selected, isAnchor: selected }]) if (init?.method === 'PUT' && path.endsWith('/casting')) { locked = true selected = true return jsonResponse({ identity: { ...identityFixture({ isLocked: true }), images: undefined }, anchor: { ...candidate, enabled: 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().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().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().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) expect(filters.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2) await filters.findAll('.filter-toggles [role="checkbox"]')[0]!.trigger('click') 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 个形态') await filters.findAll('.filter-toggles [role="checkbox"]')[1]!.trigger('click') expect(filters.get('.filter-result-count').text()).toBe('0 个形态') 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() .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() .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('.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().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('#confirm-image-cost')!.click() await flushPromises() document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')! expect(post[0]).toBe('/api/subject-forms/form-db-1/images') expect(JSON.parse(post[1]!.body as string)).toEqual({ 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().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() .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().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() .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().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().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('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => { const fetcher = vi.fn().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().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().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().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().mockResolvedValue( new Response( JSON.stringify({ data: { id: 'shot-db-1-1', videoPrompt: '', 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('') expect(wrapper.find('script').exists()).toBe(false) }) it('项目筛选无结果时可清除条件并返回真实列表', async () => { vi.stubGlobal( 'fetch', vi.fn().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() .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('#topic')! topic.value = ' 雨夜来信 ' topic.dispatchEvent(new Event('input', { bubbles: true })) const count = document.querySelector('#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().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('.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('') expect(wrapper.find('.script-body script').exists()).toBe(false) }) })