import { computed, ref } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { NSelect } from 'naive-ui' import { readFileSync } from 'node:fs' import BreakdownPage from '@/features/breakdown/BreakdownPage.vue' import { projectContextKey, type useProjectData } from '@/features/projects/context' import type { ProjectDetail } from '@/features/projects/types' import type { Checkpoint } from '@/features/workflows/types' import type { BreakdownModule, EpisodePlan } from '@/features/breakdown/types' import { drawerPanel, selectMenu } from '@/testing/naive' import { readAllStyles } from '@/testing/styles' let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() }) /** 长主体、长分镜与长任务列表,用真实 Naive 滚动组件验证内容边界。 */ function checkpoint(): Checkpoint { const plan: EpisodePlan = { episodeNo: 1, episodeTitle: '长剧集', storyGoal: '', centralConflict: '', emotionalArc: '', pacing: '', endingHook: '', beats: Array.from({ length: 25 }, (_, i) => ({ beatNo: i + 1, title: `节拍 ${i + 1}`, purpose: 'action', description: '节拍内容'.repeat(100), visualFocus: '', narrativeGoal: '', emotionalTone: '', estimatedDurationSeconds: 5, subjectRefs: [], isKeyBeat: false })) } return { checkpointId: 'long-breakdown', workflowName: 'breakdown', createdAt: '2026-08-28T00:00:00Z', state: { workflowExecution: { executionId: 'run', status: 'completed', startedAt: '2026-08-28T00:00:00Z' }, breakdownResult: { subjectCandidates: (['character', 'scene', 'prop'] as BreakdownModule[]).flatMap(module => Array.from({ length: 30 }, (_, i) => ({ profileId: `${module}-${i}`, name: `${module} 主体 ${i + 1}`, ref: `@${module}${i}`, description: '很长的主体描述。'.repeat(100), module, appearance_prompt: '外观描述' })) ), subjectForms: [], storyboardPlans: [plan], storyboardEpisodeShots: [ { episodeNo: 1, episodePlan: plan, beatShots: plan.beats.map(beat => ({ beatNo: beat.beatNo, shots: [ { shotNo: 1, title: `镜头 ${beat.beatNo}`, description: '镜头内容', visualFocus: '', subjectRefs: [], durationSeconds: 5 } ] })) } ] }, tasks: Array.from({ length: 50 }, (_, i) => ({ taskId: `task-${i}`, module: 'character', group: { groupId: `group-${i}`, groupNo: i + 1, startEpisodeNo: i + 1, endEpisodeNo: i + 1, episodes: [] }, status: 'failed', attempt: 1, errorMessage: `任务错误 ${i + 1}` })) } } } /** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */ function mountPage(records = [checkpoint()], episodes = 1): ReturnType { const project: ProjectDetail = { id: 'breakdown-scroll-test', title: '滚动回归', topic: '', style: '', status: 'completed', createdAt: '', updatedAt: '', characters: [], world: null, episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : [] } const data = ref({ project, checkpoints: records }) const provided: ReturnType = { data, project: computed(() => data.value.project), checkpoints: computed(() => data.value.checkpoints), loading: ref(false), error: ref(''), updatedAt: ref(''), refresh: vi.fn<() => Promise>().mockResolvedValue() } wrapper = mount(BreakdownPage, { attachTo: document.body, global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } } }) return provided } /** 切换真实标签,不直接修改组件内部状态。 */ async function selectTab(label: string) { await wrapper! .findAll('.result-toolbar .n-tabs-tab') .find(tab => tab.text().startsWith(label))! .trigger('click') await flushPromises() } describe('拆解页内容滚动', () => { it('拆解设置保留输入标签、单行单位与模块说明,改版后仍校验模块和剧本状态', async () => { const fetcher = vi.fn() vi.stubGlobal('fetch', fetcher) const provided = mountPage() await wrapper!.get('[data-workspace-tools]').trigger('click') await flushPromises() const config = drawerPanel().get('.breakdown-config') expect(config.get('label[for="group-size"]').text()).toContain('每组集数') expect(config.get('#group-size').element.value).toBe('3') expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组') expect(config.get('.breakdown-modules-field .n-form-item-label').text()).toContain('抽取模块') const options = config.findAll('.breakdown-module-option') expect(options).toHaveLength(3) const preview = config.get('.breakdown-preview-button') expect(preview.element.disabled).toBe(false) for (const option of options) { const checkbox = option.get('[role="checkbox"]') const description = option.get('.breakdown-module-description') expect(checkbox.attributes('aria-describedby')).toBe(description.attributes('id')) expect(checkbox.find('.breakdown-module-description').exists()).toBe(false) expect(description.text()).not.toBe('') await checkbox.trigger('click') } await preview.trigger('click') await flushPromises() expect(config.get('.n-form-item-feedback--error').text()).toContain('至少选择一个抽取模块') expect(fetcher).not.toHaveBeenCalled() await options[1]!.get('[role="checkbox"]').trigger('click') expect(preview.element.disabled).toBe(false) provided.data.value!.project.status = 'generating' await flushPromises() expect(preview.element.disabled).toBe(true) expect(fetcher).not.toHaveBeenCalled() }) it('拆解配置按抽屉宽度换行,单位不收缩,三类控件对齐且说明位于控件下方', () => { // DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。 const css = readAllStyles() expect(css).toMatch(/\.breakdown-settings-panel\s*\{\s*container:\s*breakdown-settings \/ inline-size;/) expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*29px;/) expect(css).toMatch(/\.breakdown-group-unit\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/) expect(css).toMatch( /\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;/ ) expect(css).toMatch( /\.breakdown-preview-button\.n-button\s*\{[^}]*align-self:\s*start;[^}]*margin-top:\s*var\(--breakdown-label-offset\);/ ) expect(css).toContain('@container breakdown-settings (max-width: 780px)') expect(css).toContain('@container breakdown-settings (max-width: 560px)') expect(css).toContain('repeat(auto-fit, minmax(min(100%, 160px), 1fr))') expect(readFileSync('src/styles/styles.css', 'utf8')).not.toContain('.breakdown-config') }) it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => { mountPage() await selectTab(label) expect(wrapper!.find('.history-panel').exists()).toBe(false) await selectMenu('拆解更多操作', '执行记录') const list = wrapper!.get('.subject-record-list') expect(list.classes()).toContain('record-list') expect(list.findAll(':scope > article')).toHaveLength(30) const toolbar = wrapper!.get('.subject-list-toolbar') expect(toolbar.element.firstElementChild).toBe(toolbar.get('.subject-list-search').element) expect(toolbar.element.lastElementChild).toBe(toolbar.get('.subject-list-count').element) expect(toolbar.get('.n-input__prefix svg').attributes('aria-hidden')).toBe('true') expect(toolbar.find(':scope > svg').exists()).toBe(false) expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体') const input = toolbar.get('input[aria-label="搜索主体"]') await input.setValue('主体 30') expect(list.findAll(':scope > article')).toHaveLength(1) expect(list.get(':scope > article h3').text()).toContain('主体 30') expect(toolbar.get('[role="status"]').text()).toBe('匹配 1 / 30 个主体') await input.setValue('不存在的主体') expect(toolbar.get('[role="status"]').text()).toBe('匹配 0 / 30 个主体') expect(wrapper!.text()).toContain('没有匹配的主体') await input.setValue('') expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体') expect(wrapper!.findAll('.subject-record-list > article')).toHaveLength(30) }) it('主体搜索与统计靠左居中,窄屏搜索拉满同一行,输入框不再使用外置图标容器', () => { // DOM 环境不计算布局,保护输入框宽度、主题统计色与窄屏单行搜索的样式契约。 const css = readAllStyles() expect(css).toMatch( /\.subject-list-toolbar\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;/ ) expect(css).toMatch( /\.subject-list-search\.n-input\s*\{[^}]*flex:\s*0 1 260px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/ ) expect(css).toMatch(/\.subject-list-count\s*\{[^}]*color:\s*var\(--app-muted\);/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.subject-list-search\.n-input\s*\{[^}]*flex:\s*1/) expect(css + readFileSync('src/styles/styles.css', 'utf8')).not.toContain('.search-field') }) it('窄屏收口拆解顶栏并隐藏重复标题,设置按钮保留图标', () => { const css = readAllStyles() expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.workspace-toolbar h2\s*\{[^}]*display:\s*none/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.workspace-tools-label\s*\{[^}]*display:\s*none/) expect(css).toMatch( /@media \(max-width: 800px\)[\s\S]*?\.workspace-tools-trigger\s*\{[^}]*height:\s*32px;[^}]*width:\s*32px/ ) }) it.each([ ['人物', 'character'], ['场景', 'scene'], ['道具', 'prop'] ] as const)('%s 的奇偶主体默认展示独立形态块,搜索重排不影响形态归属', async (label, module) => { const record = checkpoint() // 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。 record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex => [0, 1].map(formIndex => ({ formId: `${module}-${subjectIndex}-form-${formIndex}`, profileId: `${module}-${subjectIndex}`, type: module, name: `主体 ${subjectIndex + 1} 形态 ${formIndex + 1}`, isDefault: formIndex === 0, description: '形态描述', appearancePrompt: '形态提示词' })) ) mountPage([record]) await selectTab(label) const articles = wrapper!.findAll('.subject-record-list > article') for (const index of [0, 1]) { const article = articles[index]! const cards = article.findAll('.subject-form-card') expect(cards).toHaveLength(2) expect(cards[0]!.get('.n-tag').text()).toBe('默认') for (const card of cards) { expect(card.classes()).not.toContain('surface-inset') expect(card.text()).toContain(`主体 ${index + 1} 形态`) expect(card.text()).toContain('形态描述') expect(card.text()).toContain('形态提示词') } } // 原偶数主体过滤后成为首项,仍由当前 DOM 的奇偶选择器决定内外层底色。 await wrapper!.get('input[aria-label="搜索主体"]').setValue(`@${module}1`) const first = wrapper!.get('.subject-record-list > article') expect(first.findAll('.subject-form-card')).toHaveLength(2) expect(first.get('.subject-form-card').text()).toContain('主体 2 形态 1') }) it('形态块在两种主体条纹上采用相反灰阶,以间距分组且没有整块悬停态', () => { // DOM 环境不提供真实主题绘制,检查奇偶行底色、内边距和状态选择器契约。 const css = readAllStyles() expect(css).toMatch( /\.subject-form-card\s*\{[^}]*margin-top:\s*12px;[^}]*padding:\s*16px;[^}]*background:\s*var\(--app-subtle\);/ ) expect(css).toMatch( /\.subject-record-list > article:nth-child\(even\) \.subject-form-card\s*\{\s*background:\s*var\(--app-control\);/ ) expect(css).not.toMatch(/\.subject-form-card[^{}]*(?::hover|:focus-within)/) }) it('主体奇数行常驻原悬停底色,偶数行不变,整行不再随鼠标或焦点变色', () => { // DOM 环境无法模拟浏览器 :hover 命中;检查静态底色与无整行交互选择器的契约。 const css = readAllStyles() expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:\s*var\(--app-subtle\);/) expect(css).toMatch( /\.subject-record-list > article:nth-child\(odd\)\s*\{\s*background:\s*var\(--app-control\);/ ) expect(css + readFileSync('src/styles/admin.css', 'utf8')).not.toMatch( /\.subject-record-list[^{}]*(?::hover|:focus-within)/ ) // 仅移除整行变色,不影响内部链接、折叠等控件的键盘焦点提示。 expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--color-accent\);/) }) it('分镜选择与计数、入口同排,默认显示实际剧集,切换后内容和统计同步', async () => { const record = checkpoint() const result = record.state.breakdownResult! const first = result.storyboardPlans![0]! const second = { ...first, episodeNo: 2, episodeTitle: '第二集', beats: first.beats.slice(0, 2) } result.storyboardPlans!.push(second) result.storyboardEpisodeShots!.push({ episodeNo: 2, episodePlan: second, beatShots: result.storyboardEpisodeShots![0]!.beatShots.slice(0, 2) }) const provided = mountPage([record]) await selectTab('分镜') const toolbar = wrapper!.get('.breakdown-storyboard-toolbar') expect(toolbar.get('.breakdown-episode-picker > span').text()).toBe('选择剧集') const select = toolbar.getComponent(NSelect) expect(select.props('value')).toBe(1) expect(toolbar.text()).toContain('长剧集') expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('25 个节拍 · 25 个镜头') expect(toolbar.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe( '/projects/breakdown-scroll-test/storyboard' ) expect(wrapper!.find('.breakdown-storyboard > .surface-inset').exists()).toBe(false) select.vm.$emit('update:value', 2) await flushPromises() expect(select.props('value')).toBe(2) expect(toolbar.text()).toContain('第二集') expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头') expect(wrapper!.findAll('.beat-section')).toHaveLength(2) // 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。 provided.data.value!.checkpoints = [checkpoint()] await flushPromises() expect(select.props('value')).toBe(1) expect(wrapper!.findAll('.beat-section')).toHaveLength(25) }) it('分镜为空时保留空提示与进入设计入口,不显示空选择器', async () => { const record = checkpoint() record.state.breakdownResult!.storyboardPlans = [] record.state.breakdownResult!.storyboardEpisodeShots = [] mountPage([record]) await selectTab('分镜') expect(wrapper!.get('.breakdown-storyboard').text()).toContain('分镜规划尚未生成') expect(wrapper!.find('.breakdown-episode-picker').exists()).toBe(false) expect(wrapper!.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe( '/projects/breakdown-scroll-test/storyboard' ) }) it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => { // 仅保护样式契约,DOM 环境不提供真实布局坐标。 const css = readAllStyles() expect(css).toMatch(/\.breakdown-result-pad\s*\{[^}]*padding:\s*16px/) expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/) expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/) }) it.each([ ['人物', 'character 主体 30'], ['场景', 'scene 主体 30'], ['道具', 'prop 主体 30'], ['分镜', '镜头 25'], ['任务明细', '任务错误 50'] ])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => { mountPage() await selectTab(label) await selectMenu('拆解更多操作', '执行记录') expect(wrapper!.find('.workspace-scroll').exists()).toBe(false) const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container') expect(results.text()).toContain(lastItem) expect(results.find('.result-toolbar').exists()).toBe(false) expect(results.find('.history-panel').exists()).toBe(false) expect(wrapper!.get('.history-panel .n-scrollbar-container').text()).toContain('执行记录') expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false') expect(results.find('.table-scroll').exists()).toBe(label === '任务明细') }) it('刷新和开关配置保留阅读位置,切换标签只重置结果区,不改变执行记录位置', async () => { const fetcher = vi.fn() vi.stubGlobal('fetch', fetcher) const provided = mountPage() await selectMenu('拆解更多操作', '执行记录') const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element const history = wrapper!.get('.history-panel .n-scrollbar-container').element results.scrollTop = 900 history.scrollTop = 120 await wrapper!.get('[data-workspace-tools]').trigger('click') await flushPromises() expect(drawerPanel().element.closest('.workspace-split')).toBeNull() await drawerPanel().get('#group-size').setValue('2') await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click') provided.data.value!.checkpoints = [checkpoint()] await flushPromises() expect(wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element).toBe(results) expect(results.scrollTop).toBe(900) await selectTab('场景') const next = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element expect(next).not.toBe(results) expect(next.scrollTop).toBe(0) expect(wrapper!.get('.history-panel .n-scrollbar-container').element).toBe(history) expect(history.scrollTop).toBe(120) await wrapper!.get('[data-workspace-tools]').trigger('click') await flushPromises() expect(drawerPanel().get('#group-size').element.value).toBe('2') expect(fetcher).not.toHaveBeenCalled() }) it('关闭设置时错误仍可见,详细校验与恢复在面板内,不挤占结果高度', async () => { const record = checkpoint() record.state.workflowExecution!.status = 'failed' record.state.workflowExecution!.errorMessage = '工作流中断' record.state.breakdownResult!.storyboardShotValidation = { valid: false, issues: [{ episodeNo: 1, message: '缺少形态绑定' }] } mountPage([record]) const feedback = wrapper!.get('.workspace-feedback') expect(feedback.text()).toContain('工作流中断') expect(feedback.text()).toContain('分镜校验未通过,共 1 项问题') expect(feedback.find('.breakdown-results-section').exists()).toBe(false) await feedback.get('button').trigger('click') await flushPromises() expect(drawerPanel().text()).toContain('缺少形态绑定') expect(drawerPanel().text()).toContain('重试失败抽取') }) it('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => { mountPage([], 0) expect(wrapper!.get('.panel-scroll .n-scrollbar-container').text()).toContain('还没有可拆解的剧集') expect(wrapper!.find('.breakdown-results-section').exists()).toBe(false) expect(wrapper!.find('[data-workspace-tools]').exists()).toBe(false) }) })