443 lines
15 KiB
JavaScript
443 lines
15 KiB
JavaScript
// 一次性验证脚本:删除分类时任务必须完整迁移到「未分类」,且能通过 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);
|