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,285 @@
// 属性测试(随机操作压测):验证「任何操作序列都不会让任务凭空消失」。
//
// 静态审查能发现"这行代码看起来不对",但发现不了"这三个操作按这个顺序做就丢数据"。
// 这个脚本随机组合 增/删/恢复/移动/重排/改名/删分类/清除已完成/往返 等操作,
// 每一步都断言两条不变量:
//
// 不变量 A不丢任务任务文本的多重集只能因「新增」而变大
// 其余任何操作都必须保持总量不变 —— 删除只是搬到回收站,不是丢弃。
// 不变量 B结构安全serialize() → parseMarkdown() 往返后任务多重集不变。
// 这是真正的验收标准,因为 Markdown 才是真相之源。往返会抓到
// 「回收站 h1 排到子分类前面 → 后面的 ## 被写到容器外 → 下次加载静默丢任务」这类结构损坏。
//
// 用法node scripts/check-trash-fuzz.mjs [轮数]
import { TaskStore, TRASH_NAME, UNCATEGORIZED_NAME, OTHER_TASKS_NAME } from '../src/task-store.js';
import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js';
// 确定性伪随机(可复现:失败时能贴出种子重跑)
let seed = Number(process.argv[3]) || 12345;
function rnd() {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
const pick = arr => arr[Math.floor(rnd() * arr.length)];
const chance = p => rnd() < p;
function makeStore() {
const store = new TaskStore();
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
return store;
}
function multiset(categories) {
const m = new Map();
for (const cat of categories) {
for (const t of cat.tasks || []) {
m.set(t.text, (m.get(t.text) || 0) + 1);
}
}
return m;
}
function msEqual(a, b) {
if (a.size !== b.size) return false;
for (const [k, v] of a) if (b.get(k) !== v) return false;
return true;
}
function msDiff(a, b) {
const keys = new Set([...a.keys(), ...b.keys()]);
const out = [];
for (const k of keys) {
const x = a.get(k) || 0, y = b.get(k) || 0;
if (x !== y) out.push(`"${k}": ${x}${y}`);
}
return out.join('; ');
}
// 结构不变式:回收站必须是最后一个;所有子分类必须排在容器之后
function structureProblems(store) {
const probs = [];
const cats = store.categories;
const trashIdx = cats.findIndex(c => c.kind === CategoryKind.TRASH);
if (trashIdx >= 0 && trashIdx !== cats.length - 1) {
probs.push(`回收站不在末位index ${trashIdx} / 共 ${cats.length}`);
}
if (cats.filter(c => c.kind === CategoryKind.TRASH).length > 1) {
probs.push('存在多个回收站分类');
}
const containerIdx = cats.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
if (containerIdx >= 0) {
cats.forEach((c, i) => {
if (c.parentOtherTasks && i < containerIdx) {
probs.push(`子分类「${c.name}」(${i}) 排在容器 (${containerIdx}) 之前`);
}
});
}
return probs;
}
let counter = 0;
const ops = [
// 新增任务(唯一允许让总量变大的操作)
function addTask(store) {
const targets = store.categories.filter(
c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH
);
if (!targets.length) return 0;
const cat = pick(targets);
const text = `任务${++counter}`;
return store.addTask(cat.name, text) ? 1 : 0;
},
// 删除任务(应该搬进回收站,总量不变)
function deleteTask(store) {
const targets = store.categories.filter(
c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH && c.tasks.length
);
if (!targets.length) return 0;
const cat = pick(targets);
store.deleteTask(cat.name, pick(cat.tasks).id);
return 0;
},
// 试图删除回收站里的任务(必须无效果)
function deleteFromTrash(store) {
const trash = store.getTrashCategory();
if (!trash || !trash.tasks.length) return 0;
store.deleteTask(TRASH_NAME, pick(trash.tasks).id);
return 0;
},
// 恢复
function restore(store) {
const trash = store.getTrashCategory();
if (!trash || !trash.tasks.length) return 0;
const id = pick(trash.tasks).id;
if (chance(0.5)) store.restoreTask(id);
else {
const targets = store.categories.filter(
c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH
);
if (targets.length) store.restoreTask(id, pick(targets).name);
}
return 0;
},
// 移动任务(含移进/移出回收站)
function moveTask(store) {
const from = store.categories.filter(c => c.tasks.length);
if (!from.length) return 0;
const src = pick(from);
const dsts = store.categories.filter(c => c.name !== src.name);
if (!dsts.length) return 0;
store.moveTask(src.name, pick(src.tasks).id, pick(dsts).name);
return 0;
},
// 勾选
function toggle(store) {
const cats = store.categories.filter(c => c.tasks.length);
if (!cats.length) return 0;
const cat = pick(cats);
store.toggleTask(cat.name, pick(cat.tasks).id);
return 0;
},
// 清除已完成(应搬进回收站)
function clearCompleted(store) {
if (chance(0.5)) store.clearCompleted();
else {
const cats = store.categories;
store.clearCompleted(pick(cats).name);
}
return 0;
},
// 新建子分类
function addSub(store) {
store.addSubCategory(`分类${++counter}`);
return 0;
},
// 删除子分类(任务应迁到「未分类」)
function delSub(store) {
const subs = store.getSubCategories().filter(c => c.name !== UNCATEGORIZED_NAME);
if (!subs.length) return 0;
store.deleteCategory(pick(subs).name);
return 0;
},
// 试图删除/改名回收站(必须无效果)
//
// 关键:要把「被拒绝」这个语义在测试里也表达出来。如果某天有人不小心把这些
// 操作改成「返回一个非 falsy 值」,但 fuzz 测试只默默调用、不 assert
// 回归就会被静悄悄放过。所以这里显式断言每个调用都返回 falsy —— 失败时
// 还能指出是哪条规则被突破。
function attackTrash(store) {
const attempts = [
['deleteCategory', store.deleteCategory(TRASH_NAME)],
['renameCategory', store.renameCategory(TRASH_NAME, `伪装${++counter}`)],
['addSubCategory', store.addSubCategory(pick(['回收站', 'trash', '垃圾桶', 'bin', 'Recycle Bin']))],
['addTask', store.addTask(TRASH_NAME, `偷渡${++counter}`)]
];
for (const [name, ret] of attempts) {
if (ret) {
rejectedCalls.push({ step: currentStep, name, ret });
}
}
return 0;
},
// 改名子分类
function renameSub(store) {
const subs = store.getSubCategories();
if (!subs.length) return 0;
store.renameCategory(pick(subs).name, `改名${++counter}`);
return 0;
},
// 子分类重排(最容易破坏数组顺序的操作)
function reorderSub(store) {
const n = store.getSubCategories().length;
if (n < 2) return 0;
store.reorderSubCategory(
Math.floor(rnd() * n),
Math.floor(rnd() * n),
chance(0.5)
);
return 0;
},
// 任务重排
function reorderTask(store) {
const cats = store.categories.filter(c => c.tasks.length > 1);
if (!cats.length) return 0;
const cat = pick(cats);
const n = cat.tasks.length;
store.reorderTask(cat.name, Math.floor(rnd() * n), Math.floor(rnd() * n));
return 0;
},
// 走一次完整的 Markdown 往返(模拟保存后重新加载)
function reload(store) {
const md = store.serialize();
store.loadFromContent(md, null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
return 0;
}
];
const ROUNDS = Number(process.argv[2]) || 4000;
const store = makeStore();
let expected = multiset(store.categories);
let fails = 0;
// 不变量 D回收站保护 —— 任何「试图修改回收站」的入口都必须返回 falsy。
// 收集所有「成功穿透」的尝试,最后统一报告。
let rejectedCalls = [];
let currentStep = 0;
for (let i = 0; i < ROUNDS; i++) {
currentStep = i;
const op = pick(ops);
const before = multiset(store.categories);
const added = op(store);
// ---- 不变量 A除「新增」外总量不变 ----
const after = multiset(store.categories);
const beforeTotal = [...before.values()].reduce((a, b) => a + b, 0);
const afterTotal = [...after.values()].reduce((a, b) => a + b, 0);
if (afterTotal !== beforeTotal + added) {
console.log(`\n✗ [第 ${i} 步 / ${op.name}] 任务总量异常:${beforeTotal}${afterTotal}(预期 +${added}`);
console.log(` 差异:${msDiff(before, after)}`);
fails++;
if (fails > 3) break;
}
// ---- 不变量 B结构安全 ----
const probs = structureProblems(store);
if (probs.length) {
console.log(`\n✗ [第 ${i} 步 / ${op.name}] 结构损坏:${probs.join(' | ')}`);
console.log(` 顺序:${store.categories.map(c => `${c.name}(${c.kind})`).join(' → ')}`);
fails++;
if (fails > 3) break;
}
// ---- 不变量 CMarkdown 往返不丢任务 ----
const rt = multiset(parseMarkdown(store.serialize()));
if (!msEqual(after, rt)) {
console.log(`\n✗ [第 ${i} 步 / ${op.name}] Markdown 往返后任务变化:`);
console.log(` 差异:${msDiff(after, rt)}`);
console.log(` 顺序:${store.categories.map(c => `${c.name}(${c.kind})`).join(' → ')}`);
console.log(`--- 序列化结果 ---\n${store.serialize()}`);
fails++;
if (fails > 3) break;
}
}
const final = multiset(store.categories);
const total = [...final.values()].reduce((a, b) => a + b, 0);
const trash = store.getTrashCategory();
console.log(`\n${'─'.repeat(52)}`);
console.log(`随机操作 ${ROUNDS} 步(种子 ${process.argv[3] || 12345}`);
console.log(`最终:${store.categories.length} 个分类 / ${total} 个任务 / 回收站 ${trash ? trash.tasks.length : 0}`);
if (rejectedCalls.length) {
console.log(`\n✗ 回收站保护被绕过 ${rejectedCalls.length} 次:`);
// 最多展示前 5 条 —— 再多就只是噪声,第一条才是真正的回归信号
for (const r of rejectedCalls.slice(0, 5)) {
console.log(` - 第 ${r.step} 步:${r.name} 返回了 ${JSON.stringify(r.ret)}`);
}
if (rejectedCalls.length > 5) console.log(` ... 以及另外 ${rejectedCalls.length - 5}`);
fails += rejectedCalls.length;
}
console.log(fails === 0 ? '✓ 全程无任务丢失、无结构损坏、往返一致、回收站保护生效' : `${fails} 处失败`);
process.exit(fails > 0 ? 1 : 0);