// 阶段 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/')
})
})