feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
+90
View File
@@ -0,0 +1,90 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NInput, NDropdown } from 'naive-ui'
import ActionMenu from '@/components/ui/ActionMenu.vue'
import DetailDisclosure from '@/components/ui/DetailDisclosure.vue'
import StyleEditor from '@/features/visual-style/components/StyleEditor.vue'
import SubjectList from '@/features/breakdown/components/SubjectList.vue'
import { selectMenu } from '@/testing/naive'
import type { SubjectCandidate } from '@/features/breakdown/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('精简入口保留功能', () => {
it('菜单按点击展开,禁用项不能绕过,触发器有可访问名称', async () => {
wrapper = mount(ActionMenu, {
attachTo: document.body,
props: {
label: '测试更多操作',
items: [
{ key: 'history', label: '执行记录' },
{ key: 'export', label: '导出', disabled: true }
]
}
})
expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
expect(document.querySelector('.n-dropdown-option-body')).toBeNull()
await selectMenu('测试更多操作', '执行记录')
expect(wrapper.emitted('select')).toEqual([['history']])
wrapper.getComponent(NDropdown).vm.$emit('select', 'export')
expect(wrapper.emitted('select')).toHaveLength(1)
})
it('补充说明默认不展开,键盘可聚焦的标题可查看原文', async () => {
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
expect(wrapper.text()).not.toContain('完整规则仍然保留')
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('完整规则仍然保留')
})
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
expect(wrapper.find('#style-constraints').exists()).toBe(false)
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
const fields = wrapper.findAllComponents(NInput)
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
await flushPromises()
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('save')).toMatchObject([
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
])
})
it('主体长文收起仅影响显示,完整内容与图片入口不丢失', async () => {
const description = '主体完整经历。'.repeat(30)
wrapper = mount(SubjectList, {
props: {
projectId: 'p',
subjects: [
{
profileId: 'p1',
ref: '@CH0001',
name: '人物',
module: 'character',
description,
appearance_prompt: '外观',
aliases: []
} as SubjectCandidate
],
forms: []
},
global: { stubs: { RouterLink: true } }
})
const text = wrapper.get('.line-clamp-3')
expect(text.text()).toBe(description)
await wrapper.get('button').trigger('click')
expect(wrapper.find('.line-clamp-3').exists()).toBe(false)
expect(wrapper.findAll('router-link-stub')).toHaveLength(2)
})
})
+79
View File
@@ -0,0 +1,79 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { NImage } from 'naive-ui'
import AssetImage from '@/components/ui/AssetImage.vue'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
})
describe('资源图片展示与原图预览', () => {
it('默认使用 cover 并禁用预览,不干扰历史缩略图选择', async () => {
wrapper = mount(AssetImage, {
attachTo: document.body,
props: { src: '/storage/history.png', alt: '历史图片' }
})
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
await wrapper.get('img').trigger('click')
await flushPromises()
expect(document.querySelector('.n-image-preview-container')).toBeNull()
})
it('特殊展示仍可显式选择 contain,样式交由 NImage 控制', () => {
wrapper = mount(AssetImage, {
props: { src: '/storage/reference.png', alt: '完整参考图', objectFit: 'contain' }
})
expect(wrapper.getComponent(NImage).props('objectFit')).toBe('contain')
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
})
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
wrapper = mount(AssetImage, {
attachTo: document.body,
props: { src: '/storage/original.png', alt: '林默 · 基础形态', objectFit: 'cover', preview: true }
})
const source = wrapper.get('img').attributes('src')
expect(wrapper.getComponent(NImage).props()).toMatchObject({
objectFit: 'cover',
previewDisabled: false,
previewSrc: source
})
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('cover')
await wrapper.get('img').trigger('click')
await flushPromises()
const image = document.querySelector<HTMLImageElement>('.n-image-preview')!
expect(image).not.toBeNull()
expect(image.getAttribute('src')).toBe(source)
expect(image.alt).toBe('林默 · 基础形态')
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(image.style.objectFit).not.toBe('cover')
expect(document.querySelector('.n-image-preview-toolbar')).not.toBeNull()
expect(wrapper.element.contains(image)).toBe(false)
document.querySelector<HTMLElement>('.n-image-preview-overlay')!.click()
await flushPromises()
expect(document.querySelector<HTMLElement>('.n-image-preview-wrapper')?.style.display ?? 'none').toBe('none')
})
it('资源加载失败只显示重试占位,不打开空预览', async () => {
wrapper = mount(AssetImage, {
attachTo: document.body,
props: { src: '/storage/missing.png', alt: '失败图片', objectFit: 'cover', preview: true }
})
await wrapper.get('img').trigger('error')
expect(wrapper.text()).toContain('图片无法加载')
expect(wrapper.findComponent(NImage).exists()).toBe(false)
expect(document.querySelector('.n-image-preview-container')).toBeNull()
await wrapper.get('button').trigger('click')
expect(wrapper.getComponent(NImage).props('previewDisabled')).toBe(false)
})
it.each([null, 'javascript:alert(1)', 'data:text/html,test'])('空地址或危险地址 %s 不进入原图预览', src => {
wrapper = mount(AssetImage, { props: { src, alt: '图片', objectFit: 'cover', preview: true } })
expect(wrapper.findComponent(NImage).exists()).toBe(false)
expect(wrapper.find('img').exists()).toBe(false)
})
})
+98
View File
@@ -0,0 +1,98 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NButton } from 'naive-ui'
import DirectoryItem from '@/components/ui/DirectoryItem.vue'
import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => wrapper?.unmount())
describe('统一目录条目', () => {
it('主体缩略图使用可选前导槽位,与标题一起保留满行点击区域', () => {
wrapper = mount(DirectoryItem, {
props: { active: true },
slots: { leading: '<span>母版缩略图</span>', default: '林默', eyebrow: '@CH0001' }
})
expect(wrapper.classes()).toContain('has-leading')
expect(wrapper.get('.directory-item-leading').text()).toBe('母版缩略图')
expect(wrapper.get('.directory-item-title').text()).toBe('林默')
expect(wrapper.get('.directory-item-leading').element.closest('button')).toBe(wrapper.element)
})
it('编号、长标题和多项状态各占独立层级,沿用 Naive 按钮', () => {
const title = '雨夜霓虹街全景与记忆当铺外的人群和闪烁灯牌'
wrapper = mount(DirectoryItem, {
props: { active: false },
slots: {
eyebrow: 'BEAT 2 / SHOT 3',
default: title,
meta: '<span class="directory-status">设计已保存</span><span class="directory-status">待状态</span>'
}
})
expect(wrapper.getComponent(NButton).props()).toMatchObject({ quaternary: true, block: true })
expect(wrapper.get('button').attributes('type')).toBe('button')
expect(wrapper.get('.directory-item-eyebrow').text()).toBe('BEAT 2 / SHOT 3')
expect(wrapper.get('.directory-item-title').text()).toBe(title)
expect(wrapper.get('.directory-item-meta').findAll('.directory-status')).toHaveLength(2)
expect(wrapper.find('.truncate').exists()).toBe(false)
expect(wrapper.findAll('.directory-item-body > span')).toHaveLength(3)
})
it('点击透传给业务层,选中态与无障碍属性保持同步', async () => {
const onClick = vi.fn<() => void>()
wrapper = mount(DirectoryItem, { props: { active: false }, attrs: { onClick }, slots: { default: '林默' } })
await wrapper.get('button').trigger('click')
expect(onClick).toHaveBeenCalledOnce()
expect(wrapper.attributes('aria-current')).toBeUndefined()
await wrapper.setProps({ active: true })
expect(wrapper.get('button').classes()).toContain('selected')
expect(wrapper.attributes('aria-current')).toBe('true')
expect(wrapper.find('.directory-item-eyebrow').exists()).toBe(false)
expect(wrapper.find('.directory-item-meta').exists()).toBe(false)
})
it('显式保护最小高度、留白、换行和窄屏横向条目,避免旧按钮样式重新挤压目录', () => {
// DOM 环境不计算视觉几何;这些约束不能替代浏览器验收。
const css = readAllStyles()
expect(css).toMatch(/\.n-button\.directory-item\s*\{[^}]*min-height:\s*88px;[^}]*padding:\s*12px 14px/)
expect(css).toMatch(/\.directory-item-body\s*\{[^}]*flex-direction:\s*column;[^}]*gap:\s*7px/)
expect(css).toMatch(/\.directory-item-title\s*\{[^}]*overflow-wrap:\s*anywhere/)
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
expect(css).toContain('grid-template-columns: clamp(240px, 22%, 280px) minmax(0, 1fr)')
expect(css).toMatch(/\.identity-subject-list-content\s*\{[^}]*gap:\s*2px;[^}]*padding:\s*0 0 8px/)
expect(css).toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*min-height:\s*86px;[^}]*padding:\s*10px 14px/)
expect(css).not.toMatch(/\.n-button\.identity-subject-item\s*\{[^}]*border-bottom:/)
expect(css).toMatch(/\.identity-thumbnail\s*\{[^}]*width:\s*48px;[^}]*height:\s*64px/)
expect(css).toMatch(/\.n-button\.identity-subject-item:not\(\.selected\)\s*\{[^}]*var\(--app-surface\)/)
})
it('侧栏与主体、镜头、剧集目录统一直角满行,保留文字内边距和移动端滚动', () => {
// happy-dom 无法计算伪元素几何,保护实际控制选中背景宽度的 CSS 规则。
const css = readAllStyles()
expect(css).toMatch(
/\.admin-sider \.n-menu \.n-menu-item-content::before\s*\{[^}]*left:\s*0;[^}]*right:\s*0;[^}]*border-radius:\s*0/
)
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*padding:\s*8px 0 16px/)
expect(css).toMatch(/\.reader-directory-content\s*\{[^}]*padding:\s*4px 0 18px/)
expect(css).not.toMatch(/\.reader-directory-content\s*\{[^}]*padding-inline:\s*[1-9]/)
for (const selector of ['directory-item', 'reader-episode-link']) {
expect(css).toMatch(new RegExp(`\\.n-button\\.${selector}\\s*\\{[^}]*width:\\s*100%`))
expect(css).toMatch(new RegExp(`\\.n-button\\.${selector}\\s*\\{[^}]*padding:\\s*12px 14px`))
expect(css).toMatch(new RegExp(`\\.n-button\\.${selector}\\s*\\{[^}]*border-radius:\\s*0`))
}
expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*padding:\s*10px 14px/)
expect(css).toMatch(/\.directory-list-content\s*\{[^}]*flex-direction:\s*row;[^}]*width:\s*max-content/)
})
it('Tabs 工具栏与筛选网格限制最小宽度,不让组件默认宽度挤压内容', () => {
const css = readAllStyles()
expect(css).toMatch(/\.workspace-tabs\.n-tabs\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav-scroll-wrapper\s*\{[^}]*min-width:\s*0/)
expect(css).toMatch(
/\.form-image-filters\s*\{[^}]*grid-template-columns:\s*minmax\(200px, 360px\) 152px minmax\(260px, 1fr\) auto/
)
expect(css).toMatch(
/@container \(max-width: 900px\)\s*\{\s*\.form-image-filters\s*\{[^}]*minmax\(0, 1fr\) 152px/
)
})
})
+268
View File
@@ -0,0 +1,268 @@
import { h } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
NAlert,
NButton,
NCard,
NCheckbox,
NConfigProvider,
NInput,
NSelect,
NTable,
NTag,
NTabPane,
NTabs
} from 'naive-ui'
import { readFileSync, readdirSync } from 'node:fs'
import { useTheme } from '@/composables/useTheme'
import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.unstubAllGlobals()
})
describe('全站平面主题', () => {
it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => {
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
const click = vi.fn<() => void>()
wrapper = mount({
setup() {
const { theme, overrides } = useTheme()
return () =>
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () =>
(['primary', 'success'] as const).flatMap(type => [
h(NButton, { type, class: `${type}-button`, onClick: click }, () => '提交'),
h(
NButton,
{ type, disabled: true, class: `${type}-disabled`, onClick: click },
() => '提交'
)
])
)
}
})
for (const type of ['primary', 'success']) {
const active = wrapper.get<HTMLButtonElement>(`.${type}-button`).element
const disabled = wrapper.get<HTMLButtonElement>(`.${type}-disabled`).element
for (const property of [
'--n-text-color',
'--n-text-color-hover',
'--n-text-color-pressed',
'--n-text-color-focus'
]) {
expect(active.style.getPropertyValue(property)).toBe('#ffffff')
}
expect(active.style.getPropertyValue('--n-color')).toBe('#078640')
expect(active.style.getPropertyValue('--n-border-focus')).toBe('none')
expect(active.style.getPropertyValue('--n-wave-opacity')).toBe('0')
expect(disabled.style.getPropertyValue('--n-color-disabled')).toBe(dark ? '#303030' : '#e4e4e4')
expect(disabled.style.getPropertyValue('--n-text-color-disabled')).toBe(dark ? '#808080' : '#8c8c8c')
expect(disabled.disabled).toBe(true)
disabled.click()
expect(click).not.toHaveBeenCalled()
}
wrapper.get<HTMLButtonElement>('.primary-button').element.click()
expect(click).toHaveBeenCalledOnce()
})
it.each([false, true])('模式 %s 的真实控件无静态描边、使用直角,并保留聚焦与错误反馈', dark => {
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
wrapper = mount({
setup() {
const { theme, overrides } = useTheme()
return () =>
h(NConfigProvider, { theme: theme.value, themeOverrides: overrides.value }, () => [
h(NButton, {}, () => '生成'),
h(NInput, { class: 'normal-input' }),
h(NInput, { status: 'error', class: 'error-input' }),
h(NCheckbox, { checked: true }),
h(NSelect, { options: [{ label: '第一集', value: 1 }] }),
h(NAlert, { type: 'error' }, () => '生成失败'),
h(NCard, {}, () => '内容'),
h(NTag, {}, () => '待设计'),
h(NTabs, { type: 'line', value: 'one' }, () => [
h(NTabPane, { name: 'one', tab: '人物' }, () => '人物内容'),
h(NTabPane, { name: 'two', tab: '场景' }, () => '场景内容')
]),
h(NTable, { striped: true }, () => h('tbody', [h('tr', [h('td', '记录')])]))
])
}
})
const value = (selector: string, name: string) =>
(wrapper!.get(selector).element as HTMLElement).style.getPropertyValue(name)
const control = dark ? '#2b2b2b' : '#f0f0f0'
for (const selector of ['.n-button', '.normal-input', '.n-base-selection', '.n-alert', '.n-card', '.n-tag']) {
expect(value(selector, '--n-border-radius')).toBe('0px')
}
for (const selector of ['.n-button', '.normal-input', '.n-base-selection']) {
expect(value(selector, '--n-border')).toBe('none')
}
// 普通按钮点击后不残留深绿描边,输入和选择控件仍保留聚焦边界。
expect(value('.n-button', '--n-border-focus')).toBe('none')
expect(value('.n-button', '--n-wave-opacity')).toBe('0')
expect(wrapper.getComponent(NButton).props('focusable')).toBe(true)
for (const selector of ['.normal-input', '.n-base-selection']) {
expect(value(selector, '--n-border-focus')).toContain(dark ? '#5cd693' : '#087c42')
}
expect(value('.n-button', '--n-color')).toBe(control)
expect(value('.normal-input', '--n-color')).toBe('var(--app-field)')
expect(value('.n-base-selection', '--n-color')).toBe('var(--app-field)')
expect(value('.n-checkbox', '--n-check-mark-color')).toBe('#ffffff')
expect(value('.n-alert', '--n-border')).toBe('none')
expect(value('.n-tag', '--n-border')).toBe('none')
expect(value('.n-card', '--n-border-color')).toBe('transparent')
expect(value('.n-table', '--n-border-color')).toBe('transparent')
expect(value('.error-input', '--n-border-error')).toContain(dark ? '#fa7373' : '#a93232')
expect(wrapper.get('.error-input').classes()).toContain('n-input--error-status')
// 不全局抹掉边框色:未勾选复选框和错误态继续由 Naive 的主题处理。
const theme = wrapper.getComponent(NConfigProvider).props('themeOverrides')!
expect(theme.common?.borderColor).not.toBe('transparent')
expect(theme.Checkbox).not.toHaveProperty('border')
expect(theme.Tabs?.barColor).toBe('#07c160')
expect(value('.n-tabs', '--n-tab-border-color')).toBe(dark ? '#333333' : '#dadada')
})
it('普通按钮只在键盘焦点可见时显示外框,不用失焦或禁止聚焦隐藏点击反馈', () => {
// DOM 环境不模拟真实鼠标/键盘焦点判定,保护共享 CSS 与触发器契约。
const css = readAllStyles()
expect(css).toMatch(
/\.n-button:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--app-accent-text\);[^}]*outline-offset:\s*2px;/
)
const tools = readFileSync('src/components/ui/WorkspaceTools.vue', 'utf8')
expect(tools).not.toContain('.blur(')
expect(tools).not.toContain(':focusable="false"')
})
it('图库筛选吸顶,关联内容横向单行滚动,不给头部和图片区制造双重滚动边界', () => {
// 静态保护吸顶和横向尺寸规则;真实视口滚动与位置仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.gallery-workspace-page \.gallery-sticky-controls\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*10;[^}]*background:\s*var\(--app-body\);/
)
expect(css).toMatch(
/\.gallery-workspace-page \.workspace-scroll > \.n-scrollbar-container\s*\{[^}]*overflow-anchor:\s*none;/
)
expect(css).toMatch(/\.asset-impact-links\s*\{[^}]*width:\s*max-content;[^}]*white-space:\s*nowrap;/)
expect(css).toMatch(/\.gallery-sticky-controls > \.form-image-filter-region\s*\{[^}]*margin-block:\s*0;/)
// 间距归列表所有,吸顶时底部不留固定色带。
expect(css).toMatch(/\.gallery-workspace-page \.form-image-grid\s*\{[^}]*padding-top:\s*12px;/)
const sticky = css.match(/\.gallery-workspace-page \.gallery-sticky-controls\s*\{([^}]*)\}/)?.[1]
expect(sticky).not.toMatch(/padding|border-bottom|::after/)
expect(css).toMatch(
/\.asset-impact-links\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*32px;[^}]*padding-block:\s*6px;/
)
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
/<WorkspacePage\b[^>]*>\s*<template #default>/
)
})
it('瀑布流自适应列宽,卡片保持完整并按封面尺寸预留高度', () => {
const css = readAllStyles()
expect(css).toMatch(
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*block;[^}]*columns:\s*260px;[^}]*column-gap:\s*20px;/
)
expect(css).toMatch(
/\.form-image-masonry > \.form-image-card\s*\{[^}]*break-inside:\s*avoid;[^}]*margin-bottom:\s*20px;/
)
expect(css).toMatch(
/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*var\(--form-image-aspect,\s*4\s*\/\s*3\);/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding(?:-block)?:\s*14px;[^}]*background:\s*var\(--app-subtle\);/
)
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
expect(css).toMatch(
/\.form-image-toolbar\.has-impact-picker\s*\{[^}]*grid-template-columns:\s*minmax\(220px, 340px\) minmax\(0, 1fr\);/
)
expect(css).toMatch(
/@container \(max-width: 1200px\)\s*\{\s*\.form-image-toolbar\.has-impact-picker,\s*\.form-image-toolbar\.has-impact-picker\.has-impact-actions\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/
)
expect(css).toMatch(/\.asset-impact-picker\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*8px;/)
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*gap:\s*10px/)
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*padding(?:-block)?:\s*5px/)
expect(css).toMatch(/\.asset-impact-context\s*\{[^}]*margin:\s*0/)
})
it('单图标按钮以当前控件高度为边长,尺寸与内容按钮互不影响', () => {
// happy-dom 不计算几何尺寸,此项约束共用样式和调用方,实际布局仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.n-button\.icon-button\s*\{[^}]*width:\s*var\(--n-height\);[^}]*min-width:\s*var\(--n-height\);[^}]*height:\s*var\(--n-height\);[^}]*padding:\s*0/
)
expect(css).toMatch(
/\.project-status-filters \.n-radio-button\s*\{[^}]*min-width:\s*76px;[^}]*padding-inline:\s*18px/
)
expect(css).toMatch(/\.project-status-filters\.n-radio-group\s*\{[^}]*flex-wrap:\s*wrap;[^}]*height:\s*auto;/)
for (const file of [
'src/App.vue',
'src/components/ui/ThemeToggle.vue',
'src/components/ui/WorkspaceTools.vue',
'src/features/projects/ProjectsPage.vue',
'src/features/subject-identity/SubjectIdentityPage.vue'
]) {
const source = readFileSync(file, 'utf8')
expect(source).toContain('icon-button')
expect(source).not.toMatch(/<NButton\b[^>]*\bcircle\b/)
}
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
})
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
// happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。
const css = readAllStyles()
expect(css).not.toMatch(/border-radius:\s*[1-9]/)
expect(css).not.toMatch(/border(?:-[\w]+)?:\s*1px (?:solid|dashed) var\(--(?:app-border|color-line)\)/)
expect(css).toMatch(/\.surface-inset\s*\{[^}]*background:\s*var\(--app-subtle\)/)
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:/)
expect(css).toMatch(/\.directory-group-heading\s*\{[^}]*background:\s*var\(--app-control\)/)
expect(css).toMatch(/\.directory-status\s*\{[^}]*background:\s*var\(--app-control\)/)
expect(css).toMatch(/\.n-button\.directory-item:focus-visible\s*\{[^}]*outline:\s*2px/)
expect(css).toMatch(
/\.n-button\.directory-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/
)
})
it('斑马纹首项保留统一顶部留白,背景色平滑过渡并遵循减少动态效果设置', () => {
// DOM 环境不计算实际内边距,检查共享规则及之前覆盖首项留白的工具类。
const css = readFileSync('src/styles/styles.css', 'utf8')
expect(css).toMatch(
/\.record-list > article\s*\{[^}]*padding:\s*20px;[^}]*transition:\s*background-color 160ms ease;/
)
expect(css).toMatch(
/@media \(prefers-reduced-motion: reduce\)[\s\S]*transition-duration:\s*0\.01ms !important;/
)
for (const file of [
'src/features/breakdown/components/SubjectList.vue',
'src/features/create-drama/CreateDramaPage.vue'
]) {
expect(readFileSync(file, 'utf8')).not.toContain('first:pt-0')
}
})
it('所有页面不再通过工具类添加装饰边框或圆角', () => {
const files = readdirSync('src', { recursive: true, encoding: 'utf8' }).filter(path => path.endsWith('.vue'))
for (const path of files) {
const source = readFileSync(`src/${path}`, 'utf8')
for (const match of source.matchAll(/\bclass="([^"]*)"/g)) {
expect(match[1]).not.toMatch(
/(?:^|\s)(?:rounded(?:-[\w-]+)?|border|border-[trbl](?:-\d+)?|divide-[xy])(?:\s|$)/
)
}
}
})
})
+368
View File
@@ -0,0 +1,368 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NConfigProvider, NDrawer, NDrawerContent, NMenu, NModal, NScrollbar, NTooltip } from 'naive-ui'
import App from '@/App.vue'
import WorkspacePage from '@/components/ui/WorkspacePage.vue'
import AppDialog from '@/components/ui/AppDialog.vue'
import WorkspaceTools from '@/components/ui/WorkspaceTools.vue'
import ProjectLayout from '@/features/projects/ProjectLayout.vue'
import ConfirmAction from '@/features/workflows/ConfirmAction.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import { projectsApi } from '@/features/projects/api'
import { readFileSync } from 'node:fs'
import { drawerPanel } from '@/testing/naive'
import { readAllStyles } from '@/testing/styles'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
localStorage.clear()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
describe('管理后台组件边界', () => {
it('Tabs suffix 与标签共用导航行,窄屏换行但不产生外层滚动', () => {
// DOM 测试不计算坐标;保护对齐尺寸与容器断点,视觉验收仍需浏览器。
const css = readAllStyles()
expect(css).toMatch(/\.workspace-tabs > \.n-tabs-nav\s*\{[^}]*align-items:\s*stretch;/)
expect(css).toMatch(/\.workspace-tabs \.n-tabs-nav__suffix\s*\{[^}]*align-items:\s*center;/)
expect(css).toMatch(/\.tabs-toolbar-actions\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*40px;/)
expect(css).toContain('@container tabs-toolbar (max-width: 600px)')
expect(css).not.toContain('.result-toolbar > .n-button')
})
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
const adminCss = readAllStyles()
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
})
it('业务样式不再直接开启浏览器原生滚动条', () => {
const css = readAllStyles()
expect(css).not.toMatch(/overflow(?:-[xy])?\s*:\s*(?:auto|scroll)\b/)
})
it('历史缩略图尺寸在非分层样式中覆盖 Naive 按钮,原图不能撑大预览或横向条目', () => {
// happy-dom 不计算几何;专门保护此前失效的层叠和固定尺寸约束。
const css = readFileSync('src/styles/admin.css', 'utf8')
expect(css).not.toContain('@layer')
const all = readAllStyles()
expect(all).toMatch(
/\.n-button\.image-history-item\s*\{[^}]*width:\s*128px;[^}]*min-width:\s*128px;[^}]*max-width:\s*128px;[^}]*flex:\s*0 0 128px;[^}]*padding:\s*6px/
)
expect(all).toMatch(/\.image-history-item \.asset-image\s*\{[^}]*height:\s*88px;[^}]*min-height:\s*0/)
expect(all).toMatch(
/\.asset-image\.asset-image-preview\s*\{[^}]*height:\s*min\(42dvh,\s*420px\);[^}]*aspect-ratio:\s*auto/
)
expect(all).toMatch(/\.image-history\.n-scrollbar\s*\{[^}]*max-width:\s*100%;[^}]*min-width:\s*0/)
expect(all).toMatch(/\.image-history-content\s*\{[^}]*display:\s*flex;[^}]*width:\s*max-content/)
})
it('选角面板保留说明间距,卡片按可用宽度排列并覆盖按钮默认高度', () => {
// 保护间距与换行契约,真实宽高和暗色效果仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(/\.identity-tools-intro\s*\{[^}]*margin-bottom:\s*24px/)
expect(css).toMatch(
/\.casting-list\s*\{[^}]*repeat\(auto-fit, minmax\(min\(100%, 260px\), 1fr\)\);[^}]*gap:\s*12px;[^}]*margin-top:\s*24px/
)
expect(css).toMatch(
/\.n-button\.casting-item\s*\{[^}]*height:\s*auto;[^}]*min-height:\s*76px;[^}]*padding:\s*12px 14px/
)
expect(css).toMatch(/\.casting-item-name\s*\{[^}]*overflow-wrap:\s*anywhere/)
expect(css).toMatch(/\.casting-item-status\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.n-button\.casting-item\.selected\s*\{[^}]*var\(--app-selected\)[^}]*var\(--app-accent\)/)
})
it('拆解分栏跟随剩余高度收缩,移动端历史区不挤出结果区', () => {
// DOM 环境不计算几何,保护完整的 flex → grid → NScrollbar 高度链。
const css = readAllStyles()
expect(css).toMatch(/\.breakdown-results-section\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
expect(css).toMatch(/\.breakdown-results-section > \.result-toolbar\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(
/\.breakdown-results-section > \.content-with-history\s*\{[^}]*flex:\s*1;[^}]*height:\s*auto;[^}]*min-height:\s*0;[^}]*grid-template-rows:\s*minmax\(0, 1fr\)/
)
expect(css).toContain('grid-template-rows: minmax(0, 1fr) min(25%, 130px)')
expect(css).toMatch(/\.panel-scroll\.n-scrollbar\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0/)
})
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
wrapper = mount(WorkspacePage, {
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
})
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
const content = wrapper.get('.workspace-scroll')
expect(content.classes()).toContain('n-scrollbar')
expect(content.text()).toBe('正文')
expect(content.find('h2').exists()).toBe(false)
expect(wrapper.get('.workspace-heading-scroll .n-scrollbar-content').text()).toBe('固定操作区')
})
it('分栏工作区不再包一层整页滚动', () => {
wrapper = mount(WorkspacePage, {
props: { split: true },
slots: { default: '<aside>目录</aside><article>正文</article>' }
})
expect(wrapper.findComponent(NScrollbar).exists()).toBe(false)
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
})
it('图标主题菜单保留三种模式,设置入口位于侧栏底部并支持折叠', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
{
path: '/projects/:projectId/:workspace',
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
}
]
})
await router.push('/projects/test/production')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8)
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
expect(wrapper.get('.theme-toggle').text()).toBe('')
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
for (const [key, label, icon] of [
['dark', '暗黑模式', 'moon'],
['light', '浅色模式', 'sun'],
['system', '跟随系统', 'monitor']
] as const) {
await wrapper.get('.theme-toggle').trigger('click')
await flushPromises()
const option = [...document.querySelectorAll<HTMLElement>('.n-dropdown-option-body')].find(
item => item.textContent?.trim() === label
)
expect(option).toBeDefined()
option!.click()
await flushPromises()
expect(localStorage.getItem('drama-studio-theme')).toBe(key)
expect(wrapper.get('.theme-toggle svg').classes()).toContain(`lucide-${icon}`)
expect(wrapper.get('.theme-toggle').attributes('aria-label')).toBe(`主题模式:${label}`)
const expectedTheme = key === 'system' ? systemTheme : key
expect(document.documentElement.dataset.theme).toBe(expectedTheme)
expect(wrapper.getComponent(NConfigProvider).props('theme')?.name ?? 'light').toBe(expectedTheme)
}
expect(wrapper.find('.admin-topbar [aria-label="后端连接"]').exists()).toBe(false)
expect(wrapper.find('.admin-nav-scroll [aria-label="后端连接"]').exists()).toBe(false)
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
expect(wrapper.getComponent(NMenu).props()).toMatchObject({
collapsedWidth: 64,
iconSize: 20,
collapsedIconSize: 20,
indent: 22,
rootIndent: 22
})
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
const settings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(settings.text()).toBe('')
await settings.trigger('click')
await flushPromises()
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8)
})
it('侧栏菜单独立滚动,页脚不参与滚动且窄屏展开平移主栏', () => {
const css = readAllStyles()
expect(css).toMatch(/\.admin-sider\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;[^}]*height:\s*100%/)
expect(css).toMatch(/\.admin-nav-scroll\.n-scrollbar\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0/)
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toContain('@media (max-width: 800px)')
expect(css).toMatch(/transition:\s*width\s+0\.32s/)
expect(css).toContain('calc(100% - var(--admin-sider-collapsed))')
expect(css).toContain('@media (prefers-reduced-motion: reduce)')
expect(css).not.toContain('.admin-sider-mask')
expect(css).not.toContain('.admin-sider.is-overlay')
for (const path of ['storyboard/StoryboardPage.vue', 'production/ProductionPage.vue']) {
const source = readFileSync(`src/features/${path}`, 'utf8')
expect(source).not.toContain('<span>剧集</span')
expect(source).toMatch(/aria-label="选择(?:分镜|生产)剧集"/)
}
})
it('窄屏展开导航复用同一侧栏并右移主栏,不压缩主栏宽度', async () => {
const realMatchMedia = window.matchMedia.bind(window)
vi.stubGlobal('matchMedia', (query: string) => {
if (query.includes('max-width: 800px')) {
return {
matches: true,
media: query,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent() {
return false
}
}
}
return realMatchMedia(query)
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
{
path: '/projects/:projectId/:workspace',
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
}
]
})
await router.push('/projects/test/production')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.find('.admin-nav-drawer').exists()).toBe(false)
await wrapper.get('[aria-label="展开侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-collapsed')
expect(wrapper.get('.admin-sider').classes()).not.toContain('is-overlay')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 208px')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8)
expect(wrapper.get('[aria-label="折叠侧栏"]').attributes('aria-expanded')).toBe('true')
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
})
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
wrapper = mount(AppDialog, {
attachTo: document.body,
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
slots: { default: '<p>内容</p>' }
})
await flushPromises()
expect(wrapper.getComponent(NModal).props()).toMatchObject({
closable: false,
maskClosable: false,
closeOnEsc: false
})
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
})
it('抽屉挂载 body 覆盖完整视口,开关保留列表与配置草稿', async () => {
wrapper = mount(WorkspacePage, {
attachTo: document.body,
props: { split: true, compact: true },
slots: {
header: () => h(WorkspaceTools, { title: '测试操作' }, () => h('input', { 'aria-label': '配置草稿' })),
default: '<article class="test-list">主体列表</article>'
}
})
const list = wrapper.get('.test-list').element
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
expect(wrapper.get('.workspace-heading').find('[data-workspace-tools]').exists()).toBe(true)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props()).toMatchObject({
to: 'body',
blockScroll: true,
placement: 'right',
width: 'min(960px, 100%)',
trapFocus: true,
closeOnEsc: true
})
expect(wrapper.getComponent(NDrawerContent).props('nativeScrollbar')).toBe(false)
expect(wrapper.find('.workspace-tools-drawer').exists()).toBe(false)
const container = drawerPanel().element.closest('.n-drawer-container')!
expect(container.parentElement).toBe(document.body)
expect(container.closest('main, .admin-content, .workspace-page')).toBeNull()
expect(container.querySelector('.n-drawer-mask')).not.toBeNull()
expect(drawerPanel().find('.n-scrollbar-container').exists()).toBe(true)
await drawerPanel().get('input[aria-label="配置草稿"]').setValue('保留配置')
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
expect(wrapper.get('.test-list').element).toBe(list)
await wrapper.get('[data-workspace-tools]').trigger('click')
await flushPromises()
expect(drawerPanel().get<HTMLInputElement>('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
container.querySelector<HTMLElement>('.n-drawer-mask')!.click()
await flushPromises()
expect(wrapper.getComponent(NDrawer).props('show')).toBe(false)
expect(wrapper.get('.test-list').element).toBe(list)
wrapper.unmount()
wrapper = undefined
expect(document.querySelector('.n-drawer-container')).toBeNull()
})
it('完成项目解锁全部左侧链接,标题不再显示状态标签和刷新按钮', async () => {
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue({
id: 'navigation-test',
title: '导航测试项目',
topic: '测试主题',
style: null,
status: 'completed',
createdAt: '',
updatedAt: '',
episodes: [],
characters: [],
world: null,
reviews: [],
tasks: []
})
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const paths = [
'create-drama',
'breakdown',
'visual-style',
'subject-identity',
'subject-images',
'storyboard',
'production'
]
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/projects/:projectId',
component: ProjectLayout,
children: paths.map(path => ({
path,
component: { render: () => h(WorkspacePage, {}, () => path) }
}))
}
]
})
await router.push('/projects/navigation-test/create-drama')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('[aria-label="项目工作流"]').exists()).toBe(false)
expect(wrapper.get('.project-title').text()).toBe('导航测试项目')
for (const path of paths) {
await wrapper.get(`.n-menu a[href="/projects/navigation-test/${path}"]`).trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/projects/navigation-test/' + path)
expect(wrapper.get('.project-view .workspace-page').text()).toBe(path)
}
expect(detail).toHaveBeenCalledOnce()
expect(wrapper.find('.project-header .n-button').exists()).toBe(false)
expect(wrapper.find('.project-header .n-tag').exists()).toBe(false)
})
it('确认按钮有稳定根元素承接调用方间距,生产按钮显式保留上下留白', () => {
wrapper = mount(ConfirmAction, {
props: { label: '测试操作', description: '仅展示,不提交请求' },
attrs: { class: 'mt-4' }
})
expect(wrapper.element.tagName).toBe('SPAN')
expect(wrapper.classes()).toContain('confirm-action')
expect(wrapper.classes()).toContain('mt-4')
expect(wrapper.get('button').text()).toBe('测试操作')
expect(readAllStyles()).toMatch(
/\.production-pipeline-card > \.confirm-action\s*\{[^}]*margin-block:\s*16px 4px/
)
})
})
+86
View File
@@ -0,0 +1,86 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePolling } from '@/composables/usePolling'
afterEach(() => vi.useRealTimers())
describe('异步轮询生命周期', () => {
it('关闭定时刷新后仍支持首次读取、手动刷新及查询目标切换', async () => {
vi.useFakeTimers()
const key = ref('first')
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
const scope = effectScope()
const query = scope.run(() => usePolling(key, loader, false))!
await Promise.resolve()
expect(query.data.value).toBe('first')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(1)
await query.refresh()
expect(loader).toHaveBeenCalledTimes(2)
key.value = 'second'
await nextTick()
await Promise.resolve()
expect(query.data.value).toBe('second')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
})
it('响应式暂停清除旧定时器,恢复只创建一个轮询链', async () => {
vi.useFakeTimers()
const interval = ref<number | false>(1000)
const loader = vi.fn<() => Promise<string>>(async () => '数据')
const scope = effectScope()
scope.run(() => usePolling(ref('p'), loader, interval))
await Promise.resolve()
interval.value = false
await nextTick()
await vi.advanceTimersByTimeAsync(5000)
expect(loader).toHaveBeenCalledTimes(1)
interval.value = 1000
await nextTick()
await vi.advanceTimersByTimeAsync(2000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
await vi.advanceTimersByTimeAsync(5000)
expect(loader).toHaveBeenCalledTimes(3)
})
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
vi.useFakeTimers()
const key = ref('first')
const finish = new Map<string, (value: string) => void>()
const loader = vi.fn<(id: string) => Promise<string>>(
(id: string) =>
new Promise<string>(resolve => {
finish.set(id, resolve)
})
)
const scope = effectScope()
const query = scope.run(() => usePolling(key, loader, 1000))!
key.value = 'second'
await nextTick()
finish.get('second')!('新项目')
await Promise.resolve()
finish.get('first')!('旧项目')
await Promise.resolve()
expect(query.data.value).toBe('新项目')
scope.stop()
await vi.advanceTimersByTimeAsync(10_000)
expect(loader).toHaveBeenCalledTimes(2)
})
it('失败时保留已有数据,并向页面显示错误', async () => {
const scope = effectScope()
const loader = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('上次成功数据')
.mockRejectedValueOnce(new Error('连接已断开'))
const query = scope.run(() => usePolling(ref('p'), loader))!
await Promise.resolve()
await query.refresh()
expect(query.data.value).toBe('上次成功数据')
expect(query.error.value).toBe('连接已断开')
scope.stop()
})
})
+192
View File
@@ -0,0 +1,192 @@
import { effectScope, nextTick, type EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useTheme } from '@/composables/useTheme'
import { readFileSync } from 'node:fs'
let scope: EffectScope | undefined
afterEach(() => {
scope?.stop()
scope = undefined
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
/** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */
function setup(dark = false) {
const listeners = new Set<(event: MediaQueryListEvent) => void>()
vi.stubGlobal('matchMedia', () => ({
matches: dark,
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)
}))
scope = effectScope()
const state = scope.run(useTheme)!
return {
...state,
listeners,
change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
}
}
/** 将十六进制颜色换算为 sRGB 相对亮度。 */
function luminance(hex: string) {
const channels = [1, 3, 5].map(index => {
const value = parseInt(hex.slice(index, index + 2), 16) / 255
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
}
/** 检查主题文字对比度,避免绿色按钮和浅色选中项难以辨认。 */
function contrast(foreground: string, background: string) {
const values = [luminance(foreground), luminance(background)]
return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05)
}
describe('微信风格明暗主题', () => {
it('浅色 Tabs 分隔线使用约定的 #dadada,与页面和内容表面保留灰阶差异', () => {
const { overrides } = setup()
const color = overrides.value.Tabs!.tabBorderColor!
if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串')
expect(color).toBe('#dadada')
expect(contrast(color, '#ededed')).toBeGreaterThan(1.15)
expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15)
})
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
const state = setup()
expect(state.preference.value).toBe('system')
expect(document.documentElement.dataset.theme).toBe('light')
state.change(true)
await nextTick()
expect(state.isDark.value).toBe(true)
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(state.listeners.size).toBe(1)
scope!.stop()
expect(state.listeners.size).toBe(0)
})
it('手动选择优先于系统,并持久化到下一次挂载', async () => {
const state = setup(true)
state.preference.value = 'light'
await nextTick()
state.change(true)
await nextTick()
expect(state.isDark.value).toBe(false)
expect(localStorage.getItem('drama-studio-theme')).toBe('light')
scope!.stop()
const restored = setup(true)
expect(restored.preference.value).toBe('light')
expect(restored.theme.value).toBeNull()
})
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
const state = setup()
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.common?.bodyColor).toBe('#ededed')
state.preference.value = 'dark'
await nextTick()
expect(state.theme.value?.name).toBe('dark')
expect(state.overrides.value.common?.primaryColor).toBe('#07c160')
expect(state.overrides.value.common?.bodyColor).toBe('#111111')
expect(state.overrides.value.Button?.textColorPrimary).toBe('#ffffff')
expect(document.documentElement.dataset.theme).toBe('dark')
})
it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => {
const state = setup(dark)
const { common, Menu, Button, Layout, Input, Select } = state.overrides.value
const css = readFileSync('src/styles/admin.css', 'utf8')
const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]!
const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]!
const tokens = Object.fromEntries(
[...(lightTokens + (dark ? darkTokens : '')).matchAll(/--app-([\w-]+):\s*(#[\da-f]+);/g)].map(match => [
match[1],
match[2]
])
)
expect(common).toMatchObject({
bodyColor: tokens.body,
cardColor: tokens.surface,
modalColor: tokens.surface,
tableColor: tokens.surface,
inputColor: tokens.surface,
borderColor: tokens.border,
textColor1: tokens.ink,
textColor3: tokens.muted,
primaryColor: tokens.accent,
successColor: tokens.success,
errorColor: tokens.danger
})
expect(Layout).toMatchObject({ color: tokens.body, headerColor: tokens.surface, siderColor: tokens.subtle })
expect(Menu).toMatchObject({
borderRadius: '0px',
itemColorActive: tokens.selected,
itemColorActiveCollapsed: tokens.selected,
itemTextColorActive: tokens['accent-text']
})
// 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。
expect(common?.borderRadius).toBe('0px')
expect(common?.borderRadiusSmall).toBe('0px')
expect(Input?.color).toBe('var(--app-field)')
expect(Button?.color).toBe(tokens.control)
expect(Button?.colorHover).toBe(tokens['control-hover'])
expect(Select?.peers?.InternalSelection?.color).toBe('var(--app-field)')
expect(contrast(tokens.field!, tokens.body!)).toBeGreaterThan(1.1)
expect(contrast(tokens.control!, tokens.surface!)).toBeGreaterThan(1.1)
expect(state.overrides.value.Checkbox?.checkMarkColor).toBe('#ffffff')
expect(Button?.textColorPrimary).toBe(tokens['on-accent'])
expect(Button?.colorPrimary).toBe(tokens['button-primary'])
expect(Button?.colorHoverPrimary).toBe(tokens['button-hover'])
expect(Button?.colorPressedPrimary).toBe(tokens['button-pressed'])
expect(Button?.colorDisabledPrimary).toBe(tokens['disabled-bg'])
expect(Button?.textColorDisabledPrimary).toBe(tokens['disabled-text'])
for (const background of [Button!.colorPrimary!, Button!.colorHoverPrimary!, Button!.colorPressedPrimary!]) {
expect(contrast(String(Button!.textColorPrimary), String(background))).toBeGreaterThanOrEqual(4.5)
}
expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5)
expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5)
expect(contrast(tokens['accent-text']!, tokens.selected!)).toBeGreaterThanOrEqual(4.5)
})
it('系统切换时浏览器主题色与页面同步', async () => {
const meta = document.createElement('meta')
meta.name = 'theme-color'
document.head.appendChild(meta)
try {
const state = setup()
expect(meta.content).toBe('#ededed')
state.change(true)
await nextTick()
expect(meta.content).toBe('#111111')
state.preference.value = 'light'
await nextTick()
expect(meta.content).toBe('#ededed')
} finally {
meta.remove()
}
})
it('无效存储回退系统模式', () => {
localStorage.setItem('drama-studio-theme', 'unexpected')
const state = setup(true)
expect(state.preference.value).toBe('system')
expect(state.isDark.value).toBe(true)
})
it('禁用存储仍能切换当前会话的主题', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage blocked')
})
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage blocked')
})
const state = setup()
state.preference.value = 'dark'
await nextTick()
expect(document.documentElement.dataset.theme).toBe('dark')
})
})
@@ -0,0 +1,438 @@
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,
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('拆解设置保留输入标签、单行单位与模块说明,改版后仍校验模块和剧本状态', 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()).toBe('每组集数')
expect(config.get<HTMLInputElement>('#group-size').element.value).toBe('3')
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
expect(config.get('fieldset > legend').text()).toBe('抽取模块')
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')
}
expect(preview.element.disabled).toBe(true)
expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
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*28px;/)
expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin(?:-bottom)?:\s*(?:0 0 )?8px/)
expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*line-height:\s*20px/)
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 + readFileSync('src/styles/styles.css', 'utf8')).not.toContain('.search-field')
})
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-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/)
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)
})
})
@@ -0,0 +1,197 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EpisodeReader from '@/features/create-drama/EpisodeReader.vue'
import { readAllStyles } from '@/testing/styles'
import type { Episode } from '@/features/projects/types'
const episodes: Episode[] = [
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
{ episode: 2, title: '雨夜', content: '<script>不要执行模型内容</script>', hook: '门外有人' },
{ episode: 3, title: '重逢', content: '' }
]
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** happy-dom 不计算排版;注入明确的容器几何,但保持真实 NScrollbar 和原生 scroll 事件路径。 */
async function mountReader() {
wrapper = mount(EpisodeReader, { props: { episodes } })
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
const directory = wrapper.get<HTMLElement>('.reader-directory-scroll .n-scrollbar-container').element
const tops: Record<number, number> = { 1: 32, 2: 700, 3: 1480 }
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 400 })
Object.defineProperty(directory, 'clientHeight', { configurable: true, value: 164 })
const originalRect = HTMLElement.prototype.getBoundingClientRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this === reader) return new DOMRect(0, 100, 600, reader.clientHeight)
if (this === directory) return new DOMRect(0, 50, 200, directory.clientHeight)
if (this.matches('.reader-chapter')) {
return new DOMRect(0, 100 + tops[Number(this.dataset.episode)]! - reader.scrollTop, 600, 600)
}
if (this.matches('.reader-episode-link')) {
return new DOMRect(0, 58 + (Number(this.dataset.episode) - 1) * 72 - directory.scrollTop, 180, 64)
}
return originalRect.call(this)
})
const readerScroll = vi.spyOn(reader, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
// 模拟浏览器边界:末集最小高度及上下留白让其标题能抵达阅读线。
reader.scrollTop = Math.max(0, Math.min(options.top ?? 0, Math.max(...Object.values(tops)) - 32))
reader.dispatchEvent(new Event('scroll'))
})
const directoryScroll = vi.spyOn(directory, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
if (typeof options !== 'object') return
directory.scrollTop = Math.max(0, options.top ?? 0)
directory.dispatchEvent(new Event('scroll'))
})
await wrapper.setProps({ episodes: [...episodes] })
return { reader, directory, tops, readerScroll, directoryScroll }
}
/** 获取目录中的唯一当前集,避免只验证按钮类名而漏掉无障碍状态。 */
function currentEpisode() {
const current = wrapper!.findAll('.reader-episode-link[aria-current="location"]')
expect(current).toHaveLength(1)
return Number(current[0]!.attributes('data-episode'))
}
describe('连续剧本阅读器', () => {
it('正文容器靠左并保留目录间距,宽屏不再使用自动外边距居中', () => {
// DOM 测试不计算布局;保护正文定位样式,目录联动仍由后续交互测试验证。
const css = readAllStyles()
const content = css.match(/\.reader-content\s*\{([^}]+)\}/)![1]!
expect(content).toContain('max-width: 900px')
expect(content).toContain('margin-inline: 0')
expect(content).toContain('padding: 32px')
expect(content).toContain('text-align: left')
expect(content).not.toContain('auto')
expect(css).toMatch(/@media \(max-width: 600px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/)
})
it('按编号连续渲染全部剧集、元数据和空正文,标题不截断,模型内容只按文本显示', () => {
const reversed = episodes.toReversed()
wrapper = mount(EpisodeReader, { props: { episodes: reversed } })
expect(wrapper.findAll('.reader-chapter').map(item => Number(item.attributes('data-episode')))).toEqual([
1, 2, 3
])
expect(reversed[0]?.episode).toBe(3)
expect(wrapper.findAll('.reader-chapter .script-body')).toHaveLength(2)
expect(wrapper.get('.script-summary').text()).toBe('第一集摘要')
expect(wrapper.text()).toContain('信件失踪')
expect(wrapper.text()).toContain('门外有人')
expect(wrapper.text()).toContain('本集正文尚未写入')
expect(wrapper.text()).toContain('<script>不要执行模型内容</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.findAll('.n-scrollbar-container')).toHaveLength(2)
expect(wrapper.get('.reader-scroll [role="region"]').attributes('tabindex')).toBe('0')
expect(wrapper.get('.reader-episode-link').attributes('aria-controls')).toBe(
wrapper.get('.reader-chapter').attributes('id')
)
})
it('点击目录通过 NScrollbar 定位首集、中间集和短末集,不滚动 window', async () => {
const { reader, readerScroll } = await mountReader()
const pageScroll = vi.spyOn(window, 'scrollTo')
for (const [episode, top] of [
[2, 668],
[3, 1448],
[1, 0]
] as const) {
await wrapper!.get(`.reader-episode-link[data-episode="${episode}"]`).trigger('click')
expect(readerScroll).toHaveBeenLastCalledWith(expect.objectContaining({ top, behavior: 'auto' }))
expect(reader.scrollTop).toBe(top)
expect(currentEpisode()).toBe(episode)
}
expect(pageScroll).not.toHaveBeenCalled()
})
it('正文双向滚动同步当前集,目录项离开可见区时自动露出', async () => {
const { reader, directory, directoryScroll } = await mountReader()
reader.scrollTop = 1450
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(3)
expect(directoryScroll).toHaveBeenCalled()
expect(directory.scrollTop).toBeGreaterThan(0)
reader.scrollTop = 0
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(0)
})
it('同集内阅读不反复挪动目录,也不因目录滚动而改变正文', async () => {
const { reader, directory, directoryScroll, readerScroll } = await mountReader()
directory.scrollTop = 60
directory.dispatchEvent(new Event('scroll'))
reader.scrollTop = 120
reader.dispatchEvent(new Event('scroll'))
await nextTick()
expect(currentEpisode()).toBe(1)
expect(directory.scrollTop).toBe(60)
expect(directoryScroll).not.toHaveBeenCalled()
expect(readerScroll).not.toHaveBeenCalled()
})
it('后台刷新与追加剧集不销毁正文或跳回首集,新增集可以定位', async () => {
const { reader, tops } = await mountReader()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
const chapter = wrapper!.get('.reader-chapter[data-episode="2"]').element
tops[4] = 2200
await wrapper!.setProps({
episodes: [...episodes.map(item => ({ ...item })), { episode: 4, title: '回家', content: '结局' }]
})
expect(reader.scrollTop).toBe(668)
expect(currentEpisode()).toBe(2)
expect(wrapper!.get('.reader-chapter[data-episode="2"]').element).toBe(chapter)
await wrapper!.get('.reader-episode-link[data-episode="4"]').trigger('click')
expect(reader.scrollTop).toBe(2168)
expect(currentEpisode()).toBe(4)
})
it('内容尺寸和可见性变化重新测量,卸载释放尺寸监听', async () => {
const observers: TestObserver[] = []
/** 仅模拟尺寸通知;断言仍走真实组件挂载与卸载生命周期。 */
class TestObserver {
targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) {
observers.push(this)
}
observe(target: Element) {
this.targets.add(target)
}
unobserve(target: Element) {
this.targets.delete(target)
}
disconnect = vi.fn<() => void>(() => this.targets.clear())
notify() {
this.callback([], this as unknown as ResizeObserver)
}
}
vi.stubGlobal('ResizeObserver', TestObserver)
const { reader, tops } = await mountReader()
const observer = observers.find(item => item.targets.has(wrapper!.get('.reader-content').element))!
expect(observer).toBeDefined()
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 0 })
observer.notify()
await nextTick()
expect(currentEpisode()).toBe(2)
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 500 })
tops[2] = 500
observer.notify()
await nextTick()
expect(wrapper!.get('.reader-content').attributes('style')).toContain('--reader-height: 500px')
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
expect(reader.scrollTop).toBe(468)
wrapper!.unmount()
wrapper = undefined
expect(observer.disconnect).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,270 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectContextKey } from '@/features/projects/context'
import { testProjectContext } from '@/testing/project-context'
import { getOperation } from '@/features/workflows/operations'
import { formFixture } from '@/features/subject-images/testing/fixtures'
import { directionsResult } from '@/features/storyboard/testing/fixtures'
import { checkPipeline, useAdvancedProduction } from '@/features/production/useAdvancedProduction'
import { useProduction } from '@/features/production/useProduction'
import { getProductionSession } from '@/features/production/model'
import AdvancedProduction from '@/features/production/components/AdvancedProduction.vue'
import { expandSections } from '@/testing/naive'
let wrapper: VueWrapper | undefined
/** 从实际挂载的确认弹窗查找操作按钮。 */
function button(label: string) {
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
for (const id of ['capability-test', 'new-project']) {
Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' })
Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null })
}
})
/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */
function server() {
const context = testProjectContext()
const form = formFixture('capability-test')
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
inProgress: 0,
stalePrimaryKeyframe: 0,
items: [
{
shotId: 'shot-1',
shotNo: 1,
episodeNo: 1,
beatNo: 1,
status: 'ready',
issues: [] as { code: string; reason: string }[],
primaryKeyframeId: 'keyframe-1',
primaryKeyframeStale: false
}
]
}
const receipt = {
projectId: 'capability-test',
completed: true,
needsManualReview: false,
stopReason: '',
errors: ['一个提示词未生成'],
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
let data: unknown = context.project.value
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
else if (path.endsWith('/subject-forms')) data = [form]
else if (path.includes('/readiness')) data = readiness
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
else if (path.endsWith('/videos/status'))
data = {
total: 1,
completed: 0,
queued: 0,
running: 0,
pending: 0,
failed: 0,
cancelled: 0,
notStarted: 1,
items: []
}
else if (init?.method === 'POST')
data = path.endsWith('/production/start')
? receipt
: { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] }
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
context,
form,
readiness,
receipt,
fetcher,
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
}
}
async function advanced() {
const data = server()
let service!: ReturnType<typeof useAdvancedProduction>
wrapper = mount(
defineComponent({
setup() {
service = useAdvancedProduction()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: data.context } } }
)
await flushPromises()
return { ...data, service }
}
describe('高级串联生产的安全边界', () => {
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
const { service, fetcher, posts } = await advanced()
expect(fetcher).not.toHaveBeenCalled()
await service.start()
expect(posts()).toHaveLength(0)
await service.preflight()
expect(service.check.value?.issues).toEqual([])
expect(posts()).toHaveLength(0)
await service.start()
expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
expect(
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
).toHaveLength(2)
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
expect(service.check.value).toBeNull()
})
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
const { service, readiness, posts } = await advanced()
await service.preflight()
readiness.items[0]!.primaryKeyframeStale = true
await service.start()
expect(posts()).toHaveLength(0)
expect(service.check.value?.issues.join()).toContain('有效主首帧')
expect(getOperation('capability-test').error).toContain('条件变化')
})
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
const { context, form, readiness } = server()
context.data.value!.project.status = 'need_review'
form.images = []
readiness.inProgress = 1
const result = await checkPipeline('capability-test')
expect(result.issues.join()).toContain('剧本尚未完成')
expect(result.issues.join()).toContain('所有形态主图')
expect(result.issues.join()).toContain('活动视频任务')
})
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
const { readiness } = server()
readiness.items[0]!.status = 'skipped'
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
const result = await checkPipeline('capability-test')
expect(result.issues.join()).toContain('视频前置检查未通过')
})
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
const { service, context, posts } = await advanced()
await service.preflight()
getOperation('capability-test').pending = true
await service.start()
expect(posts()).toHaveLength(0)
getOperation('capability-test').pending = false
context.data.value!.project.status = 'failed'
await service.start()
expect(posts()).toHaveLength(0)
context.data.value!.project.id = 'new-project'
await flushPromises()
expect(service.check.value).toBeNull()
})
it('已提交的长请求只写回原项目的回执,不污染新项目', async () => {
const { service, context, fetcher, receipt } = await advanced()
await service.preflight()
const original = fetcher.getMockImplementation()!
let finish!: (value: Response) => void
fetcher.mockImplementation((url, init) =>
String(url).endsWith('/production/start')
? new Promise(resolve => {
finish = resolve
})
: original(url, init)
)
const pending = service.start()
await flushPromises()
context.data.value!.project.id = 'new-project'
await flushPromises()
finish(new Response(JSON.stringify({ data: receipt })))
await pending
expect(service.session.value.pipelineReceipt).toBeNull()
expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test')
})
it('回执项目不匹配时不发布成功回执', async () => {
const { service, receipt } = await advanced()
await service.preflight()
receipt.projectId = 'wrong'
await service.start()
expect(service.session.value.pipelineReceipt).toBeNull()
expect(getOperation('capability-test').error).toContain('回执项目不匹配')
})
it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => {
const { context, receipt, posts } = server()
wrapper = mount(AdvancedProduction, {
attachTo: document.body,
global: { provide: { [projectContextKey as symbol]: context } }
})
await flushPromises()
await expandSections()
expect(button('启动串联生产').disabled).toBe(true)
button('检查串联生产条件').click()
await flushPromises()
button('启动串联生产').click()
await flushPromises()
expect(button('确认启动串联生产').disabled).toBe(true)
expect(posts()).toHaveLength(0)
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
await flushPromises()
button('确认启动串联生产').click()
await flushPromises()
expect(posts()).toHaveLength(1)
expect(document.body.textContent).toContain('流程返回不等于成片完成')
expect(document.body.textContent).toContain(receipt.errors[0])
expect(document.body.textContent).toContain('已提交 1')
})
})
describe('首帧批量参数', () => {
it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => {
const { context, posts } = server()
let service!: ReturnType<typeof useProduction>
wrapper = mount(
defineComponent({
setup() {
service = useProduction()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: context } } }
)
await flushPromises()
service.keyframeLimit.value = 2
service.keyframeWidth.value = 2048
await service.run('keyframes')
expect(posts()).toHaveLength(0)
service.keyframeHeight.value = 2048
await service.run('keyframes')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
concurrency: 2,
force: false,
episodeNo: 1,
limit: 2,
width: 2048,
height: 2048
})
service.keyframeLimit.value = -1
await service.run('keyframes')
expect(posts()).toHaveLength(1)
await service.run('prompts')
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false })
service.keyframeLimit.value = ''
service.keyframeWidth.value = ''
service.keyframeHeight.value = ''
service.force.value = true
await flushPromises()
await service.run('keyframes')
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true })
})
})
@@ -0,0 +1,133 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mediaAssetUrl } from '@/lib/assets'
import KeyframeDialog from '@/features/production/components/KeyframeDialog.vue'
import { productionApi } from '@/features/production/api'
import {
isActiveVideo,
issueLabel,
primaryKeyframe,
primaryVideo,
productionStatusLabel,
validOptionalSize
} from '@/features/production/model'
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从 Naive 弹窗中查找精确按钮。 */
function button(label: string): HTMLButtonElement {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
describe('镜头生产数据契约', () => {
it('可选尺寸必须成对留空或填写正整数', () => {
expect(validOptionalSize('', '')).toBe(true)
expect(validOptionalSize(1920, 1080)).toBe(true)
expect(validOptionalSize(1920, '')).toBe(false)
expect(validOptionalSize('', 1080)).toBe(false)
expect(validOptionalSize(0, 1080)).toBe(false)
expect(validOptionalSize(10.5, 1080)).toBe(false)
})
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
for (const status of ['pending', 'queued', 'running'] as const)
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
expect(isActiveVideo(videoFixture())).toBe(false)
})
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown')
})
it('视频地址与图片使用相同的安全协议限制', () => {
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
})
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/readiness'))
return new Response(
JSON.stringify({
data: {
total: 1,
ready: 1,
skipped: 0,
inProgress: 0,
blocked: 0,
missingPrompt: 0,
missingKeyframe: 0,
missingReference: 0,
items: []
}
})
)
return new Response(
JSON.stringify({
data: {
total: 1,
targetCount: 1,
created: 1,
skipped: 0,
readiness: {},
failed: 0,
failures: []
}
})
)
})
vi.stubGlobal('fetch', fetcher)
await productionApi.videoReadiness('project/1', true)
await productionApi.generateVideos('project/1', { concurrency: 3, force: true })
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
concurrency: 3,
force: true
})
})
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
expect(button('确认生成首帧').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
await wrapper.setProps({ open: false })
await flushPromises()
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
})
+515
View File
@@ -0,0 +1,515 @@
import { defineComponent, reactive, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
import { testProjectContext } from '@/testing/project-context'
import { productionApi } from '@/features/production/api'
import { qualityApi } from '@/features/production/quality-api'
import {
allowedTextLines,
qualityKey,
qualitySession,
qualityTargets,
savedVideoValidation,
videoRepairInfo,
validQualityInput
} from '@/features/production/quality'
import type { KeyframeReadiness } from '@/features/production/types'
import type { QualityInput, QualityTarget } from '@/features/production/quality.types'
import { useQuality } from '@/features/production/useQuality'
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
import QualityDialog from '@/features/production/components/QualityDialog.vue'
import QualityResult from '@/features/production/components/QualityResult.vue'
import ModelCapabilities from '@/features/production/components/ModelCapabilities.vue'
let wrapper: VueWrapper | undefined
let sequence = 0
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
function server() {
const projectId = `quality-test-${sequence++}`
const project = testProjectContext(projectId).project.value!
const validation = {
passed: true,
summary: '身份与造型一致',
subjects: [],
subjectCountConsistent: true,
unauthorizedText: { detected: false, texts: [] },
issues: []
}
const keyframe = keyframeFixture()
const video = videoFixture()
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingVisualStyle: 0,
missingIdentity: 0,
missingIdentityAnchor: 0,
identityUnlocked: 0,
invalidGenerationSpec: 0,
missingReference: 0,
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
} as KeyframeReadiness
const capability = {
provider: 'qwen-image',
referenceCount: 3,
maxReferenceImages: 3,
valid: true,
message: '参考图超限'
}
const keyframeResult = {
...validation,
shotId: 'shot-1',
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
isPrimary: true,
imageUrl: keyframe.imageUrl!
}
const attempt = {
attempt: 0,
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
passed: true,
issues: [],
validationDurationMs: 10
}
const repair = {
shotId: 'shot-1',
initialKeyframeId: 'keyframe-1',
finalKeyframeId: 'keyframe-1',
passed: true,
repaired: false,
primaryChanged: false,
repairAttempts: 0,
maxRepairAttempts: 1,
attempts: [attempt]
}
const batch = {
total: 1,
ready: 1,
selected: 1,
targetCount: 1,
passed: 1,
repairFailed: 0,
failed: 0,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingReference: 0,
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
const path = String(url)
let data: unknown
if (options?.method === 'POST') {
if (path.endsWith('/generate-quality')) data = batch
else if (path.includes('/videos/') && path.endsWith('/repair'))
data = {
shotId: 'shot-1',
sourceVideoId: 'video-1',
repairAttempt: 1,
maxRepairAttempts: 2,
repairInstructions: ['修复人物漂移'],
allowedTexts: [],
candidate: videoFixture({
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
videoUrl: null,
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
})
}
else if (path.endsWith('/repair')) data = repair
else if (path.includes('/videos/'))
data = {
...validation,
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
videoUrl: video.videoUrl,
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
allowedTexts: []
}
else data = keyframeResult
} else if (path.endsWith(`/projects/${projectId}`)) data = project
else if (path.includes('/readiness')) data = readiness
else if (path.endsWith('/keyframes')) data = [keyframe]
else if (path.endsWith('/videos')) data = [video]
else if (path.endsWith('/keyframe-provider-capability')) data = capability
else if (path.endsWith('/image-providers/capabilities'))
data = [
{
provider: 'qwen-image',
active: true,
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
}
]
else throw new Error('意外接口:' + path)
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
projectId,
project,
validation,
keyframe,
video,
readiness,
capability,
keyframeResult,
repair,
batch,
fetcher,
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
}
}
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
function input(patch: Partial<QualityInput> = {}): QualityInput {
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
}
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
function validationButton() {
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始视觉校验'
)!
}
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
const data = server()
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
const visible = ref(true)
const changed = vi.fn<() => void>()
let service!: ReturnType<typeof useQuality>
wrapper = mount(
defineComponent({
setup() {
service = useQuality(props, () => visible.value, changed)
return () => null
}
})
)
return { ...data, props, visible, changed, service }
}
describe('视觉质量契约与付费边界', () => {
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
const data = setup()
expect(data.fetcher).not.toHaveBeenCalled()
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
})
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
const data = setup()
data.project.status = status
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('剧本未完成')
})
it('目标不属于项目时不查询目标素材,不生成', async () => {
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
})
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
const data = setup()
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
...data.keyframeResult,
passed: undefined
} as never)
await data.service.run('validate', input())
expect(data.service.session.value.receipt).toBeNull()
expect(data.service.session.value.error).toContain('不完整')
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
})
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
const data = setup()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toContain('/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
})
it('模型参考图能力不足时阻止修复与质量批次', async () => {
const data = setup()
data.capability.valid = false
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('参考图超限')
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
})
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.durationSeconds = null
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
data.video.durationSeconds = 5
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video',
result: { sampleFrames: [{ timeSeconds: 2 }] }
})
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
})
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
Object.assign(data.batch, {
passed: 0,
repairFailed: 1,
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
})
await data.service.run('batch', input({ width: 1536, height: 1024 }))
expect(data.posts()).toHaveLength(1)
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
const receipt = data.service.session.value.receipt!
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('仍需处理')
expect(wrapper.text()).toContain('人物不一致')
await wrapper
.findAll('button')
.find(button => button.text() === '定位镜头')!
.trigger('click')
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
})
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
data.readiness.stalePrimaryKeyframe = 1
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('切换剧集')
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
})
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
const data = setup()
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
await data.service.run('repair', input(values))
expect(data.fetcher).not.toHaveBeenCalled()
data.keyframe.status = 'generating'
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
data.props.disabled = true
const count = data.fetcher.mock.calls.length
await data.service.run('validate', input())
expect(data.fetcher).toHaveBeenCalledTimes(count)
})
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
const data = setup()
let finishCheck!: (value: KeyframeReadiness) => void
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
() =>
new Promise(resolve => {
finishCheck = resolve
})
)
const pendingCheck = data.service.run('validate', input())
await flushPromises()
data.visible.value = false
finishCheck(data.readiness)
await pendingCheck
expect(data.posts()).toHaveLength(0)
data.visible.value = true
let finish!: (value: typeof data.keyframeResult) => void
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
const originalKey = data.service.key.value
const pending = data.service.run('validate', input())
await flushPromises()
await data.service.run('validate', input())
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
data.props.projectId = 'different-project'
finish(data.keyframeResult)
await pending
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
expect(data.service.session.value.receipt).toBeNull()
})
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
const data = server()
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
await qualityApi.validateVideo('shot/a', 'video/b', [])
expect(data.posts().map(([url]) => url)).toEqual([
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
])
})
})
describe('质量面板渐进展示', () => {
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
open: true,
disabled: false
}
})
await flushPromises()
expect(validationButton().disabled).toBe(true)
expect(document.body.textContent).toContain('更多参数与模型限制')
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
expect(data.fetcher).not.toHaveBeenCalled()
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
expect(validationButton().disabled).toBe(false)
validationButton().click()
await flushPromises()
expect(data.posts()).toHaveLength(1)
expect(document.body.textContent).toContain('视觉校验通过')
})
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
open: true,
disabled: false
}
})
await flushPromises()
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
await flushPromises()
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始质量生成'
)!
expect(submit.disabled).toBe(true)
expect(data.posts()).toHaveLength(0)
})
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
const data = server()
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
expect(data.fetcher).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('qwen-image · 当前启用')
expect(wrapper.text()).toContain('最多 3 张参考图')
expect(data.posts()).toHaveLength(0)
})
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
expect(savedVideoValidation('{broken')).toBeNull()
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
)
expect(data.fetcher).not.toHaveBeenCalled()
})
})
describe('视频修复候选与复检晋升', () => {
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video-repair',
result: { candidate: { status: 'queued', isPrimary: false } }
})
})
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.rawJson = JSON.stringify({
videoValidation: { ...data.validation, passed: false },
repair: { attempt: 2 }
})
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('次数上限')
})
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
data.video.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
expect(data.posts()).toHaveLength(1)
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('后端已将其设为主视频')
})
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
disabled: false,
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
}
})
await flushPromises()
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
await flushPromises()
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
expect(document.body.textContent).not.toContain('次生图')
expect(document.body.textContent).toContain('修复链次数上限')
expect(data.posts()).toHaveLength(0)
})
})
+218
View File
@@ -0,0 +1,218 @@
import { h } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import App from '@/App.vue'
import ProjectLayout from '@/features/projects/ProjectLayout.vue'
import { isProjectComplete } from '@/features/projects/access'
import { projectsApi } from '@/features/projects/api'
import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
import { readAllStyles } from '@/testing/styles'
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
localStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
})
/** 用已有剧集模拟中途生成的项目,完成与否必须取 status 而非数组长度。 */
function project(id: string, status: ProjectStatus): ProjectDetail {
return {
id,
status,
title: '访问限制测试',
topic: '',
style: null,
createdAt: '',
updatedAt: '',
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
characters: [],
world: null,
reviews: [],
tasks: []
}
}
/** 真实项目布局与侧栏,子工作区以挂载探针代替,防止测试发起实际生成请求。 */
async function openProject(initialPath: string) {
const mounted = vi.fn<(path: string) => void>()
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects', component: { render: () => h('div', '项目列表') } },
{
path: '/projects/:projectId',
component: ProjectLayout,
children: ['create-drama', ...downstream].map(path => ({
path,
component: {
setup() {
mounted(path)
return () => h('div', { class: 'workspace-probe' }, path)
}
}
}))
}
]
})
await router.push(initialPath)
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { router, mounted }
}
describe('剧本完成前的下游访问限制', () => {
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
vi.useFakeTimers()
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed'))
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router } = await openProject('/projects/manual-gallery/production')
await router.push('/projects/manual-gallery/subject-images')
await flushPromises()
await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1)
expect(checkpoints).toHaveBeenCalledTimes(1)
await router.push('/projects/manual-gallery/production')
await flushPromises()
await vi.advanceTimersByTimeAsync(6000)
expect(detail).toHaveBeenCalledTimes(2)
expect(checkpoints).toHaveBeenCalledTimes(2)
})
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
vi.useFakeTimers()
let status: ProjectStatus = 'generating'
const detail = vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
await openProject('/projects/manual-gate/subject-images')
await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1)
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
status = 'completed'
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
await refresh.trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
expect(wrapper!.get('.workspace-probe').text()).toBe('subject-images')
})
it.each(['draft', 'generating', 'need_review', 'failed'] as const)(
'%s 不解锁导航,也不挂载直接链接对应的工作区',
async status => {
vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('unfinished', status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router, mounted } = await openProject('/projects/unfinished/production')
for (const path of downstream) {
await router.push(`/projects/unfinished/${path}`)
await flushPromises()
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
}
expect(mounted).not.toHaveBeenCalled()
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6)
await wrapper!.get('.project-access-gate button').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
expect(wrapper!.get('.workspace-probe').text()).toBe('create-drama')
expect(mounted).toHaveBeenCalledExactlyOnceWith('create-drama')
}
)
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
vi.useFakeTimers()
let status: ProjectStatus = 'generating'
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { mounted } = await openProject('/projects/polling/production')
expect(mounted).not.toHaveBeenCalled()
status = 'completed'
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
status = 'need_review'
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
})
it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => {
let resolveSecond!: (value: ProjectDetail) => void
const pending = new Promise<ProjectDetail>(resolve => {
resolveSecond = resolve
})
vi.spyOn(projectsApi, 'detail').mockImplementation(async id =>
id === 'first' ? project(id, 'completed') : pending
)
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router, mounted } = await openProject('/projects/first/production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
await router.push('/projects/second/production')
await flushPromises()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
resolveSecond(project('second', 'draft'))
await flushPromises()
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
await router.push('/projects')
await flushPromises()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
})
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
const detail = vi
.spyOn(projectsApi, 'detail')
.mockRejectedValueOnce(new Error('项目读取失败'))
.mockResolvedValue(project('retry', 'completed'))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { mounted } = await openProject('/projects/retry/storyboard')
expect(mounted).not.toHaveBeenCalled()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
await wrapper!.get('.project-notices button').trigger('click')
await flushPromises()
expect(detail).toHaveBeenCalledTimes(2)
expect(mounted).toHaveBeenCalledExactlyOnceWith('storyboard')
})
it('不存在、未知状态或项目 ID 不匹配时默认锁定', () => {
expect(isProjectComplete(null, 'p')).toBe(false)
expect(isProjectComplete({ id: 'other', status: 'completed' }, 'p')).toBe(false)
expect(isProjectComplete({ id: 'p', status: 'unknown' as ProjectStatus }, 'p')).toBe(false)
expect(isProjectComplete({ id: 'p', status: 'completed' }, 'p')).toBe(true)
})
it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => {
// 保护布局契约;实际像素对齐仍需浏览器视觉验收。
const css = readAllStyles()
expect(css).toMatch(
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/
)
expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
expect(readFileSync('src/styles/styles.css', 'utf8')).toMatch(
/\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/
)
for (const path of [
'production/ProductionPage.vue',
'storyboard/StoryboardPage.vue',
'subject-identity/SubjectIdentityPage.vue',
'subject-images/SubjectImagesPage.vue'
]) {
const source = readFileSync(`src/features/${path}`, 'utf8')
expect(source).toContain('control-row-checkbox')
expect(source).not.toMatch(/<NCheckbox\b[^>]*class="[^"]*pb-[23]/)
}
})
})
@@ -0,0 +1,63 @@
import { mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import BeatShotDirectory from '@/features/storyboard/components/BeatShotDirectory.vue'
import { readAllStyles } from '@/testing/styles'
import { designedShot } from '@/features/storyboard/testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => wrapper?.unmount())
describe('Beat 两级镜头目录', () => {
it('按 Beat 和镜号排序,分组只出现一次并显示镜数,不改变原数组', () => {
const shots = [
{ ...designedShot('b2-s1'), beatNo: 2 },
{ ...designedShot('b1-s2'), shotNo: 2 },
designedShot('b1-s1')
]
wrapper = mount(BeatShotDirectory, {
props: { shots, activeId: 'b1-s1', itemClass: 'shot-link' },
slots: { meta: '<span>设计已保存</span>' }
})
const groups = wrapper.findAll('.beat-directory-group')
expect(groups).toHaveLength(2)
expect(groups[0]!.get('h4').text()).toContain('BEAT 01')
expect(groups[0]!.get('.directory-group-count').text()).toBe('2 镜')
expect(groups[1]!.get('h4').text()).toContain('BEAT 02')
expect(groups[1]!.get('.directory-group-count').text()).toBe('1 镜')
expect(wrapper.findAll('.directory-shot-number').map(item => item.text())).toEqual([
'镜头 01',
'镜头 02',
'镜头 01'
])
expect(wrapper.findAll('.directory-item-meta').every(item => item.text() === '设计已保存')).toBe(true)
expect(groups[0]!.attributes('aria-labelledby')).toBe(groups[0]!.get('h4').attributes('id'))
expect(shots.map(shot => shot.shotId)).toEqual(['b2-s1', 'b1-s2', 'b1-s1'])
})
it('不同 Beat 的同号镜头按正式 ID 选择,数据刷新后选中项保持不变', async () => {
const shots = [designedShot('b1-s1'), { ...designedShot('b2-s1'), beatNo: 2 }]
wrapper = mount(BeatShotDirectory, { props: { shots, activeId: 'b1-s1', itemClass: 'production-shot-link' } })
await wrapper.findAll('.production-shot-link')[1]!.trigger('click')
expect(wrapper.emitted('select')).toEqual([['b2-s1']])
await wrapper.setProps({
activeId: 'b2-s1',
shots: [...shots, { ...designedShot('b2-s2'), beatNo: 2, shotNo: 2 }]
})
expect(wrapper.get('.selected').attributes('aria-label')).toBe('BEAT 2 · 镜头 1 · 来信')
expect(wrapper.findAll('.selected')).toHaveLength(1)
expect(wrapper.findAll('.directory-group-count')[1]!.text()).toBe('2 镜')
})
it('组内吸顶与移动端归属提示分别保护,不在桌面重复展示 Beat', () => {
// happy-dom 不计算吸顶位置,保护 CSS 边界,实际滚动仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.directory-group-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*background:\s*var\(--app-control\)/
)
expect(css).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*none/)
expect(css).toMatch(/@media \(max-width: 760px\)[\s\S]*\.directory-mobile-beat\s*\{\s*display:\s*inline/)
expect(css).toMatch(
/@media \(max-width: 760px\)[\s\S]*\.beat-directory-group,\s*\.beat-directory-items\s*\{\s*display:\s*contents/
)
})
})
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
directionLabel,
mergeDesignedShots,
referenceImageUrl,
storyboardPrerequisites
} from '@/features/storyboard/model'
import {
directionsResult,
episodeShots,
storyboardCheckpoint,
visualStatesResult
} from '@/features/storyboard/testing/fixtures'
afterEach(() => vi.unstubAllEnvs())
describe('分镜正式数据与生成依赖', () => {
it('用 Shot ID 关联状态,用 Beat + Shot 编号补充描述,不串同编号镜头', () => {
const directions = directionsResult('p')
const states = visualStatesResult('p', 1, true)
states.beats.reverse()
states.beats[0]!.shots[0]!.visualState!.continuityNote = '第二个 Beat'
const shots = mergeDesignedShots(directions, states, episodeShots())
expect(shots.map(shot => [shot.shotId, shot.title, shot.visualState?.continuityNote])).toEqual([
['shot-db-1-1', '第1集镜头1', '信封始终在右手'],
['shot-db-1-2', '第1集镜头2', '第二个 Beat']
])
})
it('没有 Direction 的正式 Shot 仍可显示,不用 checkpoint 伪造数据库 ID', () => {
const rows = mergeDesignedShots(directionsResult('p', 1, false), visualStatesResult('p'), episodeShots())
expect(rows).toHaveLength(2)
expect(rows[0]?.direction).toBeNull()
expect(mergeDesignedShots(null, null, episodeShots())).toEqual([])
})
it('仅最新 Breakdown 决定生成前置条件,不能回退到历史完整快照', () => {
const ready = storyboardCheckpoint()
expect(storyboardPrerequisites([ready], 1)).toMatchObject({ directionEpisode: true, visualEpisode: true })
expect(storyboardPrerequisites([ready], 3)).toMatchObject({ directionEpisode: false, visualEpisode: false })
const latest = { ...ready, checkpointId: 'failed', createdAt: '2026-08-28T01:00:00Z', state: {} }
expect(storyboardPrerequisites([latest, ready], 1)).toMatchObject({
directionProject: false,
visualProject: false
})
expect(storyboardPrerequisites([{ ...latest, workflowName: 'create-drama' }, ready], 1)).toMatchObject({
directionProject: true
})
})
it('保留后端嵌套 Direction 与顶层 VisualState 的不同依赖', () => {
const checkpoint = storyboardCheckpoint()
delete checkpoint.state.breakdownResult
expect(storyboardPrerequisites([checkpoint], 1)).toMatchObject({ directionEpisode: false, visualEpisode: true })
checkpoint.state.subjectForms = []
expect(storyboardPrerequisites([checkpoint], 1).visualEpisode).toBe(false)
expect(directionLabel('future-camera-mode')).toBe('future-camera-mode')
})
it('参考图只允许 http(s) 或后端 storage 地址,拦截不可信协议和路径逃逸', () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.test/api')
expect(referenceImageUrl('/storage/image.png')).toBe('https://api.example.test/storage/image.png')
expect(referenceImageUrl('https://images.example.test/a.png')).toBe('https://images.example.test/a.png')
for (const value of [
'javascript:alert(1)',
'data:image/svg+xml,anything',
'//evil.test/img',
'/storage/../api/projects',
'/storage/\\evil.test/img'
]) {
expect(referenceImageUrl(value)).toBeNull()
}
})
})
@@ -0,0 +1,422 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NImage, NScrollbar } from 'naive-ui'
import { AssetImage } from '@/components/ui'
import IdentityImageDialog from '@/features/subject-identity/components/IdentityImageDialog.vue'
import IdentityGallery from '@/features/subject-identity/components/IdentityGallery.vue'
import CastingCandidateDialog from '@/features/subject-identity/components/CastingCandidateDialog.vue'
import { subjectIdentityApi } from '@/features/subject-identity/api'
import {
canBeAnchor,
castingStatusLabel,
currentAnchor,
groupIdentitySubjects,
mergeCastingSubjects,
readImageProvenance
} from '@/features/subject-identity/model'
import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
import { formFixture } from '@/features/subject-images/testing/fixtures'
import { expandSections, selectControl } from '@/testing/naive'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从真实 Naive 弹窗内获取确认按钮。 */
function button(label: string) {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
function input(selector: string, value: string) {
if (selector === '#identity-view' || selector === '#identity-reference') {
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
return
}
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
item.value = value
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
}
describe('身份图与母版契约', () => {
it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: true,
disabled: false,
images: [
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }),
identityImageFixture({
id: 'front',
viewType: 'front',
isAnchor: false,
imageUrl: '/storage/front.png'
})
]
}
})
const images = wrapper.findAllComponents(NImage)
expect(images).toHaveLength(3)
expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
await flushPromises()
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
const main = wrapper.get('.asset-image-preview img')
expect((main.element as HTMLImageElement).style.objectFit).toBe('contain')
expect(main.attributes('src')).toContain('/storage/front.png')
await main.trigger('click')
await flushPromises()
const original = document.querySelector<HTMLImageElement>('.n-image-preview')!
expect(original.getAttribute('src')).toBe(main.attributes('src'))
expect(original.style.objectFit).not.toBe('cover')
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
wrapper = mount(IdentityGallery, {
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
})
const panel = wrapper.get('.n-collapse-item')
expect(panel.classes()).toContain('n-collapse-item--active')
expect(panel.text()).toContain('本次实际生成提示词')
await panel.get('.n-collapse-item__header-main').trigger('click')
expect(panel.classes()).not.toContain('n-collapse-item--active')
await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
expect(panel.classes()).not.toContain('n-collapse-item--active')
await panel.get('.n-collapse-item__header-main').trigger('click')
expect(panel.classes()).toContain('n-collapse-item--active')
expect(panel.text()).toContain('更新后的实际提示词')
})
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
const images = [
identityImageFixture({
id: 'candidate',
imageUrl: '/storage/candidate.png',
isAnchor: false,
enabled: false
}),
identityImageFixture({
id: 'failed',
status: 'failed',
imageUrl: null,
isAnchor: false,
enabled: false,
error: '图片模型超时'
}),
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
identityImageFixture({
id: 'generating',
status: 'generating',
imageUrl: null,
isAnchor: false,
enabled: false
})
]
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images }
})
const history = wrapper.get('[aria-label="身份图片历史"]')
expect(history.findAll('.image-history-item')).toHaveLength(6)
expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({
xScrollable: true,
trigger: 'none',
contentStyle: { width: 'max-content' }
})
expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条')
expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element)
expect(history.text()).toContain('当前母版')
expect(history.text()).toContain('母版候选')
expect(history.text()).toContain('正面')
expect(history.text()).toContain('生成失败')
expect(history.text()).toContain('排队中')
expect(history.text()).toContain('生成中')
expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
for (const image of images) {
await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click')
expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1)
expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true')
const preview = wrapper
.findAllComponents(AssetImage)
.find(item => item.classes().includes('asset-image-preview'))!
expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null)
expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`)
}
await history.get('[aria-label="查看身份图片 failed"]').trigger('click')
expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败')
expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时')
expect(button('确认选角并锁定').disabled).toBe(true)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
const anchor = identityImageFixture({ id: 'anchor' })
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
wrapper = mount(IdentityGallery, {
props: {
subjectName: '林夏',
module: 'character',
identityLocked: true,
disabled: false,
images: [anchor, front]
}
})
await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click')
const viewport = wrapper.get<HTMLElement>('.image-history .n-scrollbar-container').element
viewport.scrollLeft = 120
await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] })
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport)
expect(viewport.scrollLeft).toBe(120)
await wrapper.setProps({ images: [anchor] })
expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条')
})
it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: ['candidate-a', 'candidate-b'].map(id =>
identityImageFixture({ id, isAnchor: false, enabled: false })
)
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(button('确认演员选择').disabled).toBe(false)
await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click')
expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员')
expect(wrapper.emitted('anchor')).toBeUndefined()
button('确认选角并锁定').click()
await flushPromises()
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['candidate-b']])
})
it('无历史图片时只展示空状态,不伪造缩略图或母版', () => {
wrapper = mount(IdentityGallery, {
props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] }
})
expect(wrapper.text()).toContain('身份参考图 · 0')
expect(wrapper.text()).toContain('尚无身份参考图')
expect(wrapper.find('.image-history').exists()).toBe(false)
expect(wrapper.find('.asset-image-preview').exists()).toBe(false)
})
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
expect(canBeAnchor(front)).toBe(false)
expect(canBeAnchor(candidate)).toBe(true)
expect(currentAnchor([front, candidate])).toBeUndefined()
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
})
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
expect(readImageProvenance('{bad')).toEqual({})
expect(readImageProvenance(null)).toEqual({})
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
identityAnchorImageId: 'anchor',
referenceImageId: undefined
})
})
it('选角就绪结果可以补入尚无形态的角色目录', () => {
const rows = mergeCastingSubjects(
groupIdentitySubjects([formFixture()]),
[
{
subjectId: 'character-without-form',
subjectRef: '@CH0002',
subjectName: '陆川',
status: 'missing_identity',
isLocked: false
}
],
'project-1'
)
expect(rows).toHaveLength(2)
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
projectId: 'project-1',
module: 'character',
forms: []
})
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
})
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-view', 'three-quarter')
input('#identity-reference', 'identity-image-1')
input('#identity-width', '2048')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
input('#identity-height', '2048')
input('#identity-image-prompt', ' 自定义身份提示词 ')
await flushPromises()
button('确认生成身份图').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([
[
{
viewType: 'three-quarter',
referenceImageId: 'identity-image-1',
width: 2048,
height: 2048,
prompt: '自定义身份提示词'
}
]
])
})
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-reference', 'identity-image-1')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
await wrapper.setProps({ images: [] })
expect(button('确认生成身份图').disabled).toBe(true)
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
})
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
module: 'character',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
const select = selectControl(wrapper!, 'id', 'identity-view')
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
expect(select.props('value')).toBe('front')
})
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
}
})
expect(button('确认选角并锁定').disabled).toBe(true)
await expandSections()
expect(wrapper.text()).toContain('<script>不执行</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ isAnchor: true })]
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
})
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
wrapper = mount(CastingCandidateDialog, {
attachTo: document.body,
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
})
await flushPromises()
expect(document.body.textContent).toContain('不会复用当前身份母版')
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
await flushPromises()
button('确认生成候选').click()
expect(wrapper.emitted('generate')).toEqual([[{}]])
})
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async () =>
new Response(
JSON.stringify({
data: {
total: 0,
targetCount: 0,
generated: 0,
skipped: 0,
skippedLocked: 0,
failed: 0,
failures: []
}
})
)
)
vi.stubGlobal('fetch', fetcher)
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/project%2F1/character-identities/generate',
'/api/subjects/subject%2F1/identity/casting-candidates',
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
])
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
})
})
@@ -0,0 +1,182 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NImage } from 'naive-ui'
import IdentityThumbnail from '@/features/subject-identity/components/IdentityThumbnail.vue'
import { identityFixture, identityImageFixture } from '@/features/subject-identity/testing/fixtures'
let wrapper: VueWrapper | undefined
let observers: {
callback: IntersectionObserverCallback
observe: ReturnType<typeof vi.fn<(element: Element) => void>>
disconnect: ReturnType<typeof vi.fn<() => void>>
}[] = []
const props = {
projectId: 'project',
subjectId: 'subject-db-1',
name: '书店',
module: 'scene',
identityId: 'identity-db-1',
refreshKey: 0
}
beforeEach(() => {
observers = []
vi.stubGlobal(
'IntersectionObserver',
class {
observe = vi.fn<(element: Element) => void>()
disconnect = vi.fn<() => void>()
unobserve = vi.fn<(element: Element) => void>()
constructor(callback: IntersectionObserverCallback) {
observers.push({ callback, observe: this.observe, disconnect: this.disconnect })
}
}
)
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 仅触发主体缩略图的可见事件,不触发 NImage 内部图片懒加载观察器。 */
async function reveal() {
const observer = observers.find(item => item.observe.mock.calls.some(([element]) => element === wrapper!.element))!
observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
await flushPromises()
expect(observer.disconnect).toHaveBeenCalled()
}
/** 测试接口返回独立 Response,读取图片元数据不消耗生成额度。 */
function response(data: unknown) {
return new Response(JSON.stringify({ data }))
}
describe('主体目录母版缩略图', () => {
it.each([
['character', '人物'],
['scene', '场景'],
['prop', '道具']
])('没有母版的 %s 显示类型占位,不补查或生成图片', async (module, label) => {
const fetcher = vi.fn<typeof fetch>()
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props: { ...props, module, source: null } })
await reveal()
expect(wrapper.text()).toBe(label)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
expect(wrapper.findComponent(NImage).exists()).toBe(false)
expect(fetcher).not.toHaveBeenCalled()
})
it('直接复用目录母版地址,图片不抢占选择点击,加载失败显示默认占位', async () => {
const fetcher = vi.fn<typeof fetch>()
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, {
attachTo: document.body,
props: { ...props, source: '/storage/anchor.png' }
})
await reveal()
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
await wrapper.get('img').trigger('click')
expect(document.querySelector('.n-image-preview-container')).toBeNull()
await wrapper.get('img').trigger('error')
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
await wrapper.setProps({ source: '/storage/new-anchor.png' })
expect(wrapper.get('img').attributes('src')).toContain('/storage/new-anchor.png')
expect(fetcher).not.toHaveBeenCalled()
})
it('进入可视区域后才 GET 图库,仅选择权威母版,刷新时重新读取', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
response([
identityImageFixture({
id: 'candidate',
isAnchor: false,
enabled: false,
imageUrl: '/storage/candidate.png'
}),
identityImageFixture({
id: 'front',
isAnchor: false,
viewType: 'front',
imageUrl: '/storage/front.png'
}),
identityImageFixture({ imageUrl: '/storage/anchor.png' })
])
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await flushPromises()
expect(fetcher).not.toHaveBeenCalled()
await reveal()
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subjects/subject-db-1/identity/images')
expect(wrapper.get('img').attributes('src')).toContain('/storage/anchor.png')
await wrapper.setProps({ refreshKey: 1 })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('目录未附带身份摘要时先校验正式身份,再读取母版', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async url =>
response(String(url).endsWith('/images') ? [identityImageFixture()] : identityFixture())
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props: { ...props, identityId: undefined } })
await reveal()
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/subjects/subject-db-1/identity',
'/api/subjects/subject-db-1/identity/images'
])
expect(wrapper.find('img').exists()).toBe(true)
})
it('切项目中止旧请求,即使迟到也不能覆盖新主体占位', async () => {
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await reveal()
await wrapper.setProps({
projectId: 'other-project',
subjectId: 'other-subject',
name: '另一主体',
source: null
})
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
finish(response([identityImageFixture()]))
await flushPromises()
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('另一主体 · 暂无母版')
})
it('候选与辅助视角不能冒充母版,跨身份图片返回错误占位', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(response([identityImageFixture({ isAnchor: false })]))
.mockResolvedValueOnce(response([identityImageFixture({ identityId: 'wrong-identity' })]))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(IdentityThumbnail, { props })
await reveal()
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
await wrapper.setProps({ refreshKey: 1 })
await flushPromises()
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
expect(wrapper.find('img').exists()).toBe(false)
})
it('危险地址不写入图片,无效图片也不会触发生图', () => {
wrapper = mount(IdentityThumbnail, { props: { ...props, source: 'javascript:alert(1)' } })
expect(wrapper.find('img').exists()).toBe(false)
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
})
})
@@ -0,0 +1,504 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NSelect } from 'naive-ui'
import { testProjectContext } from '@/testing/project-context'
import { projectContextKey } from '@/features/projects/context'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
import { referenceLocation, referenceTargets } from '@/features/production/asset-links'
import StaleKeyframeNotice from '@/features/production/components/StaleKeyframeNotice.vue'
import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
import ProductionPage from '@/features/production/ProductionPage.vue'
import { directionsResult, storyboardCheckpoint } from '@/features/storyboard/testing/fixtures'
import type { ProductionIssue } from '@/features/production/types'
import type { ShotReferences } from '@/features/storyboard/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
})
/** 镜头引用非默认形态,用于防止退化成仅按主体名搜索。 */
function fixture() {
const first = formFixture('capability-test')
const second = {
...first,
id: 'form-special',
name: '雨夜形态',
isDefault: false,
images: [imageFixture({ subjectFormId: 'form-special' })]
}
const other = {
...formFixture('capability-test'),
id: 'form-other',
subjectId: 'subject-other',
subject: { ...first.subject, id: 'subject-other', name: '路人', ref: '@CH0005' },
images: [imageFixture({ subjectFormId: 'form-other' })]
}
const references: ShotReferences = {
shotId: 'shot-target',
references: [
{
shotSubjectId: 'binding-1',
subjectId: first.subjectId,
subjectRef: first.subject.ref,
subjectName: first.subject.name,
module: 'character',
subjectFormId: second.id,
subjectFormName: second.name,
imageId: 'image-db-1',
imageUrl: '/storage/current.png'
},
{
shotSubjectId: 'binding-2',
subjectId: other.subjectId,
subjectRef: other.subject.ref,
subjectName: other.subject.name,
module: 'character',
subjectFormId: other.id,
subjectFormName: other.name,
imageId: 'other-image',
imageUrl: '/storage/other.png'
}
],
missing: []
}
const issues: ProductionIssue[] = [
{ code: 'stale_keyframe', reason: '参考资产已变化: @CH0001, @CH0005', missingSubjects: ['@CH0001', '@CH0005'] }
]
const keyframes = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 1,
items: [
{
shotId: 'shot-target',
shotNo: 1,
episodeNo: 2,
beatNo: 2,
status: 'ready',
primaryKeyframeStale: true,
primaryKeyframeId: 'keyframe-old',
issues: []
}
]
}
const videos = {
total: 1,
ready: 0,
skipped: 0,
blocked: 1,
items: [{ shotId: 'shot-target', shotNo: 1, status: 'blocked', issues }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
const data = path.endsWith('/subject-forms')
? [first, second, other]
: path.includes('/keyframes/readiness')
? keyframes
: path.includes('/videos/readiness')
? videos
: path.endsWith('/references')
? references
: []
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return { first, second, other, references, issues, keyframes, videos, fetcher }
}
/** 在真实内存路由中验证跨页 URL,不替换 RouterLink 行为。 */
async function gallery(query = '') {
const data = fixture()
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/projects/:projectId/subject-images', component: SubjectImagesPage },
{ path: '/projects/:projectId/production', component: { template: '<div />' } }
]
})
await router.push('/projects/capability-test/subject-images' + query)
wrapper = mount(SubjectImagesPage, {
attachTo: document.body,
global: { plugins: [router], provide: { [projectContextKey as symbol]: testProjectContext() } }
})
await flushPromises()
return { ...data, router }
}
describe('过期首帧到具体素材定位', () => {
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
const { fetcher } = await gallery()
const requestCount = fetcher.mock.calls.length
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const grid = wrapper!.get('.form-image-grid').element
const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
scroll.scrollTop = 240
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
expect(scroll.scrollTop).toBe(240)
expect(
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
).toBe('2048 / 2048')
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
expect(wrapper!.get('.form-image-card').text()).toContain('查看图片与记录')
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
expect(fetcher).toHaveBeenCalledTimes(requestCount)
})
it('图库与关联检查不定时刷新,手动刷新仍能更新数据', async () => {
vi.useFakeTimers()
const { fetcher } = await gallery('?sourceShotId=shot-target')
const count = fetcher.mock.calls.length
const grid = wrapper!.get('.form-image-grid').element
await vi.advanceTimersByTimeAsync(30_000)
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(count)
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
const refresh = wrapper!.findAll('button').find(button => button.text().includes('刷新图库'))!
await refresh.trigger('click')
await flushPromises()
expect(fetcher.mock.calls.length).toBeGreaterThan(count)
const refreshed = fetcher.mock.calls.length
await vi.advanceTimersByTimeAsync(30_000)
expect(fetcher).toHaveBeenCalledTimes(refreshed)
})
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const content = wrapper!.get('.workspace-scroll-content').element
const sticky = wrapper!.get('.gallery-sticky-controls').element
const toolbar = wrapper!.get('.form-image-filter-region').element
expect(sticky.parentElement).toBe(content)
expect(toolbar.parentElement).toBe(sticky)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
scroll.scrollTop = 480
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
videos.items.push({ ...videos.items[0]!, shotId: 'shot-next', shotNo: 2 })
keyframes.total = videos.total = 2
let finish!: (response: Response) => void
const original = fetcher.getMockImplementation()!
fetcher.mockImplementation((url, init) =>
String(url).endsWith('/shot-next/references')
? new Promise(resolve => {
finish = resolve
})
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
wrapper!
.findAllComponents(NSelect)
.find(item => item.attributes('aria-label') === '选择关联镜头')!
.vm.$emit('update:value', 'shot-next')
await flushPromises()
expect(wrapper!.text()).toContain('正在读取镜头关联素材')
finish(
new Response(
JSON.stringify({
data: {
...references,
shotId: 'shot-next',
references: Array.from({ length: 24 }, (_, index) => ({
...references.references[0]!,
subjectFormId: `long-form-${index}`,
subjectFormName: `很长的关联素材名称${index}`
}))
}
})
)
)
await flushPromises()
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
expect(scroll.scrollTop).toBe(480)
expect(wrapper!.get('.gallery-sticky-controls').element).toBe(sticky)
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
const links = wrapper!.get('.asset-impact-links-scroll')
expect(links.findAll('a')).toHaveLength(24)
expect(links.text()).toContain('很长的关联素材名称23')
expect(links.classes()).toContain('n-scrollbar')
expect(links.find('.n-scrollbar-container > .asset-impact-links').exists()).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => {
const { references } = fixture()
let finish!: (response: Response) => void
const nextReferences = {
...references,
shotId: 'shot-new',
references: [{ ...references.references[0]!, subjectFormId: 'form-new', subjectFormName: '新镜头形态' }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
String(url).includes('shot-target')
? new Promise(resolve => {
finish = resolve
})
: Promise.resolve(new Response(JSON.stringify({ data: nextReferences })))
)
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues: [] }
})
await flushPromises()
const signal = fetcher.mock.calls[0]?.[1]?.signal
await wrapper.setProps({ shotId: 'shot-new' })
await flushPromises()
expect(signal?.aborted).toBe(true)
finish(new Response(JSON.stringify({ data: references })))
await flushPromises()
expect(wrapper.text()).toContain('新镜头形态')
expect(wrapper.findAll('a').some(link => link.attributes('href')?.includes('form-special'))).toBe(false)
})
it('参考素材查询失败时保留引用级定位和重试,不静默选择默认形态', async () => {
const { issues } = fixture()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 500 })))
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues, inconsistent: true }
})
await flushPromises()
expect(wrapper.text()).toContain('状态待核对')
expect(wrapper.text()).toContain('重试定位')
expect(wrapper.findAll('a').every(link => !link.attributes('href')?.includes('subjectFormId'))).toBe(true)
})
it('结构化变更引用映射到实际使用的非默认形态,并保留来源镜头', () => {
const { references, issues } = fixture()
const targets = referenceTargets(references, issues)
expect(targets.map(item => item.formId)).toEqual(['form-special', 'form-other'])
expect(referenceLocation('project/1', 'shot-target', targets[0]!)).toEqual({
path: '/projects/project%2F1/subject-images',
query: { sourceShotId: 'shot-target', subjectRef: '@CH0001', subjectFormId: 'form-special' }
})
})
it('没有当前关联时只回退到引用,不猜默认形态;没有变更列表时展示关联素材', () => {
const { references } = fixture()
const targets = referenceTargets(references, [
{ code: 'stale_keyframe', reason: '历史主体被移除', missingSubjects: ['@CH0999'] }
])
expect(targets[0]?.formId).toBeUndefined()
expect(targets[0]?.label).toContain('当前形态待核对')
expect(referenceTargets(references, [])).toHaveLength(2)
})
it('生产警告的引用可点击定位,点击不提交生图', async () => {
const { issues, fetcher } = fixture()
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
await router.push('/projects/capability-test/production')
wrapper = mount(StaleKeyframeNotice, {
global: { plugins: [router] },
props: { projectId: 'capability-test', shotId: 'shot-target', issues }
})
await flushPromises()
const links = wrapper.findAll('a')
expect(links).toHaveLength(2)
expect(links[0]?.text()).toContain('雨夜形态')
await links[0]!.trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.subjectFormId).toBe('form-special')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('图库直接访问即显示下游警告,但不把有效素材误标成身份过期', async () => {
const { fetcher } = await gallery()
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('1 个镜头')
expect(wrapper!.text()).toContain('不表示形态图片本身失效')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
expect(wrapper!.text()).not.toContain('个形态主图与当前已锁定身份母版不一致')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false)
// 镜头选择和图库筛选共享上方工具栏;未选择镜头时不留下空的关联说明面板。
const toolbar = wrapper!.get('.form-image-toolbar')
const picker = toolbar.get('.asset-impact-picker').element
expect(toolbar.classes()).toContain('has-impact-picker')
expect(picker.nextElementSibling).toBe(toolbar.get('.form-image-filters').element)
expect(toolbar.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
expect(toolbar.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2)
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
const select = wrapper!
.findAllComponents(NSelect)
.find(item => item.attributes('aria-label') === '选择关联镜头')!
select.vm.$emit('update:value', 'shot-target')
await flushPromises()
expect(wrapper!.get('.asset-impact-context').element.previousElementSibling).toBe(
wrapper!.get('.form-image-filter-region').element
)
expect(wrapper!.text()).toContain('待处理首帧关联此素材')
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1)
})
it('镜头导航与筛选共行,无关联说明时不留横条,查看全部素材清除定位和筛选', async () => {
const { references, router, fetcher } = await gallery()
references.references = []
await router.push({ query: { sourceShotId: 'shot-target' } })
await flushPromises()
const picker = wrapper!.get('.asset-impact-picker')
const back = picker.get('a[aria-label="返回第 2 集 · 镜头 1"]')
expect(back.classes()).toContain('icon-button')
expect(new URL(back.attributes('href')!, 'https://local.test').searchParams.get('shotId')).toBe('shot-target')
expect(picker.get('[aria-label="查看全部素材"]').classes()).toContain('icon-button')
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('不存在的素材')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
await picker.get('[aria-label="查看全部素材"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.sourceShotId).toBeUndefined()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
expect(wrapper!.find('.asset-impact-picker a').exists()).toBe(false)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => {
const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-special')
expect(wrapper!.get('.form-image-card').classes()).toContain('form-image-card-focused')
expect(wrapper!.get('[data-focused-material]').text()).toContain('雨夜形态')
const back = wrapper!.findAll('a').find(link => link.text().includes('返回第 2 集'))!
const url = new URL(back.attributes('href')!, 'https://local.test')
expect(url.searchParams.get('episodeNo')).toBe('2')
expect(url.searchParams.get('shotId')).toBe('shot-target')
const type = wrapper!.findAllComponents(NSelect).find(item => item.attributes('aria-label') === '筛选主体类型')!
type.vm.$emit('update:value', 'scene')
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
await router.push({ query: { sourceShotId: 'shot-target', subjectFormId: 'form-other' } })
await flushPromises()
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-other')
const clear = wrapper!.findAll('button').find(button => button.text() === '清除定位')!
await clear.trigger('click')
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
})
it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
const { first, router } = await gallery('?subjectFormId=form-other')
first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新图库')!
await refresh.trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('形态主图需要更新')
await wrapper!
.findAll('button')
.find(button => button.text() === '筛选身份过期形态')!
.trigger('click')
await flushPromises()
expect(router.currentRoute.value.query.subjectFormId).toBeUndefined()
expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([
'form-db-1',
'form-special'
])
})
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致')
expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图')
})
it('镜头修复后清除过期警告,保留明确的当前状态和返回入口', async () => {
const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
videos.items[0]!.issues = []
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false)
expect(wrapper!.text()).toContain('未被标记为过期')
})
it('无效镜头和已移除形态不发跨项目请求,也不误定位其他素材', async () => {
const { fetcher } = await gallery('?sourceShotId=foreign-shot&subjectFormId=deleted-form')
expect(wrapper!.text()).toContain('来源镜头不存在或不属于当前项目')
expect(wrapper!.get('[data-focused-material]').text()).toContain('指定形态不存在')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
expect(fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot/references'))).toBe(false)
})
it('影响查询失败不能当成没有过期首帧,仍然显示已有图库', async () => {
const { fetcher } = await gallery()
const original = fetcher.getMockImplementation()!
fetcher.mockImplementation((url, init) =>
String(url).includes('/readiness')
? Promise.resolve(new Response('{}', { status: 500 }))
: original(url, init)
)
await wrapper!
.findAll('button')
.find(button => button.text() === '刷新图库')!
.trigger('click')
await flushPromises()
expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧')
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
})
it('素材返回生产页时按正式 Shot ID 选择,不误选同编号的另一个 Beat', async () => {
const context = testProjectContext()
context.data.value!.checkpoints = [storyboardCheckpoint()]
const data = directionsResult('capability-test', 2)
const target = data.beats[1]!.shots[0]!
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
return new Response(
JSON.stringify({
data: path.includes('/storyboard-directions')
? data
: path.includes('/readiness') || path.endsWith('/videos/status')
? { total: 0, items: [] }
: []
})
)
})
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/projects/:projectId/production', component: ProductionPage }]
})
await router.push({
path: '/projects/capability-test/production',
query: { episodeNo: '2', shotId: target.shotId }
})
wrapper = mount(ProductionPage, {
global: { plugins: [router], provide: { [projectContextKey as symbol]: context } }
})
await flushPromises()
expect(wrapper!.get('.production-detail').text()).toContain(target.shotId)
expect(fetcher.mock.calls.some(([url]) => String(url).includes('episodeNo=2'))).toBe(true)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
})
@@ -0,0 +1,223 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AssetImage } from '@/components/ui'
import { getOperation } from '@/features/workflows/operations'
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
import ImageGalleryDialog from '@/features/subject-images/components/ImageGalleryDialog.vue'
import { coverImage, hasRunningImages, primaryImage, validImageSize } from '@/features/subject-images/model'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
import { expandSections } from '@/testing/naive'
let wrapper: VueWrapper | undefined
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
function button(label: string): HTMLButtonElement {
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
if (!element) throw new Error('找不到按钮:' + label)
return element
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
})
describe('形态图片选择与操作', () => {
it('形态图库复用有界历史栏,多张候选与失败记录切换只更新预览', async () => {
const rows = [
imageFixture(),
imageFixture({ id: 'candidate', isPrimary: false, imageUrl: '/storage/candidate.png' }),
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null, error: '生成失败详情' })
]
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: rows })))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ImageGalleryDialog, {
attachTo: document.body,
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
})
await flushPromises()
const history = document.querySelector<HTMLElement>('[aria-label="图片历史"]')!
expect(history.querySelectorAll('.image-history-item')).toHaveLength(3)
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
expect([...history.querySelectorAll('img')].every(image => image.style.objectFit === 'cover')).toBe(true)
expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条')
expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe(
true
)
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 candidate"]')!.click()
await flushPromises()
expect(document.querySelector('.asset-image-preview img')?.getAttribute('src')).toContain(
'/storage/candidate.png'
)
expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true')
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
await flushPromises()
expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败')
expect(button('设为主参考图').disabled).toBe(true)
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]![1]?.method).toBe('GET')
expect(wrapper.emitted('changed')).toBeUndefined()
})
it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => {
const form = formFixture()
form.subject.module = module
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
await flushPromises()
expect(document.body.textContent).toContain('母版')
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
})
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture()
form.images.unshift(
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null }),
imageFixture({ id: 'candidate', isPrimary: false })
)
expect(coverImage(form)?.id).toBe('image-db-1')
form.images.pop()
expect(primaryImage(form.images)).toBeUndefined()
expect(coverImage(form)?.id).toBe('candidate')
expect(hasRunningImages(form.images)).toBe(false)
form.images.push(imageFixture({ status: 'generating', isPrimary: false }))
expect(hasRunningImages(form.images)).toBe(true)
})
it('尺寸可同时留空,但不接受单边、零、负数或非整数', () => {
expect(validImageSize('', '')).toBe(true)
expect(validImageSize(2048, 2048)).toBe(true)
for (const [width, height] of [
[1024, ''],
['', 1024],
[0, 1024],
[-1, 1024],
[10.5, 1024]
] as const)
expect(validImageSize(width, height)).toBe(false)
})
it('生图确认发送正式形态 ID,不覆盖后端模型,默认不替换已有主图', async () => {
wrapper = mount(GenerateImageDialog, {
attachTo: document.body,
props: { open: true, form: formFixture(), disabled: false }
})
await flushPromises()
expect(button('确认生成图片').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
button('确认生成图片').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
})
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
const form = formFixture()
form.images = []
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
await flushPromises()
const width = document.querySelector<HTMLInputElement>('#image-width')!
width.value = '2048'
width.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
expect(button('确认生成图片').disabled).toBe(true)
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
await flushPromises()
expect(width.value).toBe('')
expect(document.querySelector('#confirm-image-cost')!.getAttribute('aria-checked')).toBe('false')
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
button('确认生成图片').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { setPrimary: true }]])
})
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
vi.useFakeTimers()
let primary = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'PUT') {
primary = true
return new Response(JSON.stringify({ data: imageFixture() }))
}
return new Response(
JSON.stringify({
data: [
imageFixture({ isPrimary: primary }),
imageFixture({
id: 'failed',
isPrimary: false,
status: 'failed',
imageUrl: null,
error: '供应商拒绝请求'
})
]
})
)
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ImageGalleryDialog, {
attachTo: document.body,
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
})
await flushPromises()
expect(fetcher.mock.calls).toHaveLength(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(fetcher.mock.calls).toHaveLength(1)
await expandSections()
expect(document.body.textContent).toContain('<script>模型提示词</script>')
expect(document.querySelector('[role="dialog"] script')).toBeNull()
button('设为主参考图').click()
await flushPromises()
expect(fetcher.mock.calls).toHaveLength(1)
button('确认切换主图').click()
await flushPromises()
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
'/api/subject-forms/form-db-1/images/image-db-1/primary'
)
expect(wrapper.emitted('changed')).toHaveLength(1)
document.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
await flushPromises()
expect(button('设为主参考图').disabled).toBe(true)
expect(document.body.textContent).toContain('供应商拒绝请求')
})
it('关闭图片弹窗取消查询,迟到的旧形态响应不会污染再次打开的形态', async () => {
let finish!: (response: Response) => void
const fetcher = vi
.fn<typeof fetch>()
.mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
.mockImplementation(async () => new Response('{"data":[]}'))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ImageGalleryDialog, {
attachTo: document.body,
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
})
await flushPromises()
await wrapper.setProps({ open: false })
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
await wrapper.setProps({ open: true, form: { ...formFixture('gallery-test'), id: 'form-db-2' } })
await flushPromises()
finish(new Response(JSON.stringify({ data: [imageFixture()] })))
await flushPromises()
expect(document.body.textContent).not.toContain('Image ID · image-db-1')
expect(document.body.textContent).toContain('此形态尚无图片记录')
})
it('图片加载失败有占位,地址变化可恢复,危险协议不会写入 img', async () => {
wrapper = mount(AssetImage, { props: { src: '/storage/a.png', alt: '形态主图' } })
await wrapper.get('img').trigger('error')
expect(wrapper.text()).toContain('图片无法加载')
await wrapper.setProps({ src: '/storage/b.png' })
expect(wrapper.get('img').attributes('src')).toContain('/storage/b.png')
await wrapper.setProps({ src: 'javascript:alert(1)' })
expect(wrapper.find('img').exists()).toBe(false)
})
})
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { formCoverAspectRatio } from '@/features/subject-images/layout'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
describe('图库瀑布流图片比例', () => {
it.each([
[800, 1200],
[1600, 900],
[1024, 1024]
])('保留正式图片尺寸 %s × %s', (width, height) => {
const form = formFixture()
form.images = [imageFixture({ width, height })]
expect(formCoverAspectRatio(form)).toBe(`${width} / ${height}`)
})
it.each([null, 0, -1, NaN, Infinity])('尺寸 %s 无效时使用稳定占位比例', width => {
const form = formFixture()
form.images = [imageFixture({ width })]
expect(formCoverAspectRatio(form)).toBe('4 / 3')
form.images = [imageFixture({ height: width })]
expect(formCoverAspectRatio(form)).toBe('4 / 3')
})
it('无图片时保留占位,候选封面也使用自己的尺寸', () => {
const form = formFixture()
form.images = []
expect(formCoverAspectRatio(form)).toBe('4 / 3')
form.images = [imageFixture({ isPrimary: false, width: 800, height: 1200 })]
expect(formCoverAspectRatio(form)).toBe('800 / 1200')
})
})
@@ -0,0 +1,187 @@
import { defineComponent } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectContextKey } from '@/features/projects/context'
import { testProjectContext } from '@/testing/project-context'
import { getOperation } from '@/features/workflows/operations'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from '@/features/subject-images/model'
import { useSubjectImages } from '@/features/subject-images/useSubjectImages'
import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue'
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
let wrapper: VueWrapper | undefined
const projectId = 'capability-test'
/** 从实际挂载的确认弹窗查找操作按钮。 */
function find(label: string) {
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getOperation(projectId), { pending: false, error: '', notice: '', label: '' })
Object.assign(getImageSession(projectId), { receipt: null, promptReceipt: null })
})
async function setup() {
let service!: ReturnType<typeof useSubjectImages>
const context = testProjectContext()
const form = formFixture(projectId)
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
let data: unknown = [form]
if (init?.method === 'POST') {
if (path.endsWith('/generation-prompts'))
data = {
total: 2,
targetCount: 2,
generated: 1,
skipped: 0,
failed: 1,
failures: [{ subjectFormId: 'form-failed', error: '模型拒绝' }]
}
else if (path.endsWith('/generation-prompt')) {
form.generationPrompt = '正式提示词'
data = { id: form.id, subjectId: form.subjectId, generationPrompt: form.generationPrompt }
} else if (path.includes('/subject-forms/')) data = imageFixture()
else
data = {
total: 3,
targetCount: 1,
generated: 1,
skipped: 0,
failed: 0,
failures: [],
eligibleCount: 3,
remaining: 2
}
}
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(
defineComponent({
setup() {
service = useSubjectImages()
return () => null
}
}),
{ global: { provide: { [projectContextKey as symbol]: context } } }
)
await flushPromises()
return {
service,
context,
form,
fetcher,
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
}
}
describe('形态正式提示词与批量配置', () => {
it('单个使用正式形态 ID 和 force,生成提示词不会调用图片接口', async () => {
const { service, posts } = await setup()
await service.generatePrompt('unknown-form', false)
expect(posts()).toHaveLength(0)
await service.generatePrompt('form-db-1', false)
expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/subject-forms/form-db-1/generation-prompt')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ force: false })
expect(service.forms.value[0]?.generationPrompt).toBe('正式提示词')
await service.generatePrompt('form-db-1', true)
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ force: true })
})
it('提示词接口返回空正文时显示失败,不误报保存成功', async () => {
const { service, fetcher } = await setup()
fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({ data: { id: 'form-db-1', subjectId: 'subject-db-1', generationPrompt: '' } }))
)
await service.generatePrompt('form-db-1', false)
expect(getOperation(projectId).error).toContain('未确认正式提示词已保存')
})
it('批量提示词保留部分失败回执,不覆盖图片回执且不传图片上限', async () => {
const { service, posts } = await setup()
service.limit.value = 1
service.promptConcurrency.value = 4
service.promptForce.value = true
await service.generatePrompts()
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/subject-forms/generation-prompts')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 4, force: true })
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
expect(service.session.value.receipt).toBeNull()
})
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
const { service, posts } = await setup()
for (const limit of [0, -1, 1.5]) {
service.limit.value = limit
await service.generateProject()
}
expect(posts()).toHaveLength(0)
service.limit.value = 1
await service.generateProject()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
concurrency: 2,
force: false,
limit: 1
})
expect(service.session.value.receipt?.result.remaining).toBe(2)
service.limit.value = ''
await service.generateProject()
expect(JSON.parse(String(posts()[1]?.[1]?.body))).not.toHaveProperty('limit')
service.promptConcurrency.value = 0
await service.generatePrompts()
expect(posts()).toHaveLength(2)
})
it.each(['draft', 'generating', 'need_review', 'failed'] as const)('%s 剧本不能生成提示词和图片', async status => {
const { service, context, posts } = await setup()
context.data.value!.project.status = status
await service.generatePrompt('form-db-1', true)
await service.generatePrompts()
await service.generateProject()
expect(posts()).toHaveLength(0)
})
it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
const { service, form, posts } = await setup()
form.subject.module = module
form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
expect(currentIdentityAnchorId(form)).toBeUndefined()
expect(isPrimaryIdentityStale(form)).toBe(false)
form.subject.identity.isLocked = true
expect(isPrimaryIdentityStale(form)).toBe(true)
await service.query.refresh()
await service.generateStale()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
})
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
const form = formFixture()
wrapper = mount(FormPromptDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
await flushPromises()
find('生成正式提示词').click()
await flushPromises()
expect(find('确认生成正式提示词').disabled).toBe(true)
expect(wrapper.emitted('generate')).toBeUndefined()
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
await flushPromises()
find('确认生成正式提示词').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', false]])
})
it('缺少正式和原始提示词仍可确认生图,由后端补齐而非前端编造', async () => {
const form = formFixture()
form.appearancePrompt = null
form.generationPrompt = null
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
await flushPromises()
document.querySelector<HTMLElement>('#confirm-image-cost')!.click()
await flushPromises()
const submit = [...document.querySelectorAll('button')].find(
item => item.textContent?.trim() === '确认生成图片'
)!
expect(submit.disabled).toBe(false)
submit.click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
})
})
@@ -0,0 +1,166 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WorkflowDiagnosticsDialog from '@/features/workflows/WorkflowDiagnosticsDialog.vue'
import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from '@/features/workflows/diagnostics'
import type { Checkpoint } from '@/features/workflows/types'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 后端将 checkpoint 固定标为 completed,测试确保界面不把它当执行成功。 */
const groups: WorkflowTimelineGroup[] = [
{
phase: '其它',
nodeCount: 22,
durationMs: 22000,
durationText: '22s',
nodes: Array.from({ length: 22 }, (_, index) => ({
index: index + 1,
checkpointId: `checkpoint-${index}`,
nodeName: `node-${index}`,
phase: '其它',
status: 'completed',
durationMs: 1000,
durationText: '1s',
retryCount: 0,
createdAt: '2026-09-01T00:00:00Z'
}))
}
]
const metrics = {
projectId: 'diagnostics-test',
nodeCount: 22,
totalDurationText: '22s',
retryCount: 1,
successRate: 100,
failedNodeCount: 0
}
describe('运行观测入口', () => {
it('关闭时不请求,打开只读两个接口,完整展示超过 18 条记录且不伪造成功率', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : groups }))
)
vi.stubGlobal('fetch', fetcher)
const checkpoints: Checkpoint[] = [
{
checkpointId: 'checkpoint-0',
workflowName: 'breakdown',
createdAt: '2026-09-01T00:00:00Z',
state: {
workflowExecution: {
status: 'failed',
executionId: 'execution-1',
startedAt: '2026-09-01T00:00:00Z'
}
}
}
]
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'diagnostics-test', open: false, checkpoints }
})
await flushPromises()
expect(fetcher).not.toHaveBeenCalled()
await wrapper.setProps({ open: true })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(22)
expect(document.body.textContent).toContain('包含失败记录')
expect(document.body.textContent).not.toContain('100%')
const input = document.querySelector<HTMLInputElement>('[aria-label="搜索运行记录"]')!
input.value = 'checkpoint-21'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(1)
})
it('一个观测接口失败仍显示另一项,不启动恢复或生产', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async url =>
String(url).endsWith('/metrics')
? new Response(JSON.stringify({ error: '指标不可用' }), { status: 500 })
: new Response(JSON.stringify({ data: groups }))
)
vi.stubGlobal('fetch', fetcher)
const result = await loadWorkflowDiagnostics('project/1')
expect(result.metrics).toBeNull()
expect(result.groups?.[0]?.nodes).toHaveLength(22)
expect(result.errors[0]).toContain('指标')
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/metrics')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/timeline/grouped')
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
})
it('指标项目不匹配时拒绝展示', async () => {
vi.stubGlobal(
'fetch',
vi
.fn<typeof fetch>()
.mockImplementation(
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : [] }))
)
)
const result = await loadWorkflowDiagnostics('other-project')
expect(result.metrics).toBeNull()
expect(result.errors.join()).toContain('不匹配的项目')
})
it('切项目会取消旧查询,晚到响应不会写进新项目', async () => {
const pending: ((response: Response) => void)[] = []
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
String(url).includes('/old/')
? new Promise(resolve => pending.push(resolve))
: Promise.resolve(
new Response(
JSON.stringify({
data: String(url).endsWith('/metrics')
? { ...metrics, projectId: 'new', nodeCount: 0 }
: []
})
)
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'old', open: true, checkpoints: [] }
})
await flushPromises()
const signal = fetcher.mock.calls[0]?.[1]?.signal
await wrapper.setProps({ projectId: 'new' })
await flushPromises()
expect(signal?.aborted).toBe(true)
pending[0]!(new Response(JSON.stringify({ data: { ...metrics, projectId: 'old' } })))
pending[1]!(new Response(JSON.stringify({ data: groups })))
await flushPromises()
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(0)
expect(document.body.textContent).toContain('全项目记录 0')
await wrapper.setProps({ open: false })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(4)
})
it('关闭弹窗时取消仍未返回的查询', async () => {
const finishes: ((response: Response) => void)[] = []
const fetcher = vi.fn<typeof fetch>().mockImplementation(() => new Promise(resolve => finishes.push(resolve)))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(WorkflowDiagnosticsDialog, {
attachTo: document.body,
props: { projectId: 'diagnostics-test', open: true, checkpoints: [] }
})
await flushPromises()
await wrapper.setProps({ open: false })
await flushPromises()
expect(fetcher.mock.calls.every(([, init]) => init?.signal?.aborted)).toBe(true)
finishes[0]!(new Response(JSON.stringify({ data: metrics })))
finishes[1]!(new Response(JSON.stringify({ data: groups })))
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { getOperation, runOperation } from '@/features/workflows/operations'
describe('项目级长请求互斥', () => {
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
let fail!: (reason: Error) => void
const action = vi.fn<() => Promise<unknown>>(
() =>
new Promise((_resolve, reject) => {
fail = reject
})
)
const first = runOperation('operation-test', '拆解', action)
expect(await runOperation('operation-test', '改写', action)).toBe(false)
expect(action).toHaveBeenCalledTimes(1)
fail(new Error('连接中断'))
expect(await first).toBe(false)
expect(getOperation('operation-test')).toMatchObject({ pending: false, error: '连接中断', notice: '' })
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from '@/features/workflows/selectors'
import type { Checkpoint } from '@/features/workflows/types'
import type { BreakdownState } from '@/features/breakdown/types'
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
return { checkpointId: String(index), workflowName, createdAt: new Date(index * 1000).toISOString(), state }
}
describe('Checkpoint 选择与恢复', () => {
it('按 graph 隔离并保持输入不可变', () => {
const records = [checkpoint(2, {}), checkpoint(1, {}, 'create-drama')]
expect(workflowCheckpoints(records, 'breakdown').map(item => item.checkpointId)).toEqual(['2'])
expect(records[0]?.checkpointId).toBe('2')
})
it('只有错误的最新 checkpoint 仍能展示上一份成果,同时保留最新失败状态', () => {
const result = breakdownSnapshot([
checkpoint(1, {
breakdownResult: { subjectCandidates: [] },
runConfig: { groupSize: 3, modules: ['character'], episodeGroups: [] }
}),
checkpoint(2, {
workflowExecution: {
executionId: 'e',
status: 'failed',
startedAt: '',
errorMessage: '模型未返回 JSON'
}
})
])
expect(result?.runConfig?.groupSize).toBe(3)
expect(result?.workflowExecution?.status).toBe('failed')
})
it('新阶段不能继承上一轮 completed 状态', () => {
const result = breakdownSnapshot([
checkpoint(1, {
workflowExecution: { executionId: 'e', status: 'completed', startedAt: '' },
breakdownResult: {}
}),
checkpoint(2, { runConfig: { groupSize: 2, modules: ['scene'], episodeGroups: [] } })
])
expect(result?.workflowExecution).toBeUndefined()
expect(result?.runConfig?.groupSize).toBe(2)
})
it('恢复窗口和后端一致,过旧的失败任务不启用重试', () => {
const records = Array.from({ length: 11 }, (_, index) =>
checkpoint(
index,
index === 0
? {
tasks: [
{
taskId: 't',
module: 'prop',
status: 'failed',
attempt: 1,
group: { groupId: 'g', groupNo: 1, startEpisodeNo: 1, endEpisodeNo: 1, episodes: [] }
}
]
}
: {}
)
)
expect(recoveryOptions(records).retry).toBe(false)
expect(recoveryOptions(records.slice(0, 10)).retry).toBe(true)
})
})
+180
View File
@@ -0,0 +1,180 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, optionalResource, request } from '@/lib/http'
import { projectsApi } from '@/features/projects/api'
import { breakdownApi } from '@/features/breakdown/api'
import { storyboardApi } from '@/features/storyboard/api'
import { subjectImagesApi } from '@/features/subject-images/api'
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('后端 API 契约', () => {
it('图库读取与单图、批量和切换主图使用各自接口,模型由后端统一配置', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":[]}'))
vi.stubGlobal('fetch', fetcher)
await subjectImagesApi.listForms('a/b')
await subjectImagesApi.listImages('form/id')
await subjectImagesApi.generate('form/id', {
setPrimary: false,
width: 2048,
height: 2048,
prompt: '本次提示词'
})
await subjectImagesApi.generateProject('a/b', { concurrency: 2, force: false })
await subjectImagesApi.setPrimary('form/id', 'image/id')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/projects/a%2Fb/subject-forms', 'GET'],
['/api/subject-forms/form%2Fid/images', 'GET'],
['/api/subject-forms/form%2Fid/images', 'POST'],
['/api/projects/a%2Fb/subject-images/generate', 'POST'],
['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT']
])
expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({
setPrimary: false,
width: 2048,
height: 2048,
prompt: '本次提示词'
})
expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({
concurrency: 2,
force: false
})
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
})
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await storyboardApi.generateDirection('a/b', 2)
await storyboardApi.generateDirections('p', { concurrency: 2, force: false })
await storyboardApi.generateVisualState('p', 2, 0)
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }],
['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
[
'/api/projects/p/storyboard-visual-states/generate-test',
'POST',
{ episodeNo: 2, persist: true, maxRepairAttempts: 0 }
],
[
'/api/projects/p/storyboard-visual-states/generate',
'POST',
{ concurrency: 3, force: true, maxRepairAttempts: 0 }
]
])
})
it('查询按剧集,镜头工具使用数据库 ID,读取提示词仍需显式 POST', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await storyboardApi.directions('p', 4)
await storyboardApi.visualStates('p', 4)
await storyboardApi.references('shot/id')
await storyboardApi.generationSpec('shot/id')
await storyboardApi.generatePrompt('shot/id', false)
await storyboardApi.generatePrompts('p', { concurrency: 2, force: false })
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/p/storyboard-directions?episodeNo=4',
'/api/projects/p/storyboard-visual-states?episodeNo=4',
'/api/storyboard-shots/shot%2Fid/references',
'/api/storyboard-shots/shot%2Fid/generation-spec',
'/api/storyboard-shots/shot%2Fid/video-prompt',
'/api/projects/p/video-prompts/generate'
])
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false })
})
it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => {
vi.useFakeTimers()
let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
const pending = storyboardApi.generateVisualState('p', 1, 2)
await vi.advanceTimersByTimeAsync(120_000)
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}'))
await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false })
})
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
const fetcher = vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'p1' }] })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ projectId: 'p2', status: 'generating' }), { status: 202 })
)
vi.stubGlobal('fetch', fetcher)
expect(await projectsApi.list()).toEqual([{ id: 'p1' }])
expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
projectId: 'p2',
status: 'generating'
})
expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({
topic: '故事',
style: '悬疑',
episodeCount: 3
})
})
it('只将缺少 checkpoint 的 404 视为可选结果,保留服务器错误', async () => {
expect(await optionalResource(Promise.reject(new ApiError('没有 checkpoint', 404)))).toBeNull()
await expect(optionalResource(Promise.reject(new ApiError('数据库异常', 500)))).rejects.toMatchObject({
status: 500
})
})
it('保留错误细节且拒绝 HTML 代理错误', async () => {
vi.stubGlobal(
'fetch',
vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 })
)
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
)
await expect(request('/projects')).rejects.toMatchObject({ status: 400, details: ['缺少形态'] })
await expect(request('/projects')).rejects.toThrow('接口未返回 JSON')
})
it('预览模块使用逗号参数,三种恢复不误发到 start', async () => {
const fetcher = vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockImplementation(() => Promise.resolve(new Response('{"data":{}}')))
vi.stubGlobal('fetch', fetcher)
await breakdownApi.preview('a/b', { groupSize: 3, modules: ['character', 'scene'] })
for (const action of ['retry', 'resume-shots', 'resume-storyboard'] as const)
await breakdownApi.run('p', action)
expect(fetcher.mock.calls.map(call => call[0])).toEqual([
'/api/projects/a%2Fb/breakdown-preview?groupSize=3&modules=character%2Cscene',
'/api/projects/p/breakdown/retry',
'/api/projects/p/breakdown/resume-shots',
'/api/projects/p/breakdown/resume-storyboard'
])
})
it('长工作流不使用普通查询的超时,不自动重试 POST', async () => {
vi.useFakeTimers()
let finish!: (value: Response) => void
const fetcher = vi.fn<(url: string, options: RequestInit) => Promise<Response>>().mockImplementation(
() =>
new Promise<Response>(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
const running = breakdownApi.run('p', 'start', { groupSize: 3, modules: ['prop'] })
await vi.advanceTimersByTimeAsync(120_000)
expect(fetcher).toHaveBeenCalledTimes(1)
expect(fetcher.mock.calls[0]![1].signal?.aborted).toBe(false)
finish(new Response('{"data":{"workflowExecution":{"status":"failed"}}}'))
expect(await running).toMatchObject({ workflowExecution: { status: 'failed' } })
})
})