/**
* core.js 单元测试
* 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁
*/
import { MarkdownEditor } from '../src/core';
// 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;
}
};
}
// jsdom 未实现 scrollIntoView(大纲点击跳转依赖),提供 no-op stub
if (typeof Element !== 'undefined' && typeof (Element.prototype as any).scrollIntoView !== 'function') {
(Element.prototype as any).scrollIntoView = () => {};
}
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('insert replace:true 替换选区', () => {
ed.setValue('hello world');
ed.textarea.selectionStart = 0;
ed.textarea.selectionEnd = 5;
ed.insert('hi', { replace: true });
expect(ed.getValue()).toBe('hi world');
});
test('insert replace:false 在光标处插入(保留选区后的内容)', () => {
ed.setValue('abcdef');
ed.textarea.selectionStart = 3;
ed.textarea.selectionEnd = 3;
ed.insert('X');
expect(ed.getValue()).toBe('abcXdef');
});
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('同名插件重复 use 只安装一次', () => {
let installs = 0;
const p = { name: 'dup-plugin', install: () => { installs++; } };
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
ed.use(p);
ed.use(p);
ed.use(p);
expect(installs).toBe(1);
expect(ed.getPlugins().length).toBe(1);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('unuse 后同名插件可重新安装', () => {
let installs = 0;
const p = { name: 're-install', install: () => { installs++; }, destroy() {} };
ed.use(p);
ed.unuse('re-install');
ed.use(p);
expect(installs).toBe(2);
expect(ed.getPlugins().length).toBe(1);
});
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 });
// jsdom 的 scrollHeight 恒为 0,mock 出可滚动尺寸以驱动比例联动
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.textarea.scrollTop = 750; // textarea 滚动比例 0.5
ed.textarea.dispatchEvent(new Event('scroll'));
// preview 按同比例滚动:0.5 * (1000 - 500) = 250
expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
test('分屏模式下 preview 滚动反向联动 textarea', () => {
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 });
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.previewPane.scrollTop = 250; // preview 滚动比例 0.5
ed.previewPane.dispatchEvent(new Event('scroll'));
// textarea 按同比例滚动:0.5 * (2000 - 500) = 750
expect(ed.textarea.scrollTop).toBe(750);
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 - 历史栈合并', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('连续输入触发防抖合并', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
// 初始历史栈长度(构造时已 push 一次)
const initialLen = ed._history.length;
// 连续快速输入
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 }));
// 防抖期内历史栈不应增长
expect(ed._history.length).toBe(initialLen);
// 推进防抖时间(默认 400ms),触发 _pushHistory
jest.advanceTimersByTime(400);
// 防抖合并后历史栈只增加 1 条
expect(ed._history.length).toBe(initialLen + 1);
ed.destroy();
});
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();
});
});
describe('MarkdownEditor - v0.1.2 readOnly 模式', () => {
test('config.readOnly 设置 textarea readonly', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true, value: 'locked' });
expect(ed.textarea.readOnly).toBe(true);
expect(ed.el.classList.contains('me-readonly')).toBe(true);
ed.destroy();
});
test('setReadOnly 动态切换', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isReadOnly()).toBe(false);
ed.setReadOnly(true);
expect(ed.isReadOnly()).toBe(true);
expect(ed.textarea.readOnly).toBe(true);
expect(ed.el.classList.contains('me-readonly')).toBe(true);
ed.setReadOnly(false);
expect(ed.isReadOnly()).toBe(false);
expect(ed.textarea.readOnly).toBe(false);
expect(ed.el.classList.contains('me-readonly')).toBe(false);
ed.destroy();
});
test('readOnly 模式下内容仍可读取', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true, value: '# hello' });
expect(ed.getValue()).toBe('# hello');
ed.destroy();
});
test('getStatus 包含 readOnly 字段', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true });
expect(ed.getStatus().readOnly).toBe(true);
ed.destroy();
});
test('autofocus 在 readOnly 模式下不聚焦', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true, autofocus: true });
// readOnly textarea 不应被 focus(autofocus 被跳过)
expect(ed.textarea.readOnly).toBe(true);
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.3 修复验证', () => {
test('exec 支持 _customActions 自定义动作', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
let called = false;
ed._customActions = { myAction: () => { called = true; } };
ed.exec('myAction');
expect(called).toBe(true);
ed.destroy();
});
test('refresh 清除渲染缓存并强制重渲染', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# test', mode: 'split' });
const first = ed.previewEl.innerHTML;
// setValue 不改变内容,正常 _render 会跳过
ed.setValue('# test', { silent: true });
// refresh 强制重渲染
ed.refresh();
// 内容一致,预览应一致
expect(ed.previewEl.innerHTML).toBe(first);
ed.destroy();
});
test('focus/blur 监听器接收正确参数', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
let focusArg;
ed.on('focus', (...args) => { focusArg = args; });
ed.textarea.dispatchEvent(new Event('focus', { bubbles: true }));
// focus 事件只传 editor 一次
expect(focusArg.length).toBe(1);
expect(focusArg[0]).toBe(ed);
ed.destroy();
});
test('destroy 后 focus/blur 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.destroy();
expect(() => ed.focus()).not.toThrow();
expect(() => ed.blur()).not.toThrow();
expect(ed.isDisabled()).toBe(false);
});
});
// ============ v0.1.7 新增测试 ============
describe('MarkdownEditor - v0.1.7 行号装订线', () => {
test('lineNumbers 开启时创建 gutter', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
expect(ed.gutter).not.toBeNull();
expect(ed.gutter.children.length).toBeGreaterThan(0);
ed.destroy();
});
test('lineNumbers 关闭时不显示', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
expect(ed.gutter).not.toBeNull();
expect(ed.gutter.style.display).toBe('none');
ed.destroy();
});
test('输入后行号更新', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb', lineNumbers: true });
ed.textarea.value = 'a\nb\nc';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
expect(ed.gutter.children.length).toBe(3);
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.7 自动格式化', () => {
test('列表项行尾 Enter 自动延续 -', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '- item' });
ed.textarea.setSelectionRange(6, 6); // 光标在行尾
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toContain('- ');
ed.destroy();
});
test('空列表项 Enter 结束列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '- ' });
ed.textarea.setSelectionRange(2, 2);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toBe('\n');
ed.destroy();
});
test('有序列表自动递增', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '1. first' });
ed.textarea.setSelectionRange(8, 8);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toContain('2. ');
ed.destroy();
});
test('引用块延续 >', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '> quote' });
ed.textarea.setSelectionRange(7, 7);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toContain('> ');
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.7 括号自动闭合', () => {
test('无选区输入 ( 自动闭合', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello', autoBrackets: true });
ed.textarea.setSelectionRange(5, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true }));
expect(ed.getValue()).toBe('hello()');
expect(ed.textarea.selectionStart).toBe(6); // 光标在括号内
ed.destroy();
});
test('有选区输入 ( 包裹选中文本', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world', autoBrackets: true });
ed.textarea.setSelectionRange(0, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true }));
expect(ed.getValue()).toBe('(hello) world');
ed.destroy();
});
test('autoBrackets 关闭时不闭合', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello', autoBrackets: false });
ed.textarea.setSelectionRange(5, 5);
// jsdom 不模拟原生键盘插入,验证不抛错即可
expect(() => {
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true }));
}).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.7 outline', () => {
test('outline 关闭时不创建面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hi\n## there', outline: false, mode: 'split' });
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.destroy();
});
});
// ============ v0.1.12 新增测试 ============
describe('MarkdownEditor - v0.1.12 Zen 模式', () => {
test('toggleZen 切换状态', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
ed.toggleZen();
expect(ed.isZen()).toBe(true);
expect(ed.el.classList.contains('me-zen')).toBe(true);
ed.toggleZen();
expect(ed.isZen()).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.12 Word Wrap', () => {
test('toggleWordWrap 切换换行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isWordWrap()).toBe(true);
ed.toggleWordWrap();
expect(ed.isWordWrap()).toBe(false);
expect(ed.textarea.style.whiteSpace).toBe('pre');
ed.toggleWordWrap();
expect(ed.isWordWrap()).toBe(true);
ed.destroy();
});
});
describe('MarkdownEditor - v0.1.12 shortcutHelp 插件', () => {
test('按 ? 弹出快捷键面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.use('shortcutHelp');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
// cleanup
const panel = document.querySelector('.me-shortcut-overlay');
if (panel) panel.remove();
ed.destroy();
});
});
// ============ v0.2.0 覆盖率补齐测试 ============
describe('MarkdownEditor - v0.2.0 Smart Enter 引用空行', () => {
test('空引用行 Enter 结束引用', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '> ' });
ed.textarea.setSelectionRange(2, 2);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue()).toBe('\n');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 括号闭合边界', () => {
test('光标在同类引号后跳过而不插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '"hello" world', autoBrackets: true });
ed.textarea.setSelectionRange(6, 6); // 光标在闭合引号前
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
// 跳过闭合引号,selectionStart 前进一位
expect(ed.textarea.selectionStart).toBe(7);
ed.destroy();
});
test('光标在单词中间不自动闭合引号', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world', autoBrackets: true });
ed.textarea.setSelectionRange(3, 3); // 光标在 'l' 和 'l' 之间
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '"', bubbles: true }));
// 不闭合,因为 \w 检查
expect(ed.getValue()).toBe('hello world');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 exec 补充', () => {
test('exec zen 切换 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
ed.exec('zen');
expect(ed.isZen()).toBe(true);
ed.exec('zen');
expect(ed.isZen()).toBe(false);
ed.destroy();
});
test('exec wordwrap 切换换行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isWordWrap()).toBe(true);
ed.exec('wordwrap');
expect(ed.isWordWrap()).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 setWordWrap API', () => {
test('setWordWrap 设置换行模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.setWordWrap(false);
expect(ed.isWordWrap()).toBe(false);
expect(ed.textarea.style.whiteSpace).toBe('pre');
ed.setWordWrap(true);
expect(ed.isWordWrap()).toBe(true);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 右键菜单和快捷键管理', () => {
test('registerContextMenu 设置菜单项', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerContextMenu([{ label: 'Copy', action: 'copy' }]);
expect(ed._contextMenuItems.length).toBe(1);
ed.destroy();
});
test('getShortcuts 空列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(Array.isArray(ed.getShortcuts())).toBe(true);
expect(ed.getShortcuts().length).toBe(0);
ed.destroy();
});
test('unregisterShortcut 移除注册', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerShortcut('Ctrl+K', 'link');
expect(ed.getShortcuts().length).toBe(1);
ed.unregisterShortcut('Ctrl+K');
expect(ed.getShortcuts().length).toBe(0);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 工具栏管理', () => {
test('removeToolbarButton 移除按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const before = ed.toolbarEl.querySelectorAll('.me-btn').length;
ed.removeToolbarButton('bold');
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(before - 1);
ed.destroy();
});
test('configureToolbar 重建工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.configureToolbar(['undo', 'redo']);
expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(2);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 render 错误处理', () => {
test('render 抛错时显示错误信息', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: '# test', mode: 'split',
render: () => { throw new Error('boom'); },
});
expect(ed.previewEl.innerHTML).toContain('渲染失败');
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 outline 构建', () => {
test('outline 启用时构建面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1\n## H2\n### H3', outline: true, mode: 'split' });
ed._buildOutline();
const panel = ed.el.querySelector('.me-outline');
expect(panel).not.toBeNull();
if (panel) {
const links = panel.querySelectorAll('a');
expect(links.length).toBeGreaterThanOrEqual(2);
}
ed.destroy();
});
test('outline 树结构完整(嵌套 ul 正确闭合)', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {
value: '# A\n## A1\n### A1a\n# B\n## B1\n### B1a\n#### B1a1\n## B2',
outline: true, mode: 'split',
});
ed._buildOutline();
const panel = ed.el.querySelector('.me-outline');
const html = panel.innerHTML;
const ulOpen = (html.match(//g) || []).length;
const ulClose = (html.match(/<\/ul>/g) || []).length;
expect(ulOpen).toBe(ulClose);
const liOpen = (html.match(/- /g) || []).length;
expect(liOpen).toBe(liClose);
expect(liOpen).toBe(8);
expect(panel.querySelectorAll('a').length).toBe(8);
ed.destroy();
});
test('outline 长文档线性构建不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const lines = [];
for (let i = 0; i < 200; i++) {
const lvl = (i % 4) + 1;
lines.push('#'.repeat(lvl) + ' Heading ' + i);
}
const ed = new MarkdownEditor(c, { value: lines.join('\n'), outline: true, mode: 'split' });
ed._buildOutline();
const panel = ed.el.querySelector('.me-outline');
expect(panel.querySelectorAll('a').length).toBe(200);
ed.destroy();
});
});
describe('MarkdownEditor - v0.4.1 outline 启动初始化', () => {
test('outline: true 构造后自动构建面板(无需手动调用)', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1\n## H2', outline: true, mode: 'split' });
const panel = ed.el.querySelector('.me-outline');
expect(panel).not.toBeNull();
expect(panel!.querySelectorAll('a').length).toBe(2);
ed.destroy();
});
test('outline: true + edit 模式构造后切换 split 自动构建面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1', outline: true, mode: 'edit' });
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.setMode('split');
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
ed.destroy();
});
test('outline: false 构造后不创建面板', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1', outline: false, mode: 'split' });
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.destroy();
});
});
describe('MarkdownEditor - v0.4.1 edit 模式初始行号', () => {
test('edit 模式构造后 gutter 已渲染行号', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb\nc', mode: 'edit' });
expect(ed.gutter.children.length).toBe(3);
ed.destroy();
});
test('preview 模式构造后 gutter 也渲染行号', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb\nc\nd', mode: 'preview' });
expect(ed.gutter.children.length).toBe(4);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 gutter 更新', () => {
test('gutter 行数不变时跳过 DOM 更新', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb', lineNumbers: true, mode: 'split' });
// 初始渲染已经填充了 gutter
const before = ed.gutter.children.length;
expect(before).toBeGreaterThan(0);
// 再次调用 _renderGutter 应该跳过
ed._renderGutter();
expect(ed.gutter.children.length).toBe(before);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
test('getStatus 包含插件名列表', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.use({ name: 'test-p', install() {} });
const s = ed.getStatus();
expect(s.plugins).toContain('test-p');
ed.destroy();
});
});
// ============ v0.2.1 覆盖率补齐测试 ============
describe('MarkdownEditor - v0.2.1 工具栏键盘导航', () => {
test('ArrowRight 聚焦下一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
const first = btns[0] as HTMLButtonElement;
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
// 焦点应移动
expect(document.activeElement).not.toBe(first);
ed.destroy();
});
test('ArrowLeft 聚焦上一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[1] as HTMLButtonElement).focus();
(btns[1] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('Home 聚焦第一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[2] as HTMLButtonElement).focus();
(btns[2] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
expect(document.activeElement).toBe(btns[0]);
ed.destroy();
});
test('End 聚焦最后一个按钮', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
(btns[0] as HTMLButtonElement).focus();
(btns[0] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
expect(document.activeElement).toBe(btns[btns.length - 1]);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 ARIA live', () => {
test('_initAriaLive 创建 sr-only 元素', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const live = ed.el.querySelector('.me-sr-only');
expect(live).not.toBeNull();
expect(live!.getAttribute('aria-live')).toBe('polite');
ed.destroy();
});
test('_announce 设置消息', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed._announce('test message');
const live = ed.el.querySelector('.me-sr-only') as HTMLElement;
expect(live).not.toBeNull();
// jsdom 中 requestAnimationFrame 可能不被支持,但不应抛错
expect(() => ed._announce('hello')).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 当前行高亮', () => {
test('_updateCurrentLine 高亮当前行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
ed.textarea.setSelectionRange(7, 7); // 第二行
ed._updateCurrentLine();
const active = ed.gutter.querySelector('.me-gutter-active');
expect(active).not.toBeNull();
ed.destroy();
});
test('_updateCurrentLine 无 gutter 时不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
expect(() => ed._updateCurrentLine()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 右键上下文菜单', () => {
test('contextmenu 事件显示菜单', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
const menu = document.querySelector('.me-context-menu');
expect(menu).not.toBeNull();
// 清理
if (menu) menu.remove();
ed.destroy();
});
test('上下文菜单点击外部关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
expect(document.querySelector('.me-context-menu')).not.toBeNull();
document.body.click();
// once listener removes it
expect(document.querySelector('.me-context-menu')).toBeNull();
ed.destroy();
});
test('registerContextMenu 添加自定义菜单项', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerContextMenu([{ label: 'Custom Action', onClick: jest.fn() }]);
expect(ed._contextMenuItems.length).toBe(1);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 copy API', () => {
test('copyAsMarkdown 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsMarkdown()).not.toThrow();
ed.destroy();
});
test('copyAsHTML 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# hello' });
expect(() => ed.copyAsHTML()).not.toThrow();
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 beforeChange/afterChange 钩子', () => {
test('setValue 触发 beforeChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('beforeChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('old', 'new', ed);
ed.destroy();
});
test('setValue 触发 afterChange', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'old' });
const handler = jest.fn();
ed.on('afterChange', handler);
ed.setValue('new');
expect(handler).toHaveBeenCalledWith('new', ed);
ed.destroy();
});
test('全局 beforeChange/afterChange 钩子', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const beforeFn = jest.fn();
const afterFn = jest.fn();
MarkdownEditor.on('beforeChange', beforeFn);
MarkdownEditor.on('afterChange', afterFn);
const ed = new MarkdownEditor(c, { value: 'test' });
ed.setValue('updated');
expect(beforeFn).toHaveBeenCalled();
expect(afterFn).toHaveBeenCalled();
MarkdownEditor.off('beforeChange', beforeFn);
MarkdownEditor.off('afterChange', afterFn);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
test('_saveDividerPosition / _restoreDividerPosition 不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
expect(() => ed._saveDividerPosition()).not.toThrow();
expect(() => ed._restoreDividerPosition()).not.toThrow();
ed.destroy();
});
});
// ============ v0.2.2 formatTable 测试 ============
describe('MarkdownEditor - v0.2.2 formatTable', () => {
test('exec formatTable 对齐表格列', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '| a | b |\n| --- | --- |\n| x | yyy |' });
ed.textarea.setSelectionRange(10, 10); // 光标在表格内
ed.exec('formatTable');
const v = ed.getValue();
// Both columns padded to max width
expect(v).toMatch(/\| a\s+\| b\s+\|/);
expect(v).toMatch(/\| x\s+\| yyy\s+\|/);
ed.destroy();
});
test('exec formatTable 无表格时不变', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'just some text' });
ed.exec('formatTable');
expect(ed.getValue()).toBe('just some text');
ed.destroy();
});
});
// ============ v0.2.2 gutter 增量更新 ============
describe('MarkdownEditor - v0.2.2 gutter 增量更新', () => {
test('行数减少时 gutter 正确移除多余行', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a\nb\nc', lineNumbers: true });
expect(ed.gutter.children.length).toBe(3);
ed.setValue('a');
expect(ed.gutter.children.length).toBe(1);
ed.destroy();
});
test('行数增加时 gutter 正确追加', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'a', lineNumbers: true });
expect(ed.gutter.children.length).toBe(1);
ed.setValue('a\nb\nc\nd');
expect(ed.gutter.children.length).toBe(4);
ed.destroy();
});
});
// ============ v0.2.2 大纲滚动跟踪 ============
describe('MarkdownEditor - v0.2.2 大纲滚动跟踪', () => {
test('_trackOutlineScroll 注册滚动监听', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '# H1\n## H2\n### H3', outline: true, mode: 'split' });
ed._buildOutline();
// 滚动 preview 应不抛错
expect(() => ed.previewPane.dispatchEvent(new Event('scroll'))).not.toThrow();
ed.destroy();
});
});
// ============ v0.2.3 光标/选区 API ============
describe('MarkdownEditor - v0.2.3 光标/选区 API', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'line one\nline two\nline three' });
});
afterEach(() => ed.destroy());
test('getSelectedText 返回选区文本', () => {
ed.textarea.setSelectionRange(0, 8);
expect(ed.getSelectedText()).toBe('line one');
});
test('getSelectedText 无选区返回空字符串', () => {
ed.textarea.setSelectionRange(5, 5);
expect(ed.getSelectedText()).toBe('');
});
test('getCursorPosition 返回行列', () => {
ed.textarea.setSelectionRange(0, 0);
let pos = ed.getCursorPosition();
expect(pos.line).toBe(1);
expect(pos.column).toBe(0);
// 第二行位置 5
ed.textarea.setSelectionRange(14, 14); // 'line two' 的 'line ' 之后
pos = ed.getCursorPosition();
expect(pos.line).toBe(2);
expect(pos.column).toBeGreaterThanOrEqual(0);
});
test('setCursorPosition 移动到指定位置', () => {
ed.setCursorPosition(2, 3);
expect(ed.textarea.selectionStart).toBeGreaterThan(0);
expect(ed.textarea.selectionStart).toBe(ed.textarea.selectionEnd);
});
test('setCursorPosition 超出范围自动 clamp', () => {
ed.setCursorPosition(99, 999);
// 不应抛错,应在末尾
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
});
test('scrollToLine 不抛错', () => {
// 构建多行内容测试滚动
ed.setValue(Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'));
expect(() => ed.scrollToLine(30)).not.toThrow();
expect(() => ed.scrollToLine(1)).not.toThrow();
expect(() => ed.scrollToLine(999)).not.toThrow();
});
test('selectLine 选中指定行', () => {
ed.selectLine(2);
const sel = ed.getSelectedText();
expect(sel).toBe('line two');
});
test('selectLine 超出范围自动 clamp', () => {
ed.selectLine(100);
expect(() => ed.selectLine(0)).not.toThrow();
});
test('selectAll 选中全部内容', () => {
ed.selectAll();
expect(ed.getSelectedText()).toBe('line one\nline two\nline three');
});
test('selectAll 在空内容上不抛错', () => {
ed.setValue('');
expect(() => ed.selectAll()).not.toThrow();
});
});
// ============ v0.3.1 增量统计 ============
describe('MarkdownEditor - v0.3.1 增量统计', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: '' });
});
afterEach(() => ed.destroy());
const fullStats = (v: string) => {
const cnChars = (v.match(/[\u4e00-\u9fa5]/g) || []).length;
const enWords = (v.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
return { characters: v.length, chineseChars: cnChars, englishWords: enWords, words: cnChars + enWords, lines: v ? v.split('\n').length : 0 };
};
test('增量结果与全量一致(逐步输入)', () => {
const inputs = ['hello world', 'hello world foo', 'hello world foobar', '你好世界 hello', '你好世界\nhello\n世界', '中文 x 混合 test\n第二行', ''];
for (const v of inputs) {
ed.setValue(v, { silent: true });
const stats = ed.getStats();
const expectStats = fullStats(v);
expect(stats.characters).toBe(expectStats.characters);
expect(stats.chineseChars).toBe(expectStats.chineseChars);
expect(stats.englishWords).toBe(expectStats.englishWords);
expect(stats.words).toBe(expectStats.words);
expect(stats.lines).toBe(expectStats.lines);
}
});
test('单词中间删除字符后统计正确', () => {
ed.setValue('hello world', { silent: true });
expect(ed.getStats().englishWords).toBe(2);
ed.setValue('helloworld', { silent: true });
expect(ed.getStats().englishWords).toBe(1);
ed.setValue('hello', { silent: true });
expect(ed.getStats().englishWords).toBe(1);
});
test('追加单词与删除行后统计正确', () => {
ed.setValue('hello world', { silent: true });
ed.setValue('hello world extra', { silent: true });
expect(ed.getStats().englishWords).toBe(3);
ed.setValue('hello world extra\nnew line', { silent: true });
expect(ed.getStats().lines).toBe(2);
ed.setValue('single', { silent: true });
expect(ed.getStats().lines).toBe(1);
});
test('连续追加 50 次后与全量一致', () => {
ed.setValue('初始文本 with words', { silent: true });
for (let i = 0; i < 50; i++) {
ed.setValue(ed.getValue() + ' 追加' + i + 'x', { silent: true });
}
const v = ed.getValue();
const stats = ed.getStats();
const expectStats = fullStats(v);
expect(stats.chineseChars).toBe(expectStats.chineseChars);
expect(stats.englishWords).toBe(expectStats.englishWords);
expect(stats.lines).toBe(expectStats.lines);
});
test('gutter 行数跟随统计缓存', () => {
ed.setValue('a\nb\nc', { silent: true });
expect(ed.gutter.children.length).toBe(3);
ed.setValue('a\nb', { silent: true });
expect(ed.gutter.children.length).toBe(2);
});
});
// ============ v0.3.1 P2 体验一致性 ============
describe('MarkdownEditor - v0.3.1 undo 光标恢复', () => {
test('undo 后光标位置被 clamp 恢复', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.setValue('hello world');
ed.textarea.setSelectionRange(11, 11);
ed.setValue('hello world foo');
ed.textarea.setSelectionRange(15, 15);
ed.undo();
expect(ed.getValue()).toBe('hello world');
expect(ed.textarea.selectionStart).toBe(11);
expect(ed.textarea.selectionEnd).toBe(11);
ed.destroy();
});
test('redo 后光标不越界', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
ed.setValue('ab');
ed.undo();
ed.redo();
expect(ed.getValue()).toBe('ab');
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
ed.destroy();
});
});
describe('MarkdownEditor - v0.3.1 selectionChange 与浮动工具栏解耦', () => {
test('floatingToolbar: false 时 selectionChange 仍触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world', floatingToolbar: false });
const handler = jest.fn();
ed.on('selectionChange', handler);
ed.textarea.setSelectionRange(0, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
ed.destroy();
});
test('floatingToolbar: false 时 cursorMove 仍触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'ab', floatingToolbar: false });
const handler = jest.fn();
ed.on('cursorMove', handler);
ed.textarea.setSelectionRange(1, 1);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
expect(handler).toHaveBeenCalled();
ed.destroy();
});
});
describe('MarkdownEditor - v0.3.1 unregisterShortcut 大小写归一', () => {
test('注册 Ctrl+B 后可用 ctrl+b 注销', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.registerShortcut('Ctrl+B', 'bold');
expect(ed.getShortcuts().length).toBe(1);
ed.unregisterShortcut('ctrl+b');
expect(ed.getShortcuts().length).toBe(0);
ed.destroy();
});
});
describe('MarkdownEditor - v0.3.1 toast 动画接入', () => {
test('所有内置动画类型不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
['fade', 'slide', 'scale', 'bounce', 'flip', 'rotate', 'zoom'].forEach((a) => {
ed.toast('msg', { animation: a, duration: 0 });
const t = ed.el.querySelector('.me-toast');
expect(t).not.toBeNull();
expect(t.className).toContain(`me-toast-${a}`);
t.remove();
});
ed.destroy();
});
test('toast 应用动画进入样式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.toast('hello', { animation: 'slide', duration: 0 });
const t = ed.el.querySelector('.me-toast') as HTMLElement;
expect(t).not.toBeNull();
expect(t.style.transition).toContain('400ms');
expect(t.style.opacity).toBe('0');
t.remove();
ed.destroy();
});
});
describe('MarkdownEditor - v0.3.1 拖放图片大小限制', () => {
test('超大图片拖入时 toast 警告且不插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const toastSpy = jest.spyOn(ed, 'toast').mockImplementation(() => ed);
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(1024 * 1024)], 'big.png', { type: 'image/png' });
const dt = { files: [file] };
const evt = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(evt, 'dataTransfer', { value: dt });
ed.textarea.dispatchEvent(evt as DragEvent);
expect(toastSpy).toHaveBeenCalled();
expect(insertSpy).not.toHaveBeenCalled();
toastSpy.mockRestore();
insertSpy.mockRestore();
ed.destroy();
});
test('小图片拖入正常插入', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '' });
const insertSpy = jest.spyOn(ed, 'insert').mockImplementation(() => ed);
const file = new File([new ArrayBuffer(1024)], 'ok.png', { type: 'image/png' });
const dt = { files: [file] };
const evt = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(evt, 'dataTransfer', { value: dt });
ed.textarea.dispatchEvent(evt as DragEvent);
expect(insertSpy).not.toHaveBeenCalled(); // FileReader 异步,同步阶段仅校验
insertSpy.mockRestore();
ed.destroy();
});
});
describe('MarkdownEditor - v0.4.0 实例 locale 决定 UI 文案', () => {
test('实例 locale 为 en-US 时状态栏与按钮为英文', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello', locale: 'en-US', wordCount: true });
expect(ed.statusEl.textContent).toContain('Characters');
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
expect(boldBtn.title).toBe('Bold');
ed.destroy();
});
test('实例 locale 为 zh-CN 时状态栏与按钮为中文(不受浏览器默认影响)', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '你好', locale: 'zh-CN', wordCount: true });
expect(ed.statusEl.textContent).toContain('字符');
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]') as HTMLElement;
expect(boldBtn.title).toBe('粗体');
expect(ed.textarea.getAttribute('aria-label')).toBe('编辑');
ed.destroy();
});
test('右键菜单标签跟随实例 locale', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'text', locale: 'en-US' });
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 10, clientY: 10, bubbles: true }));
const menu = document.querySelector('.me-context-menu');
expect(menu.textContent).toContain('Undo');
if (menu) menu.remove();
ed.destroy();
});
});
// ============ v0.2.3 内容操作 API ============
describe('MarkdownEditor - v0.2.3 内容操作 API', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'hello world, hello universe' });
});
afterEach(() => ed.destroy());
test('replaceAll 替换所有匹配', () => {
const count = ed.replaceAll('hello', 'hi');
expect(count).toBe(2);
expect(ed.getValue()).toBe('hi world, hi universe');
});
test('replaceAll 无匹配返回 0', () => {
const count = ed.replaceAll('xyz', 'abc');
expect(count).toBe(0);
});
test('replaceAll 空字符串返回 0', () => {
expect(ed.replaceAll('', 'x')).toBe(0);
});
test('replaceAll caseSensitive 选项', () => {
const count = ed.replaceAll('Hello', 'hi', false);
expect(count).toBe(2);
});
test('replaceAllRegex 正则替换', () => {
const count = ed.replaceAllRegex(/hello/gi, 'hi');
expect(count).toBe(2);
expect(ed.getValue()).toBe('hi world, hi universe');
});
test('replaceAllRegex 无匹配返回 0', () => {
expect(ed.replaceAllRegex(/xyz/g, 'abc')).toBe(0);
});
test('replaceAllRegex 空模式返回 0', () => {
expect(ed.replaceAllRegex(null, 'x')).toBe(0);
});
test('replaceAll 触发 change 事件', () => {
const handler = jest.fn();
ed.on('change', handler);
ed.replaceAll('hello', 'hi');
expect(handler).toHaveBeenCalled();
});
test('replaceAll 替换文本中的 $ 按字面处理', () => {
ed.setValue('a b a');
const count = ed.replaceAll('a', '$1');
expect(count).toBe(2);
expect(ed.getValue()).toBe('$1 b $1');
});
test('replaceAllRegex 保留正则捕获组语义', () => {
ed.setValue('hello 2024');
ed.replaceAllRegex(/(\d{4})/, '$1!');
expect(ed.getValue()).toBe('hello 2024!');
});
test('lineCount 返回行数', () => {
expect(ed.lineCount()).toBe(1);
ed.setValue('a\nb\nc');
expect(ed.lineCount()).toBe(3);
});
test('lineCount 空内容返回 0', () => {
ed.setValue('');
expect(ed.lineCount()).toBe(0);
});
test('getLine 获取指定行', () => {
ed.setValue('line1\nline2\nline3');
expect(ed.getLine(1)).toBe('line1');
expect(ed.getLine(2)).toBe('line2');
expect(ed.getLine(3)).toBe('line3');
});
test('getLine 越界返回空字符串', () => {
expect(ed.getLine(0)).toBe('');
expect(ed.getLine(999)).toBe('');
});
});
// ============ v0.2.3 浮动工具栏 ============
describe('MarkdownEditor - v0.2.3 浮动工具栏', () => {
test('floatingToolbar 默认创建', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
const style = document.getElementById('me-float-style');
expect(style).not.toBeNull();
expect(ed._floatingToolbar).toBeNull();
expect(ed._floatingEnabled).toBe(true);
ed.destroy();
});
test('floatingToolbar: false 不启用', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello', floatingToolbar: false });
expect(ed._floatingEnabled).toBe(false);
expect(ed._floatingToolbar).toBeNull();
ed.destroy();
});
test('选中文本时显示浮动工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
// 手动创建并显示
ed._buildFloatingToolbar();
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-float-toolbar')).toBe(true);
// 检查包含按钮
const btns = ed._floatingToolbar!.querySelectorAll('.me-btn');
expect(btns.length).toBe(5); // bold, italic, code, link, strikethrough
ed.destroy();
});
test('destroy 清理浮动工具栏', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
ed._buildFloatingToolbar();
expect(ed._floatingToolbar).not.toBeNull();
ed.destroy();
expect(ed._floatingToolbar).toBeNull();
});
test('selectionChange 事件触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' });
const handler = jest.fn();
ed.on('selectionChange', handler);
// Fire keyup after setting selection — selectionChange is emitted synchronously in the handler
ed.textarea.setSelectionRange(0, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0]).toHaveProperty('start', 0);
expect(handler.mock.calls[0][0]).toHaveProperty('end', 5);
expect(handler.mock.calls[0][0]).toHaveProperty('text', 'hello');
ed.destroy();
});
test('cursorMove 事件在按键后触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'ab' });
const handler = jest.fn();
ed.on('cursorMove', handler);
ed.textarea.setSelectionRange(1, 1);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight', bubbles: true }));
expect(handler).toHaveBeenCalled();
ed.destroy();
});
});
// ============ v0.2.3 config.onLinkClick 修复验证 ============
describe('MarkdownEditor - v0.2.3 onLinkClick 类型修复', () => {
test('onLinkClick 在 DEFAULTS 中存在', () => {
const { DEFAULTS } = require('../src/constants');
expect('onLinkClick' in DEFAULTS).toBe(true);
expect(DEFAULTS.onLinkClick).toBeNull();
});
test('onLinkClick 回调正常触发', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const handler = jest.fn();
const ed = new MarkdownEditor(c, {
value: '[click](https://example.com)',
onLinkClick: handler,
mode: 'split',
});
// Need to render first
const link = ed.previewEl.querySelector('a');
if (link) {
link.click();
expect(handler).toHaveBeenCalledWith('https://example.com', 'click', ed);
}
ed.destroy();
});
});
// ============ v0.2.3 getStatus 新字段验证 ============
describe('MarkdownEditor - v0.2.3 getStatus', () => {
test('getStatus 返回完整结构', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { readOnly: true });
const s = ed.getStatus();
expect(s.readOnly).toBe(true);
expect(s.disabled).toBe(false);
expect(Array.isArray(s.plugins)).toBe(true);
ed.destroy();
});
test('getStatus locale 跟随实例语言', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { locale: 'en-US' });
ed.setLocale('de');
expect(ed.getStatus().locale).toBe('de');
expect(ed.getLocale()).toBe('de');
ed.destroy();
});
});
// ============ v0.2.5 maxLength / zenMode / 分隔条隔离 ============
describe('MarkdownEditor - v0.2.5 maxLength', () => {
test('setValue 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 10 });
ed.setValue('0123456789ABC');
expect(ed.getValue()).toBe('0123456789');
expect(ed.textarea.value).toBe('0123456789');
ed.destroy();
});
test('insert 超长内容被截断', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 5, value: 'ab' });
ed.focus();
ed.textarea.setSelectionRange(2, 2);
ed.insert('CDEFG');
expect(ed.getValue()).toBe('abCDE');
ed.destroy();
});
test('textarea maxlength 属性已绑定', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 42 });
expect(ed.textarea.maxLength).toBe(42);
ed.destroy();
});
test('maxLength 为 0 时不限制', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
const long = 'x'.repeat(10000);
ed.setValue(long);
expect(ed.getValue().length).toBe(10000);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zenMode 初始状态', () => {
test('zenMode: true 启动即进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed.isZen()).toBe(true);
expect(ed.el.classList.contains('me-zen')).toBe(true);
expect(ed.toolbarEl.style.opacity).toBe('0');
ed.destroy();
});
test('默认不进入 Zen 模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
test('zenMode 初始开启后 toggleZen 可关闭', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
ed.toggleZen();
expect(ed.isZen()).toBe(false);
expect(ed.el.classList.contains('me-zen')).toBe(false);
ed.destroy();
});
});
describe('MarkdownEditor - v0.4.1 zenMaxWidth 专注模式宽度', () => {
test('默认 zenMaxWidth 为 960', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.getZenMaxWidth()).toBe(960);
ed.destroy();
});
test('配置 zenMaxWidth 数字转为 px 并写入 CSS 变量', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMaxWidth: 1200 });
expect(ed.getZenMaxWidth()).toBe(1200);
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('1200px');
ed.destroy();
});
test('配置 zenMaxWidth 字符串原样写入 CSS 变量', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMaxWidth: 'min(1200px, 90%)' });
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('min(1200px, 90%)');
ed.destroy();
});
test('配置 zenMaxWidth: false 清除 CSS 变量(不限制宽度)', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMaxWidth: false });
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('');
ed.destroy();
});
test('setZenMaxWidth 运行时更新宽度且仅在 zen 时生效类', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.setZenMaxWidth(1080);
expect(ed.getZenMaxWidth()).toBe(1080);
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('1080px');
ed.setZenMaxWidth(false);
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('');
ed.setZenMaxWidth('60vw');
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('60vw');
ed.destroy();
});
test('zenMode: true 启动时应用 zenMaxWidth 变量', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true, zenMaxWidth: 1000 });
expect(ed.isZen()).toBe(true);
expect(ed.el.style.getPropertyValue('--md-zen-max-width')).toBe('1000px');
ed.destroy();
});
});
describe('MarkdownEditor - v0.4.1 wordWrap: false 配置生效', () => {
test('wordWrap: false 初始 whiteSpace 为 pre', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { wordWrap: false });
expect(ed.isWordWrap()).toBe(false);
expect(ed.textarea.style.whiteSpace).toBe('pre');
ed.toggleWordWrap();
expect(ed.isWordWrap()).toBe(true);
expect(ed.textarea.style.whiteSpace).toBe('pre-wrap');
ed.destroy();
});
test('默认 wordWrap 为 true', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.isWordWrap()).toBe(true);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 分隔条实例隔离', () => {
test('分隔条位置按实例 id 保存', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { id: 'divider-test-1' });
ed.editorPane.style.flex = '0 0 30%';
ed._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-divider-test-1')).toBe('0 0 30%');
expect(localStorage.getItem('metona-editor-divider')).toBeNull();
ed.destroy();
});
test('不同实例互不覆盖分隔条位置', () => {
localStorage.clear();
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const a = new MarkdownEditor(c, { id: 'dva' });
const b = new MarkdownEditor(c, { id: 'dvb' });
a.editorPane.style.flex = '0 0 20%';
b.editorPane.style.flex = '0 0 70%';
a._saveDividerPosition();
b._saveDividerPosition();
expect(localStorage.getItem('metona-editor-divider-dva')).toBe('0 0 20%');
expect(localStorage.getItem('metona-editor-divider-dvb')).toBe('0 0 70%');
a.destroy(); b.destroy();
});
});
// ============ v0.2.5 复查:maxLength 兜底 + zen destroy 清理 ============
describe('MarkdownEditor - v0.2.5 maxLength 兜底', () => {
test('exec 命令路径不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 20, value: '12345678901234567890' });
ed.focus();
ed.textarea.setSelectionRange(0, 0);
ed.exec('indent');
expect(ed.getValue().length).toBeLessThanOrEqual(20);
ed.destroy();
});
test('Smart Enter 不突破 maxLength', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 30, value: '- item 123456789012345678901234' });
ed.focus();
ed.textarea.setSelectionRange(ed.getValue().length, ed.getValue().length);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ed.getValue().length).toBeLessThanOrEqual(30);
ed.destroy();
});
});
describe('MarkdownEditor - v0.2.5 zen destroy 清理', () => {
test('destroy 移除 zen mousemove 监听', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
expect(ed._zenMouseHandler).not.toBeNull();
const spy = jest.spyOn(document, 'removeEventListener');
ed.destroy();
expect(spy).toHaveBeenCalledWith('mousemove', expect.any(Function));
spy.mockRestore();
});
test('destroy 后 mousemove 不再修改 toolbar', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { zenMode: true });
const toolbar = ed.toolbarEl;
ed.destroy();
toolbar.style.opacity = '1';
document.dispatchEvent(new MouseEvent('mousemove', { clientY: 10 }));
expect(toolbar.style.opacity).toBe('1');
});
});
// ============ v0.2.5 复查:链式 API 契约 ============
describe('MarkdownEditor - v0.2.5 链式返回', () => {
test('toggleFloatingToolbar 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.toggleFloatingToolbar()).toBe(ed);
ed.destroy();
});
test('registerContextMenu 返回 this', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(ed.registerContextMenu([{ label: 'x', onClick: () => {} }])).toBe(ed);
ed.destroy();
});
});
// ============ v0.2.5 复查:maxLength 默认值不触发浏览器 IndexSizeError ============
describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
test('不配置 maxLength 时保持默认 -1 且不显式赋值', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
// 模拟浏览器 setter 行为:负值抛 IndexSizeError
const textareaProto = HTMLTextAreaElement.prototype;
const originalDesc = Object.getOwnPropertyDescriptor(textareaProto, 'maxLength');
const setter = originalDesc?.set;
let threw = false;
if (setter) {
Object.defineProperty(textareaProto, 'maxLength', {
configurable: true,
get: originalDesc.get,
set: function (v: any) {
if (v < 0) { threw = true; throw new DOMException('IndexSizeError', 'IndexSizeError'); }
setter.call(this, v);
},
});
}
const ed = new MarkdownEditor(c, {});
expect(threw).toBe(false);
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
ed.destroy();
if (setter) Object.defineProperty(textareaProto, 'maxLength', originalDesc);
});
test('maxLength 配置为 0 时不触发 setter 报错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { maxLength: 0 });
expect(ed.textarea.maxLength).toBeGreaterThanOrEqual(0);
ed.destroy();
});
});
// ============ v0.4.2 事件补齐 ============
describe('MarkdownEditor - v0.4.2 事件补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('渲染路径触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi', mode: 'split' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.setValue('# changed');
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('getHTML 触发 beforeRender / afterRender 事件', () => {
const ed = new MarkdownEditor(container, { value: '# hi' });
const before = jest.fn(); const after = jest.fn();
ed.on('beforeRender', before); ed.on('afterRender', after);
ed.getHTML();
expect(before).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('toggleZen 触发 zenChange 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onZen = jest.fn();
ed.on('zenChange', onZen);
ed.toggleZen();
expect(onZen).toHaveBeenCalledWith(true, ed);
ed.toggleZen();
expect(onZen).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('toggleFullscreen / exitFullscreen 触发 fullscreen 事件', () => {
const ed = new MarkdownEditor(container, { value: 'test' });
const onFs = jest.fn();
ed.on('fullscreen', onFs);
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
expect(onFs).toHaveBeenCalledWith(true, ed);
ed.exitFullscreen();
expect(ed.isFullscreen()).toBe(false);
expect(onFs).toHaveBeenLastCalledWith(false, ed);
ed.destroy();
});
test('全局钩子 beforeCreate / beforeDestroy / afterDestroy 触发', () => {
const beforeCreate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn();
MarkdownEditor.on('beforeCreate', beforeCreate);
MarkdownEditor.on('beforeDestroy', beforeDestroy);
MarkdownEditor.on('afterDestroy', afterDestroy);
const ed = new MarkdownEditor(container, {});
expect(beforeCreate).toHaveBeenCalledTimes(1);
ed.destroy();
expect(beforeDestroy).toHaveBeenCalledTimes(1);
expect(afterDestroy).toHaveBeenCalledTimes(1);
MarkdownEditor.off('beforeCreate', beforeCreate);
MarkdownEditor.off('beforeDestroy', beforeDestroy);
MarkdownEditor.off('afterDestroy', afterDestroy);
});
test('copyAsMarkdown 写剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write: jest.fn() } });
const ed = new MarkdownEditor(container, { value: '# copy test' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsMarkdown();
await new Promise((r) => setTimeout(r, 0));
expect(writeText).toHaveBeenCalledWith('# copy test');
expect(onCopy).toHaveBeenCalledWith({ type: 'markdown' }, ed);
delete (navigator as any).clipboard;
ed.destroy();
});
test('copyAsHTML 写富文本剪贴板并触发 copy 事件', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
const write = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
// jsdom 无 ClipboardItem,注入 stub 供富文本写入
const items: any[] = [];
(global as any).ClipboardItem = class { constructor(it: any) { items.push(it); } };
const ed = new MarkdownEditor(container, { value: '**bold**' });
const onCopy = jest.fn();
ed.on('copy', onCopy);
ed.copyAsHTML();
await new Promise((r) => setTimeout(r, 0));
expect(write).toHaveBeenCalledTimes(1);
expect(write.mock.calls[0][0]).toHaveLength(1);
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
delete (global as any).ClipboardItem;
delete (navigator as any).clipboard;
ed.destroy();
});
});
// ============ v0.4.2 配置项补齐 ============
describe('MarkdownEditor - v0.4.2 配置项补齐', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('placeholder 配置生效', () => {
const ed = new MarkdownEditor(container, { placeholder: '自定义占位' });
expect(ed.textarea.placeholder).toBe('自定义占位');
ed.destroy();
});
test('未配置 placeholder 时使用翻译默认值', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.placeholder).toBe(ed.t('placeholder'));
ed.destroy();
});
test('height 数字转为 px', () => {
const ed = new MarkdownEditor(container, { height: 520 });
expect(ed.el.style.height).toBe('520px');
ed.destroy();
});
test('height 字符串原样使用', () => {
const ed = new MarkdownEditor(container, { height: '100%' });
expect(ed.el.style.height).toBe('100%');
ed.destroy();
});
test('spellcheck 默认关闭', () => {
const ed = new MarkdownEditor(container, {});
expect(ed.textarea.spellcheck).toBe(false);
ed.destroy();
});
test('spellcheck: true 开启拼写检查', () => {
const ed = new MarkdownEditor(container, { spellcheck: true });
expect(ed.textarea.spellcheck).toBe(true);
ed.destroy();
});
test('tabSize 自定义值生效且 Tab 插入对应空格数', () => {
const ed = new MarkdownEditor(container, { tabSize: 4 });
expect(ed.textarea.style.tabSize).toBe('4');
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe(' ');
ed.destroy();
});
test('tabSize: 0 时 Tab 插入制表符', () => {
const ed = new MarkdownEditor(container, { tabSize: 0 });
ed.textarea.setSelectionRange(0, 0);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('\t');
ed.destroy();
});
test('historyDebounce 自定义防抖间隔生效', () => {
jest.useFakeTimers();
const ed = new MarkdownEditor(container, { historyDebounce: 100 });
const before = ed._history.length;
ed.textarea.value = 'typed';
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
jest.advanceTimersByTime(50);
expect(ed._history.length).toBe(before);
jest.advanceTimersByTime(60);
expect(ed._history.length).toBe(before + 1);
jest.useRealTimers();
ed.destroy();
});
});
// ============ v0.4.2 undo/redo 滚动保持 ============
describe('MarkdownEditor - v0.4.2 undo/redo 滚动保持', () => {
test('undo/redo 后预览区滚动位置按比例保持', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'v1', mode: 'split' });
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
ed.setValue('v2\n\nmore content');
ed.previewPane.scrollTop = 250; // 滚动比例 50%
ed.undo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.redo();
expect(ed.previewPane.scrollTop).toBe(250);
ed.destroy();
});
});
// ============ v0.4.2 大纲光标定位 ============
describe('MarkdownEditor - v0.4.2 大纲光标定位', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('点击大纲按源码行号定位光标(重复标题不串位)', () => {
const md = '# alpha\n\ntext\n\n# beta\n\n# alpha\n';
const ed = new MarkdownEditor(container, { value: md, outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links.length).toBe(3);
// 第三个链接对应第二个 'alpha' 标题(源码第 7 行)
(links[2] as HTMLElement).click();
expect(ed.getCursorPosition().line).toBe(7);
ed.destroy();
});
test('data-line 记录源码行号(1 基)', () => {
const ed = new MarkdownEditor(container, { value: '# a\n\n## b\n', outline: true, mode: 'split' });
const links = ed.el.querySelectorAll('.me-outline a');
expect(links[0].getAttribute('data-line')).toBe('1');
expect(links[1].getAttribute('data-line')).toBe('3');
ed.destroy();
});
test('setOutline 运行时开关大纲面板', () => {
const ed = new MarkdownEditor(container, { value: '# title\n\nbody', outline: false, mode: 'split' });
expect(ed.isOutline()).toBe(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.setOutline(true);
expect(ed.isOutline()).toBe(true);
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
ed.setOutline(false);
expect(ed.el.querySelector('.me-outline')).toBeNull();
ed.destroy();
});
});
// ============ v0.4.2 setLocale 即时刷新 ============
describe('MarkdownEditor - v0.4.2 setLocale 即时刷新', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('setLocale 即时刷新状态栏统计标签', () => {
const ed = new MarkdownEditor(container, { value: 'hello', locale: 'zh-CN' });
expect(ed.statusEl!.textContent).toContain('字符');
ed.setLocale('en-US');
expect(ed.statusEl!.textContent).toContain('Characters');
expect(ed.statusEl!.textContent).not.toContain('字符');
ed.destroy();
});
test('setLocale 即时刷新大纲标题', () => {
const ed = new MarkdownEditor(container, { value: '# t', outline: true, locale: 'zh-CN', mode: 'split' });
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('大纲');
ed.setLocale('en-US');
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('Outline');
ed.destroy();
});
});
// ============ v0.4.2 浮动工具栏中文选区 ============
describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
test('中文选区触发浮动工具栏显示且定位计算不抛错', async () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: '这是一段中文文本内容' });
ed.textarea.setSelectionRange(0, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.destroy();
});
});