This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
// src/markdown-editor.js 测试 —— IME compositionend dirty flush
//
// 回归背景updateListener 里 `!isComposing` 守卫在 IME 合成期间屏蔽 onChange
// 回调(避免 CJK 每个拼音字母触发一次 scheduleLivePreview 全页重建)。但
// compositionend 后 CM6 不一定再产生新 transaction —— 此时 onChange 永远不
// 触发state.isDirty 保持 falsesave() 走 !isDirty 早退返回 true用户
// 看到中文已上屏但磁盘文件没写入。
//
// 修复compositionend handler 主动比较 doc 与 lastSavedDoc不同则补发
// onChange 让 app 层翻脏态。本文件覆盖这条新路径。
//
// 也覆盖:
// - IME 期间 _composing 标记正确翻转
// - IME 取消doc 未变)不触发 onChange
// - dispose 后 compositionend 不抛
// - applyUserChange 在 IME 期间抛 EDITOR_COMPOSING
//
// 注意jsdom 不实现 contenteditable / 真实 IME 事件,所以直接调
// editor._onCompositionStart / _onCompositionEnd 模拟。
/* @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownEditor } from '../../src/markdown-editor.js';
// jsdom 不实现 scrollIntoViewMarkdownEditor 本身不调,但 CM6 basicSetup 可能
if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// CM6 的 requestAnimationFrame 探测 —— jsdom 没有polyfill
if (typeof window.requestAnimationFrame === 'undefined') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
// CM6 RectangleMarker 测量选中区时调 Range#getClientRectsjsdom 没实现
// Range#textRange 是 jsdom 假实现但没挂 getClientRects。每次 measure 抛 TypeError
// 会被 vitest 写到 stderr污染测试输出。返回空数组让 CM6 跳过选中区绘制。
if (typeof Range.prototype.getClientRects !== 'function') {
Range.prototype.getClientRects = function () {
// 真实浏览器返回 DOMRectListCM6 只迭代 + 读 .top/.left/.width/.height。
// 用 array-like 占位即可,避免导入完整 DOMRect 带来的兼容开销
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(opts = {}) {
const el = document.createElement('div');
document.body.appendChild(el);
const onChange = vi.fn();
const onSave = vi.fn();
const editor = new MarkdownEditor({ element: el, onChange, onSave });
return { editor, el, onChange, onSave };
}
describe('MarkdownEditor IME compositionend dirty flush', () => {
let editor, el, onChange;
beforeEach(() => {
({ editor, el, onChange } = createEditor());
editor.loadFile({ path: '/test.md', name: 'test.md' }, 'hello');
});
it('IME 提交后 doc 变化 → compositionend 补发 onChange', () => {
// 初始doc === lastSavedDoc → isDirty = false
expect(editor.isDirty()).toBe(false);
expect(onChange).not.toHaveBeenCalled();
// 模拟 IME 开始
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 模拟 IME 期间 CM6 把中文写入了 doc。
// userEvent: 'input.type.compose' 让 updateListener 识别为 IME 合成事件,
// isComposing=true → onChange 被守卫屏蔽(这是原来的设计意图)。
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '你好世界' },
userEvent: 'input.type.compose',
});
// 合成期间 onChange 被守卫屏蔽(这是原来的设计意图)
expect(onChange).not.toHaveBeenCalled();
// 但 doc 已经变了
expect(view.state.doc.toString()).toBe('你好世界');
expect(editor.isDirty()).toBe(true);
// 模拟 IME 结束 —— 这是修复的关键compositionend 必须补发 onChange
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('IME 取消doc 未变)→ compositionend 不补发 onChange', () => {
expect(editor.isDirty()).toBe(false);
editor._onCompositionStart();
expect(editor._composing).toBe(true);
// 用户取消 IME按 Esc—— doc 没变
editor._onCompositionEnd();
expect(editor._composing).toBe(false);
// doc 没变 → 不应触发 onChange
expect(onChange).not.toHaveBeenCalled();
});
it('IME 期间已有 onChange非 IME 字符触发)→ compositionend 不重复调', () => {
// 场景IME 期间用户也敲了一个英文字符updateListener 因 !isComposing
// 守卫不调 onChange但如果将来守卫被移除或行为变化compositionend 也不
// 应该重复调(因为 doc 已在 updateListener 里触发过 onChange
// 注意:当前实现中 updateListener 在 isComposing 时不调 onChange所以
// 这个测试验证 compositionend 只按「doc != lastSavedDoc」判断
// 不关心 onChange 之前是否已被调过。
editor._onCompositionStart();
const view = editor.view;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '中文' },
userEvent: 'input.type.compose',
});
// 手动模拟 updateListener 路径已调过 onChange假设守卫被绕过的场景
onChange.mockClear();
// compositionend 仍然会触发 onChange因为 doc != lastSavedDoc
// 这是「宁可多调一次,不可漏调」的设计选择
editor._onCompositionEnd();
expect(onChange).toHaveBeenCalledTimes(1);
});
it('dispose 后 compositionend 不抛', () => {
editor._onCompositionStart();
editor.dispose();
// dispose 后 view 被销毁compositionend 不应抛
expect(() => editor._onCompositionEnd()).not.toThrow();
expect(editor._composing).toBe(false);
});
it('IME 期间 applyUserChange 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.applyUserChange('新内容')).toThrow('EDITOR_COMPOSING');
});
it('非 IME 期间 applyUserChange 不抛', () => {
expect(() => editor.applyUserChange('新内容')).not.toThrow();
});
// audit fix (Round 4 F2)setExternalContent 也要 IME 守卫。
// fs-watcher 在 IME 合成期推「外部修改」→ view.dispatch 全量替换 doc
// 会摧毁 IME 合成 buffer已输入的拼音字符消失。与 applyUserChange 行为
// 对齐:合成期间抛 EDITOR_COMPOSING 让调用方 catch 后给用户「请稍后再试」。
it('IME 期间 setExternalContent 抛 EDITOR_COMPOSING', () => {
editor._onCompositionStart();
expect(() => editor.setExternalContent('外部内容')).toThrow('EDITOR_COMPOSING');
// doc 没被替换
expect(editor.getContent()).toBe('hello');
});
it('非 IME 期间 setExternalContent 不抛 + lastSavedDoc 同步更新', () => {
expect(editor.isDirty()).toBe(false);
editor.setExternalContent('外部内容');
expect(editor.getContent()).toBe('外部内容');
expect(editor.isDirty()).toBe(false);
});
});
describe('MarkdownEditor 基础行为', () => {
it('loadFile 后 isDirty = false', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
expect(editor.isDirty()).toBe(false);
expect(editor.getContent()).toBe('content');
});
it('输入后 isDirty = true', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'content');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'changed' },
});
expect(editor.isDirty()).toBe(true);
expect(editor.getContent()).toBe('changed');
});
it('markSaved 后 isDirty 归零', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, 'old');
editor.view.dispatch({
changes: { from: 0, to: editor.view.state.doc.length, insert: 'new' },
});
expect(editor.isDirty()).toBe(true);
editor.markSaved();
expect(editor.isDirty()).toBe(false);
});
it('unload 后再 compositionend 不抛view 已 null', () => {
const { editor } = createEditor();
editor.loadFile({ path: '/x.md', name: 'x.md' }, '');
editor.unload();
expect(() => editor._onCompositionEnd()).not.toThrow();
});
});