docs: 阶段0 技术验证(选型/架构/原型与验证记录)
This commit is contained in:
62
prototype/tests/editing.test.ts
Normal file
62
prototype/tests/editing.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// 阶段 0 编辑行为验收:撤销/重做、程序化插入后的选区落点、输入规则即时渲染。
|
||||
// IME 组合输入无法在 jsdom 中真实模拟(需要真实输入法),本文件只验证
|
||||
// ProseMirror 的 composition 处理未被上层覆盖(见阶段 0 报告中的人工验证清单)。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { editorViewCtx } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown, callUndo, callRedo } from '../src/editor'
|
||||
|
||||
let editors: Editor[] = []
|
||||
afterEach(async () => {
|
||||
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
|
||||
editors = []
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function load(markdown: string): Promise<Editor> {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, markdown)
|
||||
editors.push(editor)
|
||||
await editor.create()
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('编辑行为', () => {
|
||||
it('撤销恢复到原文,重做再次应用修改', async () => {
|
||||
const editor = await load('原始内容\n')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.insertText('追加文字', view.state.doc.content.size))
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('追加文字')
|
||||
callUndo(editor)
|
||||
expect(getMarkdown(editor)).not.toContain('追加文字')
|
||||
expect(getMarkdown(editor)).toContain('原始内容')
|
||||
callRedo(editor)
|
||||
expect(getMarkdown(editor)).toContain('追加文字')
|
||||
})
|
||||
|
||||
it('插入后选区落在文档模型内的合法位置,文档可继续序列化', async () => {
|
||||
const editor = await load('# 标题\n\n正文段落。\n')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const pos = view.state.doc.content.size
|
||||
view.dispatch(view.state.tr.insertText('尾巴'))
|
||||
const sel = view.state.selection
|
||||
expect(sel.from).toBeGreaterThan(0)
|
||||
expect(sel.from).toBeLessThanOrEqual(view.state.doc.content.size + 1)
|
||||
expect(pos).toBeGreaterThan(0)
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('尾巴')
|
||||
})
|
||||
|
||||
it('编辑器 DOM 为 contenteditable 且启用了 ProseMirror 历史栈', async () => {
|
||||
const editor = await load('检查 DOM\n')
|
||||
const ok = editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
return view.dom.getAttribute('contenteditable') === 'true'
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
})
|
||||
})
|
||||
78
prototype/tests/roundtrip.test.ts
Normal file
78
prototype/tests/roundtrip.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// 阶段 0 核心验收:Markdown -> 文档模型 -> Markdown 双向转换保真。
|
||||
// 断言分两层:
|
||||
// 1) 内容保真:关键文本/结构在序列化结果中仍存在(允许语法规范化,如列表符号统一);
|
||||
// 2) 往返稳定:第二轮 parse(serialize(x)) 再 serialize 与第一轮结果逐字一致
|
||||
// —— 这是「保存后重开不漂移」的硬指标。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor'
|
||||
import { BASIC_MD, COMPLEX_MD } from '../src/fixtures'
|
||||
|
||||
let editors: Editor[] = []
|
||||
afterEach(async () => {
|
||||
for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } }
|
||||
editors = []
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function load(markdown: string): Promise<Editor> {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const editor = createMarkdownEditor(root, markdown)
|
||||
editors.push(editor)
|
||||
await editor.create()
|
||||
return editor
|
||||
}
|
||||
|
||||
async function roundtrip(markdown: string): Promise<{ once: string; twice: string }> {
|
||||
const first = await load(markdown)
|
||||
const once = getMarkdown(first)
|
||||
const second = await load(once)
|
||||
const twice = getMarkdown(second)
|
||||
return { once, twice }
|
||||
}
|
||||
|
||||
describe('Markdown 双向转换', () => {
|
||||
it('基础结构保真:标题/粗斜体/删除线/行内代码/引用/列表/分割线/链接', async () => {
|
||||
const { once, twice } = await roundtrip(BASIC_MD)
|
||||
expect(once).toMatch(/^# 一级标题示例/m)
|
||||
expect(once).toMatch(/^## 二级标题/m)
|
||||
expect(once).toContain('**粗体**')
|
||||
expect(once).toContain('删除线')
|
||||
expect(once).toContain('`行内代码`')
|
||||
expect(once).toContain('这是一段引用')
|
||||
expect(once).toContain('无序列表第一项')
|
||||
expect(once).toContain('嵌套列表项')
|
||||
expect(once).toContain('有序列表第二项')
|
||||
expect(once).toContain('https://example.com')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it('复杂结构保真:任务列表状态/表格/代码块语言与内容/图片/六级标题', async () => {
|
||||
const { once, twice } = await roundtrip(COMPLEX_MD)
|
||||
// 任务列表:未完成与已完成两种状态都必须保留
|
||||
expect(once).toMatch(/-\s\[ \]\s待完成事项/)
|
||||
expect(once).toMatch(/-\s\[[xX]\]\s已完成事项/)
|
||||
// 表格:表头、单元格与右对齐列
|
||||
expect(once).toContain('名称')
|
||||
expect(once).toContain('苹果')
|
||||
expect(once).toContain('香蕉')
|
||||
// 代码块:语言标记与代码原文
|
||||
expect(once).toContain('typescript')
|
||||
expect(once).toContain('const greeting')
|
||||
expect(once).toContain('你好,世界')
|
||||
// 图片路径保留
|
||||
expect(once).toContain('images/demo.png')
|
||||
// 最深标题层级
|
||||
expect(once).toMatch(/^###### 六级标题/m)
|
||||
// Mermaid 源码作为代码块原文保留(渲染在阶段 3)
|
||||
expect(once).toContain('graph TD')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it('空文档与纯中文段落往返稳定', async () => {
|
||||
const { once, twice } = await roundtrip('只有一段中文,没有任何格式标记。\n')
|
||||
expect(once).toContain('只有一段中文')
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
})
|
||||
16
prototype/tests/setup.ts
Normal file
16
prototype/tests/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// jsdom 缺失的布局 API 垫片:ProseMirror View 在量测坐标时会调用它们。
|
||||
// 只补「不抛错」的最小实现,不参与任何断言逻辑。
|
||||
type RectLike = { x: number; y: number; width: number; height: number; top: number; left: number; right: number; bottom: number; toJSON: () => object }
|
||||
const emptyRect: RectLike = { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, toJSON: () => ({}) }
|
||||
|
||||
if (typeof Range !== 'undefined') {
|
||||
Range.prototype.getClientRects = Range.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
|
||||
Range.prototype.getBoundingClientRect = Range.prototype.getBoundingClientRect ?? (() => emptyRect as DOMRect)
|
||||
}
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.getClientRects = Element.prototype.getClientRects ?? (() => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList)
|
||||
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView ?? (() => undefined)
|
||||
}
|
||||
if (typeof document !== 'undefined' && !document.elementFromPoint) {
|
||||
document.elementFromPoint = () => null
|
||||
}
|
||||
Reference in New Issue
Block a user