feat: v0.4.2 — 审查清偿:8 项修复 + setOutline API + 871 测试
修复:插件面板实例级 i18n / undo-redo 滚动保持 / shortcutHelp 卸载残留 / CSS.escape 环境崩溃 / 大纲光标按源码行号定位 / setLocale 即时刷新 / 浮动工具栏 CJK 宽度 / ko 语言包括号 新增:setOutline(isOutline) 运行时大纲 API、getRenderCacheSize 观测接口 测试:841 → 871(补 7 个事件断言、5 个配置项、修 3 处恒真断言) 工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
This commit is contained in:
+323
-3
@@ -17,6 +17,11 @@ if (typeof window !== 'undefined' && typeof window.PointerEvent === 'undefined')
|
||||
};
|
||||
}
|
||||
|
||||
// jsdom 未实现 scrollIntoView(大纲点击跳转依赖),提供 no-op stub
|
||||
if (typeof Element !== 'undefined' && typeof (Element.prototype as any).scrollIntoView !== 'function') {
|
||||
(Element.prototype as any).scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
describe('MarkdownEditor - 构造', () => {
|
||||
let container;
|
||||
|
||||
@@ -781,10 +786,32 @@ describe('MarkdownEditor - 同步滚动', () => {
|
||||
document.body.appendChild(c);
|
||||
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
||||
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
|
||||
ed.textarea.scrollTop = 100;
|
||||
// jsdom 的 scrollHeight 恒为 0,mock 出可滚动尺寸以驱动比例联动
|
||||
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
|
||||
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.textarea.scrollTop = 750; // textarea 滚动比例 0.5
|
||||
ed.textarea.dispatchEvent(new Event('scroll'));
|
||||
// preview 应被同步滚动(scrollTop > 0 或为 0,取决于尺寸)
|
||||
expect(typeof ed.previewPane.scrollTop).toBe('number');
|
||||
// preview 按同比例滚动:0.5 * (1000 - 500) = 250
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('分屏模式下 preview 滚动反向联动 textarea', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const longText = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
|
||||
const ed = new MarkdownEditor(c, { value: longText, mode: 'split', syncScroll: true });
|
||||
Object.defineProperty(ed.textarea, 'scrollHeight', { configurable: true, value: 2000 });
|
||||
Object.defineProperty(ed.textarea, 'clientHeight', { configurable: true, value: 500 });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.previewPane.scrollTop = 250; // preview 滚动比例 0.5
|
||||
ed.previewPane.dispatchEvent(new Event('scroll'));
|
||||
// textarea 按同比例滚动:0.5 * (2000 - 500) = 750
|
||||
expect(ed.textarea.scrollTop).toBe(750);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
@@ -3009,3 +3036,296 @@ describe('MarkdownEditor - v0.2.5 maxLength 默认值', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 事件补齐 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 事件补齐', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('渲染路径触发 beforeRender / afterRender 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# hi', mode: 'split' });
|
||||
const before = jest.fn(); const after = jest.fn();
|
||||
ed.on('beforeRender', before); ed.on('afterRender', after);
|
||||
ed.setValue('# changed');
|
||||
expect(before).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getHTML 触发 beforeRender / afterRender 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# hi' });
|
||||
const before = jest.fn(); const after = jest.fn();
|
||||
ed.on('beforeRender', before); ed.on('afterRender', after);
|
||||
ed.getHTML();
|
||||
expect(before).toHaveBeenCalledTimes(1);
|
||||
expect(after).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toggleZen 触发 zenChange 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'test' });
|
||||
const onZen = jest.fn();
|
||||
ed.on('zenChange', onZen);
|
||||
ed.toggleZen();
|
||||
expect(onZen).toHaveBeenCalledWith(true, ed);
|
||||
ed.toggleZen();
|
||||
expect(onZen).toHaveBeenLastCalledWith(false, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('toggleFullscreen / exitFullscreen 触发 fullscreen 事件', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'test' });
|
||||
const onFs = jest.fn();
|
||||
ed.on('fullscreen', onFs);
|
||||
ed.toggleFullscreen();
|
||||
expect(ed.isFullscreen()).toBe(true);
|
||||
expect(onFs).toHaveBeenCalledWith(true, ed);
|
||||
ed.exitFullscreen();
|
||||
expect(ed.isFullscreen()).toBe(false);
|
||||
expect(onFs).toHaveBeenLastCalledWith(false, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全局钩子 beforeCreate / beforeDestroy / afterDestroy 触发', () => {
|
||||
const beforeCreate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn();
|
||||
MarkdownEditor.on('beforeCreate', beforeCreate);
|
||||
MarkdownEditor.on('beforeDestroy', beforeDestroy);
|
||||
MarkdownEditor.on('afterDestroy', afterDestroy);
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(beforeCreate).toHaveBeenCalledTimes(1);
|
||||
ed.destroy();
|
||||
expect(beforeDestroy).toHaveBeenCalledTimes(1);
|
||||
expect(afterDestroy).toHaveBeenCalledTimes(1);
|
||||
MarkdownEditor.off('beforeCreate', beforeCreate);
|
||||
MarkdownEditor.off('beforeDestroy', beforeDestroy);
|
||||
MarkdownEditor.off('afterDestroy', afterDestroy);
|
||||
});
|
||||
|
||||
test('copyAsMarkdown 写剪贴板并触发 copy 事件', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write: jest.fn() } });
|
||||
const ed = new MarkdownEditor(container, { value: '# copy test' });
|
||||
const onCopy = jest.fn();
|
||||
ed.on('copy', onCopy);
|
||||
ed.copyAsMarkdown();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(writeText).toHaveBeenCalledWith('# copy test');
|
||||
expect(onCopy).toHaveBeenCalledWith({ type: 'markdown' }, ed);
|
||||
delete (navigator as any).clipboard;
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('copyAsHTML 写富文本剪贴板并触发 copy 事件', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
const write = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, writable: true, value: { writeText, write } });
|
||||
// jsdom 无 ClipboardItem,注入 stub 供富文本写入
|
||||
const items: any[] = [];
|
||||
(global as any).ClipboardItem = class { constructor(it: any) { items.push(it); } };
|
||||
const ed = new MarkdownEditor(container, { value: '**bold**' });
|
||||
const onCopy = jest.fn();
|
||||
ed.on('copy', onCopy);
|
||||
ed.copyAsHTML();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
expect(write.mock.calls[0][0]).toHaveLength(1);
|
||||
expect(onCopy).toHaveBeenCalledWith({ type: 'html' }, ed);
|
||||
delete (global as any).ClipboardItem;
|
||||
delete (navigator as any).clipboard;
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 配置项补齐 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 配置项补齐', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('placeholder 配置生效', () => {
|
||||
const ed = new MarkdownEditor(container, { placeholder: '自定义占位' });
|
||||
expect(ed.textarea.placeholder).toBe('自定义占位');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('未配置 placeholder 时使用翻译默认值', () => {
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(ed.textarea.placeholder).toBe(ed.t('placeholder'));
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('height 数字转为 px', () => {
|
||||
const ed = new MarkdownEditor(container, { height: 520 });
|
||||
expect(ed.el.style.height).toBe('520px');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('height 字符串原样使用', () => {
|
||||
const ed = new MarkdownEditor(container, { height: '100%' });
|
||||
expect(ed.el.style.height).toBe('100%');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('spellcheck 默认关闭', () => {
|
||||
const ed = new MarkdownEditor(container, {});
|
||||
expect(ed.textarea.spellcheck).toBe(false);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('spellcheck: true 开启拼写检查', () => {
|
||||
const ed = new MarkdownEditor(container, { spellcheck: true });
|
||||
expect(ed.textarea.spellcheck).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('tabSize 自定义值生效且 Tab 插入对应空格数', () => {
|
||||
const ed = new MarkdownEditor(container, { tabSize: 4 });
|
||||
expect(ed.textarea.style.tabSize).toBe('4');
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe(' ');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('tabSize: 0 时 Tab 插入制表符', () => {
|
||||
const ed = new MarkdownEditor(container, { tabSize: 0 });
|
||||
ed.textarea.setSelectionRange(0, 0);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
expect(ed.getValue()).toBe('\t');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('historyDebounce 自定义防抖间隔生效', () => {
|
||||
jest.useFakeTimers();
|
||||
const ed = new MarkdownEditor(container, { historyDebounce: 100 });
|
||||
const before = ed._history.length;
|
||||
ed.textarea.value = 'typed';
|
||||
ed.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(ed._history.length).toBe(before);
|
||||
jest.advanceTimersByTime(60);
|
||||
expect(ed._history.length).toBe(before + 1);
|
||||
jest.useRealTimers();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 undo/redo 滚动保持 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 undo/redo 滚动保持', () => {
|
||||
test('undo/redo 后预览区滚动位置按比例保持', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'v1', mode: 'split' });
|
||||
Object.defineProperty(ed.previewPane, 'scrollHeight', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(ed.previewPane, 'clientHeight', { configurable: true, value: 500 });
|
||||
ed.setValue('v2\n\nmore content');
|
||||
ed.previewPane.scrollTop = 250; // 滚动比例 50%
|
||||
ed.undo();
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.redo();
|
||||
expect(ed.previewPane.scrollTop).toBe(250);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 大纲光标定位 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 大纲光标定位', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('点击大纲按源码行号定位光标(重复标题不串位)', () => {
|
||||
const md = '# alpha\n\ntext\n\n# beta\n\n# alpha\n';
|
||||
const ed = new MarkdownEditor(container, { value: md, outline: true, mode: 'split' });
|
||||
const links = ed.el.querySelectorAll('.me-outline a');
|
||||
expect(links.length).toBe(3);
|
||||
// 第三个链接对应第二个 'alpha' 标题(源码第 7 行)
|
||||
(links[2] as HTMLElement).click();
|
||||
expect(ed.getCursorPosition().line).toBe(7);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('data-line 记录源码行号(1 基)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# a\n\n## b\n', outline: true, mode: 'split' });
|
||||
const links = ed.el.querySelectorAll('.me-outline a');
|
||||
expect(links[0].getAttribute('data-line')).toBe('1');
|
||||
expect(links[1].getAttribute('data-line')).toBe('3');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setOutline 运行时开关大纲面板', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# title\n\nbody', outline: false, mode: 'split' });
|
||||
expect(ed.isOutline()).toBe(false);
|
||||
expect(ed.el.querySelector('.me-outline')).toBeNull();
|
||||
ed.setOutline(true);
|
||||
expect(ed.isOutline()).toBe(true);
|
||||
expect(ed.el.querySelector('.me-outline')).not.toBeNull();
|
||||
ed.setOutline(false);
|
||||
expect(ed.el.querySelector('.me-outline')).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 setLocale 即时刷新 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 setLocale 即时刷新', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('setLocale 即时刷新状态栏统计标签', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'hello', locale: 'zh-CN' });
|
||||
expect(ed.statusEl!.textContent).toContain('字符');
|
||||
ed.setLocale('en-US');
|
||||
expect(ed.statusEl!.textContent).toContain('Characters');
|
||||
expect(ed.statusEl!.textContent).not.toContain('字符');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setLocale 即时刷新大纲标题', () => {
|
||||
const ed = new MarkdownEditor(container, { value: '# t', outline: true, locale: 'zh-CN', mode: 'split' });
|
||||
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('大纲');
|
||||
ed.setLocale('en-US');
|
||||
expect(ed.el.querySelector('.me-outline-title')!.textContent).toBe('Outline');
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 浮动工具栏中文选区 ============
|
||||
|
||||
describe('MarkdownEditor - v0.4.2 浮动工具栏中文选区', () => {
|
||||
test('中文选区触发浮动工具栏显示且定位计算不抛错', async () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '这是一段中文文本内容' });
|
||||
ed.textarea.setSelectionRange(0, 6);
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(ed._floatingToolbar).not.toBeNull();
|
||||
expect(ed._floatingToolbar!.classList.contains('me-visible')).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
+9
-6
@@ -29,7 +29,7 @@ describe('index.ts - 全局 API', () => {
|
||||
|
||||
test('VERSION 是字符串', () => {
|
||||
expect(typeof VERSION).toBe('string');
|
||||
expect(VERSION).toBe('0.4.1');
|
||||
expect(VERSION).toBe('0.4.2');
|
||||
});
|
||||
|
||||
test('api.default 是 api 本身', () => {
|
||||
@@ -193,14 +193,17 @@ describe('index.ts - 解析器导出', () => {
|
||||
});
|
||||
|
||||
test('registerBlockHandler 注册自定义块', () => {
|
||||
let matched = false;
|
||||
registerBlockHandler({
|
||||
name: 'testBlock',
|
||||
name: 'testBlockIndex',
|
||||
priority: 50,
|
||||
test: () => null,
|
||||
parse: (_l, i) => ({ token: null, newIndex: i }),
|
||||
test: (line) => { if (line === '!!!custom!!!') { matched = true; return true; } return null; },
|
||||
parse: (_l, i) => ({ token: { type: 'testBlockIndex', content: 'hit' }, newIndex: i + 1 }),
|
||||
});
|
||||
// 不抛错即通过
|
||||
expect(true).toBe(true);
|
||||
const html = parseMarkdown('!!!custom!!!\nafter');
|
||||
// 自定义 handler 被真实命中并产出 token(回退渲染为 me-block-* div)
|
||||
expect(matched).toBe(true);
|
||||
expect(html).toContain('me-block-testBlockIndex');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 覆盖 Markdown 解析器的所有语法分支与安全特性
|
||||
*/
|
||||
|
||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler } from '../src/parser';
|
||||
import { parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, getRenderCacheSize, registerBlockHandler } from '../src/parser';
|
||||
|
||||
describe('parseMarkdown - 基础', () => {
|
||||
test('空输入返回空字符串', () => {
|
||||
@@ -909,12 +909,18 @@ describe('parseMarkdown - v0.1.15 registerBlockHandler', () => {
|
||||
describe('parseMarkdown - 覆盖率:渲染缓存淘汰', () => {
|
||||
test('缓存超过上限触发 FIFO 淘汰', () => {
|
||||
// MAX_CACHE_SIZE = 300,填充 301 个极短文本触发淘汰(避免 CI 超时)
|
||||
clearRenderCache();
|
||||
const short = 'x';
|
||||
for (let i = 0; i < 301; i++) {
|
||||
parseMarkdown(short + i);
|
||||
}
|
||||
// 不应抛错,且至少有一次淘汰发生
|
||||
expect(true).toBe(true);
|
||||
// 缓存条目数被 FIFO 上限约束在 300,证明淘汰真实发生
|
||||
expect(getRenderCacheSize()).toBe(300);
|
||||
// 超限后再写入仍维持上限
|
||||
parseMarkdown('overflow-entry');
|
||||
expect(getRenderCacheSize()).toBe(300);
|
||||
clearRenderCache();
|
||||
expect(getRenderCacheSize()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+112
-4
@@ -570,7 +570,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
||||
input.value = 'foo';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ed.el.querySelector('.me-search-next').click();
|
||||
expect(ed.textarea.selectionStart).toBeGreaterThanOrEqual(0);
|
||||
// 首个匹配位于 0,且选中内容即搜索词
|
||||
expect(ed.textarea.selectionStart).toBe(0);
|
||||
expect(ed.getSelectedText()).toBe('foo');
|
||||
});
|
||||
|
||||
test('查找上一个高亮匹配', () => {
|
||||
@@ -580,7 +582,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
||||
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);
|
||||
// next 选中第一处(0),prev 回绕到最后一处('foo bar foo baz foo' 中 16)
|
||||
expect(ed.textarea.selectionStart).toBe(16);
|
||||
expect(ed.getSelectedText()).toBe('foo');
|
||||
});
|
||||
|
||||
test('空查询不抛错', () => {
|
||||
@@ -600,7 +604,8 @@ describe('searchReplace 插件 - 完整流程', () => {
|
||||
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);
|
||||
expect(ed.textarea.selectionStart).toBe(0);
|
||||
expect(ed.getSelectedText()).toBe('foo');
|
||||
});
|
||||
|
||||
test('Shift+Enter 在 findInput 上查找上一个', () => {
|
||||
@@ -609,7 +614,9 @@ describe('searchReplace 插件 - 完整流程', () => {
|
||||
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);
|
||||
// 初始光标在 0,prev 无更早匹配时回绕到最后一处(16)
|
||||
expect(ed.textarea.selectionStart).toBe(16);
|
||||
expect(ed.getSelectedText()).toBe('foo');
|
||||
});
|
||||
|
||||
test('替换行 display 切换(none ↔ flex)', () => {
|
||||
@@ -1301,3 +1308,104 @@ describe('v0.2.5 imagePaste 尺寸限制', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 fileSystem 事件补齐 ============
|
||||
|
||||
describe('fileSystem 插件 - fileOpened / fileSaved 事件', () => {
|
||||
afterEach(() => {
|
||||
delete (window as any).showOpenFilePicker;
|
||||
delete (window as any).showSaveFilePicker;
|
||||
});
|
||||
|
||||
test('openFile 触发 fileOpened 事件并载入内容', async () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '' });
|
||||
const onOpened = jest.fn();
|
||||
ed.on('fileOpened', onOpened);
|
||||
const fakeFile = { name: 'doc.md', text: async () => '# opened content' };
|
||||
const handle = { getFile: async () => fakeFile };
|
||||
(window as any).showOpenFilePicker = async () => [handle];
|
||||
ed.use('fileSystem');
|
||||
const result = await (ed as any).openFile();
|
||||
expect(result).toEqual({ name: 'doc.md', content: '# opened content', handle });
|
||||
expect(ed.getValue()).toBe('# opened content');
|
||||
expect(onOpened).toHaveBeenCalledWith({ name: 'doc.md', handle }, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('saveFile 触发 fileSaved 事件', async () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '# save me' });
|
||||
const onSaved = jest.fn();
|
||||
ed.on('fileSaved', onSaved);
|
||||
const writable = { write: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined) };
|
||||
const handle = { createWritable: async () => writable };
|
||||
(window as any).showOpenFilePicker = async () => [];
|
||||
(window as any).showSaveFilePicker = async () => handle;
|
||||
ed.use('fileSystem');
|
||||
const ok = await (ed as any).saveFile();
|
||||
expect(ok).toBe(true);
|
||||
expect(writable.write).toHaveBeenCalledWith('# save me');
|
||||
expect(onSaved).toHaveBeenCalledWith({ handle }, ed);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.4.2 插件面板实例级 i18n ============
|
||||
|
||||
describe('v0.4.2 插件面板实例级 i18n', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
test('searchReplace 面板文案跟随实例 locale(en-US)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
|
||||
ed.use('searchReplace');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||
expect(input.placeholder).toBe('Find');
|
||||
const replaceBtn = ed.el.querySelector('.me-search-replace-all');
|
||||
expect(replaceBtn.textContent).toBe('Replace All');
|
||||
ed.el.querySelector('.me-search-close').click();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('searchReplace 面板文案跟随实例 locale(zh-CN)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'zh-CN' });
|
||||
ed.use('searchReplace');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
const input = ed.el.querySelector('.me-search-find') as HTMLInputElement;
|
||||
expect(input.placeholder).toBe('查找内容');
|
||||
ed.el.querySelector('.me-search-close').click();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('shortcutHelp 面板文案跟随实例 locale(en-US)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'foo', locale: 'en-US' });
|
||||
ed.use('shortcutHelp');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
|
||||
const overlay = document.querySelector('.me-shortcut-overlay') as HTMLElement;
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay.querySelector('h3')!.textContent).toContain('Shortcuts');
|
||||
expect(overlay.textContent).toContain('Bold');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('unuse shortcutHelp 移除已打开的面板(不残留 DOM)', () => {
|
||||
const ed = new MarkdownEditor(container, { value: 'foo' });
|
||||
ed.use('shortcutHelp');
|
||||
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true }));
|
||||
expect(document.querySelector('.me-shortcut-overlay')).not.toBeNull();
|
||||
ed.unuse('shortcutHelp');
|
||||
expect(document.querySelector('.me-shortcut-overlay')).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user