// 即时渲染装饰层单测(方案 §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)`,文本上链接类;不产出 ', () => { 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 => 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) // 不产出 ,源码原样可见 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('').toDOM() expect(dom.children).toHaveLength(0) expect(dom.innerHTML).not.toContain('') }) it('图片 widget 不写入 innerHTML,只设置属性', () => { const dom = new ImageWidget('/api/images/1', '">').toDOM() expect(dom.querySelector('script')).toBeNull() expect(dom.getAttribute('src')).toBe('/api/images/1') expect(dom.getAttribute('alt')).toBe('">') }) 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) }) })