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(9) 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(9) }) 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(9) 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.find('.project-header').exists()).toBe(false) expect(document.title).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() }) 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/ ) }) })