import { effectScope, nextTick, type EffectScope } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTheme } from '@/composables/useTheme' import { readFileSync } from 'node:fs' let scope: EffectScope | undefined afterEach(() => { scope?.stop() scope = undefined localStorage.clear() delete document.documentElement.dataset.theme document.documentElement.style.colorScheme = '' vi.unstubAllGlobals() vi.restoreAllMocks() }) /** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */ function setup(dark = false) { const listeners = new Set<(event: MediaQueryListEvent) => void>() vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener) })) scope = effectScope() const state = scope.run(useTheme)! return { ...state, listeners, change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent)) } } /** 将十六进制颜色换算为 sRGB 相对亮度。 */ function luminance(hex: string) { const channels = [1, 3, 5].map(index => { const value = parseInt(hex.slice(index, index + 2), 16) / 255 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 }) return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722 } /** 检查主题文字对比度,避免绿色按钮和浅色选中项难以辨认。 */ function contrast(foreground: string, background: string) { const values = [luminance(foreground), luminance(background)] return (Math.max(...values) + 0.05) / (Math.min(...values) + 0.05) } describe('微信风格明暗主题', () => { it('浅色 Tabs 分隔线使用约定的 #dadada,与页面和内容表面保留灰阶差异', () => { const { overrides } = setup() const color = overrides.value.Tabs!.tabBorderColor! if (typeof color !== 'string') throw new Error('Tabs 分隔线颜色必须为字符串') expect(color).toBe('#dadada') expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15) expect(contrast(color, '#ffffff')).toBeGreaterThan(1.15) }) it('默认跟随系统并实时响应,销毁时移除监听', async () => { const state = setup() expect(state.preference.value).toBe('system') expect(document.documentElement.dataset.theme).toBe('light') state.change(true) await nextTick() expect(state.isDark.value).toBe(true) expect(document.documentElement.style.colorScheme).toBe('dark') expect(state.listeners.size).toBe(1) scope!.stop() expect(state.listeners.size).toBe(0) }) it('手动选择优先于系统,并持久化到下一次挂载', async () => { const state = setup(true) state.preference.value = 'light' await nextTick() state.change(true) await nextTick() expect(state.isDark.value).toBe(false) expect(localStorage.getItem('drama-studio-theme')).toBe('light') scope!.stop() const restored = setup(true) expect(restored.preference.value).toBe('light') expect(restored.theme.value).toBeNull() }) it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => { const state = setup() expect(state.overrides.value.common?.primaryColor).toBe('#07c160') expect(state.overrides.value.common?.bodyColor).toBe('#ffffff') state.preference.value = 'dark' await nextTick() expect(state.theme.value?.name).toBe('dark') expect(state.overrides.value.common?.primaryColor).toBe('#07c160') expect(state.overrides.value.common?.bodyColor).toBe('#111111') expect(state.overrides.value.Button?.textColorPrimary).toBe('#ffffff') expect(document.documentElement.dataset.theme).toBe('dark') }) it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => { const state = setup(dark) const { common, Menu, Button, Layout, Input, Select } = state.overrides.value const css = readFileSync('src/styles/admin.css', 'utf8') const lightTokens = css.match(/:root\s*\{([^}]+)\}/)![1]! const darkTokens = css.match(/:root\[data-theme='dark'\]\s*\{([^}]+)\}/)![1]! const tokens = Object.fromEntries( [...(lightTokens + (dark ? darkTokens : '')).matchAll(/--app-([\w-]+):\s*(#[\da-f]+);/g)].map(match => [ match[1], match[2] ]) ) expect(common).toMatchObject({ bodyColor: tokens.body, cardColor: tokens.surface, modalColor: tokens.surface, tableColor: tokens.surface, inputColor: tokens.surface, borderColor: tokens.border, textColor1: tokens.ink, textColor3: tokens.muted, primaryColor: tokens.accent, successColor: tokens.success, errorColor: tokens.danger }) expect(Layout).toMatchObject({ color: tokens.body, headerColor: tokens.surface, siderColor: tokens.subtle }) expect(Menu).toMatchObject({ borderRadius: '0px', itemColorActive: tokens.selected, itemColorActiveCollapsed: tokens.selected, itemTextColorActive: tokens['accent-text'] }) // 全站直角;输入区域与普通按钮共享底色,保持明暗模式一致。 expect(common?.borderRadius).toBe('0px') expect(common?.borderRadiusSmall).toBe('0px') expect(Input?.color).toBe('var(--app-field)') expect(Button?.color).toBe(tokens.control) expect(Button?.colorHover).toBe(tokens['control-hover']) expect(Select?.peers?.InternalSelection?.color).toBe('var(--app-field)') expect(contrast(tokens.field!, tokens.body!)).toBeGreaterThan(1.1) expect(contrast(tokens.control!, tokens.surface!)).toBeGreaterThan(1.1) expect(state.overrides.value.Checkbox?.checkMarkColor).toBe('#ffffff') expect(Button?.textColorPrimary).toBe(tokens['on-accent']) expect(Button?.colorPrimary).toBe(tokens['button-primary']) expect(Button?.colorHoverPrimary).toBe(tokens['button-hover']) expect(Button?.colorPressedPrimary).toBe(tokens['button-pressed']) expect(Button?.colorDisabledPrimary).toBe(tokens['disabled-bg']) expect(Button?.textColorDisabledPrimary).toBe(tokens['disabled-text']) for (const background of [Button!.colorPrimary!, Button!.colorHoverPrimary!, Button!.colorPressedPrimary!]) { expect(contrast(String(Button!.textColorPrimary), String(background))).toBeGreaterThanOrEqual(4.5) } expect(contrast(tokens.ink!, tokens.surface!)).toBeGreaterThanOrEqual(4.5) expect(contrast(tokens.muted!, tokens.subtle!)).toBeGreaterThanOrEqual(4.5) expect(contrast(tokens['accent-text']!, tokens.selected!)).toBeGreaterThanOrEqual(4.5) }) it('系统切换时浏览器主题色与页面同步', async () => { const meta = document.createElement('meta') meta.name = 'theme-color' document.head.appendChild(meta) try { const state = setup() expect(meta.content).toBe('#ffffff') state.change(true) await nextTick() expect(meta.content).toBe('#111111') state.preference.value = 'light' await nextTick() expect(meta.content).toBe('#ffffff') } finally { meta.remove() } }) it('无效存储回退系统模式', () => { localStorage.setItem('drama-studio-theme', 'unexpected') const state = setup(true) expect(state.preference.value).toBe('system') expect(state.isDark.value).toBe(true) }) it('禁用存储仍能切换当前会话的主题', async () => { vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('storage blocked') }) vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage blocked') }) const state = setup() state.preference.value = 'dark' await nextTick() expect(document.documentElement.dataset.theme).toBe('dark') }) })