update
This commit is contained in:
685
scripts/check-original-category.mjs
Normal file
685
scripts/check-original-category.mjs
Normal 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.1:picker 路径恢复后同样清掉 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('工作'); // 原分类丢失
|
||||
|
||||
// 此时任务在 trash,originalCategory='工作' 但 '工作' 已不存在
|
||||
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) 已完成任务删到 trash,originalCategory 不被覆盖
|
||||
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 === '学习');
|
||||
|
||||
// 删 → 进入 trash,originalCategory 应保持 '学习'(不被 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);
|
||||
Reference in New Issue
Block a user