This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
// tests/unit/renderer/components/codemirror-history-semantics.test.ts
//
// 守「笔记撤销 / 重做」所依赖的 **CodeMirror 6 真实语义**(用真库,不用假实现)。
//
// 为什么要有这层:markdown-editor-history.test.tsx 里 CodeMirror 是被 mock 掉的,
// 它只能证明「我们的代码传了正确的参数」;证明不了「这些参数在真 CodeMirror 上
// 真的产生我们想要的结果」。若哪天 CodeMirror 改了 history 的语义(例如
// addToHistory:false 不再清栈 / setState 不再重置 history),mock 版测试照样全绿,
// 而真机上会悄悄退化成「跨笔记撤销污染」。这层就是那个沉默退化的警报器。
//
// 锁的三条真实语义(本轮实现的前提):
// 1. history 是挂在 EditorState 上的 StateField ⇒ view.setState(新 state)
// ⇒ 撤销栈**归零**(这是「切笔记清空撤销栈」唯一可靠的实现方式);
// 2. 整段替换 + Transaction.addToHistory.of(false) ⇒ 外部灌入的正文不进栈;
// 3. 关键场景:笔记 A 里敲了字 → 切到笔记 B(setState)→ 在 B 里按撤销
// ⇒ **撤不了任何东西、也不能把 A 的字撤回来**(跨笔记污染 = 数据事故:
// 撤回来的 A 内容会被 autosave 写进 B)。
//
// 变异思路(哪几处改动会红):
// - 把「切文档」从 setState 改成普通 dispatch ⇒ 例 3 红(B 里 undo 会撤掉 A 的字);
// - 去掉 addToHistory.of(false) ⇒ 例 2 红;
// - 断言写反(把「栈归零」写成「栈保留」)⇒ 例 1、例 2 红。
import { describe, expect, it } from 'vitest';
import { EditorState, Transaction } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { history, undoDepth, redoDepth, undo, redo } from '@codemirror/commands';
/** 造一个挂载在 jsdom 里的真 CodeMirror 视图。 */
function mount(doc: string): EditorView {
const host = document.createElement('div');
document.body.appendChild(host);
return new EditorView({
state: EditorState.create({ doc, extensions: [history()] }),
parent: host,
});
}
/** 模拟一次用户击键(带 userEvent,CodeMirror 才会把它记进撤销栈)。 */
function type(view: EditorView, text: string): void {
view.dispatch({ changes: { from: view.state.doc.length, insert: text }, userEvent: 'input.type' });
}
describe('CodeMirror 6 真实语义:撤销栈', () => {
it('1. setState(新 state)⇒ 撤销栈归零 + 正文/DOM 换成新文档', () => {
const view = mount('第一篇');
type(view, '改过了');
expect(undoDepth(view.state), '敲字后有 1 层可撤').toBe(1);
view.setState(EditorState.create({ doc: '第二篇', extensions: [history()] }));
expect(view.state.doc.toString(), '正文换成新文档').toBe('第二篇');
expect(undoDepth(view.state), '撤销栈必须归零').toBe(0);
expect(redoDepth(view.state), '重做栈也必须归零').toBe(0);
expect(view.dom.textContent, 'DOM 显示的也是新正文').toContain('第二篇');
expect(view.contentDOM, 'contentDOM 仍在(焦点/光标依赖它)').toBeTruthy();
view.destroy();
});
it('2. 整段替换 + addToHistory:false ⇒ 外部灌入的正文不进撤销栈', () => {
const view = mount('abc');
type(view, 'X');
expect(undoDepth(view.state)).toBe(1);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'ZZZ' },
annotations: Transaction.addToHistory.of(false),
});
expect(view.state.doc.toString()).toBe('ZZZ');
expect(undoDepth(view.state), '外部替换后旧事件被 mapping 吃掉 ⇒ 栈空').toBe(0);
expect(undo(view), '没有可撤的东西时 undo 命令返回 false').toBe(false);
view.destroy();
});
it('3. 跨笔记:在 A 里敲的字,切到 B 之后按撤销撤不回来(数据污染防线)', () => {
const view = mount('A 的正文');
type(view, '!!!');
expect(view.state.doc.toString()).toBe('A 的正文!!!');
expect(undoDepth(view.state)).toBe(1);
// 切到笔记 B:重建 state(本轮实现里由 docToken 变化触发)
view.setState(EditorState.create({ doc: 'B 的正文', extensions: [history()] }));
expect(view.state.doc.toString()).toBe('B 的正文');
// 在 B 里按撤销 ⇒ 什么也撤不了,B 的正文一个字都不能少
expect(undo(view), 'B 里没有可撤的编辑 ⇒ undo 返回 false').toBe(false);
expect(view.state.doc.toString(), 'B 的正文未被 A 的编辑污染').toBe('B 的正文');
// B 里的新编辑可以正常撤销(撤销功能本身没被 setState 弄坏)
type(view, '?');
expect(undoDepth(view.state)).toBe(1);
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('B 的正文');
view.destroy();
});
it('4. undo / redo 命令真实生效(undo 改回正文、redo 再改回来)', () => {
const view = mount('abc');
type(view, 'X');
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('abc');
expect(redoDepth(view.state), '撤销后有 1 层可重做').toBe(1);
expect(redo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('abcX');
view.destroy();
});
});