/** * themes.js 单元测试 * 覆盖主题系统:解析 / 切换 / 存储 / 监听 / 自定义主题注册 * * 注意:themes.js 使用模块级状态(currentTheme / themeListeners), * 测试间需要重置状态以避免相互影响。 */ import { themeUtils, getSystemTheme, resolveTheme, getThemeConfig, applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, saveTheme, loadTheme, initTheme, watchSystemTheme, unwatchSystemTheme, addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme, getTheme, setThemeVariables, presetThemes, createThemeManager, } from '../src/themes.js'; import { THEMES } from '../src/constants.js'; // 确保 localStorage 存在(jsdom 兼容) if (typeof global.localStorage === 'undefined') { const store = {}; global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, clear: () => { Object.keys(store).forEach((k) => delete store[k]); }, }; } // jsdom 默认无 matchMedia,需 mock 以测试 watchSystemTheme const mockMatchMedia = (matches = false) => ({ matches, addEventListener: jest.fn(), removeEventListener: jest.fn(), }); describe('主题解析', () => { test('resolveTheme 非 auto 原样返回', () => { expect(resolveTheme('light')).toBe('light'); expect(resolveTheme('dark')).toBe('dark'); expect(resolveTheme('warm')).toBe('warm'); }); test('resolveTheme auto 返回系统主题', () => { // currentTheme 默认为 'auto',调用 resolveTheme('auto') 会返回 getSystemTheme() const r = resolveTheme('auto'); expect(['light', 'dark']).toContain(r); }); test('getTheme 是 resolveTheme 的别名', () => { expect(getTheme('light')).toBe(resolveTheme('light')); }); test('getSystemTheme 返回 light 或 dark', () => { const t = getSystemTheme(); expect(['light', 'dark']).toContain(t); }); test('getThemeConfig 已知主题返回配置对象', () => { const c = getThemeConfig('light'); expect(c).toBe(THEMES.light); expect(c.bg).toBeDefined(); expect(c.accent).toBeDefined(); }); test('getThemeConfig 未知主题回退到 light', () => { const c = getThemeConfig('unknown-theme'); expect(c).toBe(THEMES.light); }); }); describe('主题切换与应用', () => { beforeEach(() => { clearThemeListeners(); resetToAuto(); localStorage.clear(); }); test('applyTheme 设置 currentTheme', () => { applyTheme('dark'); expect(getCurrentTheme()).toBe('dark'); }); test('applyTheme 在 documentElement 上设置 data-md-theme 属性', () => { applyTheme('dark'); expect(document.documentElement.getAttribute('data-md-theme')).toBe('dark'); }); test('applyTheme 切换 md-theme-* class', () => { applyTheme('light'); expect(document.documentElement.classList.contains('md-theme-light')).toBe(true); expect(document.documentElement.classList.contains('md-theme-dark')).toBe(false); applyTheme('dark'); expect(document.documentElement.classList.contains('md-theme-dark')).toBe(true); expect(document.documentElement.classList.contains('md-theme-light')).toBe(false); }); test('applyTheme 设置 CSS 变量', () => { applyTheme('dark'); const root = document.documentElement; expect(root.style.getPropertyValue('--md-bg')).toBe(THEMES.dark.bg); expect(root.style.getPropertyValue('--md-accent')).toBe(THEMES.dark.accent); }); test('switchTheme 应用并持久化', () => { switchTheme('dark'); expect(getCurrentTheme()).toBe('dark'); expect(localStorage.getItem('metona-editor-theme')).toBe('dark'); }); test('toggleTheme 在 light/dark 间切换', () => { switchTheme('light'); toggleTheme(); expect(getCurrentTheme()).toBe('dark'); toggleTheme(); expect(getCurrentTheme()).toBe('light'); }); test('resetToAuto 切回 auto', () => { switchTheme('dark'); resetToAuto(); expect(getCurrentTheme()).toBe('auto'); }); test('getResolvedTheme 返回解析后的实际主题', () => { switchTheme('dark'); expect(getResolvedTheme()).toBe('dark'); switchTheme('light'); expect(getResolvedTheme()).toBe('light'); }); }); describe('主题持久化', () => { beforeEach(() => { localStorage.clear(); resetToAuto(); }); test('saveTheme 写入 localStorage', () => { saveTheme('dark'); expect(localStorage.getItem('metona-editor-theme')).toBe('dark'); }); test('loadTheme 默认返回 auto', () => { expect(loadTheme()).toBe('auto'); }); test('loadTheme 读取已保存的值', () => { localStorage.setItem('metona-editor-theme', 'dark'); expect(loadTheme()).toBe('dark'); }); test('initTheme 加载已保存的主题', () => { localStorage.setItem('metona-editor-theme', 'dark'); initTheme(); expect(getCurrentTheme()).toBe('dark'); }); test('initTheme 无保存时默认 auto', () => { initTheme(); expect(getCurrentTheme()).toBe('auto'); }); }); describe('主题监听', () => { beforeEach(() => { clearThemeListeners(); resetToAuto(); }); test('addThemeListener 注册回调', () => { const fn = jest.fn(); addThemeListener(fn); switchTheme('dark'); expect(fn).toHaveBeenCalledWith('dark', 'dark'); }); test('addThemeListener 返回取消函数', () => { const fn = jest.fn(); const unsub = addThemeListener(fn); expect(typeof unsub).toBe('function'); unsub(); switchTheme('dark'); expect(fn).not.toHaveBeenCalled(); }); test('removeThemeListener 移除回调', () => { const fn = jest.fn(); addThemeListener(fn); removeThemeListener(fn); switchTheme('dark'); expect(fn).not.toHaveBeenCalled(); }); test('clearThemeListeners 清空所有回调', () => { const fn1 = jest.fn(); const fn2 = jest.fn(); addThemeListener(fn1); addThemeListener(fn2); clearThemeListeners(); switchTheme('dark'); expect(fn1).not.toHaveBeenCalled(); expect(fn2).not.toHaveBeenCalled(); }); test('监听器抛错不影响其他监听器', () => { const errFn = jest.fn(() => { throw new Error('boom'); }); const okFn = jest.fn(); const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); addThemeListener(errFn); addThemeListener(okFn); switchTheme('dark'); expect(errFn).toHaveBeenCalled(); expect(okFn).toHaveBeenCalled(); spy.mockRestore(); }); }); describe('系统主题监听', () => { beforeEach(() => { unwatchSystemTheme(); resetToAuto(); }); afterEach(() => { unwatchSystemTheme(); }); test('watchSystemTheme 无 matchMedia 时不抛错', () => { const orig = window.matchMedia; delete window.matchMedia; expect(() => watchSystemTheme()).not.toThrow(); window.matchMedia = orig; }); test('watchSystemTheme 注册 matchMedia 监听', () => { const orig = window.matchMedia; const mql = mockMatchMedia(false); window.matchMedia = jest.fn(() => mql); watchSystemTheme(); expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); window.matchMedia = orig; }); test('unwatchSystemTheme 移除监听', () => { const orig = window.matchMedia; const mql = mockMatchMedia(false); window.matchMedia = jest.fn(() => mql); watchSystemTheme(); unwatchSystemTheme(); expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); window.matchMedia = orig; }); test('重复 watchSystemTheme 替换旧监听', () => { const orig = window.matchMedia; const mql1 = mockMatchMedia(false); const mql2 = mockMatchMedia(false); let callCount = 0; window.matchMedia = jest.fn(() => { callCount += 1; return callCount === 1 ? mql1 : mql2; }); watchSystemTheme(); watchSystemTheme(); expect(mql1.removeEventListener).toHaveBeenCalled(); window.matchMedia = orig; }); }); describe('自定义主题注册', () => { beforeEach(() => { resetToAuto(); }); test('registerTheme 注册新主题', () => { registerTheme('ocean', { bg: '#001122', text: '#aabbcc', accent: '#00ddff', }); expect(hasTheme('ocean')).toBe(true); const c = getThemeConfig('ocean'); expect(c.bg).toBe('#001122'); expect(c.accent).toBe('#00ddff'); // 未提供字段从 light 主题继承 expect(c.muted).toBe(THEMES.light.muted); }); test('unregisterTheme 移除自定义主题', () => { registerTheme('tmp', { bg: '#000' }); expect(hasTheme('tmp')).toBe(true); unregisterTheme('tmp'); expect(hasTheme('tmp')).toBe(false); }); test('unregisterTheme 拒绝移除内置主题', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); unregisterTheme('light'); expect(spy).toHaveBeenCalled(); expect(hasTheme('light')).toBe(true); spy.mockRestore(); }); test('getAllThemes 返回所有主题副本', () => { const all = getAllThemes(); expect(all.light).toEqual(THEMES.light); expect(all.dark).toEqual(THEMES.dark); // 副本:修改不影响原始 all.light = null; expect(THEMES.light).toBeDefined(); }); test('getThemeNames 返回主题名数组', () => { const names = getThemeNames(); expect(names).toContain('light'); expect(names).toContain('dark'); expect(names).toContain('auto'); expect(names).toContain('warm'); }); test('hasTheme 检查主题存在', () => { expect(hasTheme('light')).toBe(true); expect(hasTheme('non-existent')).toBe(false); }); }); describe('setThemeVariables', () => { test('设置 CSS 变量到 documentElement', () => { setThemeVariables({ bg: '#fff', text: '#000', border: '#eee', shadow: 'none', hoverShadow: 'none', accent: '#abc', }); const root = document.documentElement; expect(root.style.getPropertyValue('--md-bg')).toBe('#fff'); expect(root.style.getPropertyValue('--md-accent')).toBe('#abc'); }); test('空 config 不抛错', () => { expect(() => setThemeVariables(null)).not.toThrow(); expect(() => setThemeVariables(undefined)).not.toThrow(); }); test('部分字段使用默认值', () => { setThemeVariables({ bg: '#fafafa' }); const root = document.documentElement; expect(root.style.getPropertyValue('--md-bg')).toBe('#fafafa'); // 未提供的 accent 使用默认 expect(root.style.getPropertyValue('--md-accent')).toBe('#3b82f6'); }); }); describe('themeUtils 代理层', () => { test('暴露所有主题 API', () => { [ 'getSystemTheme', 'resolveTheme', 'getThemeConfig', 'applyTheme', 'getCurrentTheme', 'getResolvedTheme', 'switchTheme', 'toggleTheme', 'resetToAuto', 'initTheme', 'watchSystemTheme', 'unwatchSystemTheme', 'addThemeListener', 'removeThemeListener', 'clearThemeListeners', 'registerTheme', 'unregisterTheme', 'getAllThemes', 'getThemeNames', 'hasTheme', 'saveTheme', 'loadTheme', ].forEach((fn) => { expect(typeof themeUtils[fn]).toBe('function'); }); }); test('代理方法与具名导出一致', () => { expect(themeUtils.getCurrentTheme).toBe(getCurrentTheme); expect(themeUtils.switchTheme).toBe(switchTheme); }); }); describe('presetThemes', () => { test('包含 light/dark/auto/warm', () => { expect(presetThemes.light).toBeDefined(); expect(presetThemes.dark).toBeDefined(); expect(presetThemes.auto).toBeDefined(); expect(presetThemes.warm).toBeDefined(); }); test('auto 主题 config 为字符串 "auto"', () => { expect(presetThemes.auto.config).toBe('auto'); }); test('light/dark/warm 主题 config 为对象', () => { expect(typeof presetThemes.light.config).toBe('object'); expect(typeof presetThemes.dark.config).toBe('object'); expect(typeof presetThemes.warm.config).toBe('object'); }); }); describe('createThemeManager', () => { test('返回包含全部方法的管理器对象', () => { const m = createThemeManager(); expect(typeof m.switchTheme).toBe('function'); expect(typeof m.getCurrentTheme).toBe('function'); expect(typeof m.registerTheme).toBe('function'); }); test('管理器方法可独立调用', () => { const m = createThemeManager(); m.switchTheme('dark'); expect(m.getCurrentTheme()).toBe('dark'); }); });