// 2026-10 撤销 / 重做按钮(#btn-undo / #btn-redo)背后的 MarkdownEditor 门面: // - canUndo / canRedo 跟随 CodeMirror history 深度 // - undo / redo 执行命令并返回是否生效 // - loadFile 复用 view 切文件时 history 重置(每文件一份干净 undo/redo 栈, // 按钮不会在刚打开的文件上误导「有可撤销」) // app.js 顶层不静态 import CM6(audit P2-9),按钮接线测不到这里, // 语义正确性全部收敛在本文件的 MarkdownEditor 方法层。 // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; 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(), }); } // 模拟一次用户输入(独立 history 事件:userEvent 不同不会被 newGroupDelay 合并) function type(editor, text) { editor.view.dispatch({ changes: { from: editor.view.state.doc.length, insert: text }, userEvent: 'input.type', }); } describe('MarkdownEditor 撤销/重做门面(2026-10)', () => { let editor; beforeEach(() => { editor = createEditor(); }); it('view 未创建(还没 loadFile)→ canUndo / canRedo 均 false,undo/redo 返回 false', () => { expect(editor.canUndo()).toBe(false); expect(editor.canRedo()).toBe(false); expect(editor.undo()).toBe(false); expect(editor.redo()).toBe(false); }); it('首次 loadFile 后(fresh view)→ 无可撤无可重做', () => { editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello'); expect(editor.canUndo()).toBe(false); expect(editor.canRedo()).toBe(false); }); it('输入 → canUndo true;undo() 回退 doc 且 canRedo true;redo() 再前滚', () => { editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello'); type(editor, ' world'); expect(editor.view.state.doc.toString()).toBe('hello world'); expect(editor.canUndo()).toBe(true); expect(editor.canRedo()).toBe(false); expect(editor.undo()).toBe(true); expect(editor.view.state.doc.toString()).toBe('hello'); expect(editor.canUndo()).toBe(false); expect(editor.canRedo()).toBe(true); expect(editor.redo()).toBe(true); expect(editor.view.state.doc.toString()).toBe('hello world'); expect(editor.canRedo()).toBe(false); }); it('历史耗尽时 undo 空操作返回 false,不吞事件', () => { editor.loadFile({ path: '/a.md', name: 'a.md' }, 'x'); expect(editor.undo()).toBe(false); expect(editor.view.state.doc.toString()).toBe('x'); }); it('切文件(复用 view)→ 前一文件的编辑记录被清空,doc 是新文件内容', () => { editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); type(editor, '!'); // A 有可撤的一步 expect(editor.canUndo()).toBe(true); editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); expect(editor.view.state.doc.toString()).toBe('BBB'); // 关键断言:不能把 A 的编辑 history 带进 B(否则 ↶ 在新文件上亮着, // 按下去会退回 A 的内容 —— 旧实现的跨文件撤销 bug) expect(editor.canUndo()).toBe(false); expect(editor.canRedo()).toBe(false); }); it('切文件后在新文件里编辑 → 撤销只回到新文件初始内容', () => { editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); type(editor, '!!!'); editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); type(editor, '?'); editor.undo(); expect(editor.view.state.doc.toString()).toBe('BBB'); }); it('loadFile 切文件不触发 onChange(setState 非 transaction,不 fire updateListener)', () => { // 2026-10 语义变更:旧实现「dispatch 替换 doc」每次切文件都会伪报一次 // onChange(触发多余的 live preview / auto-save 空转)。setState 重建 state // 不派发事务 → onChange 不跑;脏态复位由 openFile 尾部显式完成。 const onChange = vi.fn(); const el = document.createElement('div'); document.body.appendChild(el); const ed = new MarkdownEditor({ element: el, onChange, onSave: vi.fn() }); ed.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); // 首次建 view:0 次 ed.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); // setState 复用:0 次 expect(onChange).not.toHaveBeenCalled(); }); });