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

443 lines
15 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.
// 一次性验证脚本:删除分类时任务必须完整迁移到「未分类」,且能通过 Markdown 往返。
//
// 关键:只检查内存状态是不够的 —— 任务可能挂在一个「脱离 categories 数组」或
// 「序列化后无法被 parser 认回」的分类上。所以每个用例都做
// 内存变更 → serialize() → parseMarkdown() → 重新数任务
// 这才能证明数据真的落盘且能读回来。
//
// 用法node scripts/check-delete-category.mjs
import { TaskStore, UNCATEGORIZED_NAME, OTHER_TASKS_NAME } from '../src/task-store.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js';
// 收集一份「任务文本 → 出现次数」的多重集,用于比较迁移前后是否等价
function taskMultiset(categories) {
const m = new Map();
for (const cat of categories) {
for (const t of cat.tasks || []) {
const key = `${t.text}|${t.completed ? 1 : 0}|${t.important ? 1 : 0}`;
m.set(key, (m.get(key) || 0) + 1);
}
}
return m;
}
function multisetEqual(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 describeMultiset(m) {
return [...m.entries()].map(([k, v]) => `${k}×${v}`).sort().join(', ') || '(空)';
}
// 构造一个 store绕过 window.api不触发真实落盘
// 核心断言:删除 name 后,任务多重集不变,且经过 Markdown 往返仍不变
function expectNoLoss(label, markdown, deleteName) {
console.log(`\n[${label}] 删除「${deleteName}`);
const store = makeStore(markdown);
const before = taskMultiset(store.categories);
const targetCat = store.getCategory(deleteName);
const movedCount = targetCat ? targetCat.tasks.length : 0;
const ok = store.deleteCategory(deleteName);
check('deleteCategory 返回 true', ok === true, `实际返回 ${ok}`);
check(`分类「${deleteName}」已移除`, !store.categories.some(c => c.name === deleteName));
// 1) 内存层面:任务多重集必须完全一致
const afterMem = taskMultiset(store.categories);
check(
'内存中任务无丢失',
multisetEqual(before, afterMem),
`迁移前 [${describeMultiset(before)}]\n 迁移后 [${describeMultiset(afterMem)}]`
);
// 2) 任务必须真的落在「未分类」里
const fallback = store.categories.find(c => c.name === UNCATEGORIZED_NAME);
if (movedCount > 0) {
check(`${UNCATEGORIZED_NAME}」存在于 categories 数组中`, !!fallback);
if (fallback) {
check(
`${UNCATEGORIZED_NAME}」承接了 ${movedCount} 项任务`,
fallback.tasks.length >= movedCount,
`实际只有 ${fallback.tasks.length}`
);
}
}
// 3) 往返测试:序列化 → 重新解析 → 任务仍必须完整
// 这一步能抓出「分类脱离数组」或「写成孤立 ## 被 parser 丢弃」的情况
const md = store.serialize();
const reparsed = parseMarkdown(md);
const afterRoundTrip = taskMultiset(reparsed);
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, afterRoundTrip),
`往返前 [${describeMultiset(before)}]\n 往返后 [${describeMultiset(afterRoundTrip)}]\n --- 生成的 Markdown ---\n${md.split('\n').map(l => ' ' + l).join('\n')}`
);
return store;
}
console.log('='.repeat(64));
console.log('删除分类:任务迁移到「未分类」—— 数据完整性验证');
console.log('='.repeat(64));
// ---------- 用例 1「未分类」已存在删除中间的分类 ----------
expectNoLoss('用例1 未分类已存在', `# 当前任务
- [ ] 今日任务A
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2
- [ ] 工作任务3 ⭐
## 学习
- [ ] 学习任务1
## ${UNCATEGORIZED_NAME}
- [ ] 已有的零散任务
`, '工作');
// ---------- 用例 2「未分类」缺失删除容器后的第一个子分类 ----------
// 这是最危险的场景:新建「未分类」会插到 oIdx+1正好和被删分类的原索引重叠
expectNoLoss('用例2 未分类缺失+删首个子分类', `# 当前任务
- [ ] 今日任务A
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2
- [ ] 工作任务3 ⭐
## 学习
- [ ] 学习任务1
`, '工作');
// ---------- 用例 3「未分类」缺失删除最后一个子分类 ----------
expectNoLoss('用例3 未分类缺失+删末个子分类', `# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 生活
- [ ] 生活任务1
- [✓] 生活任务2
`, '生活');
// ---------- 用例 4「未分类」缺失且这是唯一的子分类 ----------
expectNoLoss('用例4 未分类缺失+唯一子分类', `# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 唯一分类里的任务1
- [✓] 唯一分类里的任务2 ⭐
`, '工作');
// ---------- 用例 5删除空分类 ----------
console.log('\n[用例5] 删除空分类');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 空分类
`);
const subsBefore = store.getSubCategories().map(c => c.name);
const ok = store.deleteCategory('空分类');
check('deleteCategory 返回 true', ok === true);
const subsAfter = store.getSubCategories().map(c => c.name);
check('空分类已移除', !subsAfter.includes('空分类'));
// 删空分类不该凭空造出一个「未分类」
const spawnedUncategorized =
!subsBefore.includes(UNCATEGORIZED_NAME) && subsAfter.includes(UNCATEGORIZED_NAME);
check(
`删除空分类不会凭空创建「${UNCATEGORIZED_NAME}`,
!spawnedUncategorized,
`删除后子分类:${subsAfter.join(' / ')}`
);
}
// ---------- 用例 6「未分类」自身不可删除 ----------
console.log('\n[用例6] 「未分类」不可删除');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## ${UNCATEGORIZED_NAME}
- [ ] 兜底里的任务
`);
const ok = store.deleteCategory(UNCATEGORIZED_NAME);
check('deleteCategory 返回 false', ok === false, `实际返回 ${ok}`);
check(
`${UNCATEGORIZED_NAME}」仍在`,
store.categories.some(c => c.name === UNCATEGORIZED_NAME)
);
check('兜底里的任务仍在', taskMultiset(store.categories).size === 1);
}
// ---------- 用例 7特殊分类不可删除 ----------
console.log('\n[用例7] 特殊分类不可删除');
{
const store = makeStore(`# 当前任务
- [ ] 今日任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务
`);
const before = taskMultiset(store.categories);
check('删除「当前任务」被拒绝', store.deleteCategory('当前任务') === false);
check(`删除「${OTHER_TASKS_NAME}」被拒绝`, store.deleteCategory(OTHER_TASKS_NAME) === false);
check('删除不存在的分类被拒绝', store.deleteCategory('不存在的分类') === false);
check('任务完全未受影响', multisetEqual(before, taskMultiset(store.categories)));
}
// ---------- 用例 8连续删除多个分类 ----------
console.log('\n[用例8] 连续删除多个有任务的分类');
{
const store = makeStore(`# 当前任务
- [ ] 今日任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [ ] 工作任务2
## 学习
- [✓] 学习任务1
## 生活
- [ ] 生活任务1 ⭐
`);
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
store.deleteCategory('学习');
store.deleteCategory('生活');
check('三个分类全部移除', store.getSubCategories().every(c => c.name === UNCATEGORIZED_NAME));
check(
'内存中任务无丢失',
multisetEqual(before, taskMultiset(store.categories)),
`前 [${describeMultiset(before)}]\n 后 [${describeMultiset(taskMultiset(store.categories))}]`
);
const reparsed = parseMarkdown(store.serialize());
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, taskMultiset(reparsed)),
`往返后 [${describeMultiset(taskMultiset(reparsed))}]\n --- Markdown ---\n${store.serialize().split('\n').map(l => ' ' + l).join('\n')}`
);
}
// ---------- 用例 9结构不变量 —— 子分类必须紧跟在容器之后 ----------
// writeMarkdown 依赖这个顺序parentOtherTasks 的分类写成 ##
// 如果它出现在 # 容器之前parser 会因 insideOtherTasks=false 而丢弃整段
console.log('\n[用例9] 结构不变量:子分类必须在容器之后');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
`);
store.deleteCategory('工作');
const containerIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
const subIdxs = store.categories
.map((c, i) => (c.parentOtherTasks ? i : -1))
.filter(i => i >= 0);
check('存在 OTHER_TASKS 容器', containerIdx >= 0);
check(
'所有子分类都排在容器之后',
subIdxs.every(i => i > containerIdx),
`容器在 ${containerIdx},子分类在 [${subIdxs.join(', ')}]`
);
}
// ---------- 用例 10不产生重名「未分类」 ----------
// 重名会让 getCategory() 只认第一个,后续 addTask/toggleTask 全打到错的对象上
console.log('\n[用例10] 不产生重名「未分类」');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## ${UNCATEGORIZED_NAME}
- [ ] 兜底任务1
`);
// 人为破坏标记,模拟历史数据里 parentOtherTasks 缺失的「未分类」
const u = store.categories.find(c => c.name === UNCATEGORIZED_NAME);
u.parentOtherTasks = false;
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
const dupes = store.categories.filter(c => c.name === UNCATEGORIZED_NAME);
check(
`${UNCATEGORIZED_NAME}」只有一个(无重名)`,
dupes.length === 1,
`实际有 ${dupes.length}`
);
check(`${UNCATEGORIZED_NAME}」的 parentOtherTasks 已补正`, dupes[0]?.parentOtherTasks === true);
check('内存中任务无丢失', multisetEqual(before, taskMultiset(store.categories)));
const reparsed = parseMarkdown(store.serialize());
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, taskMultiset(reparsed)),
`往返后 [${describeMultiset(taskMultiset(reparsed))}]`
);
}
// ---------- 用例 11容器缺失时不能写出孤立的 ## ----------
// writeMarkdown 把 parentOtherTasks 写成 `## X`parseMarkdown 只在 `# 全部任务`
// 之后才认 `##`。孤立的 `## 未分类` 不会让任务消失,但会静默改写归属:
// parser 跳过该 h2 时不重置 currentCategory任务被并进上一个 h1通常是「当前任务」
// 所以这里要断言的是「归属正确」,不只是「总数正确」。
console.log('\n[用例11] 容器缺失时任务归属不被改写');
{
const store = makeStore(`# 当前任务
- [ ] 今日原有任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2 ⭐
`);
// 人为移除 OTHER_TASKS 容器,模拟结构受损的数据
const cIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
store.categories.splice(cIdx, 1);
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
check(
'OTHER_TASKS 容器已被重建',
store.categories.some(c => c.kind === CategoryKind.OTHER_TASKS)
);
const containerIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
const subIdxs = store.categories.map((c, i) => (c.parentOtherTasks ? i : -1)).filter(i => i >= 0);
check(
'所有子分类都排在容器之后',
subIdxs.every(i => i > containerIdx),
`容器在 ${containerIdx},子分类在 [${subIdxs.join(', ')}]`
);
const md = store.serialize();
const reparsed = parseMarkdown(md);
check(
'Markdown 往返后任务总数不变',
multisetEqual(before, taskMultiset(reparsed)),
`往返前 [${describeMultiset(before)}]\n 往返后 [${describeMultiset(taskMultiset(reparsed))}]`
);
// v3 起没有 TODAY 真实分类 —— 「今日原有任务」在加载时被 _migrateTodayCategory
// 搬到了首个子分类,并打上 @当前 标记。所以这里要验证的是:
// 1. parseMarkdown 后不存在任何 kind=TODAY 的分类(迁移彻底)
// 2. 删除「工作」后,工作里未完成的 1 项任务 + 今日 1 项任务 → 「未分类」
// v3.4 后,[✓] 任务会在加载时被搬到「# 已完成任务」分类,所以「工作」
// 删完后只剩 2 项进「未分类」,原 [✓] 的「工作任务2」落在「# 已完成任务」)
// 3. 「未分类」里的「今日原有任务」带 @当前 标记,被「当前」智能视图聚合得到
const todayAfter = reparsed.find(c => c.kind === CategoryKind.TODAY);
const uncatAfter = reparsed.find(c => c.name === UNCATEGORIZED_NAME);
const completedAfter = reparsed.find(c => c.kind === CategoryKind.COMPLETED);
check(
'v3 已无 TODAY 真实分类(# 当前任务 在迁移时被搬空)',
!todayAfter,
`仍有 TODAY 分类(${todayAfter?.tasks.length ?? '?'} 项)`
);
check(
`搬走的 2 项任务(含 @当前 的)留在「${UNCATEGORIZED_NAME}`,
uncatAfter && uncatAfter.tasks.length === 2,
`实际 ${uncatAfter ? uncatAfter.tasks.length + ' 项' : '分类不存在'}\n --- Markdown ---\n${md.split('\n').map(l => ' ' + l).join('\n')}`
);
check(
'原 [✓] 的「工作任务2」落在「# 已完成任务」v3.4 自动搬迁)',
completedAfter?.tasks?.some(t => t.text === '工作任务2'),
`已完成任务分类任务:${completedAfter?.tasks?.map(t => t.text).join(' / ') || '无'}`
);
// 「当前」智能视图能聚合到那条带 @当前 标记的「今日原有任务」
const currentView = store._buildSmartListView('current');
check(
'「当前」智能视图能聚合到 @当前 标记的「今日原有任务」',
currentView.tasks.some(t => t.text === '今日原有任务'),
`当前视图任务:${currentView.tasks.map(t => t.text).join(' / ')}`
);
}
// ---------- 用例 12删除当前选中分类后视图切到任务的新家 ----------
console.log('\n[用例12] 删除选中分类后视图跟随任务');
{
const store = makeStore(`# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 空的
`);
store.selectCategory('工作');
store.deleteCategory('工作');
check(
`删有任务的分类 → 视图切到「${UNCATEGORIZED_NAME}`,
store.selectedCategoryName === UNCATEGORIZED_NAME,
`实际是「${store.selectedCategoryName}`
);
store.selectCategory('空的');
store.deleteCategory('空的');
// v3 起没有 TODAY 真实分类 —— 删空分类后落到「当前」智能视图
check(
'删空分类 → 视图退回「当前」智能视图',
store.selectedSmartList === 'current',
`实际停在 selectedCategoryName=${store.selectedCategoryName} / selectedSmartList=${store.selectedSmartList}`
);
check('选中的分类/视图真实存在', store.getSelectedCategory() !== null);
}
console.log('\n' + '='.repeat(64));
console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`);
console.log('='.repeat(64));
process.exit(summary.fail === 0 ? 0 : 1);