This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

View File

@@ -0,0 +1,685 @@
// 回归测试:「原分类」标记 + 已完成任务恢复语义
//
// 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);