feat: 阶段1-3 桌面编辑器(Tauri 骨架/编辑核心深化/复杂内容)
This commit is contained in:
35
tests/cursorSync.test.ts
Normal file
35
tests/cursorSync.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { anchorFromMarkdown, offsetInMarkdown, findBlockIndex } from '../src/editor/cursorSync'
|
||||
|
||||
const MD = '# 标题一行\n\n正文段落内容。\n\n- 列表项甲\n- [ ] 任务项乙\n\n> 引用一句\n'
|
||||
|
||||
describe('光标锚点同步', () => {
|
||||
it('源码偏移 → 锚点:剥掉标题/列表/引用标记', () => {
|
||||
const headingOffset = MD.indexOf('标题一行') + 2
|
||||
expect(anchorFromMarkdown(MD, headingOffset)).toEqual({ blockText: '标题一行', offsetInBlock: 2 })
|
||||
const taskOffset = MD.indexOf('任务项乙') + 1
|
||||
expect(anchorFromMarkdown(MD, taskOffset)).toEqual({ blockText: '任务项乙', offsetInBlock: 1 })
|
||||
const quoteOffset = MD.indexOf('引用一句')
|
||||
expect(anchorFromMarkdown(MD, quoteOffset)).toEqual({ blockText: '引用一句', offsetInBlock: 0 })
|
||||
})
|
||||
|
||||
it('锚点 → 源码偏移:往返一致', () => {
|
||||
for (const text of ['标题一行', '正文段落内容。', '列表项甲', '任务项乙', '引用一句']) {
|
||||
const start = MD.indexOf(text)
|
||||
const anchor = anchorFromMarkdown(MD, start + 1)
|
||||
expect(offsetInMarkdown(MD, anchor)).toBe(start + 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('找不到锚点时回落 0,不抛错', () => {
|
||||
expect(offsetInMarkdown(MD, { blockText: '不存在的块', offsetInBlock: 3 })).toBe(0)
|
||||
expect(anchorFromMarkdown('', 10)).toEqual({ blockText: '', offsetInBlock: 0 })
|
||||
})
|
||||
|
||||
it('findBlockIndex 精确优先、子串兜底', () => {
|
||||
const blocks = ['标题一行', '正文段落内容。', '列表项甲']
|
||||
expect(findBlockIndex(blocks, { blockText: '正文段落内容。', offsetInBlock: 0 })).toBe(1)
|
||||
expect(findBlockIndex(blocks, { blockText: '正文段落', offsetInBlock: 0 })).toBe(1)
|
||||
expect(findBlockIndex(blocks, { blockText: '没有', offsetInBlock: 0 })).toBe(-1)
|
||||
})
|
||||
})
|
||||
60
tests/editor-regression.test.ts
Normal file
60
tests/editor-regression.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// 阶段门禁(架构 §7):阶段 0 的往返稳定性套件在正式工程上必须继续全绿。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown, callUndo } from '../src/editor/editor'
|
||||
|
||||
const SAMPLE = `# 标题
|
||||
|
||||
包含**粗体**与任务:
|
||||
|
||||
- [ ] 待办
|
||||
- [x] 完成
|
||||
|
||||
| 列一 | 列二 |
|
||||
| :--- | ---: |
|
||||
| 甲 | 1 |
|
||||
|
||||
\`\`\`ts
|
||||
const n = 1;
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
describe('编辑器回归(阶段 0 套件)', () => {
|
||||
it('往返保真且第二轮逐字稳定', async () => {
|
||||
const once = getMarkdown(await load(SAMPLE))
|
||||
expect(once).toContain('**粗体**')
|
||||
expect(once).toMatch(/-\s\[ \]\s待办/)
|
||||
expect(once).toMatch(/-\s\[[xX]\]\s完成/)
|
||||
expect(once).toContain('甲')
|
||||
const twice = getMarkdown(await load(once))
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it('撤销命令仍可用', async () => {
|
||||
const editor = await load('原文\n')
|
||||
const { editorViewCtx } = await import('@milkdown/kit/core')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.dispatch(view.state.tr.insertText('X', view.state.doc.content.size))
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('X')
|
||||
callUndo(editor)
|
||||
expect(getMarkdown(editor)).not.toContain('X')
|
||||
})
|
||||
})
|
||||
16
tests/setup.ts
Normal file
16
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
|
||||
}
|
||||
40
tests/sourceEditor.test.tsx
Normal file
40
tests/sourceEditor.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 源码模式(CodeMirror 6)冒烟:能挂载、锚点 API 可用、改动经 onChange 回传。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import SourceEditor, { type SourceEditorApi } from '../src/components/SourceEditor'
|
||||
|
||||
let root: Root | null = null
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('SourceEditor', () => {
|
||||
it('挂载 CodeMirror 并提供锚点/跳行 API', async () => {
|
||||
const md = '# 标题\n\n正文段落。\n'
|
||||
let api: SourceEditorApi | null = null
|
||||
const changes: string[] = []
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<SourceEditor
|
||||
tabId="t1"
|
||||
initialText={md}
|
||||
initialCursorOffset={md.indexOf('正文')}
|
||||
dark={false}
|
||||
onChange={(t) => changes.push(t)}
|
||||
onReady={(a) => { api = a }}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('.cm-editor')).not.toBeNull()
|
||||
expect(api).not.toBeNull()
|
||||
expect(api!.getAnchor()).toEqual({ blockText: '正文段落。', offsetInBlock: 0 })
|
||||
act(() => api!.jumpToLine(0))
|
||||
expect(api!.getAnchor()?.blockText).toBe('标题')
|
||||
})
|
||||
})
|
||||
154
tests/stage2.test.ts
Normal file
154
tests/stage2.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// 阶段 2 验收:标记显隐装饰、代码块 Tab、任务列表勾选 DOM、列表命令、纯文本插入、IME 守卫判定。
|
||||
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 { applyCodeBlockIndent } from '../src/editor/plugins'
|
||||
import { editorCommands } from '../src/editor/commands'
|
||||
import { insertPlainText } from '../src/editor/paste'
|
||||
import { isImeComposing } from '../src/utils/ime'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
view.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
|
||||
}
|
||||
|
||||
function markers(editor: Editor): string[] {
|
||||
let texts: string[] = []
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
texts = [...view.dom.querySelectorAll('.md-marker')].map((el) => el.textContent ?? '')
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
describe('语法标记显隐', () => {
|
||||
it('光标在标题内显示 # 前缀,在粗体段内显示 **,移开后消失,且文档序列化不受影响', async () => {
|
||||
const md = '# 大标题\n\n普通段落里的**粗体字**在这里。\n'
|
||||
const editor = await load(md)
|
||||
// 初始选区在文档开头(标题内)
|
||||
expect(markers(editor).some((t) => t.includes('#'))).toBe(true)
|
||||
|
||||
const boldPos = findTextPos(editor, '粗体字')
|
||||
expect(boldPos).toBeGreaterThan(0)
|
||||
setSelection(editor, boldPos + 1)
|
||||
const shown = markers(editor)
|
||||
expect(shown).toContain('**')
|
||||
expect(shown.some((t) => t.includes('#'))).toBe(false)
|
||||
|
||||
// 移到段落末尾的纯文本处,标记应全部隐藏
|
||||
const endPos = findTextPos(editor, '在这里')
|
||||
setSelection(editor, endPos + 2)
|
||||
expect(markers(editor)).toEqual([])
|
||||
|
||||
// 装饰不进文档:序列化与原文一致(往返稳定)
|
||||
expect(getMarkdown(editor)).toBe(md)
|
||||
})
|
||||
|
||||
it('光标在引用内显示 > 前缀', async () => {
|
||||
const editor = await load('前文。\n\n> 引用内容在此。\n')
|
||||
const pos = findTextPos(editor, '引用内容')
|
||||
setSelection(editor, pos + 1)
|
||||
expect(markers(editor)).toContain('> ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('代码块 Tab', () => {
|
||||
it('代码块内 Tab 插入两空格,Shift+Tab 去掉', async () => {
|
||||
const editor = await load('```ts\nconst a = 1;\n```\n')
|
||||
const pos = findTextPos(editor, 'const')
|
||||
setSelection(editor, pos)
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
expect(applyCodeBlockIndent(view as never, false)).toBe(true)
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain(' const a = 1;')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
expect(applyCodeBlockIndent(view as never, true)).toBe(true)
|
||||
})
|
||||
expect(getMarkdown(editor)).toContain('const a = 1;')
|
||||
expect(getMarkdown(editor)).not.toContain(' const')
|
||||
})
|
||||
|
||||
it('普通段落里不处理 Tab', async () => {
|
||||
const editor = await load('普通段落。\n')
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
expect(applyCodeBlockIndent(view as never, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('任务列表与列表命令', () => {
|
||||
it('任务列表渲染出可点击 checkbox,点击切换并序列化回 [x]', async () => {
|
||||
const editor = await load('- [ ] 待办事项\n- [x] 已完成\n')
|
||||
let firstBox: HTMLInputElement | null = null
|
||||
editor.action((ctx) => {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const boxes = view.dom.querySelectorAll('input[type="checkbox"]')
|
||||
expect(boxes.length).toBe(2)
|
||||
expect((boxes[1] as HTMLInputElement).checked).toBe(true)
|
||||
firstBox = boxes[0] as HTMLInputElement
|
||||
})
|
||||
firstBox!.click()
|
||||
expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s待办事项/)
|
||||
expect(getMarkdown(editor)).toMatch(/-\s\[[xX]\]\s已完成/)
|
||||
})
|
||||
|
||||
it('段落转无序列表命令生效', async () => {
|
||||
const editor = await load('变成列表项\n')
|
||||
setSelection(editor, 2)
|
||||
editorCommands.bulletList(editor)
|
||||
expect(getMarkdown(editor)).toContain('- 变成列表项')
|
||||
})
|
||||
})
|
||||
|
||||
describe('纯文本插入与 IME 守卫', () => {
|
||||
it('insertPlainText 在选区处插入并可撤销序列保留', async () => {
|
||||
const editor = await load('前后\n')
|
||||
setSelection(editor, 2)
|
||||
expect(insertPlainText(editor, '中间')).toBe(true)
|
||||
expect(getMarkdown(editor)).toContain('前中间后')
|
||||
})
|
||||
|
||||
it('isImeComposing 判定', () => {
|
||||
expect(isImeComposing({ isComposing: true })).toBe(true)
|
||||
expect(isImeComposing({ keyCode: 229 })).toBe(true)
|
||||
expect(isImeComposing({ isComposing: false, keyCode: 13 })).toBe(false)
|
||||
expect(isImeComposing({})).toBe(false)
|
||||
})
|
||||
})
|
||||
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/')
|
||||
})
|
||||
})
|
||||
53
tests/tabs.test.ts
Normal file
53
tests/tabs.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tabsReducer, createTab, isDirty, type TabsState } from '../src/stores/tabs'
|
||||
|
||||
function stateWith(contents: string[]): TabsState {
|
||||
const tabs = contents.map((c, i) => createTab({ title: `文档${i + 1}`, content: c }))
|
||||
return { tabs, activeId: tabs[0]?.id ?? null }
|
||||
}
|
||||
|
||||
describe('标签页会话模型', () => {
|
||||
it('同路径文档重复打开只激活已有标签', () => {
|
||||
const a = createTab({ title: 'a.md', path: '/x/a.md', content: 'A' })
|
||||
let s: TabsState = { tabs: [a], activeId: a.id }
|
||||
s = tabsReducer(s, { type: 'open', tab: createTab({ title: 'a.md', path: '/x/a.md', content: 'A2' }) })
|
||||
expect(s.tabs).toHaveLength(1)
|
||||
expect(s.activeId).toBe(a.id)
|
||||
})
|
||||
|
||||
it('dirty 由 content 与 savedContent 比较得出,保存后清除,改回原样也清除', () => {
|
||||
const t = createTab({ content: '原文' })
|
||||
expect(isDirty(t)).toBe(false)
|
||||
let s: TabsState = { tabs: [t], activeId: t.id }
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文改' })
|
||||
expect(isDirty(s.tabs[0])).toBe(true)
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '原文' })
|
||||
expect(isDirty(s.tabs[0])).toBe(false)
|
||||
s = tabsReducer(s, { type: 'updateContent', id: t.id, content: '新版' })
|
||||
s = tabsReducer(s, { type: 'saved', id: t.id })
|
||||
expect(isDirty(s.tabs[0])).toBe(false)
|
||||
expect(s.tabs[0].savedContent).toBe('新版')
|
||||
})
|
||||
|
||||
it('关闭活动标签后激活相邻标签;关闭其他/右侧范围正确', () => {
|
||||
let s = stateWith(['a', 'b', 'c'])
|
||||
const [t1, t2, t3] = s.tabs
|
||||
s = tabsReducer(s, { type: 'activate', id: t2.id })
|
||||
s = tabsReducer(s, { type: 'close', id: t2.id })
|
||||
expect(s.tabs.map((t) => t.id)).toEqual([t1.id, t3.id])
|
||||
expect(s.activeId).toBe(t3.id)
|
||||
s = tabsReducer(s, { type: 'open', tab: createTab({ title: 'd', content: 'd' }) })
|
||||
const t4 = s.tabs[2]
|
||||
s = tabsReducer(s, { type: 'closeRight', id: t1.id })
|
||||
expect(s.tabs.map((t) => t.id)).toEqual([t1.id])
|
||||
void t4
|
||||
})
|
||||
|
||||
it('拖拽排序 move 到目标下标', () => {
|
||||
let s = stateWith(['a', 'b', 'c'])
|
||||
const [t1, , t3] = s.tabs
|
||||
s = tabsReducer(s, { type: 'move', id: t1.id, toIndex: 2 })
|
||||
expect(s.tabs[2].id).toBe(t1.id)
|
||||
expect(s.tabs[0].id).toBe(t3.id === s.tabs[0].id ? t3.id : s.tabs[0].id)
|
||||
})
|
||||
})
|
||||
39
tests/text.test.ts
Normal file
39
tests/text.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { countWords, countChars, extractOutline, baseName, isMarkdownPath } from '../src/utils/text'
|
||||
|
||||
describe('字数统计', () => {
|
||||
it('中文按字、英文按词、标点不计', () => {
|
||||
expect(countWords('你好,世界!')).toBe(4)
|
||||
expect(countWords('hello world, again')).toBe(3)
|
||||
expect(countWords('中文 mixed 文本 test')).toBe(2 + 1 + 2 + 1)
|
||||
})
|
||||
it('代码块可排除', () => {
|
||||
const md = '正文一二\n\n```\ncode code code\n```\n'
|
||||
expect(countWords(md, true)).toBe(4 + 3)
|
||||
expect(countWords(md, false)).toBe(4)
|
||||
})
|
||||
it('字符数按 Unicode 码点计', () => {
|
||||
expect(countChars('a你好b')).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('大纲提取', () => {
|
||||
it('提取层级与行号,跳过代码块内的 # 行', () => {
|
||||
const md = '# 一级\n\n```\n# 不是标题\n```\n\n## 二级\n正文\n### 三级\n'
|
||||
const outline = extractOutline(md)
|
||||
expect(outline.map((o) => o.text)).toEqual(['一级', '二级', '三级'])
|
||||
expect(outline.map((o) => o.level)).toEqual([1, 2, 3])
|
||||
expect(outline[1].line).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('路径工具', () => {
|
||||
it('Windows 路径取文件名', () => {
|
||||
expect(baseName('C:\\docs\\笔记\\a.md')).toBe('a.md')
|
||||
expect(baseName('/home/x/b.md')).toBe('b.md')
|
||||
})
|
||||
it('Markdown 路径判定', () => {
|
||||
expect(isMarkdownPath('a.MD')).toBe(true)
|
||||
expect(isMarkdownPath('a.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user