Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+869
View File
@@ -0,0 +1,869 @@
/**
* 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();
});
});