feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
## Added - Reference link/image resolution: [text][ref] + ![alt][ref] with [ref]: url definitions - Right-click context menu: undo/redo/cut/copy/paste/selectAll + custom items - RTL CSS layout support for Arabic, Hebrew, Persian etc. - Japanese (ja) and Korean (ko) locales with 60+ keys each - Divider position localStorage persistence - Regex search toggle in search/replace panel - Export HTML with embedded CSS styles - beforeChange / afterChange lifecycle hooks (instance + global) - copyAsMarkdown() / copyAsHTML() clipboard APIs - CHANGELOG.md, CONTRIBUTING.md, CI workflow (.github/workflows/ci.yml) - 2 new test suites: index.test.ts, styles.test.ts (684 total tests, +74) ## Changed - autoSave plugin: closure-based state per instance instead of this context - Plugin install() now receives options as second argument - RTL locale detection: now uses language prefix (ar-SA → RTL) - Rollup dev mode: only builds UMD format - prepublishOnly now includes typecheck + test - Version bumped to 0.2.1 ## Fixed - [text][ref] now correctly renders as link (was raw text) - ![alt][ref] no longer produces empty src - autoSave plugin state isolation across multiple editor instances - Footnote definitions no longer consumed by refDef handler
This commit is contained in:
+217
-1
@@ -536,7 +536,7 @@ describe('MarkdownEditor - 构造边界', () => {
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
const ed = new MarkdownEditor(document.createElement('div'), { plugins: [plugin] });
|
||||
expect(plugin.install).toHaveBeenCalledWith(ed);
|
||||
expect(plugin.install).toHaveBeenCalledWith(ed, {});
|
||||
ed.destroy();
|
||||
expect(plugin.destroy).toHaveBeenCalledWith(ed);
|
||||
});
|
||||
@@ -1731,3 +1731,219 @@ describe('MarkdownEditor - v0.2.0 getStatus plugins', () => {
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.1 覆盖率补齐测试 ============
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 工具栏键盘导航', () => {
|
||||
test('ArrowRight 聚焦下一个按钮', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||
const first = btns[0] as HTMLButtonElement;
|
||||
first.focus();
|
||||
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
// 焦点应移动
|
||||
expect(document.activeElement).not.toBe(first);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('ArrowLeft 聚焦上一个按钮', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic'] });
|
||||
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||
(btns[1] as HTMLButtonElement).focus();
|
||||
(btns[1] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
expect(document.activeElement).toBe(btns[0]);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('Home 聚焦第一个按钮', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||
(btns[2] as HTMLButtonElement).focus();
|
||||
(btns[2] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
||||
expect(document.activeElement).toBe(btns[0]);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('End 聚焦最后一个按钮', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { toolbar: ['bold', 'italic', 'code'] });
|
||||
const btns = ed.toolbarEl.querySelectorAll('.me-btn');
|
||||
(btns[0] as HTMLButtonElement).focus();
|
||||
(btns[0] as HTMLButtonElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
||||
expect(document.activeElement).toBe(btns[btns.length - 1]);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 ARIA live', () => {
|
||||
test('_initAriaLive 创建 sr-only 元素', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
const live = ed.el.querySelector('.me-sr-only');
|
||||
expect(live).not.toBeNull();
|
||||
expect(live!.getAttribute('aria-live')).toBe('polite');
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('_announce 设置消息', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed._announce('test message');
|
||||
const live = ed.el.querySelector('.me-sr-only') as HTMLElement;
|
||||
expect(live).not.toBeNull();
|
||||
// jsdom 中 requestAnimationFrame 可能不被支持,但不应抛错
|
||||
expect(() => ed._announce('hello')).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 当前行高亮', () => {
|
||||
test('_updateCurrentLine 高亮当前行', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'line1\nline2\nline3', lineNumbers: true });
|
||||
ed.textarea.setSelectionRange(7, 7); // 第二行
|
||||
ed._updateCurrentLine();
|
||||
const active = ed.gutter.querySelector('.me-gutter-active');
|
||||
expect(active).not.toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('_updateCurrentLine 无 gutter 时不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'test', lineNumbers: false });
|
||||
expect(() => ed._updateCurrentLine()).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 右键上下文菜单', () => {
|
||||
test('contextmenu 事件显示菜单', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
|
||||
const menu = document.querySelector('.me-context-menu');
|
||||
expect(menu).not.toBeNull();
|
||||
// 清理
|
||||
if (menu) menu.remove();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('上下文菜单点击外部关闭', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'hello' });
|
||||
ed.el.dispatchEvent(new MouseEvent('contextmenu', { clientX: 100, clientY: 100, bubbles: true }));
|
||||
expect(document.querySelector('.me-context-menu')).not.toBeNull();
|
||||
document.body.click();
|
||||
// once listener removes it
|
||||
expect(document.querySelector('.me-context-menu')).toBeNull();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('registerContextMenu 添加自定义菜单项', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
ed.registerContextMenu([{ label: 'Custom Action', onClick: jest.fn() }]);
|
||||
expect(ed._contextMenuItems.length).toBe(1);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 copy API', () => {
|
||||
test('copyAsMarkdown 不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '# hello' });
|
||||
expect(() => ed.copyAsMarkdown()).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('copyAsHTML 不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: '# hello' });
|
||||
expect(() => ed.copyAsHTML()).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 beforeChange/afterChange 钩子', () => {
|
||||
test('setValue 触发 beforeChange', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'old' });
|
||||
const handler = jest.fn();
|
||||
ed.on('beforeChange', handler);
|
||||
ed.setValue('new');
|
||||
expect(handler).toHaveBeenCalledWith('old', 'new', ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setValue 触发 afterChange', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { value: 'old' });
|
||||
const handler = jest.fn();
|
||||
ed.on('afterChange', handler);
|
||||
ed.setValue('new');
|
||||
expect(handler).toHaveBeenCalledWith('new', ed);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('全局 beforeChange/afterChange 钩子', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const beforeFn = jest.fn();
|
||||
const afterFn = jest.fn();
|
||||
MarkdownEditor.on('beforeChange', beforeFn);
|
||||
MarkdownEditor.on('afterChange', afterFn);
|
||||
const ed = new MarkdownEditor(c, { value: 'test' });
|
||||
ed.setValue('updated');
|
||||
expect(beforeFn).toHaveBeenCalled();
|
||||
expect(afterFn).toHaveBeenCalled();
|
||||
MarkdownEditor.off('beforeChange', beforeFn);
|
||||
MarkdownEditor.off('afterChange', afterFn);
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
|
||||
test('_saveDividerPosition / _restoreDividerPosition 不抛错', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { mode: 'split' });
|
||||
expect(() => ed._saveDividerPosition()).not.toThrow();
|
||||
expect(() => ed._restoreDividerPosition()).not.toThrow();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* index.ts 单元测试
|
||||
* 覆盖全局 API:create / use / on / off / setTheme / setLocale / destroy / getStatus
|
||||
*/
|
||||
|
||||
import MeEditor, {
|
||||
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
||||
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache,
|
||||
registerBlockHandler, topologicalSort, validateConfig,
|
||||
VERSION,
|
||||
} from '../src/index';
|
||||
import { MarkdownEditor } from '../src/core';
|
||||
|
||||
describe('index.ts - 全局 API', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
container = document.createElement('div');
|
||||
container.id = 'test-host';
|
||||
document.body.appendChild(container);
|
||||
// Set MeEditor on window as index.ts does
|
||||
(window as any).MeEditor = MeEditor;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('VERSION 是字符串', () => {
|
||||
expect(typeof VERSION).toBe('string');
|
||||
expect(VERSION).toBe('0.2.1');
|
||||
});
|
||||
|
||||
test('api.default 是 api 本身', () => {
|
||||
expect(MeEditor).toBeDefined();
|
||||
expect(MeEditor.VERSION).toBe(VERSION);
|
||||
});
|
||||
|
||||
test('create 创建编辑器实例', () => {
|
||||
const editor = create('#test-host', { value: '# Hello' });
|
||||
expect(editor).toBeInstanceOf(MarkdownEditor);
|
||||
expect(editor.getValue()).toBe('# Hello');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test('create 接受 HTMLElement', () => {
|
||||
const editor = create(container, { value: 'text' });
|
||||
expect(editor.getValue()).toBe('text');
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
test('use 注册全局插件(通过字符串)', () => {
|
||||
const result = use('autoSave', { delay: 100 });
|
||||
expect(result).toBe(MeEditor); // 返回 api(链式)
|
||||
const status = getStatus();
|
||||
expect(status.globalPlugins).toContain('autoSave');
|
||||
});
|
||||
|
||||
test('use 未知预设插件 warn', () => {
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
use('non-existent-preset');
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('use 无效插件对象 warn', () => {
|
||||
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
use(null);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('全局插件被注入到新实例', () => {
|
||||
// Reset global plugins by destroying
|
||||
destroy();
|
||||
use('autoSave');
|
||||
const ed = create(container, { value: 'test' });
|
||||
const plugins = ed.getPlugins();
|
||||
expect(plugins.some((p: any) => p.name === 'autoSave')).toBe(true);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('on / off 全局钩子', () => {
|
||||
const fn = jest.fn();
|
||||
const unsub = on('afterCreate', fn);
|
||||
expect(typeof unsub).toBe('function');
|
||||
const ed = create(container, {});
|
||||
expect(fn).toHaveBeenCalled();
|
||||
off('afterCreate', fn);
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('setTheme 切换全局主题', () => {
|
||||
expect(() => setTheme('dark')).not.toThrow();
|
||||
});
|
||||
|
||||
test('setLocale 切换全局语言', () => {
|
||||
expect(() => setLocale('en-US')).not.toThrow();
|
||||
});
|
||||
|
||||
test('getStatus 返回状态对象', () => {
|
||||
const status = getStatus();
|
||||
expect(status).toHaveProperty('version');
|
||||
expect(status).toHaveProperty('theme');
|
||||
expect(status).toHaveProperty('locale');
|
||||
expect(status).toHaveProperty('globalPlugins');
|
||||
expect(status).toHaveProperty('presetPlugins');
|
||||
expect(Array.isArray(status.globalPlugins)).toBe(true);
|
||||
expect(Array.isArray(status.presetPlugins)).toBe(true);
|
||||
});
|
||||
|
||||
test('destroy 清理全局资源', () => {
|
||||
expect(() => destroy()).not.toThrow();
|
||||
const status = getStatus();
|
||||
expect(status.globalPlugins.length).toBe(0);
|
||||
});
|
||||
|
||||
test('window.MeEditor 被设置', () => {
|
||||
expect((window as any).MeEditor).toBeDefined();
|
||||
expect((window as any).MeEditor.VERSION).toBe(VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.ts - 解析器导出', () => {
|
||||
test('parseMarkdown 可直接调用', () => {
|
||||
const html = parseMarkdown('# Hello');
|
||||
expect(html).toContain('<h1');
|
||||
});
|
||||
|
||||
test('parseTokens 可直接调用', () => {
|
||||
const { tokens, footnotes, refs } = parseTokens('# Hi\n\n**bold**');
|
||||
expect(Array.isArray(tokens)).toBe(true);
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(typeof footnotes).toBe('object');
|
||||
expect(typeof refs).toBe('object');
|
||||
});
|
||||
|
||||
test('renderTokens 可直接调用', () => {
|
||||
const { tokens } = parseTokens('**hello**');
|
||||
const html = renderTokens(tokens);
|
||||
expect(html).toContain('<strong>');
|
||||
});
|
||||
|
||||
test('safeUrl 过滤危险协议', () => {
|
||||
expect(safeUrl('javascript:alert(1)')).toBe('');
|
||||
expect(safeUrl('https://example.com')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('slugify 生成锚点 id', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
test('clearRenderCache 不抛错', () => {
|
||||
expect(() => clearRenderCache()).not.toThrow();
|
||||
});
|
||||
|
||||
test('registerBlockHandler 注册自定义块', () => {
|
||||
registerBlockHandler({
|
||||
name: 'testBlock',
|
||||
priority: 50,
|
||||
test: () => null,
|
||||
parse: (_l, i) => ({ token: null, newIndex: i }),
|
||||
});
|
||||
// 不抛错即通过
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.ts - 具名导出', () => {
|
||||
test('create 是函数', () => expect(typeof create).toBe('function'));
|
||||
test('use 是函数', () => expect(typeof use).toBe('function'));
|
||||
test('on 是函数', () => expect(typeof on).toBe('function'));
|
||||
test('off 是函数', () => expect(typeof off).toBe('function'));
|
||||
test('setTheme 是函数', () => expect(typeof setTheme).toBe('function'));
|
||||
test('setLocale 是函数', () => expect(typeof setLocale).toBe('function'));
|
||||
test('destroy 是函数', () => expect(typeof destroy).toBe('function'));
|
||||
test('getStatus 是函数', () => expect(typeof getStatus).toBe('function'));
|
||||
test('topologicalSort 是函数', () => expect(typeof topologicalSort).toBe('function'));
|
||||
test('validateConfig 是函数', () => expect(typeof validateConfig).toBe('function'));
|
||||
});
|
||||
@@ -1118,3 +1118,110 @@ describe('parseMarkdown - 分支:嵌套列表子项渲染', () => {
|
||||
expect(innerUlCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.1 引用链接/图片测试 ============
|
||||
|
||||
describe('parseMarkdown - v0.2.1 引用链接', () => {
|
||||
test('引用链接 [text][ref] 解析为链接', () => {
|
||||
const md = 'Click [here][1]\n\n[1]: https://example.com';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
expect(html).toContain('>here</a>');
|
||||
});
|
||||
|
||||
test('引用链接忽略大小写', () => {
|
||||
const md = 'Visit [Site][MyRef]\n\n[myref]: https://example.com';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
|
||||
test('引用链接无定义时保留原文', () => {
|
||||
const md = 'Click [here][undef]';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).not.toContain('<a href=');
|
||||
expect(html).toContain('here');
|
||||
});
|
||||
|
||||
test('引用链接使用隐式引用(同文本)', () => {
|
||||
const md = 'Visit [example][]\n\n[example]: https://example.com';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
|
||||
test('引用图片 ![alt][ref] 解析', () => {
|
||||
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('<img src="https://example.com/logo.png"');
|
||||
expect(html).toContain('alt="logo"');
|
||||
});
|
||||
|
||||
test('引用图片带 title 通过 parseMarkdown', () => {
|
||||
clearRenderCache();
|
||||
const md = '![logo][img1]\n\n[img1]: https://example.com/logo.png "MyLogo"';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('src="https://example.com/logo.png"');
|
||||
expect(html).toContain('title="MyLogo"');
|
||||
});
|
||||
|
||||
test('引用链接带 title 通过 parseMarkdown', () => {
|
||||
clearRenderCache();
|
||||
const md = '[link][1]\n\n[1]: https://example.com "MyTitle"';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
expect(html).toContain('title="MyTitle"');
|
||||
});
|
||||
|
||||
test('引用链接危险协议被过滤', () => {
|
||||
const md = 'Click [bad][1]\n\n[1]: javascript:alert(1)';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
});
|
||||
|
||||
test('引用图片无定义时使用空 src', () => {
|
||||
const md = '![logo][nope]';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('src=""');
|
||||
expect(html).toContain('me-img-ref');
|
||||
});
|
||||
|
||||
test('引用定义不匹配脚注', () => {
|
||||
const md = 'Text[^1]\n\n[^1]: footnote text\n\n[link][lnk]\n\n[lnk]: https://example.com';
|
||||
const html = parseMarkdown(md);
|
||||
expect(html).toContain('me-footnotes');
|
||||
expect(html).toContain('footnote text');
|
||||
expect(html).toContain('<a href="https://example.com"');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.1 data:image 超限测试 ============
|
||||
|
||||
describe('parseMarkdown - v0.2.1 safeUrl 增强', () => {
|
||||
test('超长 data:image URL 被过滤', () => {
|
||||
// 构造一个超过 500000 字符的 data:image URL
|
||||
const longData = 'data:image/png;base64,' + 'A'.repeat(500001);
|
||||
const url = safeUrl(longData);
|
||||
expect(url).toBe('');
|
||||
});
|
||||
|
||||
test('正常 data:image 通过', () => {
|
||||
const url = safeUrl('data:image/png;base64,abc123');
|
||||
expect(url).toBe('data:image/png;base64,abc123');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.2.1 引用链接格式错误容错 ============
|
||||
|
||||
describe('parseMarkdown - v0.2.1 引用链接容错', () => {
|
||||
test('格式错误的引用图片不崩溃', () => {
|
||||
const html = parseMarkdown('![broken ref');
|
||||
expect(html).toContain('broken ref');
|
||||
});
|
||||
|
||||
test('引用定义中 JSON 解析错误被容错', () => {
|
||||
// 正常情况下 refs 中的值是 JSON 字符串
|
||||
const { refs } = parseTokens('[link][test]\n\n[test]: https://example.com');
|
||||
expect(refs['test']).toBeDefined();
|
||||
// 不应抛错
|
||||
expect(() => renderTokens([{ type: 'paragraph', text: '[x][bad]' }], { refs: { bad: 'not-json' } })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -792,8 +792,9 @@ describe('零散分支补全', () => {
|
||||
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();
|
||||
const cleanup = (ed as any).__autoSaveCleanup;
|
||||
expect(cleanup).toBeDefined();
|
||||
expect(() => cleanup.save()).not.toThrow();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
spy.mockRestore();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* styles.ts 单元测试
|
||||
* 覆盖 injectStyles / updateStyles / removeStyles
|
||||
*/
|
||||
|
||||
import { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme } from '../src/styles';
|
||||
|
||||
describe('styles.ts - injectStyles', () => {
|
||||
afterEach(() => {
|
||||
removeStyles();
|
||||
});
|
||||
|
||||
test('injectStyles 创建 <style> 元素', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el!.tagName).toBe('STYLE');
|
||||
});
|
||||
|
||||
test('injectStyles 重复调用不创建重复元素', () => {
|
||||
injectStyles();
|
||||
injectStyles();
|
||||
const els = document.querySelectorAll('#metona-editor-styles');
|
||||
expect(els.length).toBe(1);
|
||||
});
|
||||
|
||||
test('注入的 CSS 包含编辑器样式规则', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles');
|
||||
const css = el!.textContent || '';
|
||||
expect(css).toContain('.me-wrapper');
|
||||
expect(css).toContain('.me-toolbar');
|
||||
expect(css).toContain('.me-textarea');
|
||||
expect(css).toContain('.me-preview');
|
||||
expect(css).toContain('--md-bg');
|
||||
expect(css).toContain('--md-accent');
|
||||
});
|
||||
|
||||
test('注入的 CSS 包含 RTL 规则', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles');
|
||||
const css = el!.textContent || '';
|
||||
expect(css).toContain('[dir=rtl]');
|
||||
});
|
||||
|
||||
test('注入的 CSS 包含打印样式', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles');
|
||||
const css = el!.textContent || '';
|
||||
expect(css).toContain('@media print');
|
||||
});
|
||||
|
||||
test('注入的 CSS 包含 reduced-motion', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles');
|
||||
const css = el!.textContent || '';
|
||||
expect(css).toContain('prefers-reduced-motion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('styles.ts - updateStyles', () => {
|
||||
afterEach(() => {
|
||||
removeStyles();
|
||||
});
|
||||
|
||||
test('updateStyles 更新已注入的样式', () => {
|
||||
injectStyles();
|
||||
const el = document.getElementById('metona-editor-styles')!;
|
||||
const original = el.textContent;
|
||||
updateStyles();
|
||||
expect(el.textContent).toBe(original); // 内容一致
|
||||
});
|
||||
|
||||
test('updateStyles 在未注入时自动注入', () => {
|
||||
removeStyles();
|
||||
expect(document.getElementById('metona-editor-styles')).toBeNull();
|
||||
updateStyles();
|
||||
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styles.ts - removeStyles', () => {
|
||||
test('removeStyles 移除样式元素', () => {
|
||||
injectStyles();
|
||||
expect(document.getElementById('metona-editor-styles')).not.toBeNull();
|
||||
removeStyles();
|
||||
expect(document.getElementById('metona-editor-styles')).toBeNull();
|
||||
});
|
||||
|
||||
test('removeStyles 重复调用不抛错', () => {
|
||||
removeStyles();
|
||||
expect(() => removeStyles()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('styles.ts - getSystemTheme', () => {
|
||||
test('返回 light 或 dark', () => {
|
||||
const theme = getSystemTheme();
|
||||
expect(['light', 'dark']).toContain(theme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('styles.ts - watchSystemTheme', () => {
|
||||
test('返回取消函数', () => {
|
||||
const unsub = watchSystemTheme(() => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user