This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 回归测试:脏标记竞态 ——「await writeFile 期间用户又改了」必须保留 dirty
//
// v4 起去掉 800ms debounce 改为全实时同步:
// 用户改 A → _markDirty → 立即 _scheduleAutoSave → _doAutoSave 启动 →
// 序列化(含 A→ await writeFile期间用户改 B / C / D → _markDirty 只
// 设 dirty=true + 推 _dirtyVersion + 设 _needsResave=true因为有 in-flight
// → writeFile 返回 → 版本检查发现快照不含最新 → 不清 dirty
// finally 检测 _needsResave → 立即再启动一次 _doAutoSave 把累积改动写出去。
//
// 旧机制800ms debounce的关键回归仍是 v4 的核心不变:
// in-flight 期间的用户改动**必须**最终落盘,绝不能因为 dirty 被误清而丢。
//
// 跑法node scripts/check-dirty-race.mjs
// 桩出 window.api把 writeFile 换成可手动控制的 Promise
// 这样能精确卡在「await writeFile 期间」注入下一次 _markDirty。
let writeFileResolver;
const calls = { writeFile: 0 };
installApiStub({
writeFile: (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { writeFileResolver = resolve; });
}
});
const { TaskStore } = await import('../src/task-store.js');
const { CategoryKind } = await import('../src/markdown-parser.js');
// ============================================
// [1] _doAutoSave 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[1] _doAutoSave 路径下「await 期间修改」必须保留 dirty');
{
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-test.md';
// 直接 addTask 触发 _markDirty 路径
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 用内部 API 触发一次"修改",让 _markDirty 把 _dirtyVersion 推到 1
store.addTask('未分类', 'A');
ok('首次 addTask 后 dirty 为 true', store.dirty === true);
ok('首次 addTask 后 _dirtyVersion = 1', store._dirtyVersion === 1);
// 把 debounce 定时器立刻 flush 掉,让 _doAutoSave 走到 await writeFile 那一行
store._autoSave.flush();
// 此时 writeFile 已发起calls.writeFile 应为 1_doAutoSave 还在等 resolver
ok('flush 后 writeFile 被调用一次', calls.writeFile === 1);
// 「用户在 await 期间又改东西」:再次 addTask —— 只走 _markDirty不推 _writeGeneration
store.addTask('未分类', 'B');
ok('await 期间 addTask 后 dirty 仍为 true', store.dirty === true);
ok('await 期间 addTask 后 _dirtyVersion = 2', store._dirtyVersion === 2);
// 关键_writeGeneration 仍是 1没有新 save 启动)—— 这是 bug 触发条件
ok('_writeGeneration 仍为 1没有新 save 接力)', store._writeGeneration === 1);
// 释放 writeFile —— 让第一次 _doAutoSave 醒来
writeFileResolver({ ok: true });
// 给 microtask 几次 tick 让 await 后的代码跑完
await new Promise(r => setTimeout(r, 20));
// 关键断言dirty 必须仍为 true —— 否则 B 永远写不出去
if (store.dirty === true) {
ok('await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}, _dirtyVersion=${store._dirtyVersion}`);
}
}
// ============================================
// [2] saveNow 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[2] saveNow 路径下「await 期间修改」必须保留 dirty');
{
// 重置桩
calls.writeFile = 0;
let resolver2;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver2 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-saveNow.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 启动 saveNow不 await —— 让它走到 await writeFile 后挂起)
const savePromise = store.saveNow();
ok('saveNow 启动后 writeFile 被调用', calls.writeFile === 1);
// await 期间模拟用户改动
store.addTask('未分类', 'X');
ok('await 期间 addTask 后 dirty 为 true', store.dirty === true);
ok('await 期间 _dirtyVersion 已 +1', store._dirtyVersion === 1);
// 释放 + 等 saveNow 收尾
resolver2({ ok: true });
await savePromise;
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('saveNow await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('saveNow await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}`);
}
}
// ============================================
// [3] 正常路径await 期间无修改 → 仍正确清 dirty防止误伤
// ============================================
console.log('\n[3] 正常路径(无并发修改)必须正确清 dirty —— 别把修复写成永不清');
{
calls.writeFile = 0;
let resolver3;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver3 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-clean.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
let savedEmitted = false;
store.on('saved', () => { savedEmitted = true; });
store.addTask('未分类', 'Y');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 不再做任何修改,直接释放
resolver3({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === false) {
ok('正常路径下 dirty 被正确清掉', true);
} else {
bad('正常路径下 dirty 被正确清掉', `dirty=${store.dirty}`);
}
if (savedEmitted) {
ok('正常路径下 emit 了 saved 事件', true);
} else {
bad('正常路径下 emit 了 saved 事件');
}
}
// ============================================
// [4] cancelPendingSave 仍要让 in-flight 过期(回归测试现有行为)
// ============================================
console.log('\n[4] cancelPendingSave 让 in-flight 写过期dirty 保留');
{
calls.writeFile = 0;
let resolver4;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver4 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-cancel.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', 'Z');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 模拟「文件被外部改了」 → 取消 pending
store.cancelPendingSave();
// _writeGeneration 在 cancelPendingSave 里被 +1所以已不再是 1
ok('cancel 后 _writeGeneration 已被推过in-flight 失效)', store._writeGeneration > 1);
// 释放 writeFile —— 已取消dirty 必须保留
resolver4({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('cancel 后 dirty 被保留', true);
} else {
bad('cancel 后 dirty 被保留', `dirty=${store.dirty}`);
}
}
// ============================================
// [5] v4 全实时同步:单次 addTask 立即触发 writeFile无 800ms 等待)
// ============================================
console.log('\n[5] 单次 addTask 立即触发 writeFile无 debounce 延迟)');
{
calls.writeFile = 0;
let resolver5;
globalThis.window.api.writeFile = (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { resolver5 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/real-time-sync.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', '即时');
// 关键addTask 一返回 writeFile 就已经发起 —— 不再等 800ms
ok('addTask 后立即触发 writeFile无 debounce 延迟)', calls.writeFile === 1,
`calls.writeFile=${calls.writeFile}`);
// 收尾:释放 resolver 让 save 完成
resolver5({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('保存完成后 dirty 被清掉', store.dirty === false, `dirty=${store.dirty}`);
}
// ============================================
// [6] v4 尾随写in-flight 期间多次 addTaskfinally 自动补一次写覆盖最新状态
// ============================================
console.log('\n[6] in-flight 期间累积改动 → finally 自动补一次写(覆盖最新状态)');
{
calls.writeFile = 0;
// 用队列收集 resolver避免后续尾随写无法 release 导致悬挂
const resolvers = [];
globalThis.window.api.writeFile = (_path, content) => {
calls.writeFile++;
const capturedContent = content;
return new Promise((resolve) => {
resolvers.push({ resolve, capturedContent });
});
};
const store = new TaskStore();
store.filePath = '/tmp/trailing-save.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 第 1 次:直接 addTask 触发第一次 writeFile
store.addTask('未分类', 'A');
ok('addTask A 后 writeFile 被立即调用', calls.writeFile === 1);
// in-flight 期间:连续改 3 次 —— 应走 _needsResave 路径,**不**起新 writeFile
store.addTask('未分类', 'B');
store.addTask('未分类', 'C');
store.addTask('未分类', 'D');
ok('in-flight 期间 3 次 addTask 后 writeFile 仍 = 1被合并',
calls.writeFile === 1, `calls.writeFile=${calls.writeFile}`);
ok('in-flight 期间 _writeGeneration 仍 = 1没新 save 启动)',
store._writeGeneration === 1);
ok('in-flight 期间 _dirtyVersion = 4addTask 4 次)',
store._dirtyVersion === 4);
// 释放第一次写finally 应触发尾随写
resolvers[0].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('第一次释放后 writeFile 被尾随启动一次(共 2 次)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
ok('尾随写启动后 _writeGeneration = 2',
store._writeGeneration === 2);
ok('第一次释放后 dirty 仍 = true尾随写尚未完成',
store.dirty === true);
// 释放尾随写:这次应包含 ABCD 全部内容
const trailingContent = resolvers[1].capturedContent;
ok('尾随写的内容含 A/B/C/D 全部 4 条任务',
trailingContent.includes('A') &&
trailingContent.includes('B') &&
trailingContent.includes('C') &&
trailingContent.includes('D'),
`内容预览: ${trailingContent.slice(0, 200)}`);
resolvers[1].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('尾随写完成后 dirty 被清掉', store.dirty === false,
`dirty=${store.dirty}`);
ok('无更多 _needsResave → writeFile 共 2 次(不多次重复)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);