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

219 lines
10 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/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 race:editor 有字符 + state 已同步 → state.isDirty=true(save 不早退)', () => {
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 仍 false(save 早退 OK)', () => {
const state = makeState(false);
const editor = makeEditor(() => false);
syncDirtyFromEditor(state, editor);
expect(!state.isDirty).toBe(true); // save 早退是正确行为
});
it('半步保存:state=true + editor=false → state 仍 true(save 走 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.js(4111 行 + 大量模块级闭包),直接重放守卫的
// 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);
});
});