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

747 lines
32 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.
// 一次性验证脚本批量操作task-store.batchDeleteTasks / batchMoveTasks /
// batchToggleCompleted / batchUpdateMeta / batchReorderInCategory
//
// 覆盖:
// 1. 批量删除跨分类、TRASH 拒绝、空集合、顺序保留、单次 emit
// 2. 批量移到分类:跨源分类、顺序保留、目标守卫、单次 emit
// 3. 批量重排序top / bottom、跨分类拒绝、顺序保留
// 4. 批量改 metaimportant / current、TRASH 拒绝、已为 true 不重复改
// 5. 批量切勾选TRASH 允许、已为目标态不重复改
// 6. 往返保真:所有批量操作后 serialize → parseMarkdown 任务多重集不变
//
// 用法node scripts/check-batch-ops.mjs
import { TaskStore } 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 } from '../src/markdown-parser.js';
import { OTHER_TASKS_NAME, TRASH_NAME } from '../src/markdown-parser.js';
// 构造跨分类测试场景3 个子分类 + 已完成任务分类 + 6 条任务
//
// 注意v3.4fixture 里不能再用 `- [✓]` 写在普通子分类下 —— 加载时会被
// 迁移到「# 已完成任务」分类,原本所在分类会少一条,进而打挂所有断言。
// 正确做法:把已完成任务直接放在 `# 已完成任务` 段。
function setupCrossCategoryStore() {
const md = `# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务 A
- [ ] 工作任务 A2
## 学习
- [ ] 学习任务 A
## 生活
- [ ] 生活任务 A
- [ ] 生活任务 B
# 已完成任务
- [✓] 工作任务 B已完成
# ${TRASH_NAME}
`;
// 注入 back-mode settings让 batchDeleteTasks / batchReorderInCategory / batchMoveTasks 的断言
// (顺序保留 = push 顺序)保持原语义。默认 'front' 模式(最新在最上)由
// check-insert-position.mjs 覆盖。
const backSettings = {
_values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back', movePosition: 'back' },
get(k) { return this._values[k]; }
};
const store = makeStore(md, { settingsStore: backSettings });
return store;
}
// ============================================================
// [1] batchDeleteTasks —— 基本跨分类删除
// ============================================================
console.log('\n[1] batchDeleteTasks 跨分类删除');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
const life = store.getCategory('生活');
// 选 3 条来自 3 个不同分类
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '学习', taskId: study.tasks[0].id },
{ categoryName: '生活', taskId: life.tasks[0].id }
];
let changeCount = 0;
const onChange = () => { changeCount++; };
store.on('change', onChange);
const result = store.batchDeleteTasks(refs);
store.off('change', onChange);
check('返回 moved=3, skipped=0', result.moved === 3 && result.skipped === 0,
JSON.stringify(result));
check('change 事件只触发 1 次(不是 3 次)', changeCount === 1,
`实际 ${changeCount}`);
check('工作分类剩 1 条(剩 A2', work.tasks.length === 1);
check('学习分类剩 0 条', study.tasks.length === 0);
check('生活分类剩 1 条(剩 B', life.tasks.length === 1);
check('回收站创建出来 + 3 条', store.getTrashCategory()?.tasks.length === 3);
check('dirty 已被标脏', store.dirty === true);
// 顺序:回收站里 3 条按入参顺序 reverse 后 unshift。
// refs = [工作 A, 学习 A, 生活 A] → reverse → [生活 A, 学习 A, 工作 A]
// 每次 unshift 加到数组前端,所以最终顺序从上到下:工作 A、学习 A、生活 A
// 直观理解refs 中越靠前的越先 unshift所以越靠后的元素越先被加到前面 → 反转了 refs
const trash = store.getTrashCategory();
check('回收站顺序:上→下 = refs 倒序(最后删的排在最上)',
trash.tasks[0].text === '工作任务 A' &&
trash.tasks[1].text === '学习任务 A' &&
trash.tasks[2].text === '生活任务 A');
}
// ============================================================
// [2] batchDeleteTasks —— 守卫 / 边界
// ============================================================
console.log('\n[2] batchDeleteTasks 守卫');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
// 2.1 TRASH 内的任务被拒绝(彻底删除只能手工编辑 Markdown
const beforeTrashLen = store.getTrashCategory()?.tasks.length || 0;
// 先删一条进回收站
store.deleteTask('工作', work.tasks[0].id);
const trash = store.getTrashCategory();
const trashedId = trash.tasks[0].id;
const r1 = store.batchDeleteTasks([{ categoryName: TRASH_NAME, taskId: trashedId }]);
check('TRASH 内任务被拒绝moved=0, skipped=1',
r1.moved === 0 && r1.skipped === 1, JSON.stringify(r1));
check('TRASH 内的任务没被二次删', trash.tasks.find(t => t.id === trashedId) !== undefined);
check('回收站长度不变', trash.tasks.length === beforeTrashLen + 1);
// 2.2 不存在的 id
const r2 = store.batchDeleteTasks([{ categoryName: '工作', taskId: '不存在' }]);
check('不存在 id → moved=0, skipped=1', r2.moved === 0 && r2.skipped === 1);
// 2.3 不存在的分类
const r3 = store.batchDeleteTasks([{ categoryName: '不存在的分类', taskId: 'x' }]);
check('不存在分类 → moved=0, skipped=1', r3.moved === 0 && r3.skipped === 1);
// 2.4 空入参
let changeCount = 0;
store.on('change', () => changeCount++);
const r4 = store.batchDeleteTasks([]);
store.off('change', () => {});
check('空数组 → moved=0, skipped=0', r4.moved === 0 && r4.skipped === 0);
check('空数组 → change 触发 0 次', changeCount === 0,
`实际 ${changeCount}`);
// 2.5 null 入参
const r5 = store.batchDeleteTasks(null);
check('null 入参 → 安全返回', r5.moved === 0);
// 2.6 混合3 条里 1 条是 TRASH 内拒绝1 条不存在跳过1 条合法
const r6 = store.batchDeleteTasks([
{ categoryName: TRASH_NAME, taskId: trashedId },
{ categoryName: '工作', taskId: '不存在' },
{ categoryName: '工作', taskId: work.tasks[0].id }
]);
check('混合入参 → moved=1, skipped=2', r6.moved === 1 && r6.skipped === 2, JSON.stringify(r6));
}
// ============================================================
// [3] batchDeleteTasks —— 往返保真
// ============================================================
console.log('\n[3] batchDeleteTasks 序列化往返');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
store.batchDeleteTasks(refs);
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedStudy = reloaded.find(c => c.name === '学习');
const reloadedTrash = reloaded.find(c => c.name === TRASH_NAME && c.kind === CategoryKind.TRASH);
check('往返后「工作」剩 1 条', reloadedWork?.tasks?.length === 1);
check('往返后「学习」剩 0 条', reloadedStudy?.tasks?.length === 0);
check('往返后回收站有 2 条', reloadedTrash?.tasks?.length === 2,
`实际 ${reloadedTrash?.tasks?.length}`);
// 任务多重集(文本 + completed应不变 —— 包含所有 6 条(删的 2 条在 trash 里)
// v3.4 fixture 已完成任务分类有 B工作分类有 A + A2多重集按文本 + 勾选状态统计
const origSet = new Set([
'工作任务 A|false',
'工作任务 A2|false',
'工作任务 B已完成|true',
'学习任务 A|false',
'生活任务 A|false',
'生活任务 B|false'
]);
const reloadSet = new Set();
for (const cat of reloaded) {
if (cat.name === OTHER_TASKS_NAME || cat.name === TRASH_NAME) continue;
for (const t of cat.tasks) reloadSet.add(t.text + '|' + t.completed);
}
for (const t of reloadedTrash?.tasks || []) reloadSet.add(t.text + '|' + t.completed);
check('往返后任务多重集不变',
reloadSet.size === origSet.size && [...origSet].every(x => reloadSet.has(x)),
`orig=${[...origSet]}, reload=${[...reloadSet]}`);
}
// ============================================================
// [4] batchMoveTasks —— 跨源分类移动 + 顺序保留
// ============================================================
console.log('\n[4] batchMoveTasks 跨源分类移动');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
const life = store.getCategory('生活');
// 选 2 条来自工作 + 1 条来自学习 → 都到「生活」
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '工作', taskId: work.tasks[1].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
let changeCount = 0;
store.on('change', () => changeCount++);
const result = store.batchMoveTasks(refs, '生活');
store.off('change', () => {});
check('返回 moved=3', result.moved === 3, JSON.stringify(result));
check('change 触发 1 次', changeCount === 1);
check('工作分类剩 0 条', work.tasks.length === 0);
check('学习分类剩 0 条', study.tasks.length === 0);
check('生活分类原来 2 + 新增 3 = 5 条', life.tasks.length === 5);
// 顺序保留refs[0], refs[1], refs[2] 顺序 → 追加到生活末尾
// (原生活有 [生活任务 A, 生活任务 B]
check('顺序保留:生活分类末尾 3 条 = 入参顺序',
life.tasks.slice(2).map(t => t.text).join('|') ===
'工作任务 A|工作任务 A2|学习任务 A',
life.tasks.slice(2).map(t => t.text).join('|'));
}
// ============================================================
// [5] batchMoveTasks —— 目标守卫
// ============================================================
console.log('\n[5] batchMoveTasks 目标守卫');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const refs = [{ categoryName: '工作', taskId: work.tasks[0].id }];
// 5.1 目标 = 容器OTHER_TASKS→ 拒绝
const r1 = store.batchMoveTasks(refs, OTHER_TASKS_NAME);
check('目标=容器 → moved=0, skipped=1', r1.moved === 0 && r1.skipped === 1);
check('工作分类未受影响', work.tasks.length === 2);
// 5.2 用全新 store 验证「目标=TRASH → 不允许绕过 deleteTask 专属流程」
// 注意setupCrossCategoryStore 的 markdown 已含 `# ${TRASH_NAME}` 段,
// 所以加载后回收站分类已存在 —— 这里验的是「moveTask 拒绝向 TRASH 移」,
// 而不是「TRASH 没被动创建」(后者需要更特殊的初始 markdown
const freshStore = setupCrossCategoryStore();
const freshTrashBefore = freshStore.getTrashCategory()?.tasks.length || 0;
const freshWork = freshStore.getCategory('工作');
const r2 = freshStore.batchMoveTasks(
[{ categoryName: '工作', taskId: freshWork.tasks[0].id }],
TRASH_NAME
);
check('目标=回收站 → moved=0, skipped=1', r2.moved === 0 && r2.skipped === 1);
check('拒绝后回收站任务数不变(绕过路径被堵)',
freshStore.getTrashCategory()?.tasks.length === freshTrashBefore);
check('拒绝后工作分类未受影响', freshWork.tasks.length === 2);
// 5.3 目标 = 不存在的分类 → 拒绝
const r3 = store.batchMoveTasks(refs, '不存在的分类');
check('目标=不存在 → moved=0, skipped=1', r3.moved === 0 && r3.skipped === 1);
}
// ============================================================
// [6] batchMoveTasks —— toIndex 插入位置
// ============================================================
console.log('\n[6] batchMoveTasks toIndex 插入位置');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const life = store.getCategory('生活');
// 生活:[生活任务 A, 生活任务 B]
// 选工作第 1 条 → 插入到生活下标 1A 之后、B 之前)
const refs = [{ categoryName: '工作', taskId: work.tasks[0].id }];
store.batchMoveTasks(refs, '生活', 1);
check('生活分类长度 = 3', life.tasks.length === 3);
check('插入到下标 1A、工作任务 A、生活任务 B',
life.tasks[0].text === '生活任务 A' &&
life.tasks[1].text === '工作任务 A' &&
life.tasks[2].text === '生活任务 B',
life.tasks.map(t => t.text).join('|'));
}
// ============================================================
// [7] batchMoveTasks —— 切回 MANUAL 排序
// ============================================================
console.log('\n[7] batchMoveTasks 切回 MANUAL');
{
const store = setupCrossCategoryStore();
store.setSortBy('alphabet'); // 切到字母序
check('当前是字母序', store.sortBy === 'alphabet');
const work = store.getCategory('工作');
store.batchMoveTasks(
[{ categoryName: '工作', taskId: work.tasks[0].id }],
'生活'
);
check('批量移动后自动切回 MANUAL', store.sortBy === 'manual',
`实际 ${store.sortBy}`);
}
// ============================================================
// [8] batchReorderInCategory —— top / bottom
// ============================================================
console.log('\n[8] batchReorderInCategory top / bottom');
{
const store = setupCrossCategoryStore();
// 工作:[工作任务 A, 工作任务 A2]v3.4 fixtureB 已迁到「# 已完成任务」分类)
const work = store.getCategory('工作');
const aId = work.tasks[0].id;
const a2Id = work.tasks[1].id;
// 8.1 只有 1 个任务 → 置顶 / 置底都是 no-op顺序不变
const r1 = store.batchReorderInCategory('工作', [aId], 'top');
check('单条置顶 → moved=0顺序未变',
r1.moved === 0 || work.tasks[0].id === aId);
// 8.2 现在加 2 条任务,让 reorder 真正有效
store.addTask('工作', '新任务 1');
store.addTask('工作', '新任务 2');
// 工作:[工作任务 A, 工作任务 A2, 新任务 1, 新任务 2]
// 选 [工作任务 A2, 新任务 2] → 移到顶部
const idsToTop = [a2Id, work.tasks[3].id];
store.batchReorderInCategory('工作', idsToTop, 'top');
// 期望:[工作任务 A2, 新任务 2, 工作任务 A, 新任务 1]
check('top 重排后顺序正确',
work.tasks.map(t => t.text).join('|') ===
'工作任务 A2|新任务 2|工作任务 A|新任务 1',
work.tasks.map(t => t.text).join('|'));
// 8.3 选 [工作任务 A2, 新任务 2] → 移到底部
const idsToBottom = [work.tasks[0].id, work.tasks[1].id];
store.batchReorderInCategory('工作', idsToBottom, 'bottom');
// 期望:[工作任务 A, 新任务 1, 工作任务 A2, 新任务 2]
check('bottom 重排后顺序正确',
work.tasks.map(t => t.text).join('|') ===
'工作任务 A|新任务 1|工作任务 A2|新任务 2',
work.tasks.map(t => t.text).join('|'));
// 8.4 选不存在的 id → 跳过
const r2 = store.batchReorderInCategory('工作', ['不存在', aId], 'top');
check('包含不存在 id → skipped=1, moved=1',
r2.moved === 1 && r2.skipped === 1, JSON.stringify(r2));
// 8.5 不在目标分类里的 id → 跳过
const study = store.getCategory('学习');
const studyId = study.tasks[0].id;
const r3 = store.batchReorderInCategory('工作', [studyId, aId], 'top');
check('跨分类 id 跳过 → skipped=1, moved=1',
r3.moved === 1 && r3.skipped === 1, JSON.stringify(r3));
// 8.6 目标分类是 TRASH → 拒绝
const r4 = store.batchReorderInCategory(TRASH_NAME, [aId], 'top');
check('目标=TRASH → moved=0', r4.moved === 0);
// 8.7 目标分类是 OTHER_TASKS → 拒绝
const r5 = store.batchReorderInCategory(OTHER_TASKS_NAME, [aId], 'top');
check('目标=OTHER_TASKS → moved=0', r5.moved === 0);
// 8.8 position 非法 → 拒绝
const r6 = store.batchReorderInCategory('工作', [aId], 'middle');
check('position=middle → moved=0', r6.moved === 0);
}
// ============================================================
// [9] batchToggleCompleted —— 批量勾选状态切换
// ============================================================
console.log('\n[9] batchToggleCompleted 批量切勾选');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
// v3.4 fixture
// 工作:[工作任务 A (未完成), 工作任务 A2 (未完成)]
// 学习:[学习任务 A (未完成)]
// 已完成任务:[工作任务 B已完成]B 不参与本次 toggle本来就是 true
// 全部置为已完成3 条都是 false → 全部需要改 + 自动搬到 # 已完成任务
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '工作', taskId: work.tasks[1].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
let changeCount = 0;
store.on('change', () => changeCount++);
const r1 = store.batchToggleCompleted(refs, true);
store.off('change', () => {});
check('返回 changed=33 条都是 false → 全部切到 true',
r1.changed === 3, JSON.stringify(r1));
check('change 触发 1 次', changeCount === 1);
// v3.4toggle true 触发自动搬迁 —— 这 3 条应被搬到 # 已完成任务
const completedCat = store.getCompletedCategory();
check('3 条任务自动搬到已完成任务分类',
completedCat?.tasks?.length === 4 && // 原有 B + 新增 3 条
completedCat.tasks.some(t => t.text === '工作任务 A') &&
completedCat.tasks.some(t => t.text === '工作任务 A2') &&
completedCat.tasks.some(t => t.text === '学习任务 A'));
check('工作分类被搬空', work.tasks.length === 0);
check('学习分类被搬空', study.tasks.length === 0);
// 9.2 再切回未完成3 条都是 true → changed=3
// v3.5+:批量取消勾选走 _resolveRestoreTarget按各自 originalCategory 归位
// - 工作任务 A / A2originalCategory = "工作" → 回到「工作」
// - 学习任务 A originalCategory = "学习" → 回到「学习」
// 这正是本批恢复的"按原分类归位"语义 —— 旧版一律回「未分类」的行为已废弃。
const r2 = store.batchToggleCompleted(refs, false);
check('全部为目标态的反值 → changed=3', r2.changed === 3, JSON.stringify(r2));
const workAfter = store.getCategory('工作');
const studyAfter = store.getCategory('学习');
const uncategorizedAfter = store.getCategory('未分类');
check('2 条工作任务回到「工作」',
workAfter?.tasks?.length === 2 &&
workAfter.tasks.some(t => t.text === '工作任务 A') &&
workAfter.tasks.some(t => t.text === '工作任务 A2'));
check('1 条学习任务回到「学习」',
studyAfter?.tasks?.length === 1 &&
studyAfter.tasks.some(t => t.text === '学习任务 A'));
check('「未分类」保持为空(无任务落进来)',
!uncategorizedAfter || uncategorizedAfter.tasks.length === 0);
check('已完成任务只剩原本的 B',
completedCat?.tasks?.length === 1 &&
completedCat.tasks[0].text === '工作任务 B已完成');
// 9.3 TRASH 允许切勾选(恢复前常想清勾或勾上)
// 工作 / 学习 / 未分类 里的任务刚刚经过搬迁,需要一个全新的源 —— 用「生活」分类
const life = store.getCategory('生活');
store.deleteTask('生活', life.tasks[0].id);
const trash = store.getTrashCategory();
const trashedId = trash.tasks[0].id;
// 此时该任务 completed=false切到 true 应该 changed=1
const r3 = store.batchToggleCompleted(
[{ categoryName: TRASH_NAME, taskId: trashedId }],
true
);
check('TRASH 内任务允许切勾选true', r3.changed === 1, JSON.stringify(r3));
// v3.4:勾选触发自动搬迁 —— TRASH 任务 [✓] 后从回收站搬到 # 已完成任务
// (与 toggleTask 单条路径同款语义completed=true 必须活在与状态一致的分类里)
check('TRASH 任务勾上后自动搬到已完成任务分类',
completedCat?.tasks?.some(t => t.id === trashedId && t.completed === true));
check('TRASH 中已无此任务(已搬迁)',
!trash.tasks.some(t => t.id === trashedId));
// 9.4 batchToggleCompleted(true) 遵守 completedPosition
// - front默认批量打勾 → 「最新在最上」(入参里最后一条在「已完成」最前)
// - back :按入参顺序追加到「已完成」末尾(与 setupCrossCategoryStore 同款 back 语义)
// wantCompleted=false批量取消勾选属于恢复路径与 restoreTask 同款 —— 不读 *Position
// 任务归位一律按 push 末尾,已在 9.2 验证过。
}
// 9.4 单独用 front-mode settingsStore 验证批量打勾走 completedPosition
console.log('\n[9.4] batchToggleCompleted(true) 遵守 completedPosition');
{
// front 模式:默认行为
const frontSettings = {
_values: { newTaskPosition: 'front', completedPosition: 'front', trashPosition: 'front' },
get(k) { return this._values[k]; }
};
const md = `# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务 A
- [ ] 工作任务 A2
- [ ] 工作任务 A3
## 学习
- [ ] 学习任务 A
# ${TRASH_NAME}
`;
const store = makeStore(md, { settingsStore: frontSettings });
const work = store.getCategory('工作');
const study = store.getCategory('学习');
// 入参顺序A, A2, A3来自工作, 学习 A。批量打勾 → 入参里最后一条(学习 A
// 在 completedPosition='front' 时应落在「已完成任务」最前面。
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '工作', taskId: work.tasks[1].id },
{ categoryName: '工作', taskId: work.tasks[2].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
store.batchToggleCompleted(refs, true);
const completedCat = store.getCompletedCategory();
check('front 模式completed 里有 4 条', completedCat.tasks.length === 4);
check('front 模式:入参里最后一条(学习 A排在最前最新在最上',
completedCat.tasks[0].text === '学习任务 A',
completedCat.tasks.map(t => t.text).join('|'));
// front 模式:依次 unshift 入参 → 入参里最后一条最先 unshift 到 [0],其余按入参倒序填位。
// 入参顺序 = [工作 A, 工作 A2, 工作 A3, 学习 A],依次 unshift 后 = [学习 A, 工作 A3, 工作 A2, 工作 A]
// (同源内 idx=0/1/2 的顺序与入参一致,因为 parseOrder 自然等于 sourceIndex 顺序)
check('front 模式completed 顺序 = 入参倒序(同源按 sourceIndex 倒序保留)',
completedCat.tasks.map(t => t.text).join('|') === '学习任务 A|工作任务 A3|工作任务 A2|工作任务 A',
completedCat.tasks.map(t => t.text).join('|'));
}
{
// back 模式:按入参顺序追加到末尾
const backSettings = {
_values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back' },
get(k) { return this._values[k]; }
};
const md = `# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务 A
- [ ] 工作任务 A2
## 学习
- [ ] 学习任务 A
# 已完成任务
- [✓] 已有 B
# ${TRASH_NAME}
`;
const store = makeStore(md, { settingsStore: backSettings });
const work = store.getCategory('工作');
const study = store.getCategory('学习');
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '工作', taskId: work.tasks[1].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
store.batchToggleCompleted(refs, true);
const completedCat = store.getCompletedCategory();
check('back 模式completed 里有 4 条(已有 B + 新增 3 条)',
completedCat.tasks.length === 4);
// back 模式:原有 B 在前,新增 3 条按入参顺序追加
check('back 模式completed 顺序 = 原顺序 + 入参顺序',
completedCat.tasks.map(t => t.text).join('|') ===
'已有 B|工作任务 A|工作任务 A2|学习任务 A',
completedCat.tasks.map(t => t.text).join('|'));
}
// ============================================================
// [10] batchUpdateMeta —— 批量改 important / current
// ============================================================
console.log('\n[10] batchUpdateMeta 批量改 meta');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
// 10.1 全部置为 important=true
const refs = [
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '工作', taskId: work.tasks[1].id },
{ categoryName: '学习', taskId: study.tasks[0].id }
];
const r1 = store.batchUpdateMeta(refs, { important: true });
check('全部置重要 → changed=3', r1.changed === 3);
check('collectSmartListTasks().important 长度 = 3',
store.collectSmartListTasks().important.length === 3);
// 10.2 v3.6 起不再写顶部「# 重要任务」镜像段:[⭐] 由 inline 标识承载,
// 智能视图直接按 task.important=true 聚合。序列化结果不含镜像段,
// 但源任务行的 [⭐] 标记必须原样保留(带 [⭐] 的行 ≥ 3
const md = store.serialize();
check('序列化结果不含「# 重要任务」镜像段', !md.includes('# 重要任务'),
md.split('\n').slice(0, 8).join('\n'));
check('源任务 [⭐] 标记至少 3 条', (md.match(/\[⭐\]/g) || []).length >= 3);
// 10.3 混合:传 current=trueB 已经是 current=false → changed=3
store.batchUpdateMeta(refs, { current: false }); // 先全部设为 falsechanged=3
// 此时再设 current=falsechanged=0无变化
const r2 = store.batchUpdateMeta(refs, { current: false });
check('全部已经是目标值 → changed=0', r2.changed === 0, JSON.stringify(r2));
// 10.4 partial meta只传 important不传 current → current 不被改
store.batchUpdateMeta(refs, { current: true });
store.batchUpdateMeta([refs[0]], { important: true }); // 仅改 important
check('partial meta → 仅 important 字段变',
work.tasks[0].important === true && work.tasks[0].current === true);
// 10.5 空 meta → 0 changed
const r3 = store.batchUpdateMeta(refs, {});
check('空 meta → changed=0', r3.changed === 0);
// 10.6 null meta → 0 changed
const r4 = store.batchUpdateMeta(refs, null);
check('null meta → changed=0', r4.changed === 0);
}
// ============================================================
// [11] batchUpdateMeta —— TRASH 也允许改 ⭐/▶v3.6+
//
// 旧行为TRASH 内的任务被批量改 meta 时整批跳过(守卫一刀切)。
// 新行为:⭐ / ▶ 是任务级状态,回收站里也能改。其它破坏性跨分类操作
// (删除 / 移动 / 完成态切换)仍按各自守卫走,不在这里放松。
// ============================================================
console.log('\n[11] batchUpdateMeta —— TRASH 也允许改 ⭐/▶v3.6+');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
store.deleteTask('工作', work.tasks[0].id);
const trash = store.getTrashCategory();
const trashedId = trash.tasks[0].id;
const r = store.batchUpdateMeta(
[{ categoryName: TRASH_NAME, taskId: trashedId }],
{ important: true }
);
check('TRASH 内任务改 important → changed=1, skipped=0',
r.changed === 1 && r.skipped === 0, JSON.stringify(r));
check('回收站任务已被修改为 important=true',
trash.tasks[0].important === true);
// 反向:批量改 current 也是同样的对称行为
const r2 = store.batchUpdateMeta(
[{ categoryName: TRASH_NAME, taskId: trashedId }],
{ current: true }
);
check('TRASH 内任务改 current → changed=1, skipped=0',
r2.changed === 1 && r2.skipped === 0, JSON.stringify(r2));
check('回收站任务已被修改为 current=true',
trash.tasks[0].current === true);
// markdown 往返writer 应该写出 [⭐][▶] 给回收站里的这条任务
const md = store.serialize();
check('serialize 后回收站行带 [⭐][▶] 标记',
/- \[ \] \[▶\] \[⭐\].*?工作任务 A/.test(md),
md);
}
// ============================================================
// [12] 综合场景:智能列表下的 _fromCategory 路径
//
// 这是 UI 层的常见调用方式:用户在「当前任务」智能视图里选 3 条
// (来自 3 个不同子分类)→ 一键移到「工作」。
// 测试模拟 UI 收集到的 taskRefs含真实 fromCategory
// ============================================================
console.log('\n[12] 智能列表下的批量移动(按 _fromCategory');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const study = store.getCategory('学习');
const life = store.getCategory('生活');
// 给「学习任务 A」打 current 标记,模拟智能列表视图的 enriched 数据
study.tasks[0].current = true;
// UI 层在「当前任务」智能视图里看到这条任务时cat.tasks 里有 _fromCategory
// 这里直接用真实 fromCategory 构造 refs实际 UI 会从 enriched 副本读)
const refs = [
{ categoryName: '学习', taskId: study.tasks[0].id }
];
store.batchMoveTasks(refs, '工作');
check('从学习移动到工作后,学习剩 0 条', study.tasks.length === 0);
check('从学习移动到工作后,工作含学习任务 A',
work.tasks.find(t => t.text === '学习任务 A') !== undefined);
check('移动后 current 标记保留',
work.tasks.find(t => t.text === '学习任务 A').current === true);
}
// ============================================================
// [13] 综合:批量删除 + 往返 + 回收站顺序
// ============================================================
console.log('\n[13] 综合往返:批量删除后 serialize → parse 保真');
{
const store = setupCrossCategoryStore();
const work = store.getCategory('工作');
const life = store.getCategory('生活');
// 删 2 条
store.batchDeleteTasks([
{ categoryName: '工作', taskId: work.tasks[0].id },
{ categoryName: '生活', taskId: life.tasks[1].id }
]);
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedLife = reloaded.find(c => c.name === '生活');
const reloadedTrash = reloaded.find(c => c.name === TRASH_NAME && c.kind === CategoryKind.TRASH);
// 多重集比较
const liveSet = new Set();
for (const cat of reloaded) {
if (cat.name === OTHER_TASKS_NAME || cat.name === TRASH_NAME) continue;
for (const t of cat.tasks) liveSet.add(t.text + '|' + t.completed);
}
for (const t of reloadedTrash?.tasks || []) liveSet.add(t.text + '|' + t.completed);
const expected = new Set([
// v3.4 fixtureB 已迁到 # 已完成任务分类A2 仍在工作分类
'工作任务 B已完成|true',
'工作任务 A2|false',
'学习任务 A|false',
'生活任务 A|false',
'工作任务 A|false',
'生活任务 B|false'
]);
check('综合往返后任务多重集完全一致',
liveSet.size === expected.size && [...expected].every(x => liveSet.has(x)),
`live=${[...liveSet]}, expected=${[...expected]}`);
// 关键不变量task.id 不写文件
check('文件里不包含 id 注释', !md.includes('<!-- id:'));
}
// ============================================================
// 总结
// ============================================================
console.log(`\n==========\n通过 ${summary.pass} / 失败 ${summary.fail}\n==========`);
process.exit(summary.fail === 0 ? 0 : 1);