test(web): 补充即时渲染装饰层回归用例(61 条)
- editor-live-preview.test.ts(29):语法区间识别(标题/强调/行内代码/链接/图片/引用/ 列表/任务/围栏/水平线/表格,含转义、中文标点、可见区间裁剪)、行粒度 reveal 与 二分/线性等价、选区端点行、构建与安全(图片 src 白名单降级为源码文本、widget 零 innerHTML、 链接不产出 <a>) - editor-boundaries.test.ts(32):文首/文末边界、跨原子区间选区、装饰层四项不变量、 长文档惰性解析与补解析、3000 行性能基准(视口窗口/全篇单区间/最坏选区/全量解析) - 用 happy-dom 下的真实 EditorView 断言(view.focus()、装饰 DOM、atomicRanges facet 均可读), 不只是纯函数测试 - package.json / package-lock.json:新增 @codemirror/commands 为 devDependency;它本就在 依赖树中(@uiw/react-codemirror 的依赖),用于边界回归真实执行退格等编辑命令,不进产物 前端共 71 条用例全绿;docs/editor-live-rendering.md §12.5.2 的性能结论即由本文件的基准用例 与真实浏览器测量共同得出。
This commit is contained in:
Generated
+1
@@ -24,6 +24,7 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.11.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// P3 加固回归(方案 §7.2 / §7.3 / §7.4、§12.4):
|
||||
// 1) 文首边界:文档以标题/列表/任务/围栏/水平线开头;
|
||||
// 2) 文末边界:文档以水平线/图片/围栏/任务结尾(对应 CM issue #625「文末块 widget 消失」一类的回归面);
|
||||
// 3) 跨原子区间选区:全选替换、跨隐藏标记的选区替换、只选中可见内容时标记不被破坏;
|
||||
// 4) 不变量:原子区间不跨行、有序不重叠、不含可编辑内容、隐藏标记必在原子区间内;
|
||||
// 5) 性能基准:3000 行文档的装饰计算耗时(§7.4 目标 < 16ms/帧)。
|
||||
//
|
||||
// 说明:happy-dom 下 CodeMirror 可以真实创建 EditorView 并渲染装饰 DOM,
|
||||
// 因此本文件用真实 view 断言,而不是只看纯函数输出。
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { deleteCharBackward } from '@codemirror/commands'
|
||||
import { livePreview } from '../src/editor/livePreview'
|
||||
import { collectSpecs } from '../src/editor/syntax'
|
||||
import { buildDecorations, visibleSyntaxTree } from '../src/editor/decorations'
|
||||
|
||||
const views: EditorView[] = []
|
||||
|
||||
function mount(doc: string, anchor = 0, focus = true): EditorView {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor },
|
||||
extensions: [markdown({ base: markdownLanguage }), livePreview()],
|
||||
})
|
||||
const parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
const view = new EditorView({ state, parent })
|
||||
if (focus) view.focus()
|
||||
views.push(view)
|
||||
return view
|
||||
}
|
||||
|
||||
/** 通过公开 facet 读出当前生效的原子区间(升序)。 */
|
||||
function atomicRanges(view: EditorView): Array<[number, number]> {
|
||||
const out: Array<[number, number]> = []
|
||||
for (const fn of view.state.facet(EditorView.atomicRanges)) {
|
||||
fn(view).between(0, view.state.doc.length, (from, to) => {
|
||||
out.push([from, to])
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||
}
|
||||
|
||||
function lineTexts(view: EditorView): string[] {
|
||||
return [...view.dom.querySelectorAll('.cm-line')].map((el) => el.textContent ?? '')
|
||||
}
|
||||
|
||||
function replaceDoc(view: EditorView, from: number, to: number, insert: string): void {
|
||||
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const v of views.splice(0)) v.destroy()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('文首边界', () => {
|
||||
it('以标题开头:标记隐藏、整行 h1、标记区间原子化', () => {
|
||||
const doc = '# 标题\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
expect(view.dom.querySelectorAll('.cm-md-h1')).toHaveLength(1)
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
})
|
||||
|
||||
it('光标停在文档起点回退:不抛错、内容不变、光标不越界', () => {
|
||||
const doc = '# 标题\n\n正文'
|
||||
const view = mount(doc, 0)
|
||||
deleteCharBackward(view)
|
||||
expect(view.state.doc.toString()).toBe(doc)
|
||||
expect(view.state.selection.main.head).toBe(0)
|
||||
})
|
||||
|
||||
it('以任务项开头:复选框渲染在文首、标记让位、列表标记原子化', () => {
|
||||
const doc = '- [ ] 第一项\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelectorAll('.cm-md-task')).toHaveLength(1)
|
||||
expect(lineTexts(view)[0]).not.toContain('-')
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2]) // `- `
|
||||
})
|
||||
|
||||
it('以围栏开头:语言标签在文首渲染,块内不解析行内语法', () => {
|
||||
const doc = '```js\n**原样**\n```\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelector('.cm-md-fence-lang')?.textContent).toBe('js')
|
||||
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('以水平线开头:hr widget 渲染在文首', () => {
|
||||
const doc = '---\n\n正文'
|
||||
const view = mount(doc, doc.length)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
expect(lineTexts(view)[0]).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('文末边界', () => {
|
||||
it('以水平线结尾:hr widget 渲染在最后一行', () => {
|
||||
const doc = '正文\n\n---'
|
||||
const view = mount(doc, 0)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
const last = lineTexts(view).at(-1)
|
||||
expect(last).toBe('')
|
||||
expect(atomicRanges(view)).toContainEqual([doc.length - 3, doc.length])
|
||||
})
|
||||
|
||||
it('文档只有一条水平线:不抛错且渲染 hr', () => {
|
||||
const view = mount('---', 0)
|
||||
expect(view.state.doc.length).toBe(3)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('以图片结尾:img widget 在文末,src 与 alt 正确', () => {
|
||||
const doc = '正文\n\n'
|
||||
const view = mount(doc, 0)
|
||||
const img = view.dom.querySelector('.cm-md-image') as HTMLImageElement | null
|
||||
expect(img).not.toBeNull()
|
||||
expect(img?.getAttribute('src')).toBe('/api/images/9')
|
||||
expect(img?.getAttribute('alt')).toBe('配图')
|
||||
})
|
||||
|
||||
it('以围栏结尾:闭围栏隐藏,末行仍带 fenced 类', () => {
|
||||
const doc = '正文\n\n```js\nconst a = 1\n```'
|
||||
const view = mount(doc, 0)
|
||||
expect(lineTexts(view).at(-1)).toBe('')
|
||||
expect(lineTexts(view).at(-2)).toBe('const a = 1')
|
||||
expect(view.dom.querySelectorAll('.cm-md-fenced')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('以任务项结尾:复选框渲染在最后一行', () => {
|
||||
const doc = '正文\n\n- [x] 末项'
|
||||
const view = mount(doc, 0)
|
||||
const tasks = view.dom.querySelectorAll('.cm-md-task')
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect((tasks[0] as HTMLInputElement).checked).toBe(true)
|
||||
expect(lineTexts(view).at(-1)).not.toContain('[x]')
|
||||
})
|
||||
|
||||
it('文末追加内容后装饰正确重建(不给文末留残影)', () => {
|
||||
const doc = '正文\n\n---'
|
||||
const view = mount(doc, 0)
|
||||
view.dispatch({ changes: { from: doc.length, insert: '\n\n## 新小节\n\n新段落' } })
|
||||
expect(lineTexts(view).some((t) => t === '新小节')).toBe(true)
|
||||
expect(view.dom.querySelectorAll('.cm-md-h2')).toHaveLength(1)
|
||||
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('文末整体清空(全选删除)不抛错', () => {
|
||||
const doc = '# 标题\n\n- A\n\n\n\n---\n\n```js\nx\n```\n'
|
||||
const view = mount(doc, 0)
|
||||
replaceDoc(view, 0, doc.length, '')
|
||||
expect(view.state.doc.toString()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('跨原子区间选区', () => {
|
||||
const doc = '# 标题\n\n- 甲\n\n段落 **粗体** 结尾\n'
|
||||
|
||||
it('全选替换:跨多个隐藏标记的选区可整体替换', () => {
|
||||
const view = mount(doc, doc.length)
|
||||
replaceDoc(view, 0, doc.length, 'X')
|
||||
expect(view.state.doc.toString()).toBe('X')
|
||||
})
|
||||
|
||||
it('选区横跨隐藏标记:替换结果不残留标记碎片', () => {
|
||||
const view = mount(doc, doc.length)
|
||||
const boldStart = doc.indexOf('**')
|
||||
replaceDoc(view, 0, boldStart + 2, '替换')
|
||||
expect(view.state.doc.toString()).toBe('替换粗体** 结尾\n')
|
||||
})
|
||||
|
||||
it('只选中可见内容时替换,标记完整保留', () => {
|
||||
const doc2 = '段落 **粗体** 结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
const from = doc2.indexOf('粗体')
|
||||
replaceDoc(view, from, from + 2, 'X')
|
||||
expect(view.state.doc.toString()).toBe('段落 **X** 结尾')
|
||||
})
|
||||
|
||||
it('选中「标记 + 内容 + 标记」整体删除后,只剩两侧文字', () => {
|
||||
const doc2 = '段落 **粗体** 结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
replaceDoc(view, 3, 9, '')
|
||||
expect(view.state.doc.toString()).toBe('段落 结尾')
|
||||
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('删除跨行的选区(含列表行与引用行)后装饰重建正确', () => {
|
||||
const doc2 = '前言\n\n- 甲\n- 乙\n\n> 引用\n\n结尾'
|
||||
const view = mount(doc2, doc2.length)
|
||||
replaceDoc(view, 2, doc2.indexOf('结尾'), '')
|
||||
const left = view.state.doc.toString()
|
||||
expect(left).toContain('前言')
|
||||
expect(left).toContain('结尾')
|
||||
expect(left).not.toContain('甲')
|
||||
expect(left).not.toContain('引用')
|
||||
expect(view.dom.querySelectorAll('.cm-md-bullet')).toHaveLength(0)
|
||||
expect(view.dom.querySelectorAll('.cm-md-quote-bar')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('装饰层不变量', () => {
|
||||
const docs = [
|
||||
'# 标题\n\n正文 **粗** 与 *斜* 与 `码` 与 [链](https://a.example)\n',
|
||||
'- [ ] 待办\n- [x] 完成\n - 嵌套\n1. 有序\n',
|
||||
'> 一\n> > 二\n\n---\n\n```js\ncode\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n',
|
||||
'\n\n结尾\n',
|
||||
]
|
||||
|
||||
it('原子区间不跨行(CM6 硬性约束)', () => {
|
||||
for (const doc of docs) {
|
||||
for (const anchor of [0, doc.length]) {
|
||||
const view = mount(doc, anchor)
|
||||
for (const [from, to] of atomicRanges(view)) {
|
||||
expect(view.state.doc.lineAt(from).number).toBe(view.state.doc.lineAt(to).number)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('原子区间有序且互不重叠', () => {
|
||||
for (const doc of docs) {
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
for (let i = 1; i < ranges.length; i++) {
|
||||
expect(ranges[i][0]).toBeGreaterThanOrEqual(ranges[i - 1][1])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('隐藏标记一定落在原子区间内(退格可整段删净)', () => {
|
||||
const doc = '# 标题\n\n- 甲\n> 乙\n\n**粗**\n'
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
const tree = syntaxTree(view.state)
|
||||
for (const spec of collectSpecs(tree, view.state.doc, 0, doc.length)) {
|
||||
if (spec.kind !== 'hide') continue
|
||||
expect(ranges).toContainEqual([spec.from, spec.to])
|
||||
}
|
||||
})
|
||||
|
||||
it('强调 / 行内代码 / 链接的内容区间绝不被原子化(§4.2)', () => {
|
||||
const doc = '文字 **粗体** 与 `代码` 与 [链接](https://a.example) 结束\n'
|
||||
const view = mount(doc, doc.length)
|
||||
const ranges = atomicRanges(view)
|
||||
const tree = syntaxTree(view.state)
|
||||
const contentSpecs = collectSpecs(tree, view.state.doc, 0, doc.length).filter(
|
||||
(s) => s.kind === 'mark' && ['cm-md-strong', 'cm-md-code', 'cm-md-link'].includes(s.cls),
|
||||
)
|
||||
expect(contentSpecs).toHaveLength(3)
|
||||
for (const spec of contentSpecs) {
|
||||
for (const [from, to] of ranges) {
|
||||
expect(spec.from >= to || spec.to <= from).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reveal 契约:光标进入某行后该行原子区间消失,离开后恢复', () => {
|
||||
const doc = '# 标题\n\n正文 **粗** 结束\n'
|
||||
const view = mount(doc, doc.length) // 光标在末行
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
|
||||
view.dispatch({ selection: { anchor: 1 } }) // 光标进入标题行
|
||||
expect(atomicRanges(view)).not.toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('# 标题')
|
||||
|
||||
view.dispatch({ selection: { anchor: doc.length } }) // 离开
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
})
|
||||
|
||||
it('失焦(未聚焦)时不展开任何行,整篇按渲染态呈现', () => {
|
||||
const doc = '# 标题\n\n正文\n'
|
||||
const view = mount(doc, 5, false) // 不调用 focus()
|
||||
expect(view.hasFocus).toBe(false)
|
||||
expect(lineTexts(view)[0]).toBe('标题')
|
||||
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('长文档:惰性解析与装饰补齐', () => {
|
||||
function buildDoc(lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
|
||||
it('如实记录:syntaxTree 对长文档只返回已解析的前段(lezer 惰性增量)', () => {
|
||||
const partial = syntaxTree(state).length
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[parse] 3000 行文档(${doc.length} 字符):syntaxTree 首次仅覆盖 ${partial} 字符`)
|
||||
expect(partial).toBeLessThan(doc.length)
|
||||
expect(partial).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('visibleSyntaxTree 在给定预算内把可见区间补齐,装饰不再缺失', () => {
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||
const cold = syntaxTree(state).length
|
||||
const tree = visibleSyntaxTree(state, [win], 2000)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[parse] 补解析窗口 [${win.from}, ${win.to}]:树覆盖 ${cold} → ${tree.length}`)
|
||||
expect(tree.length).toBeGreaterThanOrEqual(win.to)
|
||||
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||
expect(decorations.size).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('预算受限时越界区间保持源码态,且不会抛错(等解析推进后自动补齐)', () => {
|
||||
const win = { from: state.doc.line(2900).from, to: state.doc.line(2950).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 20)
|
||||
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||
if (tree.length < win.from) expect(decorations.size).toBe(0)
|
||||
else expect(decorations.size).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('装饰不会越过已解析边界(避免对半截节点做出错误渲染)', () => {
|
||||
const partialLen = syntaxTree(state).length
|
||||
const { decorations } = buildDecorations(state, [{ from: 0, to: doc.length }], false, syntaxTree(state))
|
||||
let maxTo = 0
|
||||
decorations.between(0, doc.length, (_f, to) => {
|
||||
if (to > maxTo) maxTo = to
|
||||
})
|
||||
expect(maxTo).toBeLessThanOrEqual(partialLen)
|
||||
})
|
||||
|
||||
it('光标行远在可见区间之外时仍被装饰(CM 会单独渲染选区所在块)', () => {
|
||||
// 选区停在文档开头(第 1 行是 `## 小节 0`),可见区间取远端窗口
|
||||
const win = { from: state.doc.line(2500).from, to: state.doc.line(2510).to }
|
||||
const { decorations } = buildDecorations(state, [win], false)
|
||||
let startLineClass: string | null = null
|
||||
decorations.between(0, state.doc.line(1).to, (_f, _t, value) => {
|
||||
if (typeof value.spec.class === 'string') startLineClass = value.spec.class
|
||||
})
|
||||
expect(startLineClass).toBe('cm-md-h2')
|
||||
|
||||
// 远端窗口同样被装饰:补解析 + 采集覆盖了两段
|
||||
let farCount = 0
|
||||
decorations.between(win.from, win.to, () => {
|
||||
farCount++
|
||||
})
|
||||
expect(farCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('性能基准(§7.4:3000 行文档单帧 < 16ms)', () => {
|
||||
function buildDoc(lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
const median = (xs: number[]): number => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)]
|
||||
|
||||
it('视口窗口(约 100 行)装饰计算中位数 < 16ms', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 5000) // 预热到窗口可用
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const t0 = performance.now()
|
||||
const specs = collectSpecs(tree, state.doc, win.from, win.to)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(specs.length).toBeGreaterThan(50)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 3000 行 / 视口 100 行:collectSpecs 中位数 ${med.toFixed(3)}ms,p95 ${median(samples.slice().sort((a, b) => a - b).slice(-2)).toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(16)
|
||||
})
|
||||
|
||||
it('整篇作为单一可见区间(最坏情况)仍远低于一帧量级', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||
expect(tree.length).toBe(doc.length)
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const t0 = performance.now()
|
||||
const specs = collectSpecs(tree, state.doc, 0, doc.length)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(specs.length).toBeGreaterThan(3000)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 3000 行 / 全篇单区间:collectSpecs 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('冷启动成本实测:首次强制解析整篇 3000 行', () => {
|
||||
const doc = buildDoc(3000)
|
||||
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||
const t0 = performance.now()
|
||||
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||
const ms = performance.now() - t0
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[perf] 3000 行(${doc.length} 字符)全量解析耗时 ${ms.toFixed(1)}ms(树覆盖 ${tree.length} 字符)`)
|
||||
expect(tree.length).toBe(doc.length)
|
||||
})
|
||||
|
||||
it('最坏选区:Cmd+A 整篇选区 + 视口窗口,装饰构建仍在一帧预算内', () => {
|
||||
const doc = buildDoc(3000)
|
||||
// 整篇选区 + 聚焦(触发 reveal,展开区间多达 3000 个)
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor: 0, head: doc.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
const win = { from: state.doc.line(1500).from, to: state.doc.line(1560).to }
|
||||
const tree = visibleSyntaxTree(state, [win], 10000)
|
||||
|
||||
const samples: number[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const t0 = performance.now()
|
||||
const { decorations } = buildDecorations(state, [win], true, tree)
|
||||
samples.push(performance.now() - t0)
|
||||
expect(decorations.size).toBeGreaterThan(0)
|
||||
}
|
||||
const med = median(samples)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[perf] 最坏选区(整篇选中):buildDecorations 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||
)
|
||||
expect(med).toBeLessThan(16)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
// 即时渲染装饰层单测(方案 §8):
|
||||
// 1) syntax.ts 区间识别:标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 /
|
||||
// 任务 / 围栏 / 水平线 / 表格,覆盖嵌套、代码块内不解析、中文标点;
|
||||
// 2) reveal.ts:行粒度展开只撤销「标记替代物」,样式类保留;
|
||||
// 3) decorations.ts:白名单外的图片 src 退回源码文本;widget DOM 无注入面。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { collectSpecs, type Spec } from '../src/editor/syntax'
|
||||
import { activeLineRanges, applyReveal, intersectsAny, selectionEndpointLines } from '../src/editor/reveal'
|
||||
import { buildDecorations, IMAGE_SRC_ALLOW } from '../src/editor/decorations'
|
||||
import { FenceLangWidget, ImageWidget, TaskWidget } from '../src/editor/widgets'
|
||||
|
||||
function makeState(src: string, cursor = 0) {
|
||||
return EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: cursor },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集全文档 specs,按位置排序,便于断言。 */
|
||||
function collect(src: string): Spec[] {
|
||||
const state = makeState(src)
|
||||
const specs = collectSpecs(syntaxTree(state), state.doc, 0, src.length)
|
||||
return specs.slice().sort((a, b) => a.from - b.from || a.to - b.to)
|
||||
}
|
||||
|
||||
/** 断言用的简化视图:`kind:原文`。 */
|
||||
function digest(src: string, specs = collect(src)): string[] {
|
||||
return specs.map((s) => {
|
||||
if (s.kind === 'line') return `line(#${s.line}:${s.cls})`
|
||||
const text = src.slice(s.from, s.to)
|
||||
const extra =
|
||||
s.kind === 'bullet' || s.kind === 'quote' ? ` d${s.depth}` : s.kind === 'mark' ? ` ${s.cls}` : ''
|
||||
return `${s.kind}(${JSON.stringify(text)}${extra})`
|
||||
})
|
||||
}
|
||||
|
||||
describe('syntax.ts 区间识别', () => {
|
||||
it('标题:隐藏 `# `,整行上 h1 类,行内语法继续解析', () => {
|
||||
const src = '# 标题 **粗**'
|
||||
const d = digest(src)
|
||||
expect(d).toContain('line(#1:cm-md-h1)')
|
||||
expect(d).toContain('hide("# ")')
|
||||
expect(d).toContain('hide("**")')
|
||||
expect(d).toContain('mark("粗" cm-md-strong)')
|
||||
})
|
||||
|
||||
it('强调:隐藏定界符,内容上样式(含嵌套)', () => {
|
||||
const d = digest('a **粗** b *斜* c ~~删~~')
|
||||
expect(d).toEqual([
|
||||
'hide("**")',
|
||||
'mark("粗" cm-md-strong)',
|
||||
'hide("**")',
|
||||
'hide("*")',
|
||||
'mark("斜" cm-md-em)',
|
||||
'hide("*")',
|
||||
'hide("~~")',
|
||||
'mark("删" cm-md-del)',
|
||||
'hide("~~")',
|
||||
])
|
||||
})
|
||||
|
||||
it('行内代码:隐藏反引号,内容不二次解析(`*x*` 保持字面量)', () => {
|
||||
const d = digest('`*x*`')
|
||||
expect(d).toEqual(['hide("`")', 'mark("*x*" cm-md-code)', 'hide("`")'])
|
||||
})
|
||||
|
||||
it('链接:隐藏 `[` 与 `](url)`,文本上链接类;不产出 <a>', () => {
|
||||
const src = '[标题](https://a.example/x)'
|
||||
const d = digest(src)
|
||||
expect(d).toEqual(['hide("[")', 'mark("标题" cm-md-link)', 'hide("](https://a.example/x)")'])
|
||||
})
|
||||
|
||||
it('图片:整段替换为 image spec,带 alt 与 src', () => {
|
||||
const specs = collect('')
|
||||
const img = specs.find((s) => s.kind === 'image')
|
||||
expect(img).toMatchObject({ alt: '配图', src: '/api/images/7' })
|
||||
})
|
||||
|
||||
it('引用:每个 QuoteMark 一个竖线 widget,嵌套深度递增,同行去重加 cm-md-quote', () => {
|
||||
const src = '> 一\n> > 二'
|
||||
const d = digest(src)
|
||||
expect(d.filter((x) => x.startsWith('quote('))).toEqual(['quote(">" d1)', 'quote(">" d1)', 'quote(">" d2)'])
|
||||
// 第 2 行有两个 QuoteMark,原样各出一条行类;构建阶段按键去重后每行只剩一条
|
||||
expect(d.filter((x) => x === 'line(#1:cm-md-quote)')).toHaveLength(1)
|
||||
expect(d.filter((x) => x === 'line(#2:cm-md-quote)')).toHaveLength(2)
|
||||
|
||||
const state = makeState(src)
|
||||
const { decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||
const lineClasses: unknown[] = []
|
||||
decorations.between(0, src.length, (_f, _t, value) => {
|
||||
if (value.spec.class) lineClasses.push(value.spec.class)
|
||||
})
|
||||
expect(lineClasses.filter((c) => c === 'cm-md-quote')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('无序列表:圆点 widget 覆盖「标记 + 空格」,嵌套深度递增', () => {
|
||||
const d = digest('- A\n - B')
|
||||
expect(d.filter((x) => x.startsWith('bullet('))).toEqual(['bullet("- " d1)', 'bullet("- " d2)'])
|
||||
})
|
||||
|
||||
it('有序列表:保留数字,仅上样式,不替换不原子(决策 E3)', () => {
|
||||
const d = digest('1. 一\n2. 二')
|
||||
expect(d).toEqual(['mark("1." cm-md-ol-mark)', 'mark("2." cm-md-ol-mark)'])
|
||||
})
|
||||
|
||||
it('任务项:标记让位给复选框,不画圆点;checked 正确', () => {
|
||||
const d = digest('- [ ] 待办\n- [x] 完成')
|
||||
expect(d.some((x) => x.startsWith('bullet('))).toBe(false)
|
||||
expect(d).toContain('hide("- ")')
|
||||
expect(d.filter((x) => x.startsWith('task('))).toEqual(['task("[ ]")', 'task("[x]")'])
|
||||
const specs = collect('- [x] 完成')
|
||||
expect(specs.find((s) => s.kind === 'task')).toMatchObject({ checked: true })
|
||||
})
|
||||
|
||||
it('围栏:语言标签 + 隐藏闭围栏 + 每行 fenced 类;块内不解析行内语法', () => {
|
||||
const src = '```js\n**not bold**\n```'
|
||||
const d = digest(src)
|
||||
expect(d).toContain('fenceLang("```js")')
|
||||
expect(collect(src).find((s) => s.kind === 'fenceLang')).toMatchObject({ lang: 'js' })
|
||||
expect(d).toContain('line(#1:cm-md-fenced cm-md-fenced-first)')
|
||||
expect(d).toContain('line(#3:cm-md-fenced cm-md-fenced-last)')
|
||||
expect(d.some((x) => x.includes('cm-md-strong'))).toBe(false)
|
||||
})
|
||||
|
||||
it('无语言围栏:起始围栏直接隐藏', () => {
|
||||
const d = digest('```\nx\n```')
|
||||
expect(d.some((x) => x.startsWith('fenceLang('))).toBe(false)
|
||||
expect(d).toContain('hide("```")')
|
||||
})
|
||||
|
||||
it('水平线:整行替换', () => {
|
||||
const d = digest('---')
|
||||
expect(d).toEqual(['hr("---")'])
|
||||
})
|
||||
|
||||
it('表格:每行 cm-md-table,定界符淡化(分隔行两级)', () => {
|
||||
const src = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
||||
const d = digest(src)
|
||||
expect(d.filter((x) => x.startsWith('line('))).toEqual([
|
||||
'line(#1:cm-md-table)',
|
||||
'line(#2:cm-md-table)',
|
||||
'line(#3:cm-md-table)',
|
||||
])
|
||||
const specs = collect(src)
|
||||
const delims = specs.filter((s): s is Extract<Spec, { kind: 'tableMark' }> => s.kind === 'tableMark')
|
||||
expect(delims.some((s) => s.delimiter)).toBe(true)
|
||||
expect(delims.filter((s) => s.delimiter)).toHaveLength(1) // 只有分隔行是两级淡化
|
||||
expect(delims).toHaveLength(7) // 4 行共 7 个竖线标记
|
||||
})
|
||||
|
||||
it('中文标点与紧邻标记不误判', () => {
|
||||
const d = digest('他说:「**重点**」。')
|
||||
expect(d).toEqual(['hide("**")', 'mark("重点" cm-md-strong)', 'hide("**")'])
|
||||
})
|
||||
|
||||
it('转义星号不触发强调', () => {
|
||||
const d = digest('\\*不是斜体\\*')
|
||||
expect(d).toEqual([])
|
||||
})
|
||||
|
||||
it('可见区间裁剪:只采集区间内节点', () => {
|
||||
const src = '# 一\n\n# 二'
|
||||
const state = makeState(src)
|
||||
const line3 = state.doc.line(3)
|
||||
const specs = collectSpecs(syntaxTree(state), state.doc, line3.from, line3.to)
|
||||
expect(specs.filter((s) => s.kind === 'line')).toHaveLength(1)
|
||||
expect(specs.find((s) => s.kind === 'line')).toMatchObject({ line: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reveal.ts 光标处展开(行粒度)', () => {
|
||||
it('intersectsAny 只认真正的相交', () => {
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 5, 6)).toBe(true)
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 6, 8)).toBe(false)
|
||||
expect(intersectsAny([{ from: 0, to: 5 }], 3, 3)).toBe(false)
|
||||
})
|
||||
|
||||
it('光标行上的标记替代物被撤销,样式类保留', () => {
|
||||
const src = 'a **粗** b'
|
||||
const state = makeState(src, 5) // 光标落在粗体所在行
|
||||
const specs = collect(src)
|
||||
const shown = applyReveal(specs, activeLineRanges(state))
|
||||
expect(shown.some((s) => s.kind === 'hide')).toBe(false)
|
||||
expect(shown.some((s) => s.kind === 'mark' && s.cls === 'cm-md-strong')).toBe(true)
|
||||
})
|
||||
|
||||
it('光标在别的行时标记保持隐藏', () => {
|
||||
const src = '第一行\n第二行 **粗**'
|
||||
const state = makeState(src, 0)
|
||||
const specs = collect(src)
|
||||
const shown = applyReveal(specs, activeLineRanges(state))
|
||||
expect(shown.filter((s) => s.kind === 'hide')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('选区横跨多行时,涉及的行全部展开', () => {
|
||||
const src = '> 一\n> > 二'
|
||||
const state = EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: 0, head: src.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
const shown = applyReveal(collect(src), activeLineRanges(state))
|
||||
expect(shown.filter((s) => s.kind === 'quote')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('二分优化与线性扫描结果完全一致(含空/重叠/乱序/相邻区间)', () => {
|
||||
const src = '# 标题 **粗** 与 `码` 与 [链](https://a.example)\n\n- 甲\n> 乙\n\n---\n'
|
||||
const specs = collect(src)
|
||||
const cases: { from: number; to: number }[][] = [
|
||||
[],
|
||||
[{ from: 0, to: 2 }],
|
||||
[{ from: 0, to: 2 }, { from: 2, to: 4 }],
|
||||
[{ from: 5, to: 9 }, { from: 0, to: 3 }], // 乱序
|
||||
[{ from: 0, to: 10 }, { from: 4, to: 6 }], // 重叠
|
||||
[{ from: 0, to: src.length }], // 整篇
|
||||
[{ from: 3, to: 3 }], // 零长
|
||||
]
|
||||
for (const ranges of cases) {
|
||||
const linear = specs.filter((s) => !(s.reveal && intersectsAny(ranges, s.from, s.to)))
|
||||
expect(applyReveal(specs, ranges)).toEqual(linear)
|
||||
}
|
||||
})
|
||||
|
||||
it('selectionEndpointLines 只取选区端点行(Cmd+A 不退化为全文档装饰)', () => {
|
||||
const src = '第 1 行\n第 2 行\n第 3 行\n第 4 行'
|
||||
const state = EditorState.create({
|
||||
doc: src,
|
||||
selection: { anchor: 0, head: src.length },
|
||||
extensions: [markdown({ base: markdownLanguage })],
|
||||
})
|
||||
expect(activeLineRanges(state)).toHaveLength(4)
|
||||
const endpoints = selectionEndpointLines(state)
|
||||
expect(endpoints).toHaveLength(2)
|
||||
expect(endpoints[0].from).toBe(0)
|
||||
expect(endpoints[1].to).toBe(state.doc.line(4).to)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decorations.ts 构建与安全', () => {
|
||||
const src = '# 标题\n\n- A\n\n\n'
|
||||
|
||||
it('生成装饰集且原子区间与隐藏区间数量一致', () => {
|
||||
const state = makeState(src)
|
||||
const { decorations, atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||
expect(decorations.size).toBeGreaterThan(0)
|
||||
// hide(1: `# `) + hide(1: `- `) = 2 个原子区间
|
||||
expect(atomic.size).toBe(2)
|
||||
})
|
||||
|
||||
it('光标行上的标记不进入原子区间', () => {
|
||||
const state = makeState(src, 0) // 光标在标题行
|
||||
const { atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||
expect(atomic.size).toBe(2) // 标题标记已展开,只剩列表标记 + 图片替换
|
||||
})
|
||||
|
||||
it('未聚焦(reveal=false)时整篇按渲染态呈现,光标行的标记也隐藏(决策 E11)', () => {
|
||||
const state = makeState(src, 0)
|
||||
const { atomic, decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||
expect(atomic.size).toBe(3) // 标题 + 列表 + 图片全部原子化
|
||||
const hidden: string[] = []
|
||||
decorations.between(0, src.length, (from, to) => {
|
||||
if (to > from) hidden.push(src.slice(from, to))
|
||||
})
|
||||
expect(hidden).toContain('# ')
|
||||
})
|
||||
|
||||
it('图片 src 白名单:站内接口放行,javascript:/外链降级为源码文本', () => {
|
||||
expect(IMAGE_SRC_ALLOW.test('/api/images/12')).toBe(true)
|
||||
expect(IMAGE_SRC_ALLOW.test('data:image/png;base64,AAAA')).toBe(true)
|
||||
expect(IMAGE_SRC_ALLOW.test('javascript:alert(1)')).toBe(false)
|
||||
expect(IMAGE_SRC_ALLOW.test('https://evil.example/x.png')).toBe(false)
|
||||
|
||||
const bad = ')'
|
||||
const badState = makeState(bad)
|
||||
const badSet = buildDecorations(badState, [{ from: 0, to: bad.length }], true).decorations
|
||||
expect(badSet.size).toBe(0) // 不产出 <img>,源码原样可见
|
||||
|
||||
const goodState = makeState(src)
|
||||
const goodSet = buildDecorations(goodState, [{ from: 0, to: src.length }], true).decorations
|
||||
const widgets: unknown[] = []
|
||||
goodSet.between(0, src.length, (_from, _to, value) => {
|
||||
widgets.push(value.spec.widget)
|
||||
})
|
||||
expect(widgets.some((w) => w instanceof ImageWidget)).toBe(true)
|
||||
})
|
||||
|
||||
it('widget DOM 走 textContent,语言名里的 HTML 不被解析', () => {
|
||||
const dom = new FenceLangWidget('<img src=x onerror=alert(1)>').toDOM()
|
||||
expect(dom.children).toHaveLength(0)
|
||||
expect(dom.innerHTML).not.toContain('<img')
|
||||
expect(dom.textContent).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('图片 widget 不写入 innerHTML,只设置属性', () => {
|
||||
const dom = new ImageWidget('/api/images/1', '"><script>alert(1)</script>').toDOM()
|
||||
expect(dom.querySelector('script')).toBeNull()
|
||||
expect(dom.getAttribute('src')).toBe('/api/images/1')
|
||||
expect(dom.getAttribute('alt')).toBe('"><script>alert(1)</script>')
|
||||
})
|
||||
|
||||
it('任务 widget 挂载时不产生额外 DOM 面', () => {
|
||||
const dom = new TaskWidget(0, 3, false).toDOM({ dispatch() {} } as never) as HTMLInputElement
|
||||
expect(dom.tagName).toBe('INPUT')
|
||||
expect(dom.getAttribute('type')).toBe('checkbox')
|
||||
expect(dom.checked).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user