Files
MetonaEditor/tests/parser.test.js
T
thzxx 12875921eb release: v0.1.2 — readOnly, new syntax, plugins, optimizations
Features:
- feat(core): readOnly mode with config option and setReadOnly() API
- feat(parser): superscript ^text^ and subscript ~text~ support
- feat(parser): highlight/mark ==text== syntax
- feat(parser): table column alignment with colons (:---, :---:, ---:)
- feat(parser): emoji shortcodes 😄😊 (80+ common emojis)
- feat(plugins): imagePaste preset — paste clipboard images as base64
- feat(core): instance-level beforeRender/afterRender events
- feat(styles): print stylesheet (@media print)
- feat(styles): mark element CSS styling
- feat(styles): readOnly mode CSS (.me-readonly)

Optimizations:
- perf(core): history debounce now configurable via historyDebounce option
- perf(core): skip _render() in edit-only mode (already guarded)
- fix(core): divider drag division-by-zero guard
- fix(core): config.style deep merge with DEFAULTS
- fix(core): scroll position preserved across mode switches

Tests:
- 9 new test cases: readOnly (3), parser syntax (6)
- Total: 418 tests passing (+9 from v0.1.1)

Chores:
- version bump 0.1.1 → 0.1.2
- TypeScript definitions updated for new APIs
2026-07-23 20:53:36 +08:00

389 lines
11 KiB
JavaScript

/**
* parser.js 单元测试
* 覆盖 Markdown 解析器的所有语法分支与安全特性
*/
import { parseMarkdown, safeUrl, slugify } from '../src/parser.js';
describe('parseMarkdown - 基础', () => {
test('空输入返回空字符串', () => {
expect(parseMarkdown('')).toBe('');
expect(parseMarkdown(null)).toBe('');
expect(parseMarkdown(undefined)).toBe('');
});
test('非字符串输入被转换为字符串', () => {
expect(parseMarkdown(123)).toBe('<p>123</p>');
});
test('Windows 换行符被规范化', () => {
const html = parseMarkdown('line1\r\nline2');
expect(html).toContain('line1');
expect(html).toContain('line2');
expect(html).not.toContain('\r');
});
});
describe('parseMarkdown - 标题', () => {
test('h1 到 h6', () => {
expect(parseMarkdown('# T1')).toBe('<h1 id="t1">T1</h1>');
expect(parseMarkdown('## T2')).toBe('<h2 id="t2">T2</h2>');
expect(parseMarkdown('### T3')).toBe('<h3 id="t3">T3</h3>');
expect(parseMarkdown('#### T4')).toBe('<h4 id="t4">T4</h4>');
expect(parseMarkdown('##### T5')).toBe('<h5 id="t5">T5</h5>');
expect(parseMarkdown('###### T6')).toBe('<h6 id="t6">T6</h6>');
});
test('标题文本被内联渲染', () => {
const html = parseMarkdown('# Hello **world**');
expect(html).toContain('<strong>world</strong>');
});
test('标题生成锚点 id', () => {
expect(parseMarkdown('# Hello World')).toContain('id="hello-world"');
});
test('超过 6 个 # 不作为标题', () => {
const html = parseMarkdown('####### not heading');
expect(html).not.toContain('<h7');
expect(html).toContain('<p>');
});
});
describe('parseMarkdown - 段落', () => {
test('单行段落', () => {
expect(parseMarkdown('hello')).toBe('<p>hello</p>');
});
test('多行段落合并', () => {
const html = parseMarkdown('line1\nline2');
expect(html).toBe('<p>line1<br/>line2</p>');
});
test('空行分隔段落', () => {
const html = parseMarkdown('p1\n\np2');
expect(html).toBe('<p>p1</p>\n<p>p2</p>');
});
});
describe('parseMarkdown - 行内格式', () => {
test('粗体 **text**', () => {
expect(parseMarkdown('**bold**')).toBe('<p><strong>bold</strong></p>');
});
test('粗体 __text__', () => {
expect(parseMarkdown('__bold__')).toBe('<p><strong>bold</strong></p>');
});
test('斜体 *text*', () => {
expect(parseMarkdown('a *italic* b')).toBe('<p>a <em>italic</em> b</p>');
});
test('斜体 _text_', () => {
expect(parseMarkdown('a _italic_ b')).toBe('<p>a <em>italic</em> b</p>');
});
test('删除线 ~~text~~', () => {
expect(parseMarkdown('~~del~~')).toBe('<p><del>del</del></p>');
});
test('行内代码 `code`', () => {
const html = parseMarkdown('use `code` here');
expect(html).toContain('<code>code</code>');
});
test('行内代码内容不被解析为格式', () => {
const html = parseMarkdown('`a **b** c`');
expect(html).toContain('<code>a **b** c</code>');
expect(html).not.toContain('<strong>');
});
test('行内代码内容被转义', () => {
const html = parseMarkdown('`<script>`');
expect(html).toContain('&lt;script&gt;');
});
});
describe('parseMarkdown - 代码块', () => {
test('围栏代码块 ```', () => {
const html = parseMarkdown('```\ncode\n```');
expect(html).toContain('<pre><code');
expect(html).toContain('code');
});
test('带语言的代码块', () => {
const html = parseMarkdown('```js\nconsole.log(1)\n```');
expect(html).toContain('class="language-js"');
});
test('波浪线围栏 ~~~', () => {
const html = parseMarkdown('~~~\ncode\n~~~');
expect(html).toContain('<pre><code');
});
test('代码块内容被转义', () => {
const html = parseMarkdown('```\n<a>\n```');
expect(html).toContain('&lt;a&gt;');
});
test('highlight 钩子被调用', () => {
const env = {
highlight: (code, lang) => `<span class="hl">${code}-${lang}</span>`,
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('<span class="hl">foo-js</span>');
});
test('highlight 抛错时回退为转义输出', () => {
const env = {
highlight: () => { throw new Error('boom'); },
};
const html = parseMarkdown('```js\nfoo\n```', env);
expect(html).toContain('foo');
expect(html).toContain('<pre><code');
});
});
describe('parseMarkdown - 引用块', () => {
test('单行引用', () => {
const html = parseMarkdown('> quote');
expect(html).toContain('<blockquote>');
expect(html).toContain('quote');
});
test('多行引用', () => {
const html = parseMarkdown('> line1\n> line2');
expect(html).toContain('<blockquote>');
expect(html).toContain('line1');
expect(html).toContain('line2');
});
test('引用内可包含其他语法', () => {
const html = parseMarkdown('> # heading');
expect(html).toContain('<blockquote>');
expect(html).toContain('<h1');
});
});
describe('parseMarkdown - 列表', () => {
test('无序列表 -', () => {
const html = parseMarkdown('- a\n- b');
expect(html).toContain('<ul>');
expect(html).toContain('<li>a</li>');
expect(html).toContain('<li>b</li>');
});
test('无序列表 *', () => {
const html = parseMarkdown('* a\n* b');
expect(html).toContain('<ul>');
});
test('无序列表 +', () => {
const html = parseMarkdown('+ a\n+ b');
expect(html).toContain('<ul>');
});
test('有序列表', () => {
const html = parseMarkdown('1. one\n2. two');
expect(html).toContain('<ol>');
expect(html).toContain('<li>one</li>');
expect(html).toContain('<li>two</li>');
});
test('任务列表未完成', () => {
const html = parseMarkdown('- [ ] todo');
expect(html).toContain('me-task-item');
expect(html).toContain('<input type="checkbox" disabled/>');
expect(html).not.toContain('checked');
});
test('任务列表已完成', () => {
const html = parseMarkdown('- [x] done');
expect(html).toContain('checked');
});
test('任务列表大写 X 也算完成', () => {
const html = parseMarkdown('- [X] done');
expect(html).toContain('checked');
});
});
describe('parseMarkdown - 水平线', () => {
test('--- 作为水平线', () => {
expect(parseMarkdown('---')).toBe('<hr/>');
});
test('*** 作为水平线', () => {
expect(parseMarkdown('***')).toBe('<hr/>');
});
test('___ 作为水平线', () => {
expect(parseMarkdown('___')).toBe('<hr/>');
});
});
describe('parseMarkdown - 表格', () => {
test('基本表格', () => {
const md = '| a | b |\n| --- | --- |\n| 1 | 2 |';
const html = parseMarkdown(md);
expect(html).toContain('<table>');
expect(html).toContain('<thead>');
expect(html).toContain('<th style="text-align:left">a</th>');
expect(html).toContain('<th style="text-align:left">b</th>');
expect(html).toContain('<td style="text-align:left">1</td>');
expect(html).toContain('<td style="text-align:left">2</td>');
});
test('表格被 me-table-wrap 包裹', () => {
const md = '| a |\n| --- |\n| 1 |';
expect(parseMarkdown(md)).toContain('me-table-wrap');
});
test('非分隔行不构成表格', () => {
const html = parseMarkdown('| a |\nnot a separator');
expect(html).not.toContain('<table>');
});
});
describe('parseMarkdown - 链接与图片', () => {
test('基本链接', () => {
const html = parseMarkdown('[text](https://example.com)');
expect(html).toContain('<a href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).toContain('>text</a>');
});
test('自动链接 <url>', () => {
const html = parseMarkdown('<https://example.com>');
expect(html).toContain('<a href="https://example.com"');
});
test('图片', () => {
const html = parseMarkdown('![alt](https://example.com/img.png)');
expect(html).toContain('<img src="https://example.com/img.png"');
expect(html).toContain('alt="alt"');
expect(html).toContain('loading="lazy"');
});
});
describe('parseMarkdown - XSS 防护', () => {
test('文本中的 HTML 标签被转义', () => {
const html = parseMarkdown('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
});
test('javascript: 协议链接被过滤', () => {
const html = parseMarkdown('[click](javascript:alert(1))');
expect(html).not.toContain('href="javascript:');
});
test('vbscript: 协议被过滤', () => {
const html = parseMarkdown('[x](vbscript:msgbox(1))');
expect(html).not.toContain('href="vbscript:');
});
test('file: 协议被过滤', () => {
const html = parseMarkdown('[x](file:///etc/passwd)');
expect(html).not.toContain('href="file:');
});
test('非 image 的 data: 协议被过滤', () => {
const html = parseMarkdown('[x](data:text/html,<script>)');
expect(html).not.toContain('href="data:text/html');
});
test('data:image: 协议被允许', () => {
const html = parseMarkdown('![x](data:image/png;base64,abc)');
expect(html).toContain('src="data:image/png;base64,abc"');
});
test('图片 javascript: 协议被过滤', () => {
const html = parseMarkdown('![x](javascript:alert(1))');
expect(html).not.toContain('src="javascript:');
});
});
describe('safeUrl', () => {
test('正常 http url 原样返回', () => {
expect(safeUrl('https://example.com')).toBe('https://example.com');
});
test('空值返回空字符串', () => {
expect(safeUrl('')).toBe('');
expect(safeUrl(null)).toBe('');
expect(safeUrl(undefined)).toBe('');
});
test('javascript: 返回空', () => {
expect(safeUrl('javascript:alert(1)')).toBe('');
});
test('大小写不敏感过滤', () => {
expect(safeUrl('JAVASCRIPT:alert(1)')).toBe('');
expect(safeUrl('JavaScript:alert(1)')).toBe('');
});
test('trim 空白', () => {
expect(safeUrl(' https://example.com ')).toBe('https://example.com');
});
});
describe('slugify', () => {
test('英文转小写连字符', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('保留中文', () => {
expect(slugify('你好 世界')).toBe('你好-世界');
});
test('移除特殊字符', () => {
expect(slugify('A! B@ C#')).toBe('a-b-c');
});
test('合并多个连字符', () => {
expect(slugify('a---b')).toBe('a-b');
});
test('去除首尾连字符', () => {
expect(slugify('-hello-')).toBe('hello');
});
});
describe('parseMarkdown - v0.1.2 新语法', () => {
test('高亮标记 ==text==', () => {
const html = parseMarkdown('==highlight==');
expect(html).toContain('<mark>highlight</mark>');
});
test('上标 ^text^', () => {
const html = parseMarkdown('x^2^');
expect(html).toContain('<sup>2</sup>');
});
test('下标 ~text~', () => {
const html = parseMarkdown('H~2~O');
expect(html).toContain('<sub>2</sub>');
});
test('Emoji 短码 :smile:', () => {
const html = parseMarkdown(':smile: :rocket:');
expect(html).toContain('😊');
expect(html).toContain('🚀');
});
test('未知 Emoji 短码保留原文', () => {
const html = parseMarkdown(':unknown_emoji:');
expect(html).toContain(':unknown_emoji:');
});
test('表格对齐 :---: 居中', () => {
const md = '| a | b |\n| :---: | ---: |\n| 1 | 2 |';
const html = parseMarkdown(md);
expect(html).toContain('text-align:center');
expect(html).toContain('text-align:right');
});
});