- 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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user