Files
MetonaEditor/tests/core.test.js
T

1228 lines
37 KiB
JavaScript
Raw 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.
/**
* 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('<h1');
expect(html).toContain('Title');
});
test('insert 在光标处插入', () => {
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(() => '<p>custom</p>');
const ed = new MarkdownEditor(document.createElement('div'), {
value: '# test',
render: customRender,
});
expect(customRender).toHaveBeenCalled();
expect(ed.getHTML()).toBe('<p>custom</p>');
ed.destroy();
});
test('自定义 highlight 函数被调用', () => {
const highlight = jest.fn(() => '<span class="hl">code</span>');
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) => '<div>sanitized</div>',
});
expect(ed.getHTML()).toBe('<div>sanitized</div>');
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 使用 promptjsdom 返回 null,但不应抛错
expect(ed.getValue()).toBeDefined();
});
test('Ctrl+U 触发 underline', () => {
ed.textarea.setSelectionRange(0, 5);
key('u', { ctrl: true });
expect(ed.getValue()).toBe('<u>hello</u> 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: '<svg></svg>',
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();
});
});