import { effectScope, nextTick, type EffectScope } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' import { useTheme } from './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('默认跟随系统并实时响应,销毁时移除监听', 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('#ededed') 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('#082b17') expect(document.documentElement.dataset.theme).toBe('dark') }) it.each([false, true])('明暗模式 %s 的组件表面、语义色与业务 CSS 一致,主要文字保持可读', dark => { const state = setup(dark) const { common, Menu, Button, Layout } = state.overrides.value const css = readFileSync('src/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({ itemColorActive: tokens.selected, itemTextColorActive: tokens['accent-text'] }) expect(Button?.textColorPrimary).toBe(tokens['on-accent']) for (const background of [common!.primaryColor!, common!.primaryColorHover!, common!.primaryColorPressed!]) { expect(contrast(String(Button!.textColorPrimary), 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('#ededed') state.change(true) await nextTick() expect(meta.content).toBe('#111111') state.preference.value = 'light' await nextTick() expect(meta.content).toBe('#ededed') } 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') }) })