feat(editor): 大文档异步分片载入(docs/20)
>6 万字符文档按顶层结构安全切分:第一片立即建编辑器,其余片逐片解析 追加、片间让出主线程,窗格左下角显示载入进度;载入期间可切标签、编辑 其他文档。追加期静音回写、状态保持磁盘完整原文,载完一次性对齐。 分片与整篇解析的序列化等价有测试锁定(tests/chunk-load.test.ts)。 此前试做的已关闭重开缓存已按用户要求整笔撤回。
This commit is contained in:
105
tests/chunk-load.test.ts
Normal file
105
tests/chunk-load.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// 分片载入(docs/20):切分安全性 + 分片解析拼接与整篇解析的序列化等价。
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
|
||||
import { splitMarkdownChunks, prepareChunkLoad } from '../src/editor/chunkLoader'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 生成超过阈值的富结构文档:多标题、宽松列表、任务、表格、公式、Mermaid、含空行围栏 */
|
||||
function bigDoc(): string {
|
||||
const parts: string[] = []
|
||||
for (let s = 1; s <= 20; s++) {
|
||||
parts.push(`## 第 ${s} 节`)
|
||||
parts.push('')
|
||||
parts.push(`第 ${s} 节的正文段落,包含**加粗**、*斜体*与行内公式 $a_${s}+b$,以及足够长的中文填充文本。`.repeat(60))
|
||||
parts.push('')
|
||||
parts.push('- 条目甲')
|
||||
parts.push('')
|
||||
parts.push('- [ ] 任务乙')
|
||||
parts.push('')
|
||||
parts.push('| 列一 | 列二 |')
|
||||
parts.push('| --- | --- |')
|
||||
parts.push(`| 值 ${s} | ${s} |`)
|
||||
parts.push('')
|
||||
parts.push('```text')
|
||||
parts.push(`code ${s} 第一行`)
|
||||
parts.push('')
|
||||
parts.push('# 这行在围栏里不是标题')
|
||||
parts.push('')
|
||||
parts.push('```')
|
||||
parts.push('')
|
||||
parts.push('> 引用第一段')
|
||||
parts.push('>')
|
||||
parts.push('> 引用第二段')
|
||||
parts.push('')
|
||||
parts.push('```mermaid')
|
||||
parts.push('graph TD')
|
||||
parts.push(' A-->B')
|
||||
parts.push('```')
|
||||
parts.push('')
|
||||
parts.push(`$$\nE_${s} = mc^2\n$$`)
|
||||
parts.push('')
|
||||
parts.push('---')
|
||||
parts.push('')
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
describe('splitMarkdownChunks', () => {
|
||||
it('拼接回去与原文逐字相同,片数 > 1', () => {
|
||||
const md = bigDoc()
|
||||
expect(md.length).toBeGreaterThan(60_000)
|
||||
const chunks = splitMarkdownChunks(md)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.join('')).toBe(md)
|
||||
})
|
||||
|
||||
it('围栏内的空行与 # 行不触发切分', () => {
|
||||
const md = `# 顶\n\n\`\`\`text\n第一行\n\n# 假标题\n\n\`\`\`\n\n正文 ${'填充'.repeat(40)}`
|
||||
const chunks = splitMarkdownChunks(md)
|
||||
expect(chunks.join('')).toBe(md)
|
||||
// 没有合法切点时整篇一片(小文档本来也一片)
|
||||
expect(chunks.length).toBe(1)
|
||||
})
|
||||
|
||||
it('含链接引用定义时整篇一片(定义可能在文末被前文引用)', () => {
|
||||
const md = `${bigDoc()}\n\n[ref1]: https://example.com\n`
|
||||
expect(splitMarkdownChunks(md).length).toBe(1)
|
||||
expect(prepareChunkLoad(md)).toBeNull()
|
||||
})
|
||||
|
||||
it('小文档不走分片', () => {
|
||||
expect(prepareChunkLoad('# 小\n\n正文')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('分片载入与整篇解析等价', () => {
|
||||
it('逐片追加后的序列化 == 整篇解析的序列化', async () => {
|
||||
const md = bigDoc()
|
||||
const whole = getMarkdown(await load(md))
|
||||
const handle = prepareChunkLoad(md)
|
||||
expect(handle).not.toBeNull()
|
||||
if (!handle) return
|
||||
const editor = await load(handle.firstChunk)
|
||||
const progress: number[] = []
|
||||
await handle.appendRest(editor, (done) => { progress.push(done) }, () => false)
|
||||
expect(progress[progress.length - 1]).toBe(handle.totalChunks)
|
||||
const chunked = getMarkdown(editor)
|
||||
expect(chunked).toBe(whole)
|
||||
}, 60_000)
|
||||
})
|
||||
Reference in New Issue
Block a user