Files
MetonaEditor/tests/core.test.ts
T
thzxx d6a618af5d feat: v0.4.3 — 浮动工具栏定位引擎重做 + 程序化编辑统一刷新管线
- 浮动工具栏 mirror 镜像测量:隐藏镜像层复制 textarea 排版样式,选区锚点插零宽字符读像素坐标,CJK/软换行/跨行选区误差全部消除

- 实测尺寸替代魔法数字;滚动 rAF 节流重定位;滚出视口自动隐藏;a11y:role=toolbar + aria-label、visibility 移出 Tab 序列、键盘可达

- 修复死开关/监听器泄漏/销毁后 focus 崩溃/blur 焦点抖动误隐藏

- _afterProgrammaticEdit 统一 8 条程序化路径的渲染/行号/字数/大纲/事件刷新;右键菜单 label 转义、copyAsHTML Firefox 降级、paste 失败提示、Esc 退出全屏

- 测试 871 → 895,e2e 22 → 28 项断言(含真实浏览器浮动工具栏布局验证)
2026-08-20 20:43:01 +08:00

3636 lines
125 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* core.js 单元测试
* 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁
*/
import { MarkdownEditor } from '../src/core';
// 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('<h1');
expect(html).toContain('Title');
});
test('insert 在光标处插入', () => {
ed.setValue('abc');
ed.textarea.selectionStart = ed.textarea.selectionEnd = 1;
ed.insert('X');
expect(ed.getValue()).toBe('aXbc');
});
test('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(() => '<p>custom</p>');
const ed = new MarkdownEditor(document.createElement('div'), {
value: '# test',
render: customRender,
});
expect(customRender).toHaveBeenCalled();
expect(ed.getHTML()).toBe('<p>custom</p>');
ed.destroy();
});
test('自定义 highlight 函数被调用', () => {
const highlight = jest.fn(() => '<span class="hl">code</span>');
const ed = new MarkdownEditor(document.createElement('div'), {
value: '```js\nvar x = 1\n```',
highlight,
});
expect(highlight).toHaveBeenCalledWith('var x = 1', 'js');
ed.destroy();
});
test('自定义 sanitize 净化 HTML', () => {
const ed = new MarkdownEditor(document.createElement('div'), {
value: 'hello',
sanitize: (html) => '<div>sanitized</div>',
});
expect(ed.getHTML()).toBe('<div>sanitized</div>');
ed.destroy();
});
});
describe('MarkdownEditor - 键盘快捷键', () => {
let ed;
beforeEach(() => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
ed = new MarkdownEditor(c, { value: 'hello world' });
});
afterEach(() => ed.destroy());
function key(key, opts = {}) {
const ev = new KeyboardEvent('keydown', {
key,
ctrlKey: !!opts.ctrl,
shiftKey: !!opts.shift,
bubbles: true,
cancelable: true,
});
ed.textarea.dispatchEvent(ev);
return ev;
}
test('Tab 插入缩进(无选区,默认 tabSize=2', () => {
ed.setValue('hello world');
// 光标放在位置 5(即 'hello' 之后、原空格之前)
ed.textarea.setSelectionRange(5, 5);
key('Tab');
// 插入 2 空格:'hello' + ' ' + ' world' = 'hello world'(含原空格共 3 空格)
expect(ed.getValue()).toBe('hello world');
});
test('Tab 在行尾插入缩进', () => {
ed.setValue('hello');
ed.textarea.setSelectionRange(5, 5);
key('Tab');
expect(ed.getValue()).toBe('hello ');
});
test('Shift+Tab 反缩进(有选区)', () => {
ed.setValue(' line1\n line2');
ed.textarea.setSelectionRange(0, 14);
key('Tab', { shift: true });
// 反缩进会移除每行行首的空格
expect(ed.getValue()).toBe('line1\nline2');
});
test('Tab 有选区时整块缩进', () => {
ed.setValue('line1\nline2');
ed.textarea.setSelectionRange(0, 11);
key('Tab');
expect(ed.getValue()).toBe(' line1\n line2');
});
test('Ctrl+B 触发 bold', () => {
ed.textarea.setSelectionRange(0, 5);
key('b', { ctrl: true });
expect(ed.getValue()).toBe('**hello** world');
});
test('Ctrl+I 触发 italic', () => {
ed.textarea.setSelectionRange(0, 5);
key('i', { ctrl: true });
expect(ed.getValue()).toBe('*hello* world');
});
test('Ctrl+E 触发 code', () => {
ed.textarea.setSelectionRange(0, 5);
key('e', { ctrl: true });
expect(ed.getValue()).toBe('`hello` world');
});
test('Ctrl+K 触发 link', () => {
ed.textarea.setSelectionRange(0, 5);
key('k', { ctrl: true });
// link 使用 promptjsdom 返回 null,但不应抛错
expect(ed.getValue()).toBeDefined();
});
test('Ctrl+U 触发 underline', () => {
ed.textarea.setSelectionRange(0, 5);
key('u', { ctrl: true });
expect(ed.getValue()).toBe('<u>hello</u> world');
});
test('Ctrl+1 触发 h1', () => {
ed.textarea.setSelectionRange(0, 0);
key('1', { ctrl: true });
expect(ed.getValue()).toBe('# hello world');
});
test('Ctrl+2 触发 h2', () => {
ed.textarea.setSelectionRange(0, 0);
key('2', { ctrl: true });
expect(ed.getValue()).toBe('## hello world');
});
test('Ctrl+3 触发 h3', () => {
ed.textarea.setSelectionRange(0, 0);
key('3', { ctrl: true });
expect(ed.getValue()).toBe('### hello world');
});
test('Ctrl+Q 触发 quote', () => {
ed.textarea.setSelectionRange(0, 0);
key('q', { ctrl: true });
expect(ed.getValue()).toBe('> hello world');
});
test('Ctrl+Z 撤销', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true });
expect(ed.getValue()).toBe(initial);
});
test('Ctrl+Shift+Z 重做', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true }); // undo
key('z', { ctrl: true, shift: true }); // redo
expect(ed.getValue()).not.toBe(initial);
});
test('Ctrl+Y 重做', () => {
const initial = ed.getValue();
ed.exec('bold');
key('z', { ctrl: true }); // undo
key('y', { ctrl: true }); // redo
expect(ed.getValue()).not.toBe(initial);
});
test('Ctrl+S 触发 save 事件', () => {
const handler = jest.fn();
ed.on('save', handler);
key('s', { ctrl: true });
expect(handler).toHaveBeenCalled();
});
test('Ctrl+S 调用 onSave 回调', () => {
const onSave = jest.fn();
ed.config.onSave = onSave;
key('s', { ctrl: true });
expect(onSave).toHaveBeenCalled();
});
test('无修饰键的按键不触发快捷键', () => {
const initial = ed.getValue();
key('b');
expect(ed.getValue()).toBe(initial);
});
});
describe('MarkdownEditor - 同步滚动', () => {
test('分屏模式下 textarea 滚动联动 preview', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
// jsdom 的 scrollHeight 恒为 0mock 出可滚动尺寸以驱动比例联动
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: '<svg></svg>',
onClick,
});
const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom');
expect(btn).not.toBeNull();
btn.click();
expect(onClick).toHaveBeenCalledWith(ed);
ed.destroy();
});
test('addToolbarButton 使用 text 文本', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
ed.addToolbarButton({
action: 'txt',
title: 'Text Button',
text: 'T',
});
const btn = ed.toolbarEl.querySelector('.me-btn-custom-txt');
expect(btn).not.toBeNull();
expect(btn.innerHTML).toContain('>T<');
ed.destroy();
});
});
describe('MarkdownEditor - 工具栏点击', () => {
test('点击空白区域不抛错', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
expect(() => {
ed.toolbarEl.click();
}).not.toThrow();
ed.destroy();
});
test('点击 mode 按钮切换模式', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { mode: 'split' });
const editBtn = ed.toolbarEl.querySelector('.me-btn[data-mode="edit"]');
editBtn.click();
expect(ed.getMode()).toBe('edit');
ed.destroy();
});
test('点击 action 按钮执行命令', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'test' });
const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]');
ed.textarea.setSelectionRange(0, 4);
boldBtn.click();
expect(ed.getValue()).toBe('**test**');
ed.destroy();
});
});
describe('MarkdownEditor - 历史栈合并', () => {
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 不应被 focusautofocus 被跳过)
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(/<ul>/g) || []).length;
const ulClose = (html.match(/<\/ul>/g) || []).length;
expect(ulOpen).toBe(ulClose);
const liOpen = (html.match(/<li /g) || []).length;
const liClose = (html.match(/<\/li>/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();
});
});
// ============ v0.4.3 浮动工具栏定位引擎重做 ============
describe('MarkdownEditor - v0.4.3 浮动工具栏重做', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
/** 触发选区显示浮动工具栏(keyup 路径) */
const showToolbar = async (ed: MarkdownEditor, start: number, end: number) => {
ed.textarea.setSelectionRange(start, end);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
};
test('floatingToolbar:false 构造后 toggle 重开可用(死开关修复)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world', floatingToolbar: false });
expect(ed._floatingEnabled).toBe(false);
ed.toggleFloatingToolbar();
expect(ed._floatingEnabled).toBe(true);
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar).not.toBeNull();
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.destroy();
});
test('选区触发时懒创建镜像层且后续复用不重建', async () => {
const ed = new MarkdownEditor(container, { value: 'first line\nsecond line' });
expect(ed._floatMirror).toBeNull();
await showToolbar(ed, 0, 5);
expect(ed._floatMirror).not.toBeNull();
expect(ed._floatMirror!.classList.contains('me-float-mirror')).toBe(true);
const m1 = ed._floatMirror;
await showToolbar(ed, 11, 17);
expect(ed._floatMirror).toBe(m1);
ed.destroy();
});
test('destroy 清理镜像层 DOM', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
expect(ed._floatMirror).not.toBeNull();
ed.destroy();
expect(ed._floatMirror).toBeNull();
expect(document.querySelector('.me-float-mirror')).toBeNull();
});
test('textarea 滚动触发重定位不抛错(选区仍在视口内保持显示)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
ed.textarea.dispatchEvent(new Event('scroll', { bubbles: true }));
await new Promise((r) => setTimeout(r, 20));
// jsdom 无真实布局:验证 rAF 重定位路径执行不抛错、状态未破坏
expect(ed._floatingToolbar).not.toBeNull();
ed.destroy();
});
test('工具栏 a11y 属性(role / aria-label', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
const bar = ed._floatingToolbar!;
expect(bar.getAttribute('role')).toBe('toolbar');
expect(bar.getAttribute('aria-label')).toBe('格式化选区');
ed.destroy();
});
test('隐藏态 CSS 使用 visibility(移出 Tab 序列)', () => {
new MarkdownEditor(container, { value: 'hello' }); // 触发样式注入
const style = document.getElementById('me-float-style')!;
expect(style.textContent).toContain('visibility:hidden');
expect(style.textContent).toContain('visibility:visible');
});
test('按钮 click 触发 exec 并隐藏工具栏(键盘可达)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
await showToolbar(ed, 0, 5);
const btn = ed._floatingToolbar!.querySelector('.me-btn') as HTMLElement;
btn.click();
expect(ed.getValue()).toBe('**hello** world');
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
test('window resize 后隐藏浮动工具栏', async () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
await showToolbar(ed, 0, 5);
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
window.dispatchEvent(new Event('resize'));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
test('_measureSelectionAnchor 无选区返回 null', () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
ed.textarea.setSelectionRange(0, 0);
expect(ed._measureSelectionAnchor()).toBeNull();
ed.destroy();
});
test('blur 延迟隐藏带焦点回归校验(焦点抖动防护)', async () => {
const ed = new MarkdownEditor(container, { value: 'hello world' });
ed.textarea.focus();
ed.textarea.setSelectionRange(0, 5);
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
// 合成 blur(焦点未真实转移):延迟到期时焦点仍在 textarea,不应隐藏
ed.textarea.dispatchEvent(new Event('blur'));
await new Promise((r) => setTimeout(r, 350));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
// 真实失焦(activeElement 离开 textarea):延迟到期后隐藏
ed.textarea.blur();
await new Promise((r) => setTimeout(r, 350));
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(false);
ed.destroy();
});
});
// ============ v0.4.3 程序化编辑统一刷新管线 ============
describe('MarkdownEditor - v0.4.3 程序化编辑统一刷新管线', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('Smart Enter 延续列表后预览同步刷新', () => {
const ed = new MarkdownEditor(container, { value: '- item', mode: 'split' });
expect(ed.previewEl.querySelectorAll('li').length).toBe(1);
ed.textarea.setSelectionRange(6, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('- item\n- ');
expect(ed.previewEl.querySelectorAll('li').length).toBe(2);
ed.destroy();
});
test('Smart Enter 后触发 input 事件', () => {
const ed = new MarkdownEditor(container, { value: '- item' });
const onInput = jest.fn();
ed.on('input', onInput);
ed.textarea.setSelectionRange(6, 6);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith('- item\n- ', ed);
ed.destroy();
});
test('括号自动闭合后预览与字数统计刷新', () => {
const ed = new MarkdownEditor(container, { value: 'abc', mode: 'split' });
ed.textarea.setSelectionRange(3, 3);
ed.textarea.dispatchEvent(new KeyboardEvent('keypress', { key: '(', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('abc()');
expect(ed.getStats().characters).toBe(5);
expect(ed.previewEl.textContent).toBe('abc()');
ed.destroy();
});
test('Tab 缩进后字数统计刷新', () => {
const ed = new MarkdownEditor(container, { value: 'ab' });
ed.textarea.setSelectionRange(2, 2);
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(ed.getValue()).toBe('ab ');
expect(ed.getStats().characters).toBe(4);
ed.destroy();
});
test('insert 后行号装订线刷新', () => {
const ed = new MarkdownEditor(container, { value: 'one' });
expect(ed.gutter.children.length).toBe(1);
ed.insert('\ntwo');
expect(ed.gutter.children.length).toBe(2);
ed.destroy();
});
test('exec h1 后大纲面板延时刷新', async () => {
const ed = new MarkdownEditor(container, { value: '标题', outline: true, mode: 'split' });
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(0);
ed.textarea.setSelectionRange(0, 0);
ed.exec('h1');
await new Promise((r) => setTimeout(r, 400));
expect(ed.el.querySelectorAll('.me-outline a').length).toBe(1);
ed.destroy();
});
test('undo 后触发 afterChange 事件', () => {
const ed = new MarkdownEditor(container, { value: '' });
ed.setValue('v2');
ed.setValue('v3');
const after = jest.fn();
ed.on('afterChange', after);
ed.undo();
expect(ed.getValue()).toBe('v2');
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('replaceAll 后触发 afterChange 事件', () => {
const ed = new MarkdownEditor(container, { value: 'abc abc' });
const after = jest.fn();
ed.on('afterChange', after);
ed.replaceAll('abc', 'xyz');
expect(ed.getValue()).toBe('xyz xyz');
expect(after).toHaveBeenCalledTimes(1);
ed.destroy();
});
test('exec bold 触发 beforeChange(oldValue, newValue)', () => {
const ed = new MarkdownEditor(container, { value: 'hello' });
let oldV = ''; let newV = '';
ed.on('beforeChange', (o, n) => { oldV = o; newV = n; });
ed.textarea.setSelectionRange(0, 5);
ed.exec('bold');
expect(oldV).toBe('hello');
expect(newV).toBe('**hello**');
ed.destroy();
});
test('onInput 回调在 exec 后触发', () => {
const onInput = jest.fn();
const ed = new MarkdownEditor(container, { value: 'hello', onInput });
ed.textarea.setSelectionRange(0, 5);
ed.exec('bold');
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith('**hello**', ed);
ed.destroy();
});
});
// ============ v0.4.3 周边修复 ============
describe('MarkdownEditor - v0.4.3 周边修复', () => {
let container: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '';
container = document.createElement('div');
document.body.appendChild(container);
});
test('copyAsHTML 无 ClipboardItem 环境降级为纯文本写入', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
const write = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
delete (global as any).ClipboardItem;
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).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith('**bold**');
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
delete (navigator as any).clipboard;
ed.destroy();
});
test('右键 paste 被浏览器拒绝时 toast 提示改用 Ctrl+V', () => {
// jsdom 未实现 execCommand,直接替换为可控 stub
const orig = (document as any).execCommand;
(document as any).execCommand = jest.fn().mockReturnValue(false);
const ed = new MarkdownEditor(container, { value: 'text' });
ed._execContextAction('paste');
expect(ed.el.querySelector('.me-toast')).not.toBeNull();
expect(ed.el.querySelector('.me-toast')!.textContent).toContain('Ctrl+V');
(document as any).execCommand = orig;
ed.destroy();
});
test('全屏模式按 Esc 退出', () => {
const ed = new MarkdownEditor(container, { value: 'text' });
ed.toggleFullscreen();
expect(ed.isFullscreen()).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.isFullscreen()).toBe(false);
// 非全屏时 Esc 无副作用
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(ed.isFullscreen()).toBe(false);
ed.destroy();
});
test('右键菜单自定义 label 经 HTML 转义(防注入)', () => {
const ed = new MarkdownEditor(container, { value: 'text' });
ed.registerContextMenu([{ label: '<img src=x onerror=alert(1)>' }]);
const e = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
ed.el.dispatchEvent(e);
// 自定义项追加在默认项(6 个,sep 不算)之后,取最后一个菜单项
const items = document.querySelectorAll('.me-context-menu-item');
expect(items.length).toBe(7);
const span = items[items.length - 1].querySelector('span') as HTMLElement;
expect(span).not.toBeNull();
expect(span.innerHTML).not.toContain('<img');
expect(span.textContent).toBe('<img src=x onerror=alert(1)>');
ed._hideContextMenu();
ed.destroy();
});
});