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, NPopover, 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).toMatch(
/\.tabs-toolbar-actions \.n-button\.icon-button\s*\{[^}]*width:\s*32px;[^}]*min-width:\s*32px;[^}]*height:\s*32px;[^}]*padding:\s*0/
)
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).toMatch(
/@media \(max-width: 800px\)[\s\S]*?\.breakdown-results-section \.content-with-history:not\(\.history-hidden\) \.history-panel\s*\{[^}]*inset:\s*0/
)
expect(css).toMatch(/\.panel-scroll\.n-scrollbar\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0/)
})
it('标题与内容使用各自滚动容器,不随正文一起滚动', () => {
wrapper = mount(WorkspacePage, {
slots: { header: '
固定操作区
', default: '正文
' }
})
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: '正文' }
})
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')
expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain('lucide-menu')
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('.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({
collapsed: false,
collapsedWidth: 64,
iconSize: 20,
collapsedIconSize: 20,
indent: 22,
rootIndent: 22
})
const firstMenuIcon = () => {
const [option] = wrapper!.getComponent(NMenu).props('options') as Array<{
icon: () => ReturnType
}>
return option!.icon()
}
expect(firstMenuIcon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false)
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
await flushPromises()
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false)
expect(firstMenuIcon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false)
// 悬停只展示整列名称,不能重新创建菜单或改变正文占用宽度。
const menuElement = wrapper.get('.n-menu').element
await wrapper.get('.admin-sider').trigger('mouseenter')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
expect(wrapper.get('.n-menu').element).toBe(menuElement)
await wrapper.get('.admin-sider').trigger('mouseleave')
const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text())
expect(labels).toEqual([
'我的剧本',
'剧本创作',
'剧本拆解',
'视觉风格',
'主体身份',
'形态图片',
'分镜设计',
'镜头生产'
])
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;[^}]*min-width:\s*var\(--admin-sider-expanded\)/
)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content-header\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content__icon\s*\{[^}]*pointer-events:\s*none/)
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/)
expect(css).toContain('@media (max-width: 800px)')
expect(css).toMatch(/\.admin-sider\s*\{[^}]*transition:\s*width\s+0\.32s/)
expect(css).toMatch(/\.workspace-tools-body\s*\{[^}]*padding:\s*16px/)
expect(css).toMatch(/\.workspace-tools-drawer \.n-drawer-header[\s\S]*?padding:\s*16px/)
expect(css).toMatch(
/@media \(max-width: 800px\)[\s\S]*?\.admin-sider\s*\{[^}]*position:\s*fixed;[^}]*inset:\s*0;[^}]*width:\s*100%\s*!important;[^}]*transform:\s*translateX\(0\)/
)
expect(css).toMatch(
/\.admin-sider\.is-collapsed\s*\{[^}]*transform:\s*translateX\(-100%\);[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none/
)
expect(css).toMatch(/\.admin-layout > \.admin-main\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0/)
expect(css).toMatch(/\.mobile-sider-close\s*\{[^}]*display:\s*flex;[^}]*margin-right:\s*8px/)
expect(css).toContain('@media (prefers-reduced-motion: reduce)')
expect(css).toContain('.admin-sider.is-collapsed:is(:hover, :has(:focus-visible)) .n-menu-item-content-header')
expect(css).toContain('clip-path: inset(0);')
expect(css).toContain('transform: translateX(-6px);')
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('剧集 {
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: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBe('true')
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.find('.admin-nav-drawer').exists()).toBe(false)
expect(wrapper.getComponent(NMenu).props()).toMatchObject({
collapsedWidth: 64,
indent: 20,
rootIndent: 20
})
const [mobileOption] = wrapper.getComponent(NMenu).props('options') as Array<{
icon: () => ReturnType
}>
expect(mobileOption!.icon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false)
const collapsedBrand = wrapper.get('.admin-brand')
expect(collapsedBrand.classes()).toContain('is-collapsed')
expect(collapsedBrand.text()).toContain('短剧工作台')
const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(collapsedSettings.text()).toBe('后端连接')
expect(collapsedSettings.classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-menu')
await wrapper.get('.admin-topbar [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: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
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('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
const expandedBrand = wrapper.get('.admin-brand')
expect(expandedBrand.classes()).not.toContain('is-collapsed')
expect(expandedBrand.text()).toContain('短剧工作台')
const expandedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(expandedSettings.text()).toBe('后端连接')
expect(expandedSettings.classes()).not.toContain('is-collapsed')
expect(wrapper.get('.mobile-sider-close').attributes('aria-label')).toBe('关闭主菜单')
await wrapper.get('.mobile-sider-close').trigger('click')
await flushPromises()
expect(wrapper.get('.admin-sider').classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBe('true')
expect(wrapper.get('.admin-brand').text()).toContain('短剧工作台')
expect(wrapper.get('.admin-sider-footer [aria-label="后端连接"]').text()).toBe('后端连接')
})
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
wrapper = mount(AppDialog, {
attachTo: document.body,
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
slots: { default: '内容
' }
})
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: '主体列表'
}
})
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('input[aria-label="配置草稿"]').element.value).toBe('保留配置')
container.querySelector('.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
})
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/
)
})
})