Files
short-drama-agent-front/tests/components/ui/flatTheme.test.ts
T
GouJ 4edb0ff6cc feat: 补齐前端主流程并收紧工作台布局
统一工作台紧凑头部与形态图库间距,修复主体身份布局,并接入正式严格视频质量流水线。
2026-09-08 09:25:14 +08:00

274 lines
15 KiB
TypeScript

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 #header>/
)
})
it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => {
const css = readAllStyles()
expect(css).toMatch(
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\);[^}]*grid-auto-flow:\s*row dense;[^}]*grid-auto-rows:\s*1px;/
)
expect(css).toMatch(
/\.form-image-masonry > \.form-image-card\s*\{[^}]*grid-column-start:\s*var\(--form-image-column,\s*auto\);[^}]*grid-row-start:\s*var\(--form-image-row-start,\s*auto\);[^}]*grid-row-end:\s*span var\(--form-image-row-span,\s*1\);/
)
expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*auto;/)
expect(css).toMatch(
/\.form-image-masonry \.asset-image \.n-image,\s*\.form-image-masonry \.asset-image \.n-image img\s*\{[^}]*height:\s*auto;/
)
})
it('图库顶部统一镜头选择和筛选的背景与垂直对齐,窄屏整组换行', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readAllStyles()
expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding(?:-block)?:\s*5px;[^}]*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;/)
expect(css).toContain('@media (max-width: 800px)')
expect(css).toMatch(/\.project-status-filters\.n-radio-group\s*\{[^}]*width:\s*100%;[^}]*flex-wrap:\s*nowrap/)
expect(css).toMatch(/\.project-status-filters \.n-radio-button\s*\{[^}]*flex:\s*1;[^}]*min-width:\s*0/)
expect(css).toMatch(/\.project-index-heading \.page-description\s*\{[^}]*display:\s*none/)
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|$)/
)
}
}
})
})