870 lines
29 KiB
JavaScript
870 lines
29 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|
||
});
|