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