Files
short-drama-agent-front/tests/features/breakdown/BreakdownPage.test.ts
T

448 lines
22 KiB
TypeScript

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<typeof useProjectData> {
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<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('拆解设置保留输入标签、单行单位与模块说明,改版后仍校验模块和剧本状态', async () => {
const fetcher = vi.fn<typeof fetch>()
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<HTMLInputElement>('#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<HTMLButtonElement>('.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]!
await article.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
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<typeof fetch>()
vi.stubGlobal('fetch', fetcher)
const provided = mountPage()
await selectMenu('拆解更多操作', '执行记录')
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(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<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(drawerPanel().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(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)
})
})