Files
thzxx cb0caff4cf
CI / test-parser (push) Successful in 9m27s
CI / test-core (push) Successful in 9m34s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Failing after 5m15s
CI / verify (20.x) (push) Successful in 9m54s
CI / verify (18.x) (push) Successful in 9m56s
CI / verify (24.x) (push) Successful in 9m48s
feat: v0.4.0 — 安全修复×6 + 增量性能×3 + Playwright E2E + 实例级 i18n
0.3.0 修复版:
- 脚注 id 属性注入 XSS 防护(行内引用 + 脚注区)
- 全局插件注入移至 afterCreate(searchReplace 等依赖 textarea 的插件真正生效)+ use() 同名防重
- 行内渲染缓存附加 refs 指纹,防跨文档引用链接串数据
- 白名单裸标签 <u>/</u> 透传,underline(Ctrl+U)预览可见
- getStatus()/渲染 env 使用实例 locale;replaceAll 替换文本按字面处理
- replaceAllRegex 保留 $1 捕获组语义

0.3.1 性能版:
- 高亮规则按语言缓存(registerLanguage 覆盖失效),bench +33%
- 统计增量计算:字数/词数/行数差异区间,击键零全量扫描
- outline 树构建 O(n²) → 迭代栈 O(n)
- 拖放图片 >500KB 拦截、scrollToLine 真实行高、undo/redo 光标恢复
- selectionChange/cursorMove 与浮动工具栏解耦、unregisterShortcut 大小写归一
- toast 接入 7 种 ANIMATIONS、实例主题订阅随 destroy 断开(dispose)

0.4.0 工程版:
- Playwright 真实浏览器冒烟测试(e2e/,22 项断言)+ CI e2e job
- sideEffects: false 便于 tree-shaking
- MeEditor.destroy() 全面复位(全局钩子清理 + 内部注入钩子重建)
- 实例 locale 统一作用于全部 UI 文案(状态栏/工具栏/右键菜单/大纲)

测试 782 → 826,全绿;typecheck/lint/build 通过
2026-08-09 12:31:14 +08:00

874 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
// 即使手动设为 darkresolveTheme('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');
// 手动触发 MutationObserverjsdom 中需手动 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.3.1 实例主题 dispose', () => {
test('dispose 断开外部跟随的 MutationObserver', () => {
const div = document.createElement('div');
div.setAttribute('data-theme', 'dark');
document.body.appendChild(div);
const mockEditor = {
el: div,
container: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
ctx.syncWithElement(div);
ctx.dispose();
expect(disconnectSpy).toHaveBeenCalled();
disconnectSpy.mockRestore();
document.body.removeChild(div);
});
test('dispose 后可重复调用且不抛错', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
container: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
expect(() => { ctx.dispose(); ctx.dispose(); }).not.toThrow();
document.body.removeChild(div);
});
test('MarkdownEditor destroy 时断开实例主题订阅', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
c.setAttribute('data-theme', 'dark');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const ctx = ed.getThemeContext();
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
ctx.adopt();
ed.destroy();
expect(disconnectSpy).toHaveBeenCalled();
disconnectSpy.mockRestore();
});
});
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();
});
});