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

290 lines
13 KiB
JavaScript
Raw Permalink 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.
// 一次性验证脚本:分类 API 的「拒绝保留名」守卫。
//
// 背景store 里有几个「保留分类名」(今天类 / 全部任务类 / 回收站类,以及它们的英文别名)。
// 这些名字承担固定语义:今天类是智能视图的数据源,全部任务是子分类容器,回收站
// 是删除任务的暂存地。如果让用户给子分类起名为「回收站」,同名冲突会让 getCategory
// 只命中第一个,删除/恢复路径从此走错。
//
// 守卫点(在 task-store.js 里):
// - addSubCategory(name) → 拒绝保留名,返回 null
// - renameCategory(old, new) → 拒绝把已有分类改名为保留名,返回 false
// - deleteCategory(name) → 拒绝删 TODAY / OTHER_TASKS / TRASH
// 以及「未分类」(兜底,必须始终存在)
// - addTask(name, text) → 拒绝向容器 / 回收站添加任务(前者不存任务,
// 后者只接受 deleteTask 进入)
//
// 旧实现里这些守卫被静默写好,但 check-trash-fuzz 只覆盖了「以『回收站』为
// 目标名」的几种攻击姿势。剩余的(子分类改名成保留名、英文别名大小写变体、
// addTask 越界)走的是另一条路径,没被覆盖。这份脚本是补漏。
//
// 用法node scripts/check-category-guards.mjs
import { TaskStore, UNCATEGORIZED_NAME, OTHER_TASKS_NAME, TRASH_NAME } from '../src/task-store.js';
import { CategoryKind, TODAY_KEYS, OTHER_TASKS_KEYS, TRASH_KEYS } from '../src/markdown-parser.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
console.log('[1] addSubCategory 拒绝保留名');
{
const store = makeStore();
// 中文保留名(这里覆盖每个 keys 集合的第一个值,足以代表整组)
for (const name of ['当前任务', '今天', '今日', '全部任务', '其他任务', '待办任务', '回收站', '垃圾桶']) {
const before = store.categories.length;
const result = store.addSubCategory(name);
check(
`addSubCategory('${name}') 返回 null`,
result === null,
`实际返回 ${JSON.stringify(result)}`
);
check(
`categories 长度未变(${name} 没被建出)`,
store.categories.length === before,
`${before}${store.categories.length}`
);
}
}
{
const store = makeStore();
// 英文 / 空格变体:保留名匹配是 lower + trim所以大小写、零散空格都得拦
// 注意:不测 SMART_LIST_KEYS如 'all tasks' / 'completed tasks' / 'important')。
// 那些是 h1 智能列表段的别名 —— 写为 ## 时跟普通子分类一样被 parser 正确分类,
// 不会丢数据;强制禁掉反而禁掉合理的英文命名("All Tasks for Project X")。
for (const name of ['TODAY', 'Today', 'my day', 'todo', 'to do', 'Trash', 'Recycle Bin']) {
const result = store.addSubCategory(name);
check(
`addSubCategory('${name}') 大小写/空格变体也被拒绝`,
result === null,
`实际返回 ${JSON.stringify(result)}`
);
}
}
{
const store = makeStore();
// 已存在的子分类不能重复建(用「副业」避开 loadDefault 的内置「工作/学习/生活/未分类」)
const ok1 = store.addSubCategory('副业');
const ok2 = store.addSubCategory('副业');
check('addSubCategory 首次成功', ok1 !== null && ok1.name === '副业');
check('addSubCategory 重复同名返回 null', ok2 === null, `实际 ${JSON.stringify(ok2)}`);
// 空名 / 纯空白名也直接拒
check('addSubCategory("") 返回 null', store.addSubCategory('') === null);
check('addSubCategory(" ") 返回 null', store.addSubCategory(' ') === null);
check('addSubCategory(null) 返回 null', store.addSubCategory(null) === null);
check('addSubCategory(undefined) 返回 null', store.addSubCategory(undefined) === null);
}
console.log('\n[2] renameCategory 拒绝把已有分类改成保留名');
{
const store = makeStore();
store.addSubCategory('副业');
store.addSubCategory('阅读');
for (const newName of ['当前任务', '今日', '全部任务', '回收站', 'TODAY', 'trash']) {
const ok = store.renameCategory('副业', newName);
check(
`rename('副业' → '${newName}') 返回 false`,
ok === false,
`实际返回 ${JSON.stringify(ok)}`
);
check(
`「副业」的名字没被改成「${newName}`,
store.getCategory('副业') !== null,
`当前存在的分类:${store.categories.map(c => c.name).join('、')}`
);
}
}
console.log('\n[2b] renameCategory 拒绝重命名「未分类」本身');
{
// 「未分类」是任务兜底,它的字符串名是 store / parser / writer 共用的魔法标识符。
// 一旦改名下次删除别的有任务的子分类时_getOrCreateUncategorizedCategory 找不到
// cat.name === '未分类',会凭空新建一个同名空分类,把释放任务丢进去 —— 用户
// 改名的那个旧分类就成了孤儿,并且 markdown 写出去再读回来时还会进一步分裂。
// renameCategory 必须把这条路径堵死,与 deleteCategory 的「未分类」拦截对称。
const store = makeStore();
store.addTask(UNCATEGORIZED_NAME, '兜底里的任务');
const ok = store.renameCategory(UNCATEGORIZED_NAME, '其他');
check(
`rename('${UNCATEGORIZED_NAME}' → '其他') 返回 false`,
ok === false,
`实际返回 ${JSON.stringify(ok)}`
);
check(
`${UNCATEGORIZED_NAME}」名字没动`,
store.getCategory(UNCATEGORIZED_NAME) !== null,
`当前存在的分类:${store.categories.map(c => c.name).join('、')}`
);
// 兜底任务也不能丢
check(
`兜底里的任务仍归属「${UNCATEGORIZED_NAME}`,
store.getCategory(UNCATEGORIZED_NAME).tasks.length === 1
);
// 进一步:用「副业」做目标也不允许 —— 否则用户照样能把「未分类」换成自己的
// 名字再触发同样的孤儿分裂。
const ok2 = store.renameCategory(UNCATEGORIZED_NAME, '副业');
check('rename(未分类 → 副业) 也被拒绝', ok2 === false);
check('「未分类」依旧存在(未被改成「副业」)',
store.getCategory(UNCATEGORIZED_NAME) !== null);
}
console.log('\n[2c] 「其他」作为普通子分类名可用(非保留名)');
{
// 「其他」与「未分类」语义不同:前者是用户主动建的命名分类,后者是系统兜底。
// 之前「其他」被误放进 OTHER_TASKS_KEYS容器别名导致 addSubCategory /
// renameCategory / UI 校验全把它当保留名拒掉。回归这条用例锁住新语义。
// 1) 新建「其他」子分类应成功
const store = makeStore();
const cat = store.addSubCategory('其他');
check(
`addSubCategory('其他') 返回新分类`,
cat !== null && cat.name === '其他' && cat.parentOtherTasks === true,
`实际返回 ${JSON.stringify(cat)}`
);
// 2) 「其他」与「未分类」可以共存 —— 这是这次产品决策的核心
check(
`「未分类」兜底未动`,
store.getCategory(UNCATEGORIZED_NAME) !== null
);
check(
`「其他」与「未分类」并存`,
store.getCategory('其他') !== null && store.getCategory(UNCATEGORIZED_NAME) !== null
);
// 3) renameCategory 不拦截「其他」作为目标名
const store2 = makeStore();
store2.addSubCategory('杂事');
const ok = store2.renameCategory('杂事', '其他');
check(
`renameCategory('杂事' → '其他') 成功`,
ok === true && store2.getCategory('其他') !== null,
`实际 ok=${ok}getCategory('其他')=${store2.getCategory('其他')}`
);
// 4) 加载后空「其他」不再被静默改成「未分类」 —— 防回归到 task-store.js:2381-2395 那段迁移
const store3 = makeStore();
store3.addSubCategory('其他');
store3._ensureBaseStructure();
check(
`空「其他」在加载后仍叫「其他」(不被静默改名为「未分类」)`,
store3.getCategory('其他') !== null && store3.getCategory('其他').name === '其他'
);
check(
`「未分类」依然作为兜底存在(与「其他」共存)`,
store3.getCategory(UNCATEGORIZED_NAME) !== null
);
}
console.log('\n[3] deleteCategory 拒绝破坏特殊分类');
{
const store = makeStore();
// loadDefault 已建出 工作/学习/生活/未分类。先在「工作」里加一条任务,然后
// deleteTask → 回收站被 _getOrCreateTrashCategory 懒创建出来。
// 这一步是为了让后续 deleteCategory(回收站) 真的命中 idx >= 0 的分支 —— 否则
// 回收站不存在时 deleteCategory 走 idx < 0 的提前 return守卫代码根本没被执行。
store.addTask('工作', '待删');
store.deleteTask('工作', store.getCategory('工作').tasks[0].id);
const ok1 = store.deleteCategory(TRASH_NAME);
check('deleteCategory(回收站) 返回 false', ok1 === false);
check('回收站分类仍存在', store.getTrashCategory() !== null);
const ok2 = store.deleteCategory(OTHER_TASKS_NAME);
check('deleteCategory(全部任务) 返回 false', ok2 === false);
check('全部任务容器仍存在', store.categories.some(c => c.kind === CategoryKind.OTHER_TASKS));
const ok3 = store.deleteCategory(UNCATEGORIZED_NAME);
check('deleteCategory(未分类) 返回 false', ok3 === false);
check('「未分类」兜底仍存在', store.getCategory(UNCATEGORIZED_NAME) !== null);
}
console.log('\n[4] addTask 拒绝向容器 / 回收站添加任务');
{
const store = makeStore();
// 容器本身
const t1 = store.addTask(OTHER_TASKS_NAME, '偷渡到容器');
check('addTask(全部任务) 返回 null', t1 === null);
// 容器没有被「污染」 —— 任务列表长度为 0
const container = store.getCategory(OTHER_TASKS_NAME);
check('容器不持任务addTask 越界后 tasks 仍为 0',
container && container.tasks.length === 0);
// 回收站 —— 只能由 deleteTask 进入,普通 addTask 必须拒绝
const t2 = store.addTask(TRASH_NAME, '偷渡到回收站');
check('addTask(回收站) 返回 null', t2 === null);
// 拒绝后回收站仍然不存在 —— 因为 addTask 失败不会触发 _getOrCreateTrashCategory
// 后续 deleteTask 才会建出。这正是想要的「懒创建」行为。
check('拒绝 addTask 后回收站仍未被创建(懒创建)',
store.getTrashCategory() === null);
}
console.log('\n[5] 已存在「未分类」时,删它仍被拒绝');
{
// 防止用户在有子分类的情况下意外删掉「未分类」—— 否则下次删别的分类,
// 释放出的任务无家可归_getOrCreateUncategorizedCategory 会再建,但语义是降级而非「正常」)。
const store = makeStore();
store.addSubCategory('副业');
const ok = store.deleteCategory(UNCATEGORIZED_NAME);
check('已存在「副业」时仍拒绝删「未分类」', ok === false);
}
console.log('\n[6] reorderSubCategory 不会让「未分类」漂离底部');
{
// 「未分类」固定排在子分类末尾_normalizeOrder 内部约定)。任何让它移动的重排都会被
// _normalizeOrder 在下次加载时拉回 —— 用户看到的就是"我拖了它又自己跑回去"。
// store 层必须把这条路径堵住,避免 UI 之外的入口IPC、自动化脚本触发。
const store = makeStore();
// 默认就有「工作 / 学习 / 生活 / 未分类」四个,按 _getSubCategories 顺序
const subs = store.getSubCategories();
const uIdx = subs.findIndex(c => c.name === UNCATEGORIZED_NAME);
check(`${UNCATEGORIZED_NAME}」在子分类数组中(默认就存在)`, uIdx >= 0);
check(`${UNCATEGORIZED_NAME}」在最后一位(默认排末尾)`, uIdx === subs.length - 1);
// 1) 试图把「未分类」拖到中间 —— 被拒
if (uIdx > 0) {
const ok = store.reorderSubCategory(uIdx, 0, true);
check(`reorder(未分类 → 0) 返回 false`, ok === false,
`实际 ${JSON.stringify(ok)}`);
}
// 2) 试图把别的分类拖到「未分类」所在位置(让它跑到「未分类」前面 / 后面)——
// toIndex 在「未分类」上也必须被拒,否则"未分类"会被挤到中间。
if (subs.length >= 2 && uIdx > 0) {
const ok = store.reorderSubCategory(0, uIdx, true);
check(`reorder(0 → 未分类) 返回 false不允许把别的分类拖到未分类位置`,
ok === false, `实际 ${JSON.stringify(ok)}`);
}
// 3) 但同方向调整非「未分类」之间的相对顺序必须依然能成功(不破坏正常拖拽)
const subs2 = store.getSubCategories();
if (subs2.length >= 3) {
const ok = store.reorderSubCategory(0, 2, true);
check(`reorder(0 → 2) 仍允许(不影响「未分类」外其它分类的正常拖拽)`,
ok === true, `实际 ${JSON.stringify(ok)}`);
}
// 4) 反向 (swap 语义) 同样:把 2 → 0 不经过「未分类」应该是 ok
const subs3 = store.getSubCategories();
if (subs3.length >= 3) {
const ok = store.reorderSubCategory(2, 0, false);
check(`reorder(2 → 0, dropAbove=false) 仍允许`,
ok === true, `实际 ${JSON.stringify(ok)}`);
}
}
console.log('\n' + '='.repeat(64));
console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`);
console.log('='.repeat(64));
process.exit(summary.fail > 0 ? 1 : 0);