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

686 lines
31 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.
// 回归测试:「原分类」标记 + 已完成任务恢复语义
//
// v3.5+ 行为契约:
// 1. 勾选子分类里的任务 → task.originalCategory = categoryName
// 2. writer 写出时把 `[原分类xxx]` 排在 `[▶]/[⭐]` 之后、文本之前(标记块内)
// 3. parser 再读回来时能把 `[原分类xxx]` 抽到 task.originalCategory
// 4. 取消勾选:原分类还在 → 直接归位(不再回「未分类」),归位后清掉 originalCategory
// 5. 取消勾选:原分类丢失(删除 / 改名) → toggleTask 返回 restoreHint.kind === 'picker'
// 6. 取消勾选:没有 originalCategory 记录(兜底) → 直接去「未分类」,不弹窗
// 7. 拖拽任务到「已完成任务」分类 → 同样记录 originalCategory
// 8. 旧文件升级迁移:散落的 [✓] 任务搬到「已完成」时自动记 cat.name
// 9. v3.5.1+:恢复后清掉 originalCategory文件里不出现 `[原分类xxx] 任务` 噪音
// 10. v3.5.1+原分类标记仅在「已完成任务」「回收站」分类里输出writer 护栏)
// 11. v3.5.1+moveTask 把任务移出「已完成」到普通子分类时清 originalCategory
// 12. v3.5.1+restoreTask 从回收站恢复到普通子分类时清 originalCategory
//
// v3.5.2+ 行为契约([18] 节):
// 13. deleteTask / batchDeleteTasks 把任务移到 trash 时同步记录 originalCategory
// (与 toggleTask 把任务移到「已完成任务」时记 originalCategory 对称)
// 14. restoreTask 默认(无 toCategoryName走 _resolveRestoreTarget
// - 原分类还在 → category 分支,直接归位
// - 原分类丢失 → picker 分支,返回 restoreHint任务**仍留在 trash**
// - 无 originalCategory 记录 → fallback 分支,落「未分类」
// 15. picker 路径下 UI 调 restoreTask(taskId, picked) 完成归位 + 清 originalCategory
// 16. 已完成任务的 originalCategory 在 deleteTask 进 trash 时不被覆盖(沿用 toggle 时的值)
//
// 跑法node scripts/check-original-category.mjs
import {
TaskStore,
parseImportLine
} 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,
extractInlineMarkers
} from '../src/markdown-parser.js';
import { writeMarkdown } from '../src/markdown-writer.js';
// 往返serialize 出来再 parse 回来,断言文件层的行为
function roundTrip(store) {
return parseMarkdown(store.serialize());
}
function findCat(categories, name) {
return categories.find(c => c.name === name) || null;
}
const BASE_DOC = `# 全部任务
## 工作
- [ ] 写周报
## 学习
- [ ] 看 Rust 入门
## 未分类
- [ ] 杂事
`;
// ============================================================
// [1] extractInlineMarkers解析 [原分类xxx] 标记
// ============================================================
console.log('\n[1] extractInlineMarkers 解析 [原分类xxx] 标记');
{
const r1 = extractInlineMarkers('[原分类:工作] 给妈妈买生日礼物');
check('行首 [原分类xxx] 被识别为 originalCategory',
r1.originalCategory === '工作' && r1.text === '给妈妈买生日礼物',
`originalCategory=${r1.originalCategory}, text=${r1.text}`);
const r2 = extractInlineMarkers('看 Rust 入门 [原分类:学习]');
check('行尾 [原分类xxx] 被识别为 originalCategory',
r2.originalCategory === '学习' && r2.text === '看 Rust 入门',
`originalCategory=${r2.originalCategory}, text=${r2.text}`);
const r3 = extractInlineMarkers('[▶] [⭐] 写季度汇报 [原分类:工作]');
check('[原分类xxx] 与 [▶]/[⭐] 共存时都正确抽取',
r3.originalCategory === '工作' && r3.current && r3.important && r3.text === '写季度汇报',
`orig=${r3.originalCategory}, curr=${r3.current}, imp=${r3.important}, text=${r3.text}`);
const r4 = extractInlineMarkers('普通任务');
check('没有标记时 originalCategory === null',
r4.originalCategory === null,
`originalCategory=${r4.originalCategory}`);
const r5 = extractInlineMarkers('任务中含 [原分类:工作] 字面字符串');
check('行中的 [原分类xxx] 不被误识为标记',
r5.originalCategory === null && r5.text.includes('任务中含'),
`originalCategory=${r5.originalCategory}`);
}
// ============================================================
// [2] 勾选任务 → originalCategory 记录源分类
// ============================================================
console.log('\n[2] 勾选任务 → originalCategory 记录源分类');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
const result = store.toggleTask('工作', task.id);
check('toggleTask 返回 { ok: true }', result && result.ok === true);
check('任务已从「工作」分类移除',
!store.getCategory('工作').tasks.some(t => t.text === '写周报'));
check('任务已到「已完成任务」分类',
store.getCategory('已完成任务').tasks.some(t => t.text === '写周报'));
check('task.originalCategory === "工作"',
store.getCategory('已完成任务').tasks[0].originalCategory === '工作',
`actual=${store.getCategory('已完成任务').tasks[0].originalCategory}`);
check('task.completed === true',
store.getCategory('已完成任务').tasks[0].completed === true);
}
// ============================================================
// [3] writer 把 [原分类xxx] 排在 [▶]/[⭐] 之后、文本之前
// ============================================================
console.log('\n[3] writer 写出 [原分类xxx]');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.toggleTask('工作', task.id);
const md = store.serialize();
check('serialize 输出含 [原分类:工作]',
md.includes('[原分类:工作]'),
`output 前 400 字: ${md.slice(0, 400)}`);
check('serialize 输出仍是 [✓] 勾选态',
md.includes('- [✓]') && md.includes('写周报'));
// v3.5+[原分类xxx] 排在标记块内([▶]/[⭐] 之后、文本之前)
// 验证:写出行形如 `- [✓] [原分类:工作] 写周报`(写在本行的「已完成任务」分类下)
const completedLine = md.split('\n').find(l => l.includes('写周报') && l.includes('[✓]'));
check('原分类标记排在文本之前(标记块内)',
completedLine && /\[原分类:工作\]\s+写周报/.test(completedLine),
`line: ${completedLine}`);
}
// ============================================================
// [4] Markdown 往返parse → 仍然带 originalCategory
// ============================================================
console.log('\n[4] Markdown 往返 —— 解析回来仍带 originalCategory');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.toggleTask('工作', task.id);
// 重新 parse 自己 serialize 出来的 markdown模拟"保存后再加载"
const categories = roundTrip(store);
const completed = findCat(categories, '已完成任务');
const reloaded = completed?.tasks.find(t => t.text === '写周报');
check('往返后任务仍在「已完成任务」',
!!reloaded);
check('往返后 task.originalCategory 仍为 "工作"',
reloaded?.originalCategory === '工作',
`actual=${reloaded?.originalCategory}`);
check('往返后 task.completed === true',
reloaded?.completed === true);
}
// ============================================================
// [5] 取消勾选:原分类还在 → 直接归位
// ============================================================
console.log('\n[5] 取消勾选:原分类还在 → 直接归位');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.toggleTask('工作', task.id); // 勾选
const result = store.toggleTask('已完成任务', task.id); // 取消勾选
check('取消勾选返回 restoreHint.kind === "category"',
result && result.restoreHint && result.restoreHint.kind === 'category',
`restoreHint=${JSON.stringify(result?.restoreHint)}`);
check('取消勾选后任务回到「工作」子分类',
store.getCategory('工作').tasks.some(t => t.text === '写周报'));
check('任务不再在「已完成任务」里',
!store.getCategory('已完成任务').tasks.some(t => t.text === '写周报'));
check('任务 completed === false',
store.getCategory('工作').tasks.find(t => t.text === '写周报').completed === false);
// v3.5.1:恢复后清掉 originalCategory —— 任务已回到「工作」里,再保留标记就是噪音
check('恢复后 task.originalCategory === null',
store.getCategory('工作').tasks.find(t => t.text === '写周报').originalCategory === null);
check('恢复后 serialize 不含 [原分类:工作]',
!store.serialize().includes('[原分类:工作]'));
}
// ============================================================
// [6] 取消勾选:原分类丢失 → restoreHint.kind === 'picker'
// ============================================================
console.log('\n[6] 取消勾选:原分类丢失(删除) → 弹窗提示');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.toggleTask('工作', task.id); // 勾选,记录原分类 = "工作"
// 删除「工作」子分类 —— 模拟用户改了名或删了
store.deleteCategory('工作');
const result = store.toggleTask('已完成任务', task.id); // 取消勾选
check('原分类丢失时 restoreHint.kind === "picker"',
result && result.restoreHint && result.restoreHint.kind === 'picker',
`restoreHint=${JSON.stringify(result?.restoreHint)}`);
check('restoreHint.missing === "工作"',
result && result.restoreHint && result.restoreHint.missing === '工作',
`missing=${result?.restoreHint?.missing}`);
check('store.restoreCompletedTask 能把任务放到指定分类',
store.restoreCompletedTask(task.id, '学习') &&
store.getCategory('学习').tasks.some(t => t.text === '写周报'));
// v3.5.1picker 路径恢复后同样清掉 originalCategory
check('picker 路径恢复后 task.originalCategory === null',
store.getCategory('学习').tasks.find(t => t.text === '写周报').originalCategory === null);
check('picker 路径恢复后 serialize 不含 [原分类:工作]',
!store.serialize().includes('[原分类:工作]'));
}
// ============================================================
// [7] 取消勾选:没有 originalCategory → 直接去「未分类」
// ============================================================
console.log('\n[7] 取消勾选:没有 originalCategory 记录 → 直接去「未分类」');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
// 手动设置:勾选但不记 originalCategory模拟"很旧的文件"
store.toggleTask('工作', task.id);
// 抹掉 originalCategory 模拟"旧版没有这个字段"
const completedTask = store.getCategory('已完成任务').tasks[0];
completedTask.originalCategory = null;
const result = store.toggleTask('已完成任务', task.id); // 取消勾选
check('restoreHint.kind === "fallback"',
result && result.restoreHint && result.restoreHint.kind === 'fallback',
`restoreHint=${JSON.stringify(result?.restoreHint)}`);
check('任务落到「未分类」',
store.getCategory('未分类').tasks.some(t => t.text === '写周报'));
check('restoreHint 不带 missing不是 picker',
!result.restoreHint.missing);
}
// ============================================================
// [8] 拖入「已完成任务」分类 → 同样记录 originalCategory
// ============================================================
console.log('\n[8] 拖入「已完成任务」→ 同样记录 originalCategory');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
// 通过 moveTask 拖入「已完成任务」(拖到 completed 分类,自动 completed=true
store.moveTask('工作', task.id, '已完成任务');
const t = store.getCategory('已完成任务').tasks.find(x => x.text === '写周报');
check('拖入后任务在「已完成任务」',
!!t);
check('拖入后 originalCategory === "工作"',
t && t.originalCategory === '工作',
`actual=${t?.originalCategory}`);
check('拖入后 completed === true',
t && t.completed === true);
}
// ============================================================
// [9] 旧文件升级迁移:散落的 [✓] 任务搬到「已完成」时记原分类
// ============================================================
console.log('\n[9] 旧文件升级迁移:散落的 [✓] 任务自动记原分类');
{
// 模拟 v3.4 之前的旧文件:散落的 [✓] 任务在子分类里
const OLD_DOC = `# 全部任务
## 工作
- [ ] 写周报
- [✓] 买牛奶
## 学习
- [✓] 看 Rust 入门
## 未分类
- [ ] 杂事
`;
const store = makeStore(OLD_DOC);
// 触发迁移loadFromContent 已经跑过;这里再调一次确认幂等)
// store._migrateCompletedTasksToCategory();
const completed = store.getCategory('已完成任务');
const workTask = completed.tasks.find(t => t.text === '买牛奶');
const studyTask = completed.tasks.find(t => t.text === '看 Rust 入门');
check('旧文件的 [✓] 任务都搬到了「已完成任务」',
workTask && studyTask);
check('「工作」来源的任务 originalCategory === "工作"',
workTask && workTask.originalCategory === '工作',
`actual=${workTask?.originalCategory}`);
check('「学习」来源的任务 originalCategory === "学习"',
studyTask && studyTask.originalCategory === '学习',
`actual=${studyTask?.originalCategory}`);
// 文件层验证serialize 后能看到 [原分类xxx]
const md = store.serialize();
check('serialize 后含 [原分类:工作]',
md.includes('[原分类:工作]'));
check('serialize 后含 [原分类:学习]',
md.includes('[原分类:学习]'));
}
// ============================================================
// [10] batchToggleCompleted批量取消勾选也走 _resolveRestoreTarget
// ============================================================
console.log('\n[10] 批量取消勾选:每条任务归到各自原分类');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const studyCat = store.getCategory('学习');
const workTask = workCat.tasks.find(t => t.text === '写周报');
const studyTask = studyCat.tasks.find(t => t.text === '看 Rust 入门');
// 批量勾选
store.batchToggleCompleted([
{ categoryName: '工作', taskId: workTask.id },
{ categoryName: '学习', taskId: studyTask.id }
], true);
check('批量勾选后两条都在「已完成任务」',
store.getCategory('已完成任务').tasks.length === 2);
// 批量取消勾选
const completed = store.getCategory('已完成任务');
const t1 = completed.tasks.find(t => t.text === '写周报');
const t2 = completed.tasks.find(t => t.text === '看 Rust 入门');
store.batchToggleCompleted([
{ categoryName: '已完成任务', taskId: t1.id },
{ categoryName: '已完成任务', taskId: t2.id }
], false);
check('「写周报」回到「工作」',
store.getCategory('工作').tasks.some(t => t.text === '写周报'));
check('「看 Rust 入门」回到「学习」',
store.getCategory('学习').tasks.some(t => t.text === '看 Rust 入门'));
// v3.5.1:批量恢复后也清掉 originalCategory
check('批量恢复后「写周报」originalCategory === null',
store.getCategory('工作').tasks.find(t => t.text === '写周报').originalCategory === null);
check('批量恢复后「看 Rust 入门」originalCategory === null',
store.getCategory('学习').tasks.find(t => t.text === '看 Rust 入门').originalCategory === null);
check('批量恢复后 serialize 不含 [原分类xxx]',
!store.serialize().match(/\[原分类:/));
}
// ============================================================
// [11] parseImportLine导入对话框也支持 [原分类xxx]
// ============================================================
console.log('\n[11] parseImportLine 支持 [原分类xxx]');
{
const r1 = parseImportLine('- [ ] 看牙医 [原分类:生活]');
check('parseImportLine 把 [原分类xxx] 抽到 originalCategory',
r1 && r1.originalCategory === '生活' && r1.text === '看牙医',
`originalCategory=${r1?.originalCategory}, text=${r1?.text}`);
}
// ============================================================
// [12] CategoryKind.COMPLETED 不会被误认为恢复目标
// ============================================================
console.log('\n[12] kind=COMPLETED 的分类不能作为恢复目标');
{
const store = makeStore(BASE_DOC);
// 手动注入一个恶意分类(虽然实际不会发生,但要防御)
store.categories.push({
name: '陷阱',
kind: CategoryKind.COMPLETED, // 故意是 COMPLETED
isSpecial: true,
parentOtherTasks: false,
tasks: []
});
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.toggleTask('工作', task.id);
// 把"工作"删了,但有个 kind=COMPLETED 的同名陷阱分类
store.deleteCategory('工作');
// 改名为「工作」(让它跟 originalCategory 同名)
const trap = store.categories.find(c => c.name === '陷阱');
trap.name = '工作';
const result = store.toggleTask('已完成任务', task.id);
// 应该返回 picker因为同名"工作"分类是 COMPLETED 类型,不能作为恢复目标
check('kind=COMPLETED 的同名分类不作为恢复目标',
result && result.restoreHint && result.restoreHint.kind === 'picker',
`restoreHint=${JSON.stringify(result?.restoreHint)}`);
}
// ============================================================
// [13] v3.5.1+:原分类标记仅在「已完成 / 回收站」分类里输出writer 护栏)
// ============================================================
console.log('\n[13] writer 护栏:原分类标记仅在 COMPLETED/TRASH 输出');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const studyCat = store.getCategory('学习');
const workTask = workCat.tasks.find(t => t.text === '写周报');
const studyTask = studyCat.tasks.find(t => t.text === '看 Rust 入门');
// 1. 勾选一条 → 进入已完成 → 出现 [原分类:工作]
store.toggleTask('工作', workTask.id);
// 2. 把另一条拖到已完成 → 同样记录
store.moveTask('学习', studyTask.id, '已完成任务');
const md = store.serialize();
// 文件里 [原分类:工作] 出现在「已完成任务」段 ✓
const completedSection = md.split('# 已完成任务')[1] || '';
check('writer 在「已完成任务」段写出 [原分类:工作]',
completedSection.includes('[原分类:工作]'));
check('writer 在「已完成任务」段写出 [原分类:学习]',
completedSection.includes('[原分类:学习]'));
// 3. 通过 moveTask 把已完成任务拖回普通子分类 → originalCategory 清掉
const completedTask = store.getCategory('已完成任务').tasks[0];
store.moveTask('已完成任务', completedTask.id, '生活');
// 4. 验证「生活」段不出现 [原分类xxx]
const lifeSection = md.split('# 生活')[1]?.split('\n')[0]; // 旧 md 无生活内容
// 重新 serialize因为 moveTask 后 md 应该变了)
const mdAfter = store.serialize();
// 「生活」分类下不该出现 [原分类xxx]
const lifeIndex = mdAfter.indexOf('## 生活');
const uncategorizedIndex = mdAfter.indexOf('## 未分类');
const lifeSectionNew = mdAfter.slice(lifeIndex, uncategorizedIndex > 0 ? uncategorizedIndex : undefined);
check('moveTask 把已完成任务拖回普通子分类后,那条任务没 [原分类xxx]',
!lifeSectionNew.includes('[原分类:'),
`life section: ${lifeSectionNew.slice(0, 200)}`);
}
// ============================================================
// [14] moveTask 跨普通子分类:原始 originalCategory 保持(防御性无侵入)
// ============================================================
console.log('\n[14] moveTask 普通子分类之间互拖 → originalCategory 不受影响');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
// 故意设 originalCategory外部编辑 / 解析器的产物)
task.originalCategory = '工作';
// 从「工作」拖到「学习」
store.moveTask('工作', task.id, '学习');
const moved = store.getCategory('学习').tasks.find(t => t.text === '写周报');
// v3.5.1 进入 NORMAL 路径会清掉 originalCategory即便它本来就被外部设了
check('普通子分类之间 moveTask 后 originalCategory === null',
moved && moved.originalCategory === null,
`actual=${moved?.originalCategory}`);
}
// ============================================================
// [15] restoreTask从回收站恢复到普通子分类 → 清 originalCategory防御
// ============================================================
console.log('\n[15] restoreTask 回收站 → 普通子分类 → 清 originalCategory');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
// 勾选 → 进已完成(带 originalCategory→ 删 → 进回收站(保留状态)
store.toggleTask('工作', task.id);
store.deleteTask('已完成任务', task.id);
const trashed = store.getCategory('回收站').tasks[0];
// 模拟"防御性原始数据":外部编辑设了 originalCategory即使 completed=true 在 trash 里也罕见)
// 这里直接用 deleteTask 后的状态task.completed=true 且 task.originalCategory='工作'
check('删除已完成任务后,回收站里 originalCategory 仍在',
trashed && trashed.originalCategory === '工作' && trashed.completed === true);
// 恢复到「学习」子分类
const ok = store.restoreTask(trashed.id, '学习');
// restoreTask 看到 taskSnapshot.completed=true 会重投到「已完成任务」—— 不进 NORMAL 路径
// 这条路径下 originalCategory 应该保留
const finalTask = store.getCategory('学习').tasks.find(t => t.text === '写周报')
|| store.getCategory('已完成任务').tasks.find(t => t.text === '写周报');
if (ok && finalTask) {
const inCompleted = store.getCategory('已完成任务').tasks.some(t => t.text === '写周报');
if (inCompleted) {
check('completed 任务从回收站恢复后重投「已完成」→ originalCategory 保留',
finalTask.originalCategory === '工作',
`actual=${finalTask.originalCategory}`);
} else {
check('非 completed 任务从回收站恢复到普通分类 → originalCategory === null',
finalTask.originalCategory === null,
`actual=${finalTask.originalCategory}`);
}
}
}
// ============================================================
// [16] writer 防御性:即便普通子分类任务的 originalCategory 被外部塞了,写出也不带标记
// ============================================================
console.log('\n[16] writer 防御性护栏:普通子分类不写 [原分类xxx]');
{
// 模拟外部手工编辑:把 [原分类xxx] 强行写到普通子分类任务里
const DIRTY_DOC = `# 全部任务
## 工作
- [ ] [原分类:生活] 写周报
## 学习
- [ ] 看 Rust 入门
`;
const store = makeStore(DIRTY_DOC);
const md = store.serialize();
// 普通子分类下不应该出现 [原分类xxx]writer 护栏)
const workSection = md.split('## 工作')[1]?.split('##')[0] || '';
check('writer 护栏:普通子分类不输出 [原分类xxx]',
!workSection.includes('[原分类:'),
`work section: ${workSection.slice(0, 200)}`);
}
// ============================================================
// [17] 回收站里仍可看到 [原分类xxx](用户要求:回收站也是特殊分类,原分类有信息价值)
// ============================================================
console.log('\n[17] 回收站里保留 [原分类xxx]');
{
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
// 勾选 → 已完成 → 删 → 回收站
store.toggleTask('工作', task.id);
store.deleteTask('已完成任务', task.id);
const md = store.serialize();
const trashSection = md.split('# 回收站')[1] || '';
check('回收站段保留 [原分类:工作]',
trashSection.includes('[原分类:工作]'),
`trash section: ${trashSection.slice(0, 200)}`);
}
// ============================================================
// [18] v3.5.2+:回收站学「已完成任务」的 [原分类:] 语义 + 默认归位
//
// 行为契约:
// 1) deleteTask 从普通子分类搬到 trash → 自动写 originalCategory
// 2) writer 在「# 回收站」段同步写出 [原分类xxx]
// 3) restoreTask 默认(无 toCategoryName命中原分类 → 直接归位
// 4) restoreTask 默认 原分类丢失 → 返回 picker hint任务**仍留在 trash**
// 5) picker 路径后续 restoreTask(taskId, picked) 完成归位 + 清掉 originalCategory
// 6) batchDeleteTasks 也写 originalCategory与单条 deleteTask 对称)
// 7) 已完成任务从「已完成任务」删到 trash原分类不被覆盖沿用 toggle 时记下的)
// ============================================================
console.log('\n[18] 回收站学「已完成任务」的 [原分类:] + 默认归位');
{
// 18.1) 普通子分类删除 → 回收站里 originalCategory 已被记下
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.deleteTask('工作', task.id);
const trashed = store.getTrashCategory().tasks[0];
check('普通子分类删除后回收站里 originalCategory === "工作"',
trashed && trashed.originalCategory === '工作',
`actual=${trashed?.originalCategory}`);
// 18.2) writer 在「# 回收站」段输出 [原分类:工作]
const md = store.serialize();
const trashSection = md.split('# 回收站')[1] || '';
check('writer 在「# 回收站」段写出 [原分类:工作]trash 端到端链路打通)',
trashSection.includes('[原分类:工作]'),
`trash section: ${trashSection.slice(0, 200)}`);
// 18.3) 默认 restoreTask 走 category 分支:归到「工作」
const result = store.restoreTask(task.id);
check('默认 restoreTask 返回 { ok: true, restored: true }',
result.ok === true && result.restored === true,
`result=${JSON.stringify(result)}`);
check('restoreHint.kind === "category" + 带 targetName="工作"',
result.restoreHint && result.restoreHint.kind === 'category' &&
result.restoreHint.targetName === '工作',
`restoreHint=${JSON.stringify(result.restoreHint)}`);
check('任务归到原分类「工作」',
store.getCategory('工作').tasks.some(t => t.text === '写周报'),
`work tasks: ${store.getCategory('工作').tasks.map(t => t.text)}`);
check('回收站空',
store.getTrashCategory().tasks.length === 0);
}
{
// 18.4) 原分类丢失 → picker hint任务仍留在 trash
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const task = workCat.tasks.find(t => t.text === '写周报');
store.deleteTask('工作', task.id); // record originalCategory='工作'
store.deleteCategory('工作'); // 原分类丢失
// 此时任务在 trashoriginalCategory='工作' 但 '工作' 已不存在
const result = store.restoreTask(task.id);
check('原分类丢失时 restoreTask 返回 picker hint',
result && result.restoreHint && result.restoreHint.kind === 'picker',
`result=${JSON.stringify(result)}`);
check('picker hint.missing === "工作"',
result.restoreHint && result.restoreHint.missing === '工作',
`missing=${result.restoreHint?.missing}`);
check('picker 路径下任务**仍留在 trash**(不动任务)',
store.getTrashCategory().tasks.length === 1 &&
store.getTrashCategory().tasks[0].text === '写周报',
`trash: ${store.getTrashCategory().tasks.map(t => t.text)}`);
// 18.5) picker 选定后调 restoreTask(taskId, picked) 完成移动 + 清 originalCategory
const r2 = store.restoreTask(task.id, '学习');
check('picker 后续 restoreTask(id, "学习") 完成归位',
r2.ok === true && r2.restored === true &&
store.getCategory('学习').tasks.some(t => t.text === '写周报'),
`r2=${JSON.stringify(r2)}, study=${store.getCategory('学习').tasks.map(t => t.text)}`);
const moved = store.getCategory('学习').tasks.find(t => t.text === '写周报');
check('picker 路径恢复后 task.originalCategory === null已"回家"',
moved && moved.originalCategory === null,
`actual=${moved?.originalCategory}`);
}
{
// 18.6) batchDeleteTasks 也写 originalCategory
const store = makeStore(BASE_DOC);
const workCat = store.getCategory('工作');
const studyCat = store.getCategory('学习');
const t1 = workCat.tasks.find(t => t.text === '写周报');
const t2 = studyCat.tasks.find(t => t.text === '看 Rust 入门');
store.batchDeleteTasks([
{ categoryName: '工作', taskId: t1.id },
{ categoryName: '学习', taskId: t2.id }
]);
const trash = store.getTrashCategory().tasks;
check('batchDeleteTasks 后回收站里两条任务都带 originalCategory',
trash.length === 2 &&
trash.some(t => t.text === '写周报' && t.originalCategory === '工作') &&
trash.some(t => t.text === '看 Rust 入门' && t.originalCategory === '学习'),
`trash: ${trash.map(t => `${t.text}(${t.originalCategory})`).join(', ')}`);
}
{
// 18.7) 已完成任务删到 trashoriginalCategory 不被覆盖
const store = makeStore(BASE_DOC);
const studyCat = store.getCategory('学习');
const task = studyCat.tasks.find(t => t.text === '看 Rust 入门');
// 勾选 → toggleTask 写 originalCategory='学习'
store.toggleTask('学习', task.id);
check('toggleTask 已写 originalCategory === "学习"(前置)',
store.getCategory('已完成任务').tasks[0].originalCategory === '学习');
// 删 → 进入 trashoriginalCategory 应保持 '学习'(不被 deleteTask 覆盖)
store.deleteTask('已完成任务', task.id);
const trashed = store.getTrashCategory().tasks[0];
check('completed 任务从「已完成任务」删到 trash → originalCategory 仍为"学习"(不重写)',
trashed && trashed.originalCategory === '学习' && trashed.completed === true,
`actual=${trashed?.originalCategory}, completed=${trashed?.completed}`);
}
console.log(`\n结果:通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);