import { computed, ref } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import BreakdownPage from './BreakdownPage.vue' import { projectContextKey, type useProjectData } from '../projects/context' import type { ProjectDetail } from '../projects/types' import type { Checkpoint } from '../workflows/types' import type { BreakdownModule, EpisodePlan } from './types' 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, reviews: [], tasks: [], 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.each([ ['人物', 'character 主体 30'], ['场景', 'scene 主体 30'], ['道具', 'prop 主体 30'], ['分镜', '镜头 25'], ['任务明细', '任务错误 50'] ])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => { mountPage() await selectTab(label) 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() 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(wrapper!.get('.workspace-tools-drawer').element.closest('.workspace-split')).toBeNull() await wrapper!.get('#group-size').setValue('2') await wrapper!.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(wrapper!.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(wrapper!.get('.workspace-tools-drawer').text()).toContain('缺少形态绑定') expect(wrapper!.get('.workspace-tools-drawer').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) }) })