This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -203,3 +203,147 @@ describe('MarkdownEditor 基础行为', () => {
expect(() => editor._onCompositionEnd()).not.toThrow();
});
});
// ============================================================================
// audit R7b P1-18:单实例 EditorView 持久化
// ============================================================================
//
// 核心契约:loadFile 多次调用复用同一 view 实例,不再 _destroyView + new。
// 旧实现下:A→B→A 切文件会丢 history / selection / 折叠,30-100ms 黑闪。
describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
it('首次 loadFile 创建 view', () => {
const { editor } = createEditor();
expect(editor.view).toBeNull();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
expect(editor.view).not.toBeNull();
expect(editor.getContent()).toBe('A content');
});
it('多次 loadFile 复用同一 view 实例(A→B→A 同一 view)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content');
const viewA = editor.view;
// 切到 B
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B content');
expect(editor.view).toBe(viewA); // 引用相等
expect(editor.getContent()).toBe('B content');
// 切回 A(新内容)
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A content v2');
expect(editor.view).toBe(viewA);
expect(editor.getContent()).toBe('A content v2');
});
it('loadFile 切换文件后 selection 重置到 (0, 0)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello world');
// 用户手动把光标移到位置 5
editor.view.dispatch({ selection: { anchor: 5, head: 5 } });
expect(editor.view.state.selection.main.anchor).toBe(5);
// 切到 B → selection 应被重置
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'foo bar baz');
expect(editor.view.state.selection.main.anchor).toBe(0);
expect(editor.view.state.selection.main.head).toBe(0);
});
it('loadFile 切换后 history 仍可用(view 复用,undo stack 保留)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'one');
// 用户编辑 → 一些 transaction 计入 history
editor.view.dispatch({ changes: { from: 3, insert: ' two' } });
editor.view.dispatch({ changes: { from: 7, insert: ' three' } });
expect(editor.getContent()).toBe('one two three');
// 切到 B 再切回 A(content 是初始 one,dispatch 替换;这里测的是 view 复用,
// 不是 history 跨文件保留——后者是更大设计问题,本测试只锁 view 实例稳定)
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
expect(editor.view).not.toBeNull();
// view 还是同一个引用 → history 字段在 view 生命周期内持续
// (CM6 history field 由 EditorState 持有;view 复用 = state 延续)
const beforeUndo = editor.view.state.doc.toString();
// 触发一次新 transaction(与刚才的 history 关联)
editor.view.dispatch({ changes: { from: 1, insert: 'B' } });
// undo 应该能回到 beforeUndo
expect(editor.view.state.doc.toString()).not.toBe(beforeUndo);
});
it('loadFile 复用 view 时 _viewAbort signal 不重建(IME listener 不会重复挂)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const signal1 = editor._viewAbort;
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
const signal2 = editor._viewAbort;
// view 复用 → 不需要新的 AbortController(IME listener 还在原 signal 上)
expect(signal2).toBe(signal1);
});
it('unload → 销毁 view;再 loadFile 创建新 view', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const viewA = editor.view;
editor.unload();
expect(editor.view).toBeNull();
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
// unload 后 view 是 null;下次 loadFile 触发 _createView → 新 view
expect(editor.view).not.toBe(viewA);
expect(editor.view).not.toBeNull();
expect(editor.getContent()).toBe('B');
});
it('多次 loadFile 复用 view 时 _viewAbort signal 引用稳定(IME listener 不重建)', () => {
// 核心契约:旧实现每次 loadFile 都 _destroyView + new AbortController,
// 但 addAbort 路径在 dispose 时才 remove —— 多次切文件会让 contentDOM
// 上累积多份 IME listener(compositionend 跑多次 → onChange 多次 →
// state.isDirty 翻脏态反复 + scheduleAutoSave 多次 clearTimeout)。
// 新实现下 view 整个 lifetime 只一个 _viewAbort signal,listener 只一份。
// 验证方式:loadFile 多次,_viewAbort 引用始终相等。
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const signalRef = editor._viewAbort;
expect(signalRef).not.toBeNull();
for (let i = 0; i < 10; i++) {
editor.loadFile({ path: `/f${i}.md`, name: `f${i}.md` }, `content ${i}`);
expect(editor._viewAbort).toBe(signalRef);
}
});
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
const { editor, onChange } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
const baseCount = onChange.mock.calls.length;
for (let i = 0; i < 5; i++) {
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
}
const delta = onChange.mock.calls.length - baseCount;
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
});
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A1');
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B1');
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A2');
// loadFile 总是把 lastSavedDoc 同步到传入 content
expect(editor.lastSavedDoc).toBe('A2');
expect(editor.isDirty()).toBe(false);
});
it('getContent 在多次 loadFile 后返回当前 view 的 doc(不是 lastSavedDoc)', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
expect(editor.getContent()).toBe('A');
// 用户编辑
editor.view.dispatch({ changes: { from: 1, insert: 'A-edit' } });
expect(editor.getContent()).toBe('AA-edit');
// 切到 B
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'B');
expect(editor.getContent()).toBe('B');
// 切回 A(content 是 A 初始的,但 view 的 doc 是 A-edit 之前 dispatch 过的
// 状态——实际 loadFile 走 dispatch 全量替换,所以 doc 回到 A 初始)
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
expect(editor.getContent()).toBe('A');
});
});