/** * core.js 单元测试 * 覆盖 MarkdownEditor 类的构造、API、命令、历史栈、模式、事件、销毁 */ import { MarkdownEditor } from '../src/core.js'; // jsdom 不提供 PointerEvent,用 MouseEvent 作为基类 polyfill if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined') { window.PointerEvent = class PointerEvent extends MouseEvent { constructor(type, init = {}) { super(type, init); this.pointerId = init.pointerId || 0; this.pointerType = init.pointerType || 'mouse'; this.isPrimary = init.isPrimary !== false; } }; } describe('MarkdownEditor - 构造', () => { let container; beforeEach(() => { document.body.innerHTML = ''; container = document.createElement('div'); container.id = 'host'; document.body.appendChild(container); }); test('通过元素构造', () => { const ed = new MarkdownEditor(container, { value: '# hi' }); expect(ed.el).toBeInstanceOf(HTMLElement); expect(ed.container).toBe(container); expect(ed.getValue()).toBe('# hi'); }); test('通过选择器构造', () => { const ed = new MarkdownEditor('#host', { value: 'text' }); expect(ed.el).toBeDefined(); expect(ed.getValue()).toBe('text'); }); test('容器不存在时标记为已销毁且不抛错', () => { const ed = new MarkdownEditor('#not-exist', {}); expect(ed.isDestroyed()).toBe(true); }); test('生成唯一 id', () => { const a = new MarkdownEditor(container, {}); const b = new MarkdownEditor(document.createElement('div'), {}); expect(a.id).toBeTruthy(); expect(b.id).toBeTruthy(); expect(a.id).not.toBe(b.id); }); test('DOM 结构完整', () => { const ed = new MarkdownEditor(container, {}); expect(ed.el.className).toContain('me-wrapper'); expect(ed.toolbarEl).toBeDefined(); expect(ed.bodyEl).toBeDefined(); expect(ed.textarea).toBeInstanceOf(HTMLTextAreaElement); expect(ed.previewEl).toBeDefined(); }); test('工具栏默认渲染按钮', () => { const ed = new MarkdownEditor(container, {}); const btns = ed.toolbarEl.querySelectorAll('.me-btn'); expect(btns.length).toBeGreaterThan(10); }); test('toolbar: false 隐藏工具栏按钮', () => { const ed = new MarkdownEditor(container, { toolbar: false }); const btns = ed.toolbarEl.querySelectorAll('.me-btn'); expect(btns.length).toBe(0); }); test('初始 value 写入 textarea', () => { const ed = new MarkdownEditor(container, { value: '# Title' }); expect(ed.textarea.value).toBe('# Title'); }); test('wordCount 关闭时不创建状态栏', () => { const ed = new MarkdownEditor(container, { wordCount: false }); expect(ed.statusEl).toBeNull(); }); test('wordCount 开启时创建状态栏', () => { const ed = new MarkdownEditor(container, { wordCount: true }); expect(ed.statusEl).not.toBeNull(); }); }); describe('MarkdownEditor - 内容 API', () => { let ed; beforeEach(() => { document.body.innerHTML = ''; ed = new MarkdownEditor(document.createElement('div'), { value: '' }); }); afterEach(() => ed.destroy()); test('getValue / setValue', () => { ed.setValue('# hello'); expect(ed.getValue()).toBe('# hello'); expect(ed.textarea.value).toBe('# hello'); }); test('setValue silent 不触发 change', () => { let called = 0; ed.on('change', () => called++); ed.setValue('x', { silent: true }); expect(called).toBe(0); }); test('setValue 默认触发 change', () => { let called = 0; ed.on('change', () => called++); ed.setValue('x'); expect(called).toBeGreaterThan(0); }); test('getHTML 返回渲染后 HTML', () => { ed.setValue('# Title'); const html = ed.getHTML(); expect(html).toContain(' { ed.setValue('abc'); ed.textarea.selectionStart = ed.textarea.selectionEnd = 1; ed.insert('X'); expect(ed.getValue()).toBe('aXbc'); }); test('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('addToolbarButton 追加按钮', () => { ed.addToolbarButton({ action: 'custom', title: 'Custom' }); const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom'); expect(btn).not.toBeNull(); }); test('销毁时调用插件 destroy', () => { let destroyed = false; const ed2 = new MarkdownEditor(document.createElement('div'), {}); ed2.use({ name: 't', install() {}, destroy() { destroyed = true; } }); ed2.destroy(); expect(destroyed).toBe(true); }); }); describe('MarkdownEditor - 销毁', () => { test('destroy 移除 DOM 并标记', () => { document.body.innerHTML = ''; const ed = new MarkdownEditor(document.createElement('div'), {}); const el = ed.el; ed.destroy(); expect(ed.isDestroyed()).toBe(true); // el 已置空 expect(ed.el).toBeNull(); }); test('重复 destroy 不抛错', () => { document.body.innerHTML = ''; const ed = new MarkdownEditor(document.createElement('div'), {}); ed.destroy(); expect(() => ed.destroy()).not.toThrow(); }); test('destroy 触发 destroy 事件', () => { document.body.innerHTML = ''; const ed = new MarkdownEditor(document.createElement('div'), {}); let fired = false; ed.on('destroy', () => { fired = true; }); ed.destroy(); expect(fired).toBe(true); }); }); // ============ 补充分支测试 ============ describe('MarkdownEditor - 构造边界', () => { test('非浏览器环境直接返回(_destroyed=true)', () => { // jsdom 是浏览器环境,此分支需通过模拟无法直接覆盖 // 这里只验证 isBrowser() 返回 true 时正常路径 const ed = new MarkdownEditor(document.createElement('div'), {}); expect(ed.isDestroyed()).toBe(false); ed.destroy(); }); test('options.style 对象会被克隆', () => { const style = { color: 'red' }; const ed = new MarkdownEditor(document.createElement('div'), { style }); expect(ed.config.style).not.toBe(style); expect(ed.config.style.color).toBe('red'); ed.destroy(); }); test('非法 mode 回退到 split', () => { const ed = new MarkdownEditor(document.createElement('div'), { mode: 'invalid-mode' }); expect(ed.getMode()).toBe('split'); ed.destroy(); }); test('config.plugins 数组会被安装', () => { const plugin = { name: 'test-plugin', install: jest.fn(), destroy: jest.fn(), }; const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] }); expect(plugin.install).toHaveBeenCalledWith(ed); ed.destroy(); expect(plugin.destroy).toHaveBeenCalledWith(ed); }); test('autofocus 自动聚焦', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { autofocus: true }); // jsdom 中聚焦需要元素挂载到 document expect(ed.textarea).toBeDefined(); ed.destroy(); }); test('onCreate 回调被调用', () => { const onCreate = jest.fn(); const ed = new MarkdownEditor(document.createElement('div'), { onCreate }); expect(onCreate).toHaveBeenCalledWith(ed); ed.destroy(); }); test('onCreate 抛错被捕获', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const ed = new MarkdownEditor(document.createElement('div'), { onCreate: () => { throw new Error('boom'); }, }); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('自定义 render 函数覆盖内置解析器', () => { const customRender = jest.fn(() => '

custom

'); const ed = new MarkdownEditor(document.createElement('div'), { value: '# test', render: customRender, }); expect(customRender).toHaveBeenCalled(); expect(ed.getHTML()).toBe('

custom

'); ed.destroy(); }); test('自定义 highlight 函数被调用', () => { const highlight = jest.fn(() => 'code'); const ed = new MarkdownEditor(document.createElement('div'), { value: '```js\nvar x = 1\n```', highlight, }); expect(highlight).toHaveBeenCalledWith('var x = 1', 'js'); ed.destroy(); }); test('自定义 sanitize 净化 HTML', () => { const ed = new MarkdownEditor(document.createElement('div'), { value: 'hello', sanitize: (html) => '
sanitized
', }); expect(ed.getHTML()).toBe('
sanitized
'); ed.destroy(); }); }); describe('MarkdownEditor - 键盘快捷键', () => { let ed; beforeEach(() => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); ed = new MarkdownEditor(c, { value: 'hello world' }); }); afterEach(() => ed.destroy()); function key(key, opts = {}) { const ev = new KeyboardEvent('keydown', { key, ctrlKey: !!opts.ctrl, shiftKey: !!opts.shift, bubbles: true, cancelable: true, }); ed.textarea.dispatchEvent(ev); return ev; } test('Tab 插入缩进(无选区,默认 tabSize=2)', () => { ed.setValue('hello world'); // 光标放在位置 5(即 'hello' 之后、原空格之前) ed.textarea.setSelectionRange(5, 5); key('Tab'); // 插入 2 空格:'hello' + ' ' + ' world' = 'hello world'(含原空格共 3 空格) expect(ed.getValue()).toBe('hello world'); }); test('Tab 在行尾插入缩进', () => { ed.setValue('hello'); ed.textarea.setSelectionRange(5, 5); key('Tab'); expect(ed.getValue()).toBe('hello '); }); test('Shift+Tab 反缩进(有选区)', () => { ed.setValue(' line1\n line2'); ed.textarea.setSelectionRange(0, 14); key('Tab', { shift: true }); // 反缩进会移除每行行首的空格 expect(ed.getValue()).toBe('line1\nline2'); }); test('Tab 有选区时整块缩进', () => { ed.setValue('line1\nline2'); ed.textarea.setSelectionRange(0, 11); key('Tab'); expect(ed.getValue()).toBe(' line1\n line2'); }); test('Ctrl+B 触发 bold', () => { ed.textarea.setSelectionRange(0, 5); key('b', { ctrl: true }); expect(ed.getValue()).toBe('**hello** world'); }); test('Ctrl+I 触发 italic', () => { ed.textarea.setSelectionRange(0, 5); key('i', { ctrl: true }); expect(ed.getValue()).toBe('*hello* world'); }); test('Ctrl+E 触发 code', () => { ed.textarea.setSelectionRange(0, 5); key('e', { ctrl: true }); expect(ed.getValue()).toBe('`hello` world'); }); test('Ctrl+K 触发 link', () => { ed.textarea.setSelectionRange(0, 5); key('k', { ctrl: true }); // link 使用 prompt,jsdom 返回 null,但不应抛错 expect(ed.getValue()).toBeDefined(); }); test('Ctrl+U 触发 underline', () => { ed.textarea.setSelectionRange(0, 5); key('u', { ctrl: true }); expect(ed.getValue()).toBe('hello world'); }); test('Ctrl+1 触发 h1', () => { ed.textarea.setSelectionRange(0, 0); key('1', { ctrl: true }); expect(ed.getValue()).toBe('# hello world'); }); test('Ctrl+2 触发 h2', () => { ed.textarea.setSelectionRange(0, 0); key('2', { ctrl: true }); expect(ed.getValue()).toBe('## hello world'); }); test('Ctrl+3 触发 h3', () => { ed.textarea.setSelectionRange(0, 0); key('3', { ctrl: true }); expect(ed.getValue()).toBe('### hello world'); }); test('Ctrl+Q 触发 quote', () => { ed.textarea.setSelectionRange(0, 0); key('q', { ctrl: true }); expect(ed.getValue()).toBe('> hello world'); }); test('Ctrl+Z 撤销', () => { const initial = ed.getValue(); ed.exec('bold'); key('z', { ctrl: true }); expect(ed.getValue()).toBe(initial); }); test('Ctrl+Shift+Z 重做', () => { const initial = ed.getValue(); ed.exec('bold'); key('z', { ctrl: true }); // undo key('z', { ctrl: true, shift: true }); // redo expect(ed.getValue()).not.toBe(initial); }); test('Ctrl+Y 重做', () => { const initial = ed.getValue(); ed.exec('bold'); key('z', { ctrl: true }); // undo key('y', { ctrl: true }); // redo expect(ed.getValue()).not.toBe(initial); }); test('Ctrl+S 触发 save 事件', () => { const handler = jest.fn(); ed.on('save', handler); key('s', { ctrl: true }); expect(handler).toHaveBeenCalled(); }); test('Ctrl+S 调用 onSave 回调', () => { const onSave = jest.fn(); ed.config.onSave = onSave; key('s', { ctrl: true }); expect(onSave).toHaveBeenCalled(); }); test('无修饰键的按键不触发快捷键', () => { const initial = ed.getValue(); key('b'); expect(ed.getValue()).toBe(initial); }); }); describe('MarkdownEditor - 同步滚动', () => { test('分屏模式下 textarea 滚动联动 preview', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true }); ed.textarea.scrollTop = 100; ed.textarea.dispatchEvent(new Event('scroll')); // preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸) expect(typeof ed.previewPane.scrollTop).toBe('number'); ed.destroy(); }); test('syncScroll 关闭时不联动', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', syncScroll: false, }); const before = ed.previewPane.scrollTop; ed.textarea.scrollTop = 50; ed.textarea.dispatchEvent(new Event('scroll')); expect(ed.previewPane.scrollTop).toBe(before); ed.destroy(); }); test('非 split 模式不联动', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'test', mode: 'edit' }); const before = ed.previewPane ? ed.previewPane.scrollTop : 0; ed.textarea.scrollTop = 50; ed.textarea.dispatchEvent(new Event('scroll')); ed.destroy(); }); }); describe('MarkdownEditor - 分隔条拖拽', () => { test('pointerdown 触发拖拽(split 模式)', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'test', mode: 'split' }); // jsdom 中 getBoundingClientRect 返回全 0,但不应抛错 expect(() => { ed.dividerEl.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, bubbles: true })); window.dispatchEvent(new PointerEvent('pointermove', { clientX: 250 })); window.dispatchEvent(new PointerEvent('pointerup')); }).not.toThrow(); expect(ed.isDestroyed()).toBe(false); ed.destroy(); }); test('非 split 模式不触发拖拽', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'test', mode: 'edit' }); expect(() => { ed.dividerEl.dispatchEvent(new PointerEvent('pointerdown', { clientX: 200, bubbles: true })); }).not.toThrow(); ed.destroy(); }); }); describe('MarkdownEditor - 回调异常隔离', () => { test('onChange 抛错不影响后续 input', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'init', onChange: () => { throw new Error('boom'); }, }); expect(() => { ed.textarea.value = 'new'; ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); }).not.toThrow(); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onInput 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'init', onInput: () => { throw new Error('boom'); }, }); ed.textarea.value = 'new'; ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onFocus 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { onFocus: () => { throw new Error('boom'); }, }); ed.textarea.dispatchEvent(new Event('focus', { bubbles: true })); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onBlur 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { onBlur: () => { throw new Error('boom'); }, }); ed.textarea.dispatchEvent(new Event('blur', { bubbles: true })); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onModeChange 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { onModeChange: () => { throw new Error('boom'); }, }); ed.setMode('edit'); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onFullscreen 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { onFullscreen: () => { throw new Error('boom'); }, }); ed.toggleFullscreen(); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('onDestroy 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { onDestroy: () => { throw new Error('boom'); }, }); ed.destroy(); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); }); describe('MarkdownEditor - 插件边界', () => { test('use 字符串引用未知预设插件 warn', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); ed.use('non-existent-preset'); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('use 无效插件对象 warn', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); ed.use(null); ed.use(123); ed.use({ name: 'no-install' }); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('use install 抛错被捕获', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); const badPlugin = { name: 'bad', install: () => { throw new Error('install boom'); }, }; ed.use(badPlugin); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('addToolbarButton 无 action 直接返回', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); const before = ed.toolbarEl.querySelectorAll('.me-btn').length; ed.addToolbarButton(null); ed.addToolbarButton({ title: 'no-action' }); expect(ed.toolbarEl.querySelectorAll('.me-btn').length).toBe(before); ed.destroy(); }); test('addToolbarButton 使用 onClick 回调', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); const onClick = jest.fn(); ed.addToolbarButton({ action: 'custom', title: 'Custom', icon: '', onClick, }); const btn = ed.toolbarEl.querySelector('.me-btn-custom-custom'); expect(btn).not.toBeNull(); btn.click(); expect(onClick).toHaveBeenCalledWith(ed); ed.destroy(); }); test('addToolbarButton 使用 text 文本', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); ed.addToolbarButton({ action: 'txt', title: 'Text Button', text: 'T', }); const btn = ed.toolbarEl.querySelector('.me-btn-custom-txt'); expect(btn).not.toBeNull(); expect(btn.innerHTML).toContain('>T<'); ed.destroy(); }); }); describe('MarkdownEditor - 工具栏点击', () => { test('点击空白区域不抛错', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); expect(() => { ed.toolbarEl.click(); }).not.toThrow(); ed.destroy(); }); test('点击 mode 按钮切换模式', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { mode: 'split' }); const editBtn = ed.toolbarEl.querySelector('.me-btn[data-mode="edit"]'); editBtn.click(); expect(ed.getMode()).toBe('edit'); ed.destroy(); }); test('点击 action 按钮执行命令', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'test' }); const boldBtn = ed.toolbarEl.querySelector('.me-btn[data-action="bold"]'); ed.textarea.setSelectionRange(0, 4); boldBtn.click(); expect(ed.getValue()).toBe('**test**'); ed.destroy(); }); }); describe('MarkdownEditor - 历史栈合并', () => { test('连续输入触发防抖合并', (done) => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '' }); // 连续快速输入 ed.textarea.value = 'a'; ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); ed.textarea.value = 'ab'; ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); ed.textarea.value = 'abc'; ed.textarea.dispatchEvent(new Event('input', { bubbles: true })); // 等待防抖结束 setTimeout(() => { const initialLen = ed._history.length; // 防抖合并后历史栈不应为 3 expect(ed._history.length).toBeLessThanOrEqual(initialLen + 1); ed.destroy(); done(); }, 500); }); test('历史栈超过上限自动裁剪', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '', historyLimit: 3 }); for (let i = 0; i < 10; i++) { ed.setValue('v' + i, { silent: false }); } expect(ed._history.length).toBeLessThanOrEqual(3); ed.destroy(); }); }); describe('MarkdownEditor - 零散分支补全', () => { test('className 配置项被应用到 wrapper', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { className: 'my-cls another' }); expect(ed.el.className).toContain('my-cls'); expect(ed.el.className).toContain('another'); ed.destroy(); }); test('render 抛错时显示错误信息', () => { document.body.innerHTML = ''; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '# test', render: () => { throw new Error('render boom'); }, }); const html = ed.previewEl.innerHTML; expect(html).toContain('渲染失败'); expect(html).toContain('render boom'); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('canRedo 返回正确值', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '' }); expect(ed.canRedo()).toBe(false); ed.setValue('a'); ed.setValue('b'); ed.undo(); expect(ed.canRedo()).toBe(true); ed.redo(); expect(ed.canRedo()).toBe(false); ed.destroy(); }); test('undo/redo 触发 onChange 回调', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const onChange = jest.fn(); const ed = new MarkdownEditor(c, { value: '', onChange }); onChange.mockClear(); ed.setValue('a'); ed.setValue('b'); onChange.mockClear(); ed.undo(); expect(onChange).toHaveBeenCalled(); onChange.mockClear(); ed.redo(); expect(onChange).toHaveBeenCalled(); ed.destroy(); }); test('exec("indent")/exec("outdent") 等价于 Tab/Shift+Tab', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'hello' }); ed.textarea.selectionStart = ed.textarea.selectionEnd = 0; ed.exec('indent'); expect(ed.getValue()).toBe(' hello'); ed.textarea.selectionStart = 0; ed.textarea.selectionEnd = 7; ed.exec('outdent'); expect(ed.getValue()).toBe('hello'); ed.destroy(); }); test('exec("undo")/exec("redo") 等价于 undo()/redo()', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '' }); ed.setValue('a'); ed.setValue('b'); ed.exec('undo'); expect(ed.getValue()).toBe('a'); ed.exec('redo'); expect(ed.getValue()).toBe('b'); ed.destroy(); }); test('exec("edit"/"split"/"preview") 切换模式', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { mode: 'split' }); ed.exec('edit'); expect(ed.getMode()).toBe('edit'); ed.exec('split'); expect(ed.getMode()).toBe('split'); ed.exec('preview'); expect(ed.getMode()).toBe('preview'); ed.destroy(); }); test('exec("fullscreen") 切换全屏', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); expect(ed._fullscreen).toBe(false); ed.exec('fullscreen'); expect(ed._fullscreen).toBe(true); ed.exec('fullscreen'); expect(ed._fullscreen).toBe(false); ed.destroy(); }); test('_toggleLinePrefix 替换已存在的不同前缀', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '- hello' }); ed.textarea.selectionStart = ed.textarea.selectionEnd = 0; ed.exec('h1'); // 原本行首是 "- ",执行 h1 后应替换为 "# " expect(ed.getValue()).toBe('# hello'); ed.destroy(); }); test('setValue 触发 onChange 回调', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const onChange = jest.fn(); const ed = new MarkdownEditor(c, { value: '', onChange }); onChange.mockClear(); ed.setValue('new'); expect(onChange).toHaveBeenCalledWith('new', ed); ed.destroy(); }); test('getStatus 包含 plugins 字段', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); ed.use({ name: 'demo-plugin', install: () => {} }); const s = ed.getStatus(); expect(Array.isArray(s.plugins)).toBe(true); expect(s.plugins).toContain('demo-plugin'); ed.destroy(); }); }); describe('MarkdownEditor - v0.1.2 readOnly 模式', () => { test('config.readOnly 设置 textarea readonly', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { readOnly: true, value: 'locked' }); expect(ed.textarea.readOnly).toBe(true); expect(ed.el.classList.contains('me-readonly')).toBe(true); ed.destroy(); }); test('setReadOnly 动态切换', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); expect(ed.isReadOnly()).toBe(false); ed.setReadOnly(true); expect(ed.isReadOnly()).toBe(true); expect(ed.textarea.readOnly).toBe(true); expect(ed.el.classList.contains('me-readonly')).toBe(true); ed.setReadOnly(false); expect(ed.isReadOnly()).toBe(false); expect(ed.textarea.readOnly).toBe(false); expect(ed.el.classList.contains('me-readonly')).toBe(false); ed.destroy(); }); test('readOnly 模式下内容仍可读取', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { readOnly: true, value: '# hello' }); expect(ed.getValue()).toBe('# hello'); ed.destroy(); }); test('getStatus 包含 readOnly 字段', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { readOnly: true }); expect(ed.getStatus().readOnly).toBe(true); ed.destroy(); }); test('autofocus 在 readOnly 模式下不聚焦', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { readOnly: true, autofocus: true }); // readOnly textarea 不应被 focus(autofocus 被跳过) expect(ed.textarea.readOnly).toBe(true); ed.destroy(); }); }); describe('MarkdownEditor - v0.1.3 修复验证', () => { test('exec 支持 _customActions 自定义动作', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'hello' }); let called = false; ed._customActions = { myAction: () => { called = true; } }; ed.exec('myAction'); expect(called).toBe(true); ed.destroy(); }); test('refresh 清除渲染缓存并强制重渲染', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: '# test', mode: 'split' }); const first = ed.previewEl.innerHTML; // setValue 不改变内容,正常 _render 会跳过 ed.setValue('# test', { silent: true }); // refresh 强制重渲染 ed.refresh(); // 内容一致,预览应一致 expect(ed.previewEl.innerHTML).toBe(first); ed.destroy(); }); test('focus/blur 监听器接收正确参数', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); let focusArg; ed.on('focus', (...args) => { focusArg = args; }); ed.textarea.dispatchEvent(new Event('focus', { bubbles: true })); // focus 事件只传 editor 一次 expect(focusArg.length).toBe(1); expect(focusArg[0]).toBe(ed); ed.destroy(); }); test('destroy 后 focus/blur 不抛错', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, {}); ed.destroy(); expect(() => ed.focus()).not.toThrow(); expect(() => ed.blur()).not.toThrow(); expect(ed.isDisabled()).toBe(false); }); });