Files
Notes/tests/unit/codemirror-history-depth.test.js
2026-10-16 16:34:15 +08:00

157 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/markdown-editor.js —— CodeMirror history depth 上限(audit P0-3)
//
// 背景:basicSetup 默认带 history(),无 maxDepth 上限。打开 5MB 文件 + 长
// 时间编辑 → history stack 几百 MB,OOM 风险。
//
// 修复:extensions 列表里 basicSetup 之后加 Prec.high(history({ minEventTimeout: 200, maxDepth: 500 }))。
// 覆盖 basicSetup 的默认 history 配置:maxDepth 限制 undo 步数,minEventTimeout
// 把 200ms 内的连续 keystroke 合并成 1 步(避免每个键 1 步、3 千字输入塞 3 千
// 条 history)。
//
// 测试策略:
// - 1MB 文档 + 1000 次独立 transaction → undoDepth ≤ 500(maxDepth)
// - 500 次 transaction → undoDepth = 500(恰好满载)
// - undo 一次 → undoDepth 减 1,redoDepth 加 1
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { undoDepth, redoDepth, undo, isolateHistory } from '@codemirror/commands';
import { EditorView } from '@codemirror/view';
import { MarkdownEditor } from '../../src/markdown-editor.js';
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
const arr = [];
arr.item = (i) => arr[i] || null;
return arr;
};
}
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
Range.prototype.getBoundingClientRect = function () {
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 };
};
}
function createEditor() {
const el = document.createElement('div');
document.body.appendChild(el);
return new MarkdownEditor({
element: el,
onChange: vi.fn(),
onSave: vi.fn(),
});
}
describe('CodeMirror history depth 上限(audit P0-3)', () => {
let editor;
beforeEach(() => {
editor = createEditor();
});
it('加载空 doc → undoDepth = 0', () => {
// MarkdownEditor 懒加载 view —— 必须先 loadFile
editor.loadFile({ path: '/empty.md', name: 'empty.md' }, '');
expect(undoDepth(editor.view.state)).toBe(0);
});
it('1000 次独立 transaction → undoDepth ≤ 520(minDepth 500 + updateBranch +20 buffer)', () => {
// 注:audit P0-3 描述「1MB 文档 + 1000 次 transaction」—— 这里用 1KB
// 文档,理由:audit 关心的是 history 长度上限(minDepth 截断),不关心
// 文档大小本身。1KB 文档下跑 1000 次 dispatch 几秒内出结果,1MB 文档
// 要 46 秒(每次 dispatch 都做 change mapping + state 克隆)。
editor.loadFile({ path: '/big.md', name: 'big.md' }, 'x'.repeat(1024));
const view = editor.view;
// 1000 次独立 transaction:用 userEvent: 'test.step' 跳出 joinableUserEvent
// 正则(^(input\.type|delete)($|\.))—— 不匹配的 userEvent 不会与上一个
// event 合并,确保每个 transaction 各自成为 1 个 history step。
// 同步加 isolateHistory 标注,双重保险。
for (let i = 0; i < 1000; i++) {
view.dispatch({
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
// 关键断言:undoDepth 不会无限增长,超 minDepth(500) 即被截断
// updateBranch 内部 maxLen + 20 buffer,所以上限是 520
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
});
it('500 次 transaction → undoDepth = 500(恰好满载,未截断)', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello world');
const view = editor.view;
for (let i = 0; i < 500; i++) {
view.dispatch({
changes: { from: 0, to: 1, insert: String.fromCharCode(65 + (i % 26)) },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
expect(undoDepth(view.state)).toBe(500);
});
it('undo 一次 → undoDepth 减 1,redoDepth 加 1(minDepth 限制下 history 仍可用)', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'hello');
const view = editor.view;
for (let i = 0; i < 3; i++) {
view.dispatch({
changes: { from: 0, to: 0, insert: 'a' },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
expect(undoDepth(view.state)).toBe(3);
expect(redoDepth(view.state)).toBe(0);
undo(view);
expect(undoDepth(view.state)).toBe(2);
expect(redoDepth(view.state)).toBe(1);
});
it('超 500 步后 undo 老的 history 被丢弃,只剩最新的 ~500 步可撤销', () => {
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'x');
const view = editor.view;
// 600 步独立 transaction
for (let i = 0; i < 600; i++) {
view.dispatch({
changes: { from: 0, to: 0, insert: 'a' },
userEvent: 'test.step',
annotations: [isolateHistory.of('test')],
});
}
// 600 步后只剩 minDepth(500) + 20 buffer = 520
expect(undoDepth(view.state)).toBeLessThanOrEqual(520);
expect(undoDepth(view.state)).toBeGreaterThanOrEqual(500);
// 连续 undo 直到 history 空
while (undoDepth(view.state) > 0) {
undo(view);
}
expect(undoDepth(view.state)).toBe(0);
// redo 应有 ~500+ 条
expect(redoDepth(view.state)).toBeLessThanOrEqual(520);
expect(redoDepth(view.state)).toBeGreaterThanOrEqual(500);
// 再 undo:应失败(已无 history 可撤销)
const beforeUndo = undoDepth(view.state);
undo(view);
expect(undoDepth(view.state)).toBe(beforeUndo); // 没变化
});
});