Files
pure-note/web/tests/editor-boundaries.test.ts
T
wangairnan c8cdb56bdd
Release / release (push) Successful in 5m6s
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 的性能结论即由本文件的基准用例
与真实浏览器测量共同得出。
2026-09-11 01:03:23 +08:00

450 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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![配图](/api/images/9)'
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![x](/api/images/1)\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',
'![图](/api/images/1)\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)
})
})