/** * 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, exportCSSVars, getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme, } from '../src/themes'; import { THEMES } from '../src/constants'; import { MarkdownEditor } from '../src/core'; // 确保 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'); }); }); // ============ v0.1.5 新增测试 ============ describe('v0.1.5 resolveTheme auto 修复', () => { beforeEach(() => { clearThemeListeners(); resetToAuto(); localStorage.clear(); }); test('auto 始终解析为系统主题,不受上次手动设置污染', () => { switchTheme('dark'); expect(getCurrentTheme()).toBe('dark'); // 即使手动设为 dark,resolveTheme('auto') 仍应返回系统主题 const sys = getSystemTheme(); expect(resolveTheme('auto')).toBe(sys); }); test('resolveTheme 传入具体主题名原样返回', () => { expect(resolveTheme('light')).toBe('light'); expect(resolveTheme('dark')).toBe('dark'); expect(resolveTheme('warm')).toBe('warm'); }); test('resolveTheme 无参/空值返回系统主题', () => { const sys = getSystemTheme(); expect(resolveTheme()).toBe(sys); expect(resolveTheme(null)).toBe(sys); expect(resolveTheme('')).toBe(sys); }); }); describe('v0.1.5 exportCSSVars / getCSSVariable', () => { test('exportCSSVars 返回 CSS 变量对象', () => { applyTheme('light'); const vars = exportCSSVars(); expect(vars).toHaveProperty('--md-bg'); expect(vars).toHaveProperty('--md-accent'); }); test('exportCSSVars 从指定元素读取', () => { const div = document.createElement('div'); div.style.setProperty('--md-bg', '#fff'); document.body.appendChild(div); const vars = exportCSSVars(div); expect(vars['--md-bg']).toBe('#fff'); document.body.removeChild(div); }); test('getCSSVariable 获取单个变量', () => { applyTheme('light'); const bg = getCSSVariable('--md-bg'); expect(typeof bg).toBe('string'); expect(bg.length).toBeGreaterThan(0); }); test('getCSSVariable 自动补全 --md- 前缀', () => { applyTheme('light'); const accent = getCSSVariable('accent'); expect(typeof accent).toBe('string'); expect(accent.length).toBeGreaterThan(0); }); }); describe('v0.1.5 applyThemeToElement', () => { test('应用主题到指定元素不改变全局状态', () => { switchTheme('light'); const div = document.createElement('div'); document.body.appendChild(div); applyThemeToElement('dark', div); expect(div.getAttribute('data-md-theme')).toBe('dark'); // 全局主题不变 expect(getCurrentTheme()).toBe('light'); document.body.removeChild(div); }); test('应用 auto 主题正确解析', () => { const div = document.createElement('div'); document.body.appendChild(div); applyThemeToElement('auto', div); const resolved = resolveTheme('auto'); expect(div.getAttribute('data-md-theme')).toBe(resolved); document.body.removeChild(div); }); }); describe('v0.1.5 主题继承 registerTheme extends', () => { test('extends 继承基础主题', () => { registerTheme('ocean', { extends: 'dark', accent: '#00ddff', }); expect(hasTheme('ocean')).toBe(true); const cfg = getThemeConfig('ocean'); expect(cfg.accent).toBe('#00ddff'); // 从 dark 继承 expect(cfg.bg).toBe(THEMES.dark.bg); expect(cfg.text).toBe(THEMES.dark.text); unregisterTheme('ocean'); }); test('默认 extends light', () => { registerTheme('minimal', { accent: '#ff0000' }); const cfg = getThemeConfig('minimal'); expect(cfg.bg).toBe(THEMES.light.bg); unregisterTheme('minimal'); }); }); describe('v0.1.5 createInstanceTheme', () => { test('创建实例主题上下文', () => { const div = document.createElement('div'); document.body.appendChild(div); const mockEditor = { el: div, config: { theme: 'dark' }, _emit: jest.fn(), refresh: jest.fn(), }; const ctx = createInstanceTheme(mockEditor); expect(ctx.get()).toBe('dark'); expect(ctx.getResolved()).toBe('dark'); expect(typeof ctx.set).toBe('function'); expect(typeof ctx.toggle).toBe('function'); expect(typeof ctx.getConfig).toBe('function'); expect(typeof ctx.getVars).toBe('function'); expect(typeof ctx.syncWithElement).toBe('function'); expect(typeof ctx.adopt).toBe('function'); expect(typeof ctx.watch).toBe('function'); document.body.removeChild(div); }); test('实例主题 set 触发 _emit themeChange', () => { const div = document.createElement('div'); document.body.appendChild(div); const mockEditor = { el: div, config: { theme: 'light' }, _emit: jest.fn(), refresh: jest.fn(), }; const ctx = createInstanceTheme(mockEditor); ctx.set('dark'); expect(mockEditor._emit).toHaveBeenCalledWith('themeChange', expect.objectContaining({ theme: 'dark', resolved: 'dark', })); expect(mockEditor.refresh).toHaveBeenCalled(); document.body.removeChild(div); }); test('实例主题 toggle 切换 light/dark', () => { const div = document.createElement('div'); document.body.appendChild(div); const mockEditor = { el: div, config: { theme: 'light' }, _emit: jest.fn(), refresh: jest.fn(), }; const ctx = createInstanceTheme(mockEditor); ctx.toggle(); expect(ctx.get()).toBe('dark'); ctx.toggle(); expect(ctx.get()).toBe('light'); document.body.removeChild(div); }); test('getConfig 返回主题配置对象', () => { const div = document.createElement('div'); document.body.appendChild(div); const mockEditor = { el: div, config: { theme: 'light' }, _emit: jest.fn(), refresh: jest.fn(), }; const ctx = createInstanceTheme(mockEditor); const cfg = ctx.getConfig(); expect(cfg).toHaveProperty('bg'); expect(cfg).toHaveProperty('accent'); document.body.removeChild(div); }); test('getVars 返回实例 CSS 变量', () => { const div = document.createElement('div'); document.body.appendChild(div); const mockEditor = { el: div, config: { theme: 'light' }, _emit: jest.fn(), refresh: jest.fn(), }; const ctx = createInstanceTheme(mockEditor); applyThemeToElement('light', div); const vars = ctx.getVars(); expect(vars).toHaveProperty('--md-bg'); document.body.removeChild(div); }); }); describe('v0.1.5 followExternalTheme', () => { test('通过 data-theme 属性跟随', () => { const div = document.createElement('div'); div.setAttribute('data-theme', 'dark'); document.body.appendChild(div); let detected = null; const unsub = followExternalTheme({ element: div }, (theme) => { detected = theme; }); expect(detected).toBe('dark'); div.setAttribute('data-theme', 'light'); // 手动触发 MutationObserver(jsdom 中需手动 dispatch) const observer = new MutationObserver(() => {}); // 注:jsdom 环境 MutationObserver 行为受限,此处只验证初始化回调 unsub(); document.body.removeChild(div); }); test('通过 classMap 跟随', () => { const div = document.createElement('div'); div.className = 'theme-dark'; document.body.appendChild(div); let detected = null; followExternalTheme( { element: div, classMap: { 'theme-dark': 'dark', 'theme-light': 'light' } }, (theme) => { detected = theme; }, ); expect(detected).toBe('dark'); document.body.removeChild(div); }); test('通过 callback 跟随', () => { const div = document.createElement('div'); document.body.appendChild(div); let detected = null; followExternalTheme( { element: div, callback: () => 'warm' }, (theme) => { detected = theme; }, ); expect(detected).toBe('warm'); document.body.removeChild(div); }); test('返回取消跟随函数', () => { const div = document.createElement('div'); document.body.appendChild(div); const unsub = followExternalTheme({ element: div }, () => {}); expect(typeof unsub).toBe('function'); unsub(); document.body.removeChild(div); }); }); describe('v0.1.5 adoptFromParent', () => { test('从父容器 data-md-theme 继承', () => { const parent = document.createElement('div'); parent.setAttribute('data-md-theme', 'dark'); const child = document.createElement('div'); parent.appendChild(child); document.body.appendChild(parent); let detected = null; const unsub = adoptFromParent(child, (theme) => { detected = theme; }); expect(detected).toBe('dark'); unsub(); document.body.removeChild(parent); }); test('从父容器 class 继承', () => { const parent = document.createElement('div'); parent.className = 'dark'; const child = document.createElement('div'); parent.appendChild(child); document.body.appendChild(parent); let detected = null; adoptFromParent(child, (theme) => { detected = theme; }); expect(detected).toBe('dark'); document.body.removeChild(parent); }); test('容器无父元素不抛错', () => { const orphan = document.createElement('div'); let detected = null; expect(() => adoptFromParent(orphan, (t) => { detected = t; })).not.toThrow(); }); test('返回取消函数', () => { const parent = document.createElement('div'); const child = document.createElement('div'); parent.appendChild(child); document.body.appendChild(parent); const unsub = adoptFromParent(child, () => {}); expect(typeof unsub).toBe('function'); unsub(); document.body.removeChild(parent); }); }); describe('v0.1.5 watch 外部主题源', () => { test('函数源:回调被调用', () => { let detected = null; const unsub = watch(() => 'dark', (theme) => { detected = theme; }); expect(detected).toBe('dark'); unsub(); }); test('返回取消函数', () => { const unsub = watch(() => 'light', () => {}); expect(typeof unsub).toBe('function'); unsub(); }); }); describe('v0.1.5 MarkdownEditor 实例主题 API', () => { test('setTheme / getTheme 实例方法', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { theme: 'light' }); expect(ed.getTheme()).toBe('light'); ed.setTheme('dark'); expect(ed.getTheme()).toBe('dark'); // el 类名应更新 expect(ed.el.className).toContain('me-theme-dark'); ed.destroy(); }); test('getThemeContext 返回实例主题上下文', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); const ctx = ed.getThemeContext(); expect(ctx).not.toBeNull(); expect(typeof ctx.set).toBe('function'); expect(typeof ctx.get).toBe('function'); ed.destroy(); }); test('多实例独立主题互不干扰', () => { document.body.innerHTML = ''; const c1 = document.createElement('div'); const c2 = document.createElement('div'); document.body.appendChild(c1); document.body.appendChild(c2); const ed1 = new MarkdownEditor(c1, { theme: 'light' }); const ed2 = new MarkdownEditor(c2, { theme: 'dark' }); expect(ed1.getTheme()).toBe('light'); expect(ed2.getTheme()).toBe('dark'); ed1.setTheme('warm'); expect(ed1.getTheme()).toBe('warm'); expect(ed2.getTheme()).toBe('dark'); // ed2 不受影响 ed1.destroy(); ed2.destroy(); }); test('setTheme 触发 themeChange 事件', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); const handler = jest.fn(); ed.on('themeChange', handler); ed.setTheme('dark'); expect(handler).toHaveBeenCalled(); ed.destroy(); }); });