Files

434 lines
12 KiB
JavaScript

/**
* 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');
});
});