fix: 修复剧本拆解结果滚动并收拢配置
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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<typeof useProjectData> {
|
||||
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<typeof useProjectData> = {
|
||||
data,
|
||||
project: computed(() => data.value.project),
|
||||
checkpoints: computed(() => data.value.checkpoints),
|
||||
loading: ref(false),
|
||||
error: ref(''),
|
||||
updatedAt: ref(''),
|
||||
refresh: vi.fn<() => Promise<void>>().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<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = mountPage()
|
||||
const results = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||
const history = wrapper!.get<HTMLElement>('.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<HTMLElement>('.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<HTMLInputElement>('#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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user