// 阶段 3 验收:表格操作、数学公式、Mermaid 视图、脚注/HTML 转正、图片命令、附件链接。 import { describe, it, expect, afterEach } from 'vitest' import type { Editor } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core' import { TextSelection } from '@milkdown/kit/prose/state' import { createMarkdownEditor, getMarkdown } from '../src/editor/editor' import { editorCommands, insertMathBlock, insertMermaidBlock } from '../src/editor/commands' import { insertLinkText } from '../src/editor/paste' import { selectionInTable, isLastTableCell } from '../src/editor/plugins' let editors: Editor[] = [] afterEach(async () => { for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } } editors = [] document.body.innerHTML = '' }) async function load(md: string): Promise { const root = document.createElement('div') document.body.appendChild(root) const editor = createMarkdownEditor(root, md) editors.push(editor) await editor.create() return editor } async function roundtrip(md: string): Promise { const once = getMarkdown(await load(md)) const twice = getMarkdown(await load(once)) expect(twice).toBe(once) return once } function setSelection(editor: Editor, pos: number) { editor.action((ctx) => { const view = ctx.get(editorViewCtx) view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos))) }) } function findTextPos(editor: Editor, text: string): number { let found = -1 editor.action((ctx) => { ctx.get(editorViewCtx).state.doc.descendants((node, pos) => { if (found < 0 && node.isText && node.text?.includes(text)) found = pos + node.text.indexOf(text) return found < 0 }) }) return found } const TABLE_MD = '| 名称 | 数量 |\n| :--- | ---: |\n| 苹果 | 3 |\n| 香蕉 | 5 |\n' describe('表格', () => { it('插入 3×3 表格命令生成表头与数据行', async () => { const editor = await load('正文。\n') setSelection(editor, 2) editorCommands.insertTable(editor, 3, 3) const md = getMarkdown(editor) expect(md).toContain('|') expect(md.split('\n').filter((l) => l.includes('|')).length).toBeGreaterThanOrEqual(4) }) it('光标在单元格内可判定表格上下文与末格', async () => { const editor = await load(TABLE_MD) const pos = findTextPos(editor, '香蕉') setSelection(editor, pos) editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(selectionInTable(view.state.selection as never)).toBe(true) // “香蕉”在最后一行第一列,不是末格;其右邻“5”所在格是末格 expect(isLastTableCell(view.state as never)).toBe(false) }) const lastPos = findTextPos(editor, '5') setSelection(editor, lastPos) editor.action((ctx) => { expect(isLastTableCell(ctx.get(editorViewCtx).state as never)).toBe(true) }) }) it('加行/加列/删列改变结构且往返稳定', async () => { const editor = await load(TABLE_MD) setSelection(editor, findTextPos(editor, '苹果')) editorCommands.addRowAfter(editor) let md = getMarkdown(editor) expect(md.split('\n').filter((l) => l.startsWith('|')).length).toBe(5) // 表头+分隔+3 数据行 editorCommands.addColAfter(editor) md = getMarkdown(editor) expect(md.split('\n')[0].split('|').length).toBeGreaterThanOrEqual(5) // 3 列 editorCommands.deleteCol(editor) const twice = getMarkdown(await load(getMarkdown(editor))) expect(twice).toBe(getMarkdown(editor)) }) it('对齐命令改变列对齐并保留到序列化', async () => { const editor = await load(TABLE_MD) setSelection(editor, findTextPos(editor, '苹果')) editorCommands.setAlign(editor, 'center') const md = getMarkdown(editor) expect(md).toMatch(/:-+:/) }) }) describe('数学公式(KaTeX 插件)', () => { it('行内公式往返保留 LaTeX', async () => { const once = await roundtrip('质能方程 $E = mc^2$ 是物理学常识。\n') expect(once).toContain('E = mc^2') expect(once).toContain('$') }) it('块级公式往返保留', async () => { const once = await roundtrip('$$\n\\frac{a}{b} + \\sqrt{x}\n$$\n') expect(once).toContain('\\frac{a}{b}') expect(once).toContain('$$') }) it('插入公式块命令产出 $$ 块', async () => { const editor = await load('正文。\n') setSelection(editor, 2) insertMathBlock(editor) expect(getMarkdown(editor)).toContain('$$') expect(getMarkdown(editor)).toContain('E = mc^2') }) }) describe('Mermaid 与代码块视图', () => { it('Mermaid 块渲染出节点视图容器,源码保留', async () => { const source = '```mermaid\ngraph TD\n A[开始] --> B[结束]\n```\n' const editor = await load(source) editor.action((ctx) => { const view = ctx.get(editorViewCtx) expect(view.dom.querySelector('.mermaid-block')).not.toBeNull() }) expect(getMarkdown(editor)).toContain('graph TD') }) it('普通代码块仍是 pre>code 且内容可序列化', async () => { const editor = await load('```ts\nconst v = 42;\n```\n') editor.action((ctx) => { const code = ctx.get(editorViewCtx).dom.querySelector('pre code') expect(code?.textContent).toContain('const v = 42;') }) expect(getMarkdown(editor)).toContain('const v = 42;') }) it('插入 Mermaid 命令产出 mermaid 代码块', async () => { const editor = await load('正文。\n') setSelection(editor, 2) insertMermaidBlock(editor) const md = getMarkdown(editor) expect(md).toContain('```mermaid') expect(md).toContain('graph TD') }) }) describe('脚注与 HTML(阶段 0 探针转正)', () => { it('脚注定义与引用往返稳定', async () => { const once = await roundtrip('正文带脚注[^1],还有第二个[^note]。\n\n[^1]: 第一条脚注。\n\n[^note]: 第二条脚注。\n') expect(once).toContain('[^1]') expect(once).toContain('第一条脚注') expect(once).toContain('[^note]') }) it('行内与块级 HTML 往返保留', async () => { const once = await roundtrip('一段下划线文本。\n\n
原始 HTML 块
\n') expect(once).toContain('下划线') expect(once).toContain('原始 HTML 块') }) }) describe('图片与附件', () => { it('图片命令插入相对路径图片并往返保留', async () => { const editor = await load('看图:\n') setSelection(editor, 3) editorCommands.image(editor, '文章.assets/image-1.png', '示意图') const md = getMarkdown(editor) expect(md).toContain('文章.assets/image-1.png') const twice = getMarkdown(await load(md)) expect(twice).toBe(md) }) it('附件以链接形式插入并往返保留', async () => { const editor = await load('附件:\n') setSelection(editor, 3) expect(insertLinkText(editor, '说明.pdf', '文档.assets/说明.pdf')).toBe(true) const md = getMarkdown(editor) expect(md).toContain('说明.pdf') expect(md).toContain('文档.assets/') }) })