// 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) }) })