/** * plugins.js 单元测试 * 覆盖预设插件 autoSave / exportTool / searchReplace 及 pluginUtils */ import { MarkdownEditor } from '../src/core.js'; import { presetPlugins, pluginUtils, PluginManager } from '../src/plugins.js'; // 确保 localStorage 存在(jsdom 兼容) if (typeof global.localStorage === 'undefined') { const store = {}; global.localStorage = { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: (k) => { delete store[k]; }, clear: () => { Object.keys(store).forEach((k) => delete store[k]); }, }; } // jsdom 不提供 URL.createObjectURL / revokeObjectURL,导出插件依赖它们 if (typeof URL !== 'undefined' && typeof URL.createObjectURL !== 'function') { URL.createObjectURL = () => 'blob:mock'; URL.revokeObjectURL = () => {}; } function makeEditor(opts = {}) { document.body.innerHTML = ''; const container = document.createElement('div'); document.body.appendChild(container); return new MarkdownEditor(container, { value: 'hello', ...opts }); } describe('presetPlugins 注册表', () => { test('包含三个预设插件', () => { expect(presetPlugins.autoSave).toBeDefined(); expect(presetPlugins.exportTool).toBeDefined(); expect(presetPlugins.searchReplace).toBeDefined(); }); test('每个插件都有 name 和 install', () => { Object.values(presetPlugins).forEach((p) => { expect(typeof p.name).toBe('string'); expect(typeof p.install).toBe('function'); }); }); }); describe('autoSave 插件', () => { let ed; beforeEach(() => { localStorage.clear(); ed = makeEditor(); ed.use(presetPlugins.autoSave); }); afterEach(() => ed.destroy()); test('安装后暴露草稿 API', () => { expect(typeof ed.restoreDraft).toBe('function'); expect(typeof ed.clearDraft).toBe('function'); expect(typeof ed.getDraftKey).toBe('function'); }); test('getDraftKey 返回包含 id 的 key', () => { const key = ed.getDraftKey(); expect(key).toContain('me-draft-'); }); test('change 后内容最终写入 localStorage', (done) => { ed.setValue('autosaved content'); // autoSave 默认 delay 1000ms,等 1100ms setTimeout(() => { const key = ed.getDraftKey(); const stored = localStorage.getItem(key); expect(stored).toBe('autosaved content'); done(); }, 1150); }); test('clearDraft 清除存储', () => { const key = ed.getDraftKey(); localStorage.setItem(key, 'tmp'); ed.clearDraft(); expect(localStorage.getItem(key)).toBeNull(); }); test('restoreDraft 从存储恢复', () => { const key = ed.getDraftKey(); localStorage.setItem(key, '# restored'); ed.restoreDraft(); expect(ed.getValue()).toBe('# restored'); }); test('destroy 时清理定时器不抛错', () => { expect(() => ed.destroy()).not.toThrow(); }); }); describe('autoSave 自定义配置', () => { test('自定义 key', () => { localStorage.clear(); const ed = makeEditor(); ed.use(presetPlugins.autoSave, { key: 'my-key', delay: 50 }); ed.setValue('data'); expect(ed.getDraftKey()).toBe('my-key'); ed.destroy(); }); test('自定义 delay 生效', (done) => { localStorage.clear(); const ed = makeEditor(); ed.use(presetPlugins.autoSave, { delay: 30 }); ed.setValue('fast'); setTimeout(() => { expect(localStorage.getItem(ed.getDraftKey())).toBe('fast'); ed.destroy(); done(); }, 60); }); }); describe('exportTool 插件', () => { let ed; beforeEach(() => { ed = makeEditor({ value: '# export me' }); ed.use(presetPlugins.exportTool); }); afterEach(() => ed.destroy()); test('安装后暴露导出方法', () => { expect(typeof ed.exportMarkdown).toBe('function'); expect(typeof ed.exportHTML).toBe('function'); }); test('exportMarkdown 创建下载链接', () => { // 在原型层拦截 a.click,避免 mock createElement 导致递归 const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); ed.exportMarkdown('test.md'); expect(clickMock).toHaveBeenCalled(); clickMock.mockRestore(); }); test('exportHTML 包含 HTML 文档结构', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); expect(() => ed.exportHTML('test.html', { title: 'My Title' })).not.toThrow(); clickMock.mockRestore(); }); test('exportMarkdown 返回 editor(链式)', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); expect(ed.exportMarkdown()).toBe(ed); clickMock.mockRestore(); }); }); describe('searchReplace 插件', () => { let ed; beforeEach(() => { ed = makeEditor({ value: 'find me here, find again' }); ed.use(presetPlugins.searchReplace); }); afterEach(() => ed.destroy()); test('Ctrl+F 打开搜索面板', () => { const ta = ed.textarea; const ev = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }); ta.dispatchEvent(ev); const panel = ed.el.querySelector('.me-search'); expect(panel).not.toBeNull(); }); test('Escape 关闭面板(通过面板内输入框)', () => { const ta = ed.textarea; ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); const panel = ed.el.querySelector('.me-search'); expect(panel).not.toBeNull(); const input = panel.querySelector('.me-search-find'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(ed.el.querySelector('.me-search')).toBeNull(); }); test('查找下一个高亮选中匹配', () => { const ta = ed.textarea; ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); const panel = ed.el.querySelector('.me-search'); const input = panel.querySelector('.me-search-find'); input.value = 'find'; input.dispatchEvent(new Event('input', { bubbles: true })); panel.querySelector('.me-search-next').click(); // 选中区域应包含 'find' expect(ta.value.substring(ta.selectionStart, ta.selectionEnd)).toBe('find'); }); test('全部替换', () => { const ta = ed.textarea; ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true })); const panel = ed.el.querySelector('.me-search'); panel.querySelector('.me-search-find').value = 'find'; panel.querySelector('.me-search-replace').value = 'FIND'; panel.querySelector('.me-search-replace-all').click(); expect(ed.getValue()).toBe('FIND me here, FIND again'); }); test('单次替换仅替换当前选区匹配', () => { const ta = ed.textarea; ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true })); const panel = ed.el.querySelector('.me-search'); const findInput = panel.querySelector('.me-search-find'); findInput.value = 'find'; findInput.dispatchEvent(new Event('input', { bubbles: true })); // 先定位到第一个 panel.querySelector('.me-search-next').click(); panel.querySelector('.me-search-replace').value = 'X'; panel.querySelector('.me-search-replace-one').click(); // 第一个被替换,第二个保留 expect(ed.getValue()).toBe('X me here, find again'); }); test('destroy 移除面板与事件监听', () => { const ta = ed.textarea; ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); ed.destroy(); // destroy 后 el 为 null,无法查询,但不应抛错 expect(ed.el).toBeNull(); }); }); describe('PluginManager', () => { test('register / get / has', () => { const pm = new PluginManager(); pm.register('x', { name: 'x' }); expect(pm.has('x')).toBe(true); expect(pm.get('x').name).toBe('x'); }); test('重复 register warn', () => { const pm = new PluginManager(); const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); pm.register('x', { name: 'x' }); pm.register('x', { name: 'x' }); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); test('unregister', () => { const pm = new PluginManager(); pm.register('x', { name: 'x' }); pm.unregister('x'); expect(pm.has('x')).toBe(false); }); test('enable / disable / isEnabled', () => { const pm = new PluginManager(); pm.register('x', { name: 'x' }); expect(pm.isEnabled('x')).toBe(true); pm.disable('x'); expect(pm.isEnabled('x')).toBe(false); pm.enable('x'); expect(pm.isEnabled('x')).toBe(true); }); test('getAll / getNames', () => { const pm = new PluginManager(); pm.register('a', { name: 'a' }); pm.register('b', { name: 'b' }); expect(pm.getAll().length).toBe(2); expect(pm.getNames()).toEqual(['a', 'b']); }); test('destroy 清空', () => { const pm = new PluginManager(); pm.register('a', { name: 'a' }); pm.destroy(); expect(pm.getAll().length).toBe(0); }); }); describe('pluginUtils', () => { test('createManager 返回 PluginManager 实例', () => { expect(pluginUtils.createManager()).toBeInstanceOf(PluginManager); }); test('getPreset 返回预设插件副本', () => { const p = pluginUtils.getPreset('autoSave'); expect(p).not.toBeNull(); expect(p.name).toBe('autoSave'); // 副本:修改不影响原对象 p.name = 'changed'; expect(presetPlugins.autoSave.name).toBe('autoSave'); }); test('getPreset 未知返回 null', () => { expect(pluginUtils.getPreset('nope')).toBeNull(); }); test('getAllPresets 返回全部副本', () => { const all = pluginUtils.getAllPresets(); expect(all.autoSave).toBeDefined(); expect(all.exportTool).toBeDefined(); expect(all.searchReplace).toBeDefined(); }); test('createPlugin 构造插件对象', () => { const p = pluginUtils.createPlugin({ name: 'my', install: () => {}, }); expect(p.name).toBe('my'); expect(typeof p.install).toBe('function'); expect(typeof p.destroy).toBe('function'); // 默认提供空 destroy }); test('validatePlugin 校验', () => { expect(pluginUtils.validatePlugin({ name: 'ok', install: () => {} }).valid).toBe(true); expect(pluginUtils.validatePlugin(null).valid).toBe(false); expect(pluginUtils.validatePlugin({ name: 'x', install: 'notfn' }).valid).toBe(false); }); }); // ============ 补充分支测试 ============ describe('autoSave 插件 - 边界', () => { let ed; beforeEach(() => { document.body.innerHTML = ''; localStorage.clear(); const c = document.createElement('div'); document.body.appendChild(c); ed = new MarkdownEditor(c, { value: '' }); }); afterEach(() => ed.destroy()); test('未安装插件时 restoreDraft/clearDraft/getDraftKey 不存在', () => { // 插件方法只在 install 时挂载到 editor 上 expect(ed.restoreDraft).toBeUndefined(); expect(ed.clearDraft).toBeUndefined(); expect(ed.getDraftKey).toBeUndefined(); }); test('安装后 getDraftKey 使用配置 key', () => { ed.use(presetPlugins.autoSave, { key: 'my-draft' }); expect(ed.getDraftKey()).toBe('my-draft'); }); test('安装后 getDraftKey 默认 key 含 editor id', () => { ed.use(presetPlugins.autoSave); const k = ed.getDraftKey(); expect(k).toBe('me-draft-' + ed.id); }); test('restoreDraft 读取并恢复内容', () => { localStorage.setItem('my-draft', 'restored content'); ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 }); ed.restoreDraft(); expect(ed.getValue()).toBe('restored content'); }); test('restoreDraft 无草稿时不改变内容', () => { ed.setValue('original'); ed.use(presetPlugins.autoSave, { key: 'empty-draft', delay: 10 }); const r = ed.restoreDraft(); expect(r).toBeNull(); expect(ed.getValue()).toBe('original'); }); test('restoreDraft 返回草稿值', () => { localStorage.setItem('my-draft', 'val'); ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 }); expect(ed.restoreDraft()).toBe('val'); }); test('clearDraft 移除 localStorage 中的草稿', () => { localStorage.setItem('my-draft', 'content'); ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 }); ed.clearDraft(); expect(localStorage.getItem('my-draft')).toBeNull(); }); test('clearDraft 返回 editor(链式)', () => { ed.use(presetPlugins.autoSave, { key: 'my-draft', delay: 10 }); expect(ed.clearDraft()).toBe(ed); }); test('保存触发 autosave 事件', (done) => { ed.use(presetPlugins.autoSave, { key: 'evt-draft', delay: 10 }); ed.on('autosave', (data) => { expect(data.key).toBe('evt-draft'); expect(data.value).toBe('triggered'); done(); }); ed.setValue('triggered'); }); test('blur 事件立即保存', () => { ed.use(presetPlugins.autoSave, { key: 'blur-draft', delay: 1000 }); ed.setValue('blur-content'); // blur 触发立即保存 ed.textarea.dispatchEvent(new Event('blur', { bubbles: true })); expect(localStorage.getItem('blur-draft')).toBe('blur-content'); }); test('save 事件立即保存', () => { ed.use(presetPlugins.autoSave, { key: 'save-draft', delay: 1000 }); ed.setValue('save-content'); ed._emit('save', ed.getValue()); expect(localStorage.getItem('save-draft')).toBe('save-content'); }); }); describe('exportTool 插件 - 边界', () => { let ed; beforeEach(() => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); ed = new MarkdownEditor(c, { value: '# Hello' }); ed.use(presetPlugins.exportTool); }); afterEach(() => ed.destroy()); test('未安装插件时 exportMarkdown/exportHTML 不存在', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed2 = new MarkdownEditor(c, { value: 'test' }); expect(ed2.exportMarkdown).toBeUndefined(); expect(ed2.exportHTML).toBeUndefined(); ed2.destroy(); }); test('exportMarkdown 默认文件名(含时间戳)', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); // 捕获 download 属性 const origCreate = URL.createObjectURL; let capturedName = null; URL.createObjectURL = (blob) => { return 'blob:mock'; }; // 监听 createElement 返回的 a 元素的 download const origCE = document.createElement.bind(document); const spy = jest.spyOn(document, 'createElement').mockImplementation((tag) => { const el = origCE(tag); if (tag === 'a') { Object.defineProperty(el, 'download', { set(v) { capturedName = v; }, get() { return capturedName; }, configurable: true, }); el.click = () => {}; } return el; }); ed.exportMarkdown(); URL.createObjectURL = origCreate; clickMock.mockRestore(); spy.mockRestore(); expect(capturedName).toMatch(/^metona-\d{8}-\d{4}\.md$/); }); test('exportMarkdown 返回 editor(链式)', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); expect(ed.exportMarkdown('test.md')).toBe(ed); clickMock.mockRestore(); }); test('exportHTML 返回 editor(链式)', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); expect(ed.exportHTML('test.html')).toBe(ed); clickMock.mockRestore(); }); test('exportHTML 生成完整 HTML 文档', () => { const clickMock = jest .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => {}); let capturedBlob = null; const origCreate = URL.createObjectURL; URL.createObjectURL = (blob) => { capturedBlob = blob; return 'blob:mock'; }; ed.exportHTML('doc.html', { title: 'My Doc', css: 'body{color:red}' }); URL.createObjectURL = origCreate; clickMock.mockRestore(); expect(capturedBlob).not.toBeNull(); expect(capturedBlob.type).toBe('text/html;charset=utf-8'); }); }); describe('searchReplace 插件 - 完整流程', () => { let ed; beforeEach(() => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); ed = new MarkdownEditor(c, { value: 'foo bar foo baz foo' }); ed.use(presetPlugins.searchReplace); }); afterEach(() => ed.destroy()); function openPanel(showReplace = false) { const ev = new KeyboardEvent('keydown', { key: showReplace ? 'h' : 'f', ctrlKey: true, bubbles: true, cancelable: true, }); ed.textarea.dispatchEvent(ev); } test('Ctrl+F 打开面板', () => { openPanel(); expect(ed.el.querySelector('.me-search')).not.toBeNull(); }); test('Ctrl+H 打开替换面板', () => { openPanel(true); const panel = ed.el.querySelector('.me-search'); expect(panel).not.toBeNull(); expect(panel.dataset.replace).toBe('1'); }); test('面板已打开时 Ctrl+F 复用面板', () => { openPanel(); const panel1 = ed.el.querySelector('.me-search'); openPanel(); const panel2 = ed.el.querySelector('.me-search'); expect(ed.el.querySelectorAll('.me-search').length).toBe(1); expect(panel2).toBe(panel1); }); test('面板已打开时 Ctrl+H 切换到替换模式', () => { openPanel(false); expect(ed.el.querySelector('.me-search').dataset.replace).toBe('0'); openPanel(true); expect(ed.el.querySelector('.me-search').dataset.replace).toBe('1'); }); test('Escape 在 findInput 上关闭面板', () => { openPanel(); const input = ed.el.querySelector('.me-search-find'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(ed.el.querySelector('.me-search')).toBeNull(); }); test('查找下一个高亮匹配', () => { openPanel(); const input = ed.el.querySelector('.me-search-find'); input.value = 'foo'; input.dispatchEvent(new Event('input', { bubbles: true })); ed.el.querySelector('.me-search-next').click(); expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); }); test('查找上一个高亮匹配', () => { openPanel(); const input = ed.el.querySelector('.me-search-find'); input.value = 'foo'; input.dispatchEvent(new Event('input', { bubbles: true })); ed.el.querySelector('.me-search-next').click(); ed.el.querySelector('.me-search-prev').click(); expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); }); test('空查询不抛错', () => { openPanel(); expect(() => ed.el.querySelector('.me-search-next').click()).not.toThrow(); }); test('关闭按钮移除面板', () => { openPanel(); ed.el.querySelector('.me-search-close').click(); expect(ed.el.querySelector('.me-search')).toBeNull(); }); test('Enter 在 findInput 上查找下一个', () => { openPanel(); const input = ed.el.querySelector('.me-search-find'); input.value = 'foo'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); }); test('Shift+Enter 在 findInput 上查找上一个', () => { openPanel(); const input = ed.el.querySelector('.me-search-find'); input.value = 'foo'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, bubbles: true })); expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0); }); test('替换行 display 切换(none ↔ flex)', () => { openPanel(false); const row = ed.el.querySelector('.me-search-replace-row'); expect(row.style.display).toBe('none'); openPanel(true); expect(row.style.display).toBe('flex'); }); test('选中区域文本自动填入查找框', () => { ed.textarea.setSelectionRange(0, 3); // 选中 "foo" openPanel(); const input = ed.el.querySelector('.me-search-find'); expect(input.value).toBe('foo'); }); test('destroy 移除面板', () => { openPanel(); const panelCount = ed.el.querySelectorAll('.me-search').length; expect(panelCount).toBe(1); ed.destroy(); expect(ed.el).toBeNull(); }); test('注入样式只执行一次', () => { const styleBefore = document.getElementById('me-search-style'); const id1 = styleBefore ? styleBefore.id : null; openPanel(); openPanel(); const styles = document.querySelectorAll('#me-search-style'); expect(styles.length).toBe(1); }); }); describe('pluginUtils - 默认管理器代理', () => { test('register / unregister / get / has / getNames', () => { const p = { name: 'proxy-test', install: () => {}, destroy: () => {} }; pluginUtils.register('proxy-test', p); expect(pluginUtils.has('proxy-test')).toBe(true); // get 返回包装后的对象(含 enabled 字段),不是原始 p const got = pluginUtils.get('proxy-test'); expect(got).not.toBeNull(); expect(got.name).toBe('proxy-test'); expect(got.enabled).toBe(true); expect(pluginUtils.getNames()).toContain('proxy-test'); pluginUtils.unregister('proxy-test'); expect(pluginUtils.has('proxy-test')).toBe(false); }); test('getAll 返回数组', () => { const p = { name: 'arr-test', install: () => {} }; pluginUtils.register('arr-test', p); const all = pluginUtils.getAll(); expect(Array.isArray(all)).toBe(true); expect(all.some((x) => x.name === 'arr-test')).toBe(true); pluginUtils.unregister('arr-test'); }); test('enable / disable / isEnabled', () => { const p = { name: 'en-test', install: () => {} }; pluginUtils.register('en-test', p); expect(pluginUtils.isEnabled('en-test')).toBe(true); pluginUtils.disable('en-test'); expect(pluginUtils.isEnabled('en-test')).toBe(false); pluginUtils.enable('en-test'); expect(pluginUtils.isEnabled('en-test')).toBe(true); pluginUtils.unregister('en-test'); }); test('manager 暴露默认管理器实例', () => { expect(pluginUtils.manager).toBeDefined(); expect(typeof pluginUtils.manager.register).toBe('function'); }); test('createManager 返回新管理器实例', () => { const m = pluginUtils.createManager(); expect(m).not.toBe(pluginUtils.manager); expect(typeof m.register).toBe('function'); }); test('register 返回 this(链式)', () => { const m = pluginUtils.createManager(); const p = { name: 'chain', install: () => {} }; expect(m.register('chain', p)).toBe(m); }); test('重复注册 warn 但不抛错', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const m = pluginUtils.createManager(); const p1 = { name: 'dup', install: () => {} }; const p2 = { name: 'dup', install: () => {} }; m.register('dup', p1); m.register('dup', p2); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); test('unregister / enable / disable 返回 this(链式)', () => { const m = pluginUtils.createManager(); const p = { name: 'ret', install: () => {} }; m.register('ret', p); expect(m.unregister('ret')).toBe(m); m.register('ret2', p); expect(m.enable('ret2')).toBe(m); expect(m.disable('ret2')).toBe(m); }); test('get 未知返回 null', () => { expect(pluginUtils.get('non-existent')).toBeNull(); }); test('isEnabled 未知返回 false', () => { expect(pluginUtils.isEnabled('non-existent')).toBe(false); }); test('disable 后 get 仍返回对象(enabled=false)', () => { const m = pluginUtils.createManager(); const p = { name: 'dis', install: () => {} }; m.register('dis', p); m.disable('dis'); const got = m.get('dis'); expect(got).not.toBeNull(); expect(got.enabled).toBe(false); m.enable('dis'); expect(m.get('dis').enabled).toBe(true); }); test('destroy 清空所有插件', () => { const m = pluginUtils.createManager(); m.register('p1', { name: 'p1', install: () => {} }); m.register('p2', { name: 'p2', install: () => {} }); m.destroy(); expect(m.getNames()).toEqual([]); }); }); describe('createPlugin - 边界', () => { test('无参数也能创建', () => { const p = pluginUtils.createPlugin(); expect(p).toBeDefined(); expect(p.name).toBe('custom'); expect(typeof p.install).toBe('function'); expect(typeof p.destroy).toBe('function'); }); test('install 非函数时回退为空函数', () => { const p = pluginUtils.createPlugin({ name: 'x', install: 'notfn' }); expect(typeof p.install).toBe('function'); }); test('destroy 非函数时回退为空函数', () => { const p = pluginUtils.createPlugin({ name: 'x', destroy: 'notfn' }); expect(typeof p.destroy).toBe('function'); }); test('保留用户传入的额外字段', () => { const p = pluginUtils.createPlugin({ name: 'x', install: () => {}, customField: 'value', }); expect(p.customField).toBe('value'); }); }); describe('零散分支补全', () => { test('PluginManager.register 无效插件对象 error', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); const m = pluginUtils.createManager(); m.register('null', null); m.register('num', 123); // name 为 falsy 且 plugin 无 name 才触发无效分支 m.register(undefined, { install: () => {} }); m.register('', { install: () => {} }); expect(spy).toHaveBeenCalled(); expect(m.get('null')).toBeNull(); expect(m.get('num')).toBeNull(); expect(m.get(undefined)).toBeNull(); expect(m.get('')).toBeNull(); spy.mockRestore(); }); test('autoSave save 内部抛错时 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, { value: 'test' }); ed.use('autoSave'); // 让 editor.getValue 抛错,触发 save 的 catch 分支 jest.spyOn(ed, 'getValue').mockImplementation(() => { throw new Error('get failed'); }); const plugin = ed.getPlugins().find((p) => p.name === 'autoSave'); expect(() => plugin._save()).not.toThrow(); expect(spy).toHaveBeenCalled(); ed.destroy(); spy.mockRestore(); }); test('restoreDraft localStorage.getItem 抛错时返回 null', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'init' }); ed.use('autoSave'); const original = localStorage.getItem; localStorage.getItem = jest.fn(() => { throw new Error('read error'); }); expect(ed.restoreDraft()).toBeNull(); expect(ed.getValue()).toBe('init'); localStorage.getItem = original; ed.destroy(); }); test('searchReplace textarea 上 Escape 触发 _close', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'hello world' }); ed.use(presetPlugins.searchReplace); const plugin = ed.getPlugins().find((p) => p.name === 'searchReplace'); // Ctrl+F 打开面板 ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); expect(ed.el.querySelector('.me-search')).not.toBeNull(); expect(plugin._panel).toBeDefined(); // spy _close 验证 textarea 上 Ctrl+Escape 分支被触发 // 注:plugins.js 中 escape 分支在 if (!mod) return 之后,必须带 Ctrl/Meta const closeSpy = jest.spyOn(plugin, '_close'); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true })); expect(closeSpy).toHaveBeenCalled(); closeSpy.mockRestore(); ed.destroy(); }); test('searchReplace replaceInput Enter 替换当前匹配', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'foo bar foo' }); ed.use(presetPlugins.searchReplace); // Ctrl+H 打开替换面板 ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true })); const findInput = ed.el.querySelector('.me-search-find'); const replaceInput = ed.el.querySelector('.me-search-replace'); findInput.value = 'foo'; findInput.dispatchEvent(new Event('input', { bubbles: true })); replaceInput.value = 'baz'; // 先查找下一个选中第一个 foo ed.el.querySelector('.me-search-next').click(); // 在 replaceInput 上按 Enter 触发 replaceOne replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(ed.getValue()).toContain('baz'); ed.destroy(); }); test('searchReplace replaceInput Escape 关闭面板', () => { document.body.innerHTML = ''; const c = document.createElement('div'); document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'hello' }); ed.use(presetPlugins.searchReplace); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true, bubbles: true })); const replaceInput = ed.el.querySelector('.me-search-replace'); replaceInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(ed.el.querySelector('.me-search')).toBeNull(); ed.destroy(); }); });