update
This commit is contained in:
341
scripts/check-completed-consolidate.mjs
Normal file
341
scripts/check-completed-consolidate.mjs
Normal file
@@ -0,0 +1,341 @@
|
||||
// 回归测试:_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]) {
|
||||
// 拼接顺序的细节依赖 _migrateCompletedTasksToCategory(v3.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 个 COMPLETED(writeMarkdown 已合并)',
|
||||
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');
|
||||
Reference in New Issue
Block a user