Files
MetonaEditor/tests/plugins.test.js
T

870 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});