Files
new-markdown/tests/stage6.test.ts
Muse db81e60d71 fix(audit): 审计问题全量修复(docs/12)
高危逻辑 A1-A10:saved 写盘快照、重载卸载回写闸门与期望值校验、
?file 无条件打开、偏好读后才写+防抖、depth=0 守卫、查找命中随编辑
作废、自动保存避让冲突标签、搜索防链接环、重 IO 命令异步化+上限。
样式:--panel 更正为 --bg-panel、新增 .input 基础样式、文档主题
纸面/字色成对、打印隐藏清单补全、paperTone 驱动源码明暗。
性能:状态栏/大纲按正文 memo、大纲延后提取、Sidebar memo、专注
装饰映射复用、查找防抖、图片 base64 过 IPC。
中危一批:关窗落盘后放行、回收站去向明示、GBK 搜索、create_new
落盘、Mermaid 渲染代次守卫、多行粘贴分段等。
回归:新增 tabkey/audit-fix 集成测试(E1 Tab 抢先定性排除),
vitest 71 通过 + 2 跳过。
2026-11-13 23:34:19 +08:00

127 lines
5.9 KiB
TypeScript

// 阶段 6 测试:文档查找索引与匹配(桩文档 + 真实编辑器)、快捷键注册表、专注模式装饰。
import { describe, it, expect } from 'vitest'
import { buildTextIndex, findInIndex, type FindOptions } from '../src/editor/search'
import { comboFromEvent, effectiveCombos, resolveShortcuts, prettyCombo, comboOwner, SHORTCUT_DEFS } from '../src/utils/shortcuts'
import { createMarkdownEditor, getMarkdown } from '../src/editor/editor'
import { setFocusModeEnabled } from '../src/editor/plugins'
const OPTS: FindOptions = { caseSensitive: false, wholeWord: false, regex: false }
/** 桩文档:按 ProseMirror descendants 语义回放文本节点与位置。 */
function stubDoc(segments: { text: string; pos: number }[]) {
return {
descendants: (fn: (node: { isText: boolean; text?: string }, pos: number) => void) => {
for (const seg of segments) fn({ isText: true, text: seg.text }, seg.pos)
},
}
}
describe('查找索引与匹配', () => {
it('跨文本节点命中能映射回连续文档位置', () => {
// "Hello " 与 "World" 是两个文本节点(如中间有格式标记)
const index = buildTextIndex(stubDoc([{ text: 'Hello ', pos: 1 }, { text: 'World', pos: 7 }]))
const hits = findInIndex(index, 'o w', OPTS)
expect(hits).toHaveLength(1)
expect(hits[0].from).toBe(5)
expect(hits[0].to).toBe(8)
})
it('跨块命中被跳过(块间分隔符)', () => {
const index = buildTextIndex(stubDoc([{ text: 'abc', pos: 1 }, { text: 'def', pos: 6 }]))
expect(findInIndex(index, 'cdef', OPTS)).toHaveLength(0)
expect(findInIndex(index, 'def', OPTS)).toHaveLength(1)
})
it('大小写/整词/正则选项', () => {
const index = buildTextIndex(stubDoc([{ text: 'Cat cat scatter CAT', pos: 1 }]))
expect(findInIndex(index, 'cat', OPTS)).toHaveLength(4)
expect(findInIndex(index, 'cat', { ...OPTS, caseSensitive: true })).toHaveLength(2)
expect(findInIndex(index, 'cat', { ...OPTS, wholeWord: true })).toHaveLength(3)
expect(findInIndex(index, 'c.t', { ...OPTS, regex: true })).toHaveLength(4)
expect(() => findInIndex(index, '(', { ...OPTS, regex: true })).toThrow(/正则表达式无效/)
})
it('真实编辑器文档可查且替换后文本正确', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const editor = createMarkdownEditor(host, '# 标题\n\nhello 世界 hello\n')
await editor.create()
const { editorViewCtx } = await import('@milkdown/kit/core')
const { TextSelection } = await import('@milkdown/kit/prose/state')
const matches = editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
return findInIndex(buildTextIndex(view.state.doc), 'hello', OPTS)
})
expect(matches).toHaveLength(2)
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const m = matches[0]
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, m.from, m.to)))
expect(view.state.doc.textBetween(m.from, m.to)).toBe('hello')
view.dispatch(view.state.tr.insertText('HELLO', m.from, m.to))
})
expect(getMarkdown(editor)).toContain('HELLO 世界 hello')
await editor.destroy()
host.remove()
})
})
describe('快捷键注册表', () => {
it('默认绑定完整且无组合键冲突', () => {
const resolved = resolveShortcuts({})
expect(resolved.get('ctrl+s')).toBe('save')
expect(resolved.get('ctrl+f')).toBe('find')
expect(resolved.get('ctrl+1')).toBe('heading1')
const combos = SHORTCUT_DEFS.map((d) => d.defaultCombo)
expect(new Set(combos).size).toBe(combos.length)
})
it('覆盖、解绑与冲突查询', () => {
const overrides = { find: 'ctrl+shift+f', save: '' }
const resolved = resolveShortcuts(overrides)
expect(resolved.get('ctrl+shift+f')).toBe('find')
expect(resolved.get('ctrl+f')).toBeUndefined()
expect(resolved.get('ctrl+s')).toBeUndefined()
expect(effectiveCombos(overrides).has('save')).toBe(false) // 解绑后不在生效表内
expect(comboOwner('ctrl+shift+f', {}, 'find')?.id).toBeUndefined
expect(comboOwner('ctrl+s', {})?.id).toBe('save')
// 改绑到他人组合键时覆盖优先
expect(resolveShortcuts({ find: 'ctrl+s' }).get('ctrl+s')).toBe('find')
})
it('事件组合键归一化与展示', () => {
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: 'S' })).toBe('ctrl+shift+s')
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: true, key: '+' })).toBe('ctrl+=')
expect(comboFromEvent({ ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, key: 'Control' })).toBeNull()
expect(prettyCombo('ctrl+shift+s')).toBe('Ctrl+Shift+S')
expect(prettyCombo('')).toBe('(未绑定)')
})
})
describe('专注模式装饰', () => {
it('开启后非当前块获得 focus-dim 类,关闭后消失', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
setFocusModeEnabled(true)
const editor = createMarkdownEditor(host, '第一段文字。\n\n第二段文字。\n\n第三段文字。\n')
await editor.create()
const { editorViewCtx } = await import('@milkdown/kit/core')
const { TextSelection } = await import('@milkdown/kit/prose/state')
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
// 把光标放到第一段,触发一次选区变化让装饰重算
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)))
})
const dimmed = host.querySelectorAll('.focus-dim')
expect(dimmed.length).toBe(2)
setFocusModeEnabled(false)
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 3)))
})
expect(host.querySelectorAll('.focus-dim').length).toBe(0)
await editor.destroy()
host.remove()
})
})