300 lines
17 KiB
TypeScript
300 lines
17 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 #default>/
|
|
)
|
|
})
|
|
|
|
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()
|
|
const source = readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')
|
|
expect(source).not.toContain('class="toolbar-summary"')
|
|
expect(source).toMatch(/<WorkspaceTools\s+triggerless/)
|
|
expect(source.match(/<NPopover trigger="hover" placement="top" :disabled="isMobile">/g)).toHaveLength(4)
|
|
expect(source).toMatch(/aria-label="网格布局"/)
|
|
expect(source).toMatch(/aria-label="瀑布流布局"/)
|
|
expect(source).toMatch(/aria-label="刷新图库"/)
|
|
expect(source).toMatch(/data-workspace-tools\s+aria-label="批量生图"/)
|
|
expect(source).not.toContain('title="网格布局"')
|
|
expect(source).not.toContain('title="瀑布流布局"')
|
|
expect(css).toMatch(
|
|
/\.form-image-filter-region\s*\{[^}]*padding:\s*12px;[^}]*--app-field:\s*var\(--app-surface\);[^}]*--app-field-hover:\s*var\(--app-surface\);[^}]*background:\s*color-mix\(in srgb, var\(--app-subtle\) 96%, white\);/
|
|
)
|
|
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/)
|
|
expect(css).toMatch(
|
|
/@container \(max-width: 460px\)\s*\{[^}]*\.form-image-filters\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*gap:\s*10px;/
|
|
)
|
|
expect(css).toMatch(/\.search-filter\s*\{[^}]*flex:\s*1 1 calc\(100% - 130px\);[^}]*min-width:\s*0;/)
|
|
expect(css).toMatch(/\.module-filter\s*\{[^}]*flex:\s*0 0 120px;/)
|
|
expect(css).toMatch(/\.status-filter\s*\{[^}]*flex:\s*0 1 110px;[^}]*min-width:\s*90px;/)
|
|
expect(css).toMatch(
|
|
/\.form-image-display-controls\s*\{[^}]*flex:\s*1 0 152px;[^}]*width:\s*auto;[^}]*justify-content:\s*flex-end;/
|
|
)
|
|
expect(css).toMatch(/\.filter-result-count\s*\{[^}]*display:\s*none;/)
|
|
expect(css).toMatch(
|
|
/\.gallery-layout-switch \.layout-option-button:not\(\.layout-option-active\)\s*\{[^}]*background:\s*var\(--app-control-hover\);/
|
|
)
|
|
expect(css).toMatch(
|
|
/\.gallery-layout-switch\s*\{[^}]*display:\s*flex;[^}]*align-items:\s*center;[^}]*gap:\s*8px;/
|
|
)
|
|
})
|
|
|
|
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|$)/
|
|
)
|
|
}
|
|
}
|
|
})
|
|
})
|