Files
pure-note/web/tests/editor-live-preview.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

312 lines
13 KiB
TypeScript

// 即时渲染装饰层单测(方案 §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('![配图](/api/images/7)')
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![x](/api/images/1)\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 = '![x](javascript:alert(1))'
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)
})
})