Files
Todo-List/scripts/check-trash-fuzz.mjs
2026-09-12 13:55:57 +08:00

286 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.
// 属性测试(随机操作压测):验证「任何操作序列都不会让任务凭空消失」。
//
// 静态审查能发现"这行代码看起来不对",但发现不了"这三个操作按这个顺序做就丢数据"。
// 这个脚本随机组合 增/删/恢复/移动/重排/改名/删分类/清除已完成/往返 等操作,
// 每一步都断言两条不变量:
//
// 不变量 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);