Files
Notes/tests/unit/dirty-sync.test.js
2026-09-12 14:15:26 +08:00

219 lines
10 KiB
JavaScript
Raw Permalink 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/dirty-sync.js 测试Round 3 bug 修复 · 2026-08
//
// 验证单向提升同步契约:
// - state.isDirty=false + editor.isDirty()=true → state.isDirty=true提升
// - state.isDirty=true + editor.isDirty()=false → 不动(半步保存场景保留 true
// - state.isDirty=false + editor.isDirty()=false → 不动(真干净)
// - state.isDirty=true + editor.isDirty()=true → 不动(已一致)
//
// 半步保存save 飞行期用户敲字符)契约**绝对不**被破坏state.isDirty=true
// 是「doc 比 writeFile 内容多」的标志,不能被 editor 的基线对比覆盖。这是
// save-toast.js#decideSaveToast 之外的核心 invariant。
//
// 关联:[[project-dirty-sync-ime-race-2026-08]]。
/* @vitest-environment jsdom */
import { describe, it, expect } from 'vitest';
import { syncDirtyFromEditor } from '../../src/dirty-sync.js';
function makeState(isDirty = false) {
return { isDirty };
}
function makeEditor(isDirtyFn) {
return { isDirty: isDirtyFn };
}
describe('syncDirtyFromEditor —— IME race 单向提升契约', () => {
it('state=false + editor=true → 提升到 true核心 IME race bug 修复)', () => {
// 关键场景:用户用 IME 输入字符state.isDirty 因 onChange 被 updateListener
// 跳过而保持 false但 editor.doc 里有 IME 字符。同步后 state 必须跟上 truth
// 否则 save() 走 !state.isDirty 早退返回「已是最新」IME 字符丢失。
const state = makeState(false);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
it('state=true + editor=false → 不动(半步保存契约,绝不能降级)', () => {
// 半步保存save 飞行期用户敲了字符 → state.isDirty=true 表示「doc 比
// writeFile 内容多」。editor.isDirty() 返回 false 是因为 markSaved 已把
// lastSavedDoc 设为当前 doc。这是有意的「飞行期新字符」标记本函数绝不能
// 把它盖成 false —— 否则下次 save() 会走早退,飞行期字符丢失。
const state = makeState(true);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('state=false + editor=false → 不动(真干净,无提升需要)', () => {
// 干净文件刚打开state 与 editor 都没改动 —— 同步是 no-op。
const state = makeState(false);
const editor = makeEditor(() => false);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(false);
});
it('state=true + editor=true → 不动(已一致,提升无意义)', () => {
// 普通 dirty 状态state 与 editor 都认为是 dirty —— 同步是 no-op。
const state = makeState(true);
const editor = makeEditor(() => true);
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(false);
expect(state.isDirty).toBe(true);
});
it('editor.isDirty() 是惰性求值:只在 state=false 时调用(短路优化)', () => {
// 性能契约state=true 时绝不应调 editor.isDirty() —— 否则 save() 每次
// 入口都会跑 doc.toString() 这种全量字符串对比。验证 spy 不被调用。
let calls = 0;
const state = makeState(true);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(0);
});
it('editor.isDirty() 在 state=false 时确实被调用', () => {
let calls = 0;
const state = makeState(false);
const editor = makeEditor(() => { calls += 1; return false; });
syncDirtyFromEditor(state, editor);
expect(calls).toBe(1);
});
it('可重复调用(幂等):提升后再次调用是 no-op', () => {
// 多次调用不会「又提升一次」造成副作用累积。第二次 state=true 直接走短路。
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
const secondCall = syncDirtyFromEditor(state, editor);
expect(secondCall).toBe(false);
expect(state.isDirty).toBe(true);
});
it('状态可观察state 是普通的 mutable 对象,函数只写 isDirty 这一个字段', () => {
// 防回归:本函数不应动 state 上的其他字段(比如 lastSavedMtimeMs——
// 调用方app.js用 state 做 dirty 决策外还做别的mtime 比对、IPC setDirty
// 等),如果这里意外改了其他字段会引入很难追的 bug。
const state = { isDirty: false, lastSavedMtimeMs: 12345, currentFile: { path: '/x.md' } };
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
expect(state.isDirty).toBe(true);
expect(state.lastSavedMtimeMs).toBe(12345);
expect(state.currentFile).toEqual({ path: '/x.md' });
});
});
describe('syncDirtyFromEditor —— 与 save() 早退检查的协同', () => {
// 这是 Round 3 bug 的核心契约save() 入口调本函数后,!state.isDirty 早退
// 检查必须可靠 —— 即「editor 有改动」+ 「state 同步后」= 「不早退」。
//
// 这些 case 不是测 save() 本体(那需要 mock 一堆 app.js 闭包),而是测
// syncDirtyFromEditor 产生的 state 状态,确保 save() 早退条件不会误判。
it('IME raceeditor 有字符 + state 已同步 → state.isDirty=truesave 不早退)', () => {
const state = makeState(false);
const editor = makeEditor(() => true);
syncDirtyFromEditor(state, editor);
// 模拟 save() 的早退条件:`!state.isDirty`
expect(!state.isDirty).toBe(false); // 应该走 writeFile
});
it('真干净state 与 editor 都 false → state 仍 falsesave 早退 OK', () => {
const state = makeState(false);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(true); // save 早退是正确行为
});
it('半步保存state=true + editor=false → state 仍 truesave 走 writeFile', () => {
const state = makeState(true);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(false); // save 走 writeFile 落盘飞行期新字符
});
});
// ---------------------------------------------------------------------------
// applyEntries 守卫语义app.js line 2298
//
// 契约editor 有未保存字符时applyEntries 检测「文件被外部删除」必须
// 用 `editor.isDirty()`(实时真相),不能用 `state.isDirty`IME 合成
// 期间被 updateListener 的 !isComposing 守卫屏蔽,可能 stale-false
//
// Round 9 P0 bug旧实现 `!state.isDirty` → IME 期间 stale-false → 守卫
// 通过 → clearCurrentFile() → onFilesChanged line 2901 `if (!state.currentFile)
// return` 早退 → line 2917 syncDirtyFromEditor 永远不到 → line 2944 rescue
// 分支永远不到 → IME 字符随编辑器 view 销毁而丢失 + 窗口关闭 dirty=false
// 不弹保存提示 + 静默丢字。
//
// 这些测试不 import app.js4111 行 + 大量模块级闭包),直接重放守卫的
// if-condition 断言语义。app.js 里改动对应的守卫条件会被 grep 拦住。
// ---------------------------------------------------------------------------
describe('applyEntries 删除守卫语义 —— IME race 必须用 editor.isDirty()', () => {
// 重放 app.js#applyEntries 的守卫state.currentFile && state.currentFile.path && !<DIRTY_TRUTH>
function shouldClearOnFileDeleted(state, dirtyTruth) {
return Boolean(state.currentFile && state.currentFile.path && !dirtyTruth);
}
it('editor 有未保存字符IME 合成中) → 不能清 currentFile', () => {
// 关键场景:用户 IME 拼音期间 state.isDirty 是 stale-false
// 但 editor.isDirty() = true。守卫必须用 editor.isDirty() 才能
// 拦下 clearCurrentFile让 onFilesChanged 的 rescue 路径接管。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
const editorDirty = true; // IME 缓冲里有字符
const stateDirtyStale = false; // onChange 被守卫屏蔽
// 旧实现state.isDirty会清 currentFile → 丢字
expect(shouldClearOnFileDeleted(state, stateDirtyStale)).toBe(true);
// 新实现editor.isDirty()):拦下 clearCurrentFile → rescue 接管
expect(shouldClearOnFileDeleted(state, editorDirty)).toBe(false);
});
it('正常 dirty 状态 → 不能清 currentFile', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('真干净 → 清 currentFile外部删除正常处理', () => {
const state = { currentFile: { path: '/foo.md', name: 'foo.md' } };
expect(shouldClearOnFileDeleted(state, false)).toBe(true);
});
it('没有打开文件 → 不清(无需处理)', () => {
const state = { currentFile: null };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
expect(shouldClearOnFileDeleted(state, true)).toBe(false);
});
it('currentFile 缺 path 字段 → 不清(异常状态防御)', () => {
const state = { currentFile: { name: 'foo.md' /* no path */ } };
expect(shouldClearOnFileDeleted(state, false)).toBe(false);
});
it('守卫与 syncDirtyFromEditor 的协同IME 提升后 rescue 路径必能触发', () => {
// 端到端验证 P0 fix 的修复链:
// 1. IME 期间 applyEntries 用 editor.isDirty() 拦下 clearCurrentFile
// 2. onFilesChanged 不早退
// 3. syncDirtyFromEditor 把 state.isDirty 提升到 true
// 4. rescue 分支 `if (state.isDirty)` 看到 true → 落救回文件
//
// 这里只测第 1 步和第 3 步(最容易回归),第 4 步是 onFilesChanged 的
// 既有契约applyEntries 不再触发的话 rescue 自然能到)。
const state = { currentFile: { path: '/foo.md', name: 'foo.md' }, isDirty: false };
const editor = makeEditor(() => true); // IME 期间 editor 一定有未保存字符
// step 1: 守卫拦下
expect(shouldClearOnFileDeleted(state, editor.isDirty())).toBe(false);
// step 3: syncDirtyFromEditor 把 state 提升
const promoted = syncDirtyFromEditor(state, editor);
expect(promoted).toBe(true);
expect(state.isDirty).toBe(true);
});
});