feat: 阶段1-3 桌面编辑器(Tauri 骨架/编辑核心深化/复杂内容)
This commit is contained in:
190
tests/stage3.test.ts
Normal file
190
tests/stage3.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// 阶段 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<Editor> {
|
||||
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<string> {
|
||||
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('一段<u>下划线</u>文本。\n\n<div class="note">原始 HTML 块</div>\n')
|
||||
expect(once).toContain('<u>下划线</u>')
|
||||
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/')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user