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(`.${type}-button`).element const disabled = wrapper.get(`.${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('.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( /]*>\s*