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

342 lines
12 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.
// 回归测试_consolidateCompleted 的兜底行为
//
// 触发 bug 的姿势:
// 1. 用户在外部编辑器手敲多个 `# 已完成任务` —— 例如 VSCode 多光标编辑、
// Git 合并冲突没解干净、模板工具批量生成。parser 忠实地产出多个
// kind=COMPLETED 分类。
// 2. 把「已完成任务」写成容器下的 `## 已完成任务` —— 解析成 NORMAL 子分类。
//
// 旧行为:只走 parser 的 classifyByName第一次 # 已完成任务 是 COMPLETED后续
// 副本按 h1 名命中 COMPLETED_KEYS 也是 COMPLETED。后续副本里的任务在 UI 上既
// 看不到也没法恢复getCompletedCategory 只返回第一个)—— 数据无声消失。
// ## 已完成任务 子分类也类似 —— 走 NORMAL 路径completed=true 任务违反
// v3.4 数据归属不变量。
//
// 新行为_consolidateCompleted() 两段防御
// 1) 把「名字正好是规范名『已完成任务』、kind 是 NORMAL/子分类」正名为 COMPLETED
// (与 _consolidateTrash 第一段同源问题:避免 _getOrCreateCompletedCategory
// 与同名普通子分类撞名)
// 2) 多份 kind=COMPLETED → 保留第一份tasks 拼接,删副本
//
// 跑法node scripts/check-completed-consolidate.mjs
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { parseMarkdown } from '../src/markdown-parser.js';
import { writeMarkdown } from '../src/markdown-writer.js';
import { COMPLETED_NAME } from '../src/task-store.js';
// ============================================================
// [1] 多 `# 已完成任务` → 合并到第一份tasks 拼接,绝不丢任务
// ============================================================
console.log('\n[1] 多个 # 已完成任务 副本合并');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] first-done
# 已完成任务
- [✓] second-done
# 已完成任务
- [✓] third-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('多个副本 → 收敛成 1 个 COMPLETED 分类',
allCompleted.length === 1,
`actual count: ${allCompleted.length}`);
const completed = allCompleted[0];
if (completed) {
const texts = completed.tasks.map(t => t.text).join('|');
// 顺序按文件出现顺序first-done / second-done / third-done
check('tasks 拼接保留所有 3 条任务',
completed.tasks.length === 3 && texts === 'first-done|second-done|third-done',
`texts=${texts}`);
check('拼接后所有任务仍 completed=true',
completed.tasks.every(t => t.completed === true),
completed.tasks.map(t => `${t.text}(${t.completed})`).join(','));
// 数据归属不变量completed=true 任务**只能**活在 kind=COMPLETED
for (const c of store.categories) {
if (c.kind === 'completed') continue;
const strayCompleted = c.tasks.filter(t => t.completed === true);
check(`${c.name || c.kind} 分类无 completed=true 任务`,
strayCompleted.length === 0,
strayCompleted.map(t => t.text).join(','));
}
}
}
// ============================================================
// [2] 单 `# 已完成任务` → no-op不重复 / 不删错
// ============================================================
console.log('\n[2] 单副本不动');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] only-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('单副本 → 仍是 1 个 COMPLETED', allCompleted.length === 1);
check('任务未被改',
allCompleted[0]?.tasks?.length === 1 && allCompleted[0].tasks[0].text === 'only-done',
JSON.stringify(allCompleted[0]?.tasks?.map(t => t.text)));
}
// ============================================================
// [3] `## 已完成任务` 子分类 → 提升为 COMPLETED h1
// ============================================================
console.log('\n[3] ## 已完成任务 子分类正名为 h1');
{
// ## 已完成任务 在 # 全部任务 容器下 → parser 会解析成 NORMAL 子分类
const md = `# 全部任务
## 工作
- [ ] a
## 已完成任务
- [✓] promoted-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('子分类 `## 已完成任务` 被提升为 COMPLETED h1',
allCompleted.length === 1,
`actual: ${allCompleted.length} completed, all cats: ${store.categories.map(c => `${c.name}=${c.kind}`).join(',')}`);
if (allCompleted[0]) {
check('提升后任务还在',
allCompleted[0].tasks.length === 1 && allCompleted[0].tasks[0].text === 'promoted-done',
allCompleted[0].tasks.map(t => t.text).join(','));
check('提升后 isSpecial=true / parentOtherTasks=false',
allCompleted[0].isSpecial === true && allCompleted[0].parentOtherTasks === false,
`isSpecial=${allCompleted[0].isSpecial}, parentOtherTasks=${allCompleted[0].parentOtherTasks}`);
}
}
// ============================================================
// [4] 混合:多 h1 副本 + 子分类副本
// ============================================================
console.log('\n[4] 多 h1 副本 + ## 已完成任务 子分类混合');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] h1-first
## 已完成任务
- [✓] sub-promoted
# 已完成任务
- [✓] h1-second
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('混合场景 → 仍收敛成 1 个 COMPLETED',
allCompleted.length === 1,
`count=${allCompleted.length}`);
if (allCompleted[0]) {
// 拼接顺序的细节依赖 _migrateCompletedTasksToCategoryv3.4 归属迁移)的
// 内部策略h1 副本里的任务按出现顺序在前,从 NORMAL 子分类迁移过来的追加
// 在末尾。这里只验证 _consolidateCompleted 的核心承诺(不丢任务 + 任务集合
// 完整),顺序细节不锁。
const foundTexts = allCompleted[0].tasks.map(t => t.text);
check('所有 3 条任务都进了最终 COMPLETED不丢任务',
allCompleted[0].tasks.length === 3,
`tasks: ${foundTexts.join(',')}`);
const sortedActual = [...foundTexts].sort().join('|');
const sortedExpected = 'h1-first|h1-second|sub-promoted';
check('任务集合完整无丢失',
sortedActual === sortedExpected,
`actual=${sortedActual}, expected=${sortedExpected}`);
}
}
// ============================================================
// [5] roundtrip合并后写回 markdown → 再读仍是单分类 + 任务齐全
// ============================================================
console.log('\n[5] roundtrip 不再复活重复副本');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] t1
# 已完成任务
- [✓] t2
`;
const store = makeStore(md);
const md2 = writeMarkdown(store.categories);
// 写出去应该只有 1 个 `# 已完成任务`
const h1Count = (md2.match(/^# 已完成任务$/gm) || []).length;
check('写出的 markdown 只含 1 个 # 已完成任务', h1Count === 1, `count=${h1Count}`);
// 再解析回来:仍是 1 个 COMPLETED 分类 + 任务齐全
const cats2 = parseMarkdown(md2);
// 注意:再 parseMarkdown 不走 store 兜底store 的 _consolidateCompleted 只在
// loadFromContent / _ensureBaseStructure 路径跑),所以这里必须确认 writeMarkdown
// 写出的格式已经是「已合并」状态 —— 否则 store 之外的纯 parser 调用会再次看到多副本
const allCompleted = cats2.filter(c => c.kind === 'completed');
check('parser 再次解析也是 1 个 COMPLETEDwriteMarkdown 已合并)',
allCompleted.length === 1,
`count=${allCompleted.length}, kinds: ${cats2.map(c => c.kind).join(',')}`);
if (allCompleted[0]) {
const texts = allCompleted[0].tasks.map(t => t.text).sort().join('|');
check('roundtrip 任务齐全t1 + t2',
texts === 't1|t2',
texts);
}
}
// ============================================================
// [6] 与 _consolidateTrash / _consolidateUncategorized 共存
// ============================================================
console.log('\n[6] 三个 consolidate 路径同时触发,互不干扰');
{
const md = `# 全部任务
## 工作
- [ ] a
## 未分类
- [ ] orphan
## 未分类
- [ ] orphan-2
# 已完成任务
- [✓] done-1
# 已完成任务
- [✓] done-2
# 回收站
- [ ] trash-1
# 回收站
- [ ] trash-2
`;
const store = makeStore(md);
// COMPLETED 应只剩 1 份,含 done-1 + done-2
const completed = store.categories.filter(c => c.kind === 'completed');
check('COMPLETED 收敛为 1',
completed.length === 1,
`count=${completed.length}`);
if (completed[0]) {
const sortedTexts = completed[0].tasks.map(t => t.text).sort().join('|');
check('COMPLETED 含 done-1 + done-2',
sortedTexts === 'done-1|done-2',
sortedTexts);
}
// TRASH 应只剩 1 份,含 trash-1 + trash-2
const trash = store.categories.filter(c => c.kind === 'trash');
check('TRASH 收敛为 1',
trash.length === 1,
`count=${trash.length}`);
if (trash[0]) {
const sortedTexts = trash[0].tasks.map(t => t.text).sort().join('|');
check('TRASH 含 trash-1 + trash-2',
sortedTexts === 'trash-1|trash-2',
sortedTexts);
}
// UNCATEGORIZED 应只剩 1 份(## 子分类),含 orphan + orphan-2
const uncats = store.categories.filter(c => c.parentOtherTasks && c.name === '未分类');
check('## 未分类 收敛为 1',
uncats.length === 1,
`count=${uncats.length}`);
if (uncats[0]) {
const sortedTexts = uncats[0].tasks.map(t => t.text).sort().join('|');
check('## 未分类 含 orphan + orphan-2',
sortedTexts === 'orphan|orphan-2',
sortedTexts);
}
}
// ============================================================
// [7] 不撞名的子分类保持 NORMAL —— 不被错杀成 COMPLETED
// ============================================================
console.log('\n[7] 不在 COMPLETED_KEYS 的子分类保持 NORMAL不被错杀');
{
// 「mytasks」不在 COMPLETED_KEYS / OTHER_TASKS_KEYS / TRASH_KEYS 任何保留名列表里
// —— parser 解析为 NORMAL 子分类_consolidateCompleted 第一段不会动它。
// 名字也不撞 COMPLETED_NAME所以正名分支不触发。
const md = `# 全部任务
## 工作
- [ ] a
## mytasks
- [ ] b
`;
const store = makeStore(md);
// _ensureCompletedCategory 会无条件保证 1 个空 COMPLETED 分类存在UI 入口兜底),
// 所以这里只能验证「mytasks 没被错合并进 COMPLETED」且「COMPLETED 是空的」。
const completed = store.getCompletedCategory();
check('COMPLETED 分类是空mytasks 任务未被错误搬进)',
completed?.tasks?.length === 0,
`completed tasks: ${completed?.tasks?.map(t => t.text).join(',')}`);
// 关键不变量mytasks 是 NORMAL 子分类,没被改名为「已完成任务」/parentOtherTasks=false
const mytasks = store.categories.find(c => c.name === 'mytasks');
check('`## mytasks` 仍是 NORMAL 子分类(未正名/未挂到 kind=COMPLETED 上)',
mytasks?.kind === 'normal' && mytasks?.parentOtherTasks === true,
`kind=${mytasks?.kind}, parentOtherTasks=${mytasks?.parentOtherTasks}`);
// 工作分类还在,未被错误归入已完成
const work = store.getCategory('工作');
check('工作分类未被吃',
work?.tasks?.length === 1 && work.tasks[0].text === 'a',
`work tasks: ${work?.tasks?.map(t => t.text).join(',')}`);
}
printSummary('check-completed-consolidate');