/**
* core.js 单元测试
* 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁
*/
import { MarkdownEditor } from '../src/core.js';
// jsdom 不提供 PointerEvent,用 MouseEvent 作为基类 polyfill
if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined') {
window.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type, init = {}) {
super(type, init);
this.pointerId = init.pointerId || 0;
this.pointerType = init.pointerType || 'mouse';
this.isPrimary = init.isPrimary !== false;
}
};
}
describe('MarkdownEditor - 构造', () => {
let container;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
container.id = 'host';
document.body.appendChild(container);
});
test('通过元素构造', () => {
const ed = new MarkdownEditor(container, { value: '# hi' });
expect(ed.el).toBeInstanceOf(HTMLElement);
expect(ed.container).toBe(container);
expect(ed.getValue()).toBe('# hi');
});
test('通过选择器构造', () => {
const ed = new MarkdownEditor('#host', { value: 'text' });
expect(ed.el).toBeDefined();
expect(ed.getValue()).toBe('text');
});
test('容器不存在时标记为已销毁且不抛错', () => {
const ed = new MarkdownEditor('#not-exist', {});
expect(ed.isDestroyed()).toBe(true);
});
test('生成唯一 id', () => {
const a = new MarkdownEditor(container, {});
const b = new MarkdownEditor(document.createElement('div'), {});
expect(a.id).toBeTruthy();
expect(b.id).toBeTruthy();
expect(a.id).not.toBe(b.id);
});
test('DOM 结构完整', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.el.className).toContain('me-wrapper');
expect(ed.toolbarEl).toBeDefined();
expect(ed.bodyEl).toBeDefined();
expect(ed.textarea).toBeInstanceOf(HTMLTextAreaElement);
expect(ed.previewEl).toBeDefined();
});
test('工具栏默认渲染按钮', () => {
const ed = new MarkdownEditor(container, {});
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
expect(btns.length).toBeGreaterThan(10);
});
test('toolbar: false 隐藏工具栏按钮', () => {
const ed = new MarkdownEditor(container, { toolbar: false });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
expect(btns.length).toBe(0);
});
test('初始 value 写入 textarea', () => {
const ed = new MarkdownEditor(container, { value: '# Title' });
expect(ed.textarea.value).toBe('# Title');
});
test('wordCount 关闭时不创建状态栏', () => {
const ed = new MarkdownEditor(container, { wordCount: false });
expect(ed.statusEl).toBeNull();
});
test('wordCount 开启时创建状态栏', () => {
const ed = new MarkdownEditor(container, { wordCount: true });
expect(ed.statusEl).not.toBeNull();
});
});
describe('MarkdownEditor - 内容 API', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { value: '' });
});
afterEach(() => ed.destroy());
test('getValue / setValue', () => {
ed.setValue('# hello');
expect(ed.getValue()).toBe('# hello');
expect(ed.textarea.value).toBe('# hello');
});
test('setValue silent 不触发 change', () => {
let called = 0;
ed.on('change', () => called++);
ed.setValue('x', { silent: true });
expect(called).toBe(0);
});
test('setValue 默认触发 change', () => {
let called = 0;
ed.on('change', () => called++);
ed.setValue('x');
expect(called).toBeGreaterThan(0);
});
test('getHTML 返回渲染后 HTML', () => {
ed.setValue('# Title');
const html = ed.getHTML();
expect(html).toContain('
{
ed.setValue('abc');
ed.textarea.selectionStart = ed.textarea.selectionEnd = 1;
ed.insert('X');
expect(ed.getValue()).toBe('aXbc');
});
test('wrap 包裹选区', () => {
ed.setValue('hello');
ed.textarea.selectionStart = 0;
ed.textarea.selectionEnd = 5;
ed.wrap('**', '**');
expect(ed.getValue()).toBe('**hello**');
});
test('focus / blur 不抛错', () => {
expect(() => ed.focus()).not.toThrow();
expect(() => ed.blur()).not.toThrow();
});
test('enable / disable', () => {
ed.disable();
expect(ed.isDisabled()).toBe(true);
expect(ed.el.className).toContain('me-disabled');
ed.enable();
expect(ed.isDisabled()).toBe(false);
});
});
describe('MarkdownEditor - exec 命令', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { value: 'hello world' });
});
afterEach(() => ed.destroy());
function select(start, end) {
ed.textarea.selectionStart = start;
ed.textarea.selectionEnd = end === undefined ? start : end;
}
test('bold 包裹选区', () => {
select(0, 5);
ed.exec('bold');
expect(ed.getValue()).toBe('**hello** world');
});
test('italic 包裹选区', () => {
select(0, 5);
ed.exec('italic');
expect(ed.getValue()).toBe('*hello* world');
});
test('strikethrough 包裹选区', () => {
select(0, 5);
ed.exec('strikethrough');
expect(ed.getValue()).toBe('~~hello~~ world');
});
test('code 包裹选区', () => {
select(0, 5);
ed.exec('code');
expect(ed.getValue()).toBe('`hello` world');
});
test('h1 切换行首前缀', () => {
select(0, 0);
ed.exec('h1');
expect(ed.getValue()).toBe('# hello world');
});
test('quote 切换行首前缀', () => {
select(0, 0);
ed.exec('quote');
expect(ed.getValue()).toBe('> hello world');
});
test('ul 切换行首前缀', () => {
select(0, 0);
ed.exec('ul');
expect(ed.getValue()).toBe('- hello world');
});
test('ol 切换行首前缀', () => {
select(0, 0);
ed.exec('ol');
expect(ed.getValue()).toBe('1. hello world');
});
test('重复执行同一前缀命令会取消前缀', () => {
select(0, 0);
ed.exec('h1');
ed.exec('h1');
expect(ed.getValue()).toBe('hello world');
});
test('hr 插入分隔线', () => {
ed.setValue('a');
ed.textarea.selectionStart = ed.textarea.selectionEnd = 1;
ed.exec('hr');
expect(ed.getValue()).toContain('---');
});
test('link 插入链接', () => {
ed.setValue('text');
select(0, 4);
ed.exec('link');
expect(ed.getValue()).toMatch(/\[text\]\(https?:\/\//);
});
test('image 插入图片', () => {
ed.setValue('alt');
select(0, 3);
ed.exec('image');
expect(ed.getValue()).toMatch(/!\[alt\]\(https?:\/\//);
});
test('table 插入表格', () => {
ed.setValue('');
ed.exec('table');
const v = ed.getValue();
expect(v).toContain('|');
expect(v).toContain('---');
});
test('未知命令不抛错', () => {
expect(() => ed.exec('nonexistent')).not.toThrow();
});
test('exec 返回 this 支持链式', () => {
expect(ed.exec('bold')).toBe(ed);
});
});
describe('MarkdownEditor - 历史栈', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { value: '' });
});
afterEach(() => ed.destroy());
test('初始可撤销为 false', () => {
expect(ed.canUndo()).toBe(false);
});
test('setValue 后可撤销', () => {
ed.setValue('a');
expect(ed.canUndo()).toBe(true);
});
test('undo 回退', () => {
ed.setValue('a');
ed.setValue('b');
ed.undo();
expect(ed.getValue()).toBe('a');
});
test('redo 前进', () => {
ed.setValue('a');
ed.setValue('b');
ed.undo();
ed.redo();
expect(ed.getValue()).toBe('b');
});
test('无历史时 undo/redo 无副作用', () => {
expect(() => ed.undo()).not.toThrow();
expect(() => ed.redo()).not.toThrow();
});
test('undo/redo 返回 this', () => {
ed.setValue('a');
expect(ed.undo()).toBe(ed);
expect(ed.redo()).toBe(ed);
});
});
describe('MarkdownEditor - 模式与全屏', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { mode: 'split' });
});
afterEach(() => ed.destroy());
test('初始模式', () => {
expect(ed.getMode()).toBe('split');
});
test('setMode 切换', () => {
ed.setMode('edit');
expect(ed.getMode()).toBe('edit');
expect(ed.bodyEl.className).toContain('me-mode-edit');
});
test('setMode 切换到 preview', () => {
ed.setMode('preview');
expect(ed.getMode()).toBe('preview');
expect(ed.bodyEl.className).toContain('me-mode-preview');
});
test('非法模式被忽略', () => {
ed.setMode('invalid');
expect(ed.getMode()).toBe('split');
});
test('toggleFullscreen 切换全屏状态', () => {
expect(ed.isFullscreen()).toBe(false);
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
expect(ed.el.className).toContain('me-fullscreen');
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(false);
});
test('exitFullscreen', () => {
ed.toggleFullscreen();
ed.exitFullscreen();
expect(ed.isFullscreen()).toBe(false);
});
test('setMode 触发 modeChange 事件', () => {
let mode = null;
ed.on('modeChange', (m) => { mode = m; });
ed.setMode('edit');
expect(mode).toBe('edit');
});
});
describe('MarkdownEditor - 统计与状态', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { value: '' });
});
afterEach(() => ed.destroy());
test('getStats 返回统计字段', () => {
ed.setValue('hello 世界');
const stats = ed.getStats();
expect(stats).toHaveProperty('characters');
expect(stats).toHaveProperty('words');
expect(stats).toHaveProperty('chineseChars');
expect(stats).toHaveProperty('englishWords');
expect(stats).toHaveProperty('lines');
expect(stats).toHaveProperty('readingTime');
expect(stats.chineseChars).toBe(2);
});
test('getStatus 返回状态字段', () => {
const status = ed.getStatus();
expect(status).toHaveProperty('id');
expect(status).toHaveProperty('mode');
expect(status).toHaveProperty('theme');
expect(status).toHaveProperty('locale');
expect(status).toHaveProperty('fullscreen');
expect(status).toHaveProperty('destroyed');
expect(status.destroyed).toBe(false);
});
});
describe('MarkdownEditor - 事件', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), { value: '' });
});
afterEach(() => ed.destroy());
test('on 返回取消函数', () => {
const off = ed.on('change', () => {});
expect(typeof off).toBe('function');
});
test('off 取消监听', () => {
let called = 0;
const fn = () => called++;
ed.on('change', fn);
ed.setValue('a');
const before = called;
ed.off('change', fn);
ed.setValue('b');
expect(called).toBe(before);
});
test('静态 on/off 钩子', () => {
let triggered = 0;
const fn = () => { triggered++; };
MarkdownEditor.on('afterCreate', fn);
const e = new MarkdownEditor(document.createElement('div'), {});
expect(triggered).toBeGreaterThan(0);
MarkdownEditor.off('afterCreate', fn);
e.destroy();
});
});
describe('MarkdownEditor - 插件', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
ed = new MarkdownEditor(document.createElement('div'), {});
});
afterEach(() => ed.destroy());
test('use 安装对象插件', () => {
let installed = false;
ed.use({ name: 'test', install: () => { installed = true; } });
expect(installed).toBe(true);
expect(ed.getPlugins().length).toBe(1);
});
test('use 安装未知预设插件时 warn 且不崩溃', () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
ed.use('not-a-preset');
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('addToolbarButton 追加按钮', () => {
ed.addToolbarButton({ action: 'custom', title: 'Custom' });
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
expect(btn).not.toBeNull();
});
test('销毁时调用插件 destroy', () => {
let destroyed = false;
const ed2 = new MarkdownEditor(document.createElement('div'), {});
ed2.use({ name: 't', install() {}, destroy() { destroyed = true; } });
ed2.destroy();
expect(destroyed).toBe(true);
});
});
describe('MarkdownEditor - 销毁', () => {
test('destroy 移除 DOM 并标记', () => {
document.body.innerHTML = '';
const ed = new MarkdownEditor(document.createElement('div'), {});
const el = ed.el;
ed.destroy();
expect(ed.isDestroyed()).toBe(true);
// el 已置空
expect(ed.el).toBeNull();
});
test('重复 destroy 不抛错', () => {
document.body.innerHTML = '';
const ed = new MarkdownEditor(document.createElement('div'), {});
ed.destroy();
expect(() => ed.destroy()).not.toThrow();
});
test('destroy 触发 destroy 事件', () => {
document.body.innerHTML = '';
const ed = new MarkdownEditor(document.createElement('div'), {});
let fired = false;
ed.on('destroy', () => { fired = true; });
ed.destroy();
expect(fired).toBe(true);
});
});
// ============ 补充分支测试 ============
describe('MarkdownEditor - 构造边界', () => {
test('非浏览器环境直接返回(_destroyed=true)', () => {
// jsdom 是浏览器环境,此分支需通过模拟无法直接覆盖
// 这里只验证 isBrowser() 返回 true 时正常路径
const ed = new MarkdownEditor(document.createElement('div'), {});
expect(ed.isDestroyed()).toBe(false);
ed.destroy();
});
test('options.style 对象会被克隆', () => {
const style = { color: 'red' };
const ed = new MarkdownEditor(document.createElement('div'), { style });
expect(ed.config.style).not.toBe(style);
expect(ed.config.style.color).toBe('red');
ed.destroy();
});
test('非法 mode 回退到 split', () => {
const ed = new MarkdownEditor(document.createElement('div'), { mode: 'invalid-mode' });
expect(ed.getMode()).toBe('split');
ed.destroy();
});
test('config.plugins 数组会被安装', () => {
const plugin = {
name: 'test-plugin',
install: jest.fn(),
destroy: jest.fn(),
};
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
expect(plugin.install).toHaveBeenCalledWith(ed);
ed.destroy();
expect(plugin.destroy).toHaveBeenCalledWith(ed);
});
test('autofocus 自动聚焦', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { autofocus: true });
// jsdom 中聚焦需要元素挂载到 document
expect(ed.textarea).toBeDefined();
ed.destroy();
});
test('onCreate 回调被调用', () => {
const onCreate = jest.fn();
const ed = new MarkdownEditor(document.createElement('div'), { onCreate });
expect(onCreate).toHaveBeenCalledWith(ed);
ed.destroy();
});
test('onCreate 抛错被捕获', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const ed = new MarkdownEditor(document.createElement('div'), {
onCreate: () => { throw new Error('boom'); },
});
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('自定义 render 函数覆盖内置解析器', () => {
const customRender = jest.fn(() => '
custom
');
const ed = new MarkdownEditor(document.createElement('div'), {
value: '# test',
render: customRender,
});
expect(customRender).toHaveBeenCalled();
expect(ed.getHTML()).toBe('custom
');
ed.destroy();
});
test('自定义 highlight 函数被调用', () => {
const highlight = jest.fn(() => 'code');
const ed = new MarkdownEditor(document.createElement('div'), {
value: '```js\nvar x = 1\n```',
highlight,
});
expect(highlight).toHaveBeenCalledWith('var x = 1', 'js');
ed.destroy();
});
test('自定义 sanitize 净化 HTML', () => {
const ed = new MarkdownEditor(document.createElement('div'), {
value: 'hello',
sanitize: (html) => 'sanitized
',
});
expect(ed.getHTML()).toBe('sanitized
');
ed.destroy();
});
});
describe('MarkdownEditor - 键盘快捷键', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'hello world' });
});
afterEach(() => ed.destroy());
function key(key, opts = {}) {
const ev = new KeyboardEvent('keydown', {
key,
ctrlKey: !!opts.ctrl,
shiftKey: !!opts.shift,
bubbles: true,
cancelable: true,
});
ed.textarea.dispatchEvent(ev);
return ev;
}
test('Tab 插入缩进(无选区,默认 tabSize=2)', () => {
ed.setValue('hello world');
// 光标放在位置 5(即 'hello' 之后、原空格之前)
ed.textarea.setSelectionRange(5, 5);
key('Tab');
// 插入 2 空格:'hello' + ' ' + ' world' = 'hello world'(含原空格共 3 空格)
expect(ed.getValue()).toBe('hello world');
});
test('Tab 在行尾插入缩进', () => {
ed.setValue('hello');
ed.textarea.setSelectionRange(5, 5);
key('Tab');
expect(ed.getValue()).toBe('hello ');
});
test('Shift+Tab 反缩进(有选区)', () => {
ed.setValue(' line1\n line2');
ed.textarea.setSelectionRange(0, 14);
key('Tab', { shift: true });
// 反缩进会移除每行行首的空格
expect(ed.getValue()).toBe('line1\nline2');
});
test('Tab 有选区时整块缩进', () => {
ed.setValue('line1\nline2');
ed.textarea.setSelectionRange(0, 11);
key('Tab');
expect(ed.getValue()).toBe(' line1\n line2');
});
test('Ctrl+B 触发 bold', () => {
ed.textarea.setSelectionRange(0, 5);
key('b', { ctrl: true });
expect(ed.getValue()).toBe('**hello** world');
});
test('Ctrl+I 触发 italic', () => {
ed.textarea.setSelectionRange(0, 5);
key('i', { ctrl: true });
expect(ed.getValue()).toBe('*hello* world');
});
test('Ctrl+E 触发 code', () => {
ed.textarea.setSelectionRange(0, 5);
key('e', { ctrl: true });
expect(ed.getValue()).toBe('`hello` world');
});
test('Ctrl+K 触发 link', () => {
ed.textarea.setSelectionRange(0, 5);
key('k', { ctrl: true });
// link 使用 prompt,jsdom 返回 null,但不应抛错
expect(ed.getValue()).toBeDefined();
});
test('Ctrl+U 触发 underline', () => {
ed.textarea.setSelectionRange(0, 5);
key('u', { ctrl: true });
expect(ed.getValue()).toBe('hello world');
});
test('Ctrl+1 触发 h1', () => {
ed.textarea.setSelectionRange(0, 0);
key('1', { ctrl: true });
expect(ed.getValue()).toBe('# hello world');
});
test('Ctrl+2 触发 h2', () => {
ed.textarea.setSelectionRange(0, 0);
key('2', { ctrl: true });
expect(ed.getValue()).toBe('## hello world');
});
test('Ctrl+3 触发 h3', () => {
ed.textarea.setSelectionRange(0, 0);
key('3', { ctrl: true });
expect(ed.getValue()).toBe('### hello world');
});
test('Ctrl+Q 触发 quote', () => {
ed.textarea.setSelectionRange(0, 0);
key('q', { ctrl: true });
expect(ed.getValue()).toBe('> hello world');
});
test('Ctrl+Z 撤销', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true });
expect(ed.getValue()).toBe(initial);
});
test('Ctrl+Shift+Z 重做', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true }); // undo
key('z', { ctrl: true, shift: true }); // redo
expect(ed.getValue()).not.toBe(initial);
});
test('Ctrl+Y 重做', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true }); // undo
key('y', { ctrl: true }); // redo
expect(ed.getValue()).not.toBe(initial);
});
test('Ctrl+S 触发 save 事件', () => {
const handler = jest.fn();
ed.on('save', handler);
key('s', { ctrl: true });
expect(handler).toHaveBeenCalled();
});
test('Ctrl+S 调用 onSave 回调', () => {
const onSave = jest.fn();
ed.config.onSave = onSave;
key('s', { ctrl: true });
expect(onSave).toHaveBeenCalled();
});
test('无修饰键的按键不触发快捷键', () => {
const initial = ed.getValue();
key('b');
expect(ed.getValue()).toBe(initial);
});
});
describe('MarkdownEditor - 同步滚动', () => {
test('分屏模式下 textarea 滚动联动 preview', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
ed.textarea.scrollTop = 100;
ed.textarea.dispatchEvent(new Event('scroll'));
// preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸)
expect(typeof ed.previewPane.scrollTop).toBe('number');
ed.destroy();
});
test('syncScroll 关闭时不联动', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: 'line1\nline2\nline3',
syncScroll: false,
});
const before = ed.previewPane.scrollTop;
ed.textarea.scrollTop = 50;
ed.textarea.dispatchEvent(new Event('scroll'));
expect(ed.previewPane.scrollTop).toBe(before);
ed.destroy();
});
test('非 split 模式不联动', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', mode: 'edit' });
const before = ed.previewPane ? ed.previewPane.scrollTop : 0;
ed.textarea.scrollTop = 50;
ed.textarea.dispatchEvent(new Event('scroll'));
ed.destroy();
});
});
describe('MarkdownEditor - 分隔条拖拽', () => {
test('pointerdown 触发拖拽(split 模式)', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', mode: 'split' });
// jsdom 中 getBoundingClientRect 返回全 0,但不应抛错
expect(() => {
ed.dividerEl.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, bubbles: true }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: 250 }));
window.dispatchEvent(new PointerEvent('pointerup'));
}).not.toThrow();
expect(ed.isDestroyed()).toBe(false);
ed.destroy();
});
test('非 split 模式不触发拖拽', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', mode: 'edit' });
expect(() => {
ed.dividerEl.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, bubbles: true }));
}).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - 回调异常隔离', () => {
test('onChange 抛错不影响后续 input', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: 'init',
onChange: () => { throw new Error('boom'); },
});
expect(() => {
ed.textarea.value = 'new';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
}).not.toThrow();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onInput 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: 'init',
onInput: () => { throw new Error('boom'); },
});
ed.textarea.value = 'new';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onFocus 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
onFocus: () => { throw new Error('boom'); },
});
ed.textarea.dispatchEvent(new Event('focus', { bubbles: true }));
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onBlur 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
onBlur: () => { throw new Error('boom'); },
});
ed.textarea.dispatchEvent(new Event('blur', { bubbles: true }));
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onModeChange 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
onModeChange: () => { throw new Error('boom'); },
});
ed.setMode('edit');
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onFullscreen 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
onFullscreen: () => { throw new Error('boom'); },
});
ed.toggleFullscreen();
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('onDestroy 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
onDestroy: () => { throw new Error('boom'); },
});
ed.destroy();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('MarkdownEditor - 插件边界', () => {
test('use 字符串引用未知预设插件 warn', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use('non-existent-preset');
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('use 无效插件对象 warn', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use(null);
ed.use(123);
ed.use({ name: 'no-install' });
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('use install 抛错被捕获', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const badPlugin = {
name: 'bad',
install: () => { throw new Error('install boom'); },
};
ed.use(badPlugin);
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('addToolbarButton 无 action 直接返回', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
ed.addToolbarButton(null);
ed.addToolbarButton({ title: 'no-action' });
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(before);
ed.destroy();
});
test('addToolbarButton 使用 onClick 回调', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const onClick = jest.fn();
ed.addToolbarButton({
action: 'custom',
title: 'Custom',
icon: '',
onClick,
});
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
expect(btn).not.toBeNull();
btn.click();
expect(onClick).toHaveBeenCalledWith(ed);
ed.destroy();
});
test('addToolbarButton 使用 text 文本', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.addToolbarButton({
action: 'txt',
title: 'Text Button',
text: 'T',
});
const btn = ed.toolbarEl.querySelector('.me-btn-custom-txt');
expect(btn).not.toBeNull();
expect(btn.innerHTML).toContain('>T<');
ed.destroy();
});
});
describe('MarkdownEditor - 工具栏点击', () => {
test('点击空白区域不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(() => {
ed.toolbarEl.click();
}).not.toThrow();
ed.destroy();
});
test('点击 mode 按钮切换模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
const editBtn = ed.toolbarEl.querySelector('.me-btn[data-mode="edit"]');
editBtn.click();
expect(ed.getMode()).toBe('edit');
ed.destroy();
});
test('点击 action 按钮执行命令', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]');
ed.textarea.setSelectionRange(0, 4);
boldBtn.click();
expect(ed.getValue()).toBe('**test**');
ed.destroy();
});
});
describe('MarkdownEditor - 历史栈合并', () => {
test('连续输入触发防抖合并', (done) => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
// 连续快速输入
ed.textarea.value = 'a';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
ed.textarea.value = 'ab';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
ed.textarea.value = 'abc';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
// 等待防抖结束
setTimeout(() => {
const initialLen = ed._history.length;
// 防抖合并后历史栈不应为 3
expect(ed._history.length).toBeLessThanOrEqual(initialLen + 1);
ed.destroy();
done();
}, 500);
});
test('历史栈超过上限自动裁剪', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '', historyLimit: 3 });
for (let i = 0; i < 10; i++) {
ed.setValue('v' + i, { silent: false });
}
expect(ed._history.length).toBeLessThanOrEqual(3);
ed.destroy();
});
});
describe('MarkdownEditor - 零散分支补全', () => {
test('className 配置项被应用到 wrapper', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { className: 'my-cls another' });
expect(ed.el.className).toContain('my-cls');
expect(ed.el.className).toContain('another');
ed.destroy();
});
test('render 抛错时显示错误信息', () => {
document.body.innerHTML = '';
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: '# test',
render: () => { throw new Error('render boom'); },
});
const html = ed.previewEl.innerHTML;
expect(html).toContain('渲染失败');
expect(html).toContain('render boom');
expect(spy).toHaveBeenCalled();
ed.destroy();
spy.mockRestore();
});
test('canRedo 返回正确值', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
expect(ed.canRedo()).toBe(false);
ed.setValue('a');
ed.setValue('b');
ed.undo();
expect(ed.canRedo()).toBe(true);
ed.redo();
expect(ed.canRedo()).toBe(false);
ed.destroy();
});
test('undo/redo 触发 onChange 回调', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const onChange = jest.fn();
const ed = new MarkdownEditor(c, { value: '', onChange });
onChange.mockClear();
ed.setValue('a');
ed.setValue('b');
onChange.mockClear();
ed.undo();
expect(onChange).toHaveBeenCalled();
onChange.mockClear();
ed.redo();
expect(onChange).toHaveBeenCalled();
ed.destroy();
});
test('exec("indent")/exec("outdent") 等价于 Tab/Shift+Tab', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.textarea.selectionStart = ed.textarea.selectionEnd = 0;
ed.exec('indent');
expect(ed.getValue()).toBe(' hello');
ed.textarea.selectionStart = 0;
ed.textarea.selectionEnd = 7;
ed.exec('outdent');
expect(ed.getValue()).toBe('hello');
ed.destroy();
});
test('exec("undo")/exec("redo") 等价于 undo()/redo()', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.setValue('a');
ed.setValue('b');
ed.exec('undo');
expect(ed.getValue()).toBe('a');
ed.exec('redo');
expect(ed.getValue()).toBe('b');
ed.destroy();
});
test('exec("edit"/"split"/"preview") 切换模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
ed.exec('edit');
expect(ed.getMode()).toBe('edit');
ed.exec('split');
expect(ed.getMode()).toBe('split');
ed.exec('preview');
expect(ed.getMode()).toBe('preview');
ed.destroy();
});
test('exec("fullscreen") 切换全屏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed._fullscreen).toBe(false);
ed.exec('fullscreen');
expect(ed._fullscreen).toBe(true);
ed.exec('fullscreen');
expect(ed._fullscreen).toBe(false);
ed.destroy();
});
test('_toggleLinePrefix 替换已存在的不同前缀', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '- hello' });
ed.textarea.selectionStart = ed.textarea.selectionEnd = 0;
ed.exec('h1');
// 原本行首是 "- ",执行 h1 后应替换为 "# "
expect(ed.getValue()).toBe('# hello');
ed.destroy();
});
test('setValue 触发 onChange 回调', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const onChange = jest.fn();
const ed = new MarkdownEditor(c, { value: '', onChange });
onChange.mockClear();
ed.setValue('new');
expect(onChange).toHaveBeenCalledWith('new', ed);
ed.destroy();
});
test('getStatus 包含 plugins 字段', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use({ name: 'demo-plugin', install: () => {} });
const s = ed.getStatus();
expect(Array.isArray(s.plugins)).toBe(true);
expect(s.plugins).toContain('demo-plugin');
ed.destroy();
});
});