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,872 @@
// 一次性验证脚本内容导入功能task-store.parseImportLine + importTasksToCategory
//
// 覆盖:
// 1. parseImportLine 纯函数的所有解析分支checkbox / 列表符号剥离 / 普通行 / 空行)
// 2. importTasksToCategory 与序列化往返Markdown 是真相之源 —— 必须能 parse 回来)
// 3. 目标分类的守卫(容器 / 回收站 / 不存在的分类)
// 4. 0 条有效行时不污染 dirty 状态
// 5. 中文标点「、」/「。」不会被当成列表符号误剥离
//
// 用法node scripts/check-import-tasks.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 } from '../src/markdown-parser.js';
import { OTHER_TASKS_NAME, TRASH_NAME } from '../src/markdown-parser.js';
// ============================================================
// [1] parseImportLine —— checkbox 行
// ============================================================
console.log('\n[1] parseImportLine 识别 Markdown checkbox');
{
// 完成态:[✓]/[x]/[X] 一律视为已完成;与 markdown-parser 对齐
for (const marker of ['✓', 'x', 'X']) {
const r = parseImportLine(`- [${marker}] 已完成的任务`, { stripLeadingBullets: true });
check(`- [${marker}] 行 → completed=true, text 已剥掉 checkbox`,
r && r.completed === true && r.text === '已完成的任务',
JSON.stringify(r));
}
// 未完成:[ ] 是合法 checkbox字符集收紧到 ` ✓xX`,见 TASK_LINE_RE 注释)。
// 旧版 [!high] 这种"非标准 checkbox"字符现在被 fallback 接管 —— 不再被当作
// checkbox 剥掉而是当列表项文本原样保留completed=false
// - 旧 bug 是「任何 [X] 都当 checkbox」会让 `[⭐]` 这种 inline 标记被吃掉
// - fix 是「只 [ ✓xX] 是 checkbox」+「其余 [X] 当列表项文本」
const r0 = parseImportLine('- [ ] 未完成的任务', { stripLeadingBullets: true });
check('- [ ] 行 → completed=false, text 已剥掉 checkbox',
r0 && r0.completed === false && r0.text === '未完成的任务',
JSON.stringify(r0));
const r0b = parseImportLine('- [!high] 未完成的任务', { stripLeadingBullets: true });
check('- [!high] 行 → completed=false!high 当作列表项文本保留(不再当 checkbox 剥)',
r0b && r0b.completed === false && r0b.text === '[!high] 未完成的任务',
JSON.stringify(r0b));
// checkbox 行的前导列表符号 `*`/`+` 也兼容
const r1 = parseImportLine('* [ ] 用 * 开头', { stripLeadingBullets: true });
check('* [ ] 行 → 识别为 checkbox不当列表符号剥除',
r1 && r1.completed === false && r1.text === '用 * 开头',
JSON.stringify(r1));
const r2 = parseImportLine('+ [✓] 用 + 开头', { stripLeadingBullets: true });
check('+ [✓] 行 → 识别为 checkbox',
r2 && r2.completed === true && r2.text === '用 + 开头',
JSON.stringify(r2));
// checkbox 文本部分允许为空吗?早期实现返回 nullempty text 视为无效),
// 但这与 parseMarkdown 不一致 —— 文件里合法手写的 `- [x]`(无文本)会被
// loadFromContent 保留下来;导入却悄悄丢,会让用户数据「莫名消失」。
// 现在统一允许 —— text 为空字符串。明确测一下避免未来回归。
const r3 = parseImportLine('- [ ] ', { stripLeadingBullets: true });
check('checkbox 行无文本 → 接受(与 parseMarkdown 对齐)',
r3 && r3.completed === false && r3.text === '',
JSON.stringify(r3));
const r4 = parseImportLine('- [x]', { stripLeadingBullets: true });
check('纯勾选 `- [x]` → text="" 但 completed=true与 parseMarkdown 对齐)',
r4 && r4.completed === true && r4.text === '',
JSON.stringify(r4));
}
// ============================================================
// [2] parseImportLine —— 普通列表符号剥离
// ============================================================
console.log('\n[2] parseImportLine 剥离普通列表符号');
{
for (const [input, expected] of [
['- foo', 'foo'],
['* foo', 'foo'],
['+ foo', 'foo'],
['• foo', 'foo'],
['1. foo', 'foo'],
['2) foo', 'foo'],
['10. 多位数也行', '多位数也行'],
// 多个空白字符也应被吞掉(`- foo` → `foo`
['- 多空格', '多空格']
]) {
const r = parseImportLine(input, { stripLeadingBullets: true });
check(`剥离「${input}」→「${expected}`,
r && r.completed === false && r.text === expected,
JSON.stringify(r));
}
// 中文标点不是列表符号
for (const input of ['、任务', '。任务', ';任务', ',任务']) {
const r = parseImportLine(input, { stripLeadingBullets: true });
check(`中文标点「${input.charAt(0)}」不被剥离,原样保留`,
r && r.text === input,
JSON.stringify(r));
}
// 关闭选项时:前导列表符号保留
const r = parseImportLine('- foo', { stripLeadingBullets: false });
check('stripLeadingBullets=false 时保留前导「- 」',
r && r.text === '- foo',
JSON.stringify(r));
// 没有列表符号的普通行
const r2 = parseImportLine('普通任务', { stripLeadingBullets: true });
check('无符号普通行原样',
r2 && r2.text === '普通任务' && r2.completed === false,
JSON.stringify(r2));
}
// ============================================================
// [3] parseImportLine —— 空行 / 仅空白 / 默认行为
// ============================================================
console.log('\n[3] parseImportLine 空行处理');
{
check('空字符串 → null', parseImportLine('', { stripLeadingBullets: true }) === null);
check('仅空白 → null', parseImportLine(' \t ', { stripLeadingBullets: true }) === null);
check('null/undefined 输入 → null', parseImportLine(null, {}) === null);
check('默认选项 = stripLeadingBullets=true不显式传',
parseImportLine('- foo').text === 'foo',
JSON.stringify(parseImportLine('- foo')));
}
// ============================================================
// [4] importTasksToCategory —— 基本导入 + Markdown 往返
// ============================================================
console.log('\n[4] importTasksToCategory 批量导入 + 序列化往返');
{
// 注入一个让 *Position 全部走 'back' 的 settingsStore —— 本节断言「按出现顺序」= push 顺序。
// 默认 'front' 模式(最新在最上)由 check-insert-position.mjs 覆盖。
const backSettings = {
_values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: backSettings });
// 默认文档自带「工作 / 学习 / 生活」三个子分类(见 markdown-writer.createDefaultDoc
// 直接复用它们addSubCategory 对重名返回 null测试逻辑不该依赖它是否新建。
const workCat = store.getCategory('工作');
store.getCategory('学习'); // 确保复用,不创建
const input = [
'- [ ] 任务 A',
'- [✓] 任务 B已完成',
'- 任务 C',
'* 任务 D',
'1. 任务 E',
'• 任务 F',
'',
' ',
'普通行'
];
const result = store.importTasksToCategory('工作', input);
check('导入返回 ok=true', result.ok === true);
check('added = 7跳过 2 行空内容)', result.added === 7,
`实际 added=${result.added}, skipped=${result.skipped}`);
check('skipped = 2', result.skipped === 2,
`实际 skipped=${result.skipped}`);
// v3.4 数据归属不变量completed=true 的任务会被立即搬到「# 已完成任务」分类,
// 所以「工作」里只剩 6 条 pending 任务;用 find() 按 text 查,避免依赖数组下标。
check('未完成任务按出现顺序排列',
workCat.tasks.map(t => t.text).join('|') ===
'任务 A|任务 C|任务 D|任务 E|任务 F|普通行',
workCat.tasks.map(t => t.text).join('|'));
check('未完成任务中第一条 completed=false',
workCat.tasks[0].completed === false);
check('未完成任务剩余 completed=false',
workCat.tasks.slice(1).every(t => t.completed === false));
check('id 已生成', workCat.tasks.every(t => typeof t.id === 'string' && t.id.length > 0));
check('重要标记未被自动加上',
workCat.tasks.every(t => t.important === false && t.current === false));
check('dirty 已被标脏', store.dirty === true);
// 「# 已完成任务」分类应收到 1 条 - [✓] 任务 B
const completedCat = store.getCompletedCategory();
check('「# 已完成任务」收到 [✓] 任务 Bv3.4',
completedCat?.tasks?.length === 1 &&
completedCat.tasks[0].text === '任务 B已完成' &&
completedCat.tasks[0].completed === true &&
completedCat.tasks[0].originalCategory === '工作');
// 往返:导入后的内容 serialize() 出来再 parseMarkdown() 回来7 条任务都还在
const md = store.serialize();
check('序列化结果包含「## 工作」', md.includes('## 工作'),
md.split('\n').slice(0, 6).join('\n'));
// v3.4[✓] 任务不在「## 工作」段里 —— 它在「# 已完成任务」段
check('序列化包含 `[✓] 任务 B`(在「# 已完成任务」段)',
/# 已完成任务[\s\S]*\[✓\]\s*\[原分类:工作\]\s*任务 B已完成/.test(md),
md);
const reparsed = parseMarkdown(md);
const reparsedWork = reparsed.find(c => c.name === '工作');
const reparsedCompleted = reparsed.find(c => c.kind === CategoryKind.COMPLETED);
check('往返后「工作」子分类仍存在', !!reparsedWork);
check('往返后「工作」里 6 条未完成任务',
reparsedWork?.tasks.length === 6,
`实际 ${reparsedWork?.tasks?.length}`);
check('往返后「# 已完成任务」分类有 1 条',
reparsedCompleted?.tasks?.length === 1,
`实际 ${reparsedCompleted?.tasks?.length}`);
check('往返后「任务 B已完成」仍标 completed',
reparsedCompleted?.tasks?.find(t => t.text === '任务 B已完成')?.completed === true);
}
// ============================================================
// [5] importTasksToCategory —— 目标分类守卫
// ============================================================
console.log('\n[5] importTasksToCategory 目标分类守卫');
{
const store = makeStore();
// 容器OTHER_TASKS拒绝
const r1 = store.importTasksToCategory(OTHER_TASKS_NAME, ['任务']);
check('目标=容器 → ok=false, reason=INVALID_CATEGORY',
r1.ok === false && r1.reason === 'INVALID_CATEGORY',
JSON.stringify(r1));
// 回收站TRASH拒绝
// TRASH 还没创建,但我们直接传名字 —— getCategory 应找不到
const r2 = store.importTasksToCategory(TRASH_NAME, ['任务']);
check('目标=回收站 → ok=false, reason=INVALID_CATEGORY',
r2.ok === false && r2.reason === 'INVALID_CATEGORY',
JSON.stringify(r2));
// 不存在的分类名
const r3 = store.importTasksToCategory('不存在的分类', ['任务']);
check('目标=不存在的分类 → ok=false, reason=INVALID_CATEGORY',
r3.ok === false && r3.reason === 'INVALID_CATEGORY',
JSON.stringify(r3));
// 已确认容器/回收站分类确实没被偷偷建出来
check('容器分类没被创建', store.getCategory(OTHER_TASKS_NAME)?.kind === CategoryKind.OTHER_TASKS,
'容器是 base structure 自带的,但 kind 必须是 OTHER_TASKS');
check('回收站分类仍未被创建', store.getTrashCategory() === null);
// dirty 状态未被上述失败调用污染
check('守卫失败时未标 dirty', store.dirty === false);
}
// ============================================================
// [6] importTasksToCategory —— 全空输入 / 0 条有效行
// ============================================================
console.log('\n[6] importTasksToCategory 空输入');
{
const store = makeStore();
const cat = store.getCategory('工作');
const r1 = store.importTasksToCategory('工作', []);
check('空数组 → ok=true, added=0, skipped=0',
r1.ok === true && r1.added === 0 && r1.skipped === 0,
JSON.stringify(r1));
const r2 = store.importTasksToCategory('工作', ['', ' ', '\t', '']);
check('全空白行 → ok=true, added=0, skipped=4',
r2.ok === true && r2.added === 0 && r2.skipped === 4,
JSON.stringify(r2));
// 监听 change 事件0 条有效行时不应该触发
let changeCount = 0;
const handler = () => { changeCount++; };
store.on('change', handler);
store.importTasksToCategory('工作', ['', ' ']);
store.off('change', handler);
check('0 条有效行不触发 change 事件', changeCount === 0,
`change 触发了 ${changeCount}`);
check('0 条有效行不标 dirty', store.dirty === false);
// 真导入 1 条 → 应当触发
store.on('change', () => { changeCount++; });
store.importTasksToCategory('工作', ['有效任务']);
store.off('change', handler);
check('1 条有效行触发 change', changeCount >= 1,
`change 触发了 ${changeCount}`);
check('dirty 已被标脏', store.dirty === true);
// 分类里任务数 = 1前面那些 0 条导入都没留痕)
check('分类里只有 1 条任务(前面的 0 条导入未留痕)',
cat.tasks.length === 1 && cat.tasks[0].text === '有效任务',
`实际 ${cat.tasks.length}`);
}
// ============================================================
// [7] parseImportLine —— 边界场景
// ============================================================
console.log('\n[7] parseImportLine 边界场景');
{
// 「- 」/「1. 」 trim 后只剩符号 —— parseImportLine 尊重用户输入原样保留。
// 理由trim 在解析入口已经把两端的空白吃掉了,解析器只能看到单字符 `-` / `1.`
// 它们不再匹配 `\s+(.*)`(要求列表符号后至少一个空白),于是落到「普通行」分支返回原样。
// 用户在 UI 看到的就是他们输入的字符(含 trailing 空格已被 trim
const r6 = parseImportLine('- ', { stripLeadingBullets: true });
check('「- 」trim 后只剩符号 → 当成普通行返回',
r6 && r6.text === '-' && r6.completed === false,
JSON.stringify(r6));
const r7 = parseImportLine('1. ', { stripLeadingBullets: true });
check('「1. 」trim 后只剩符号 → 当成普通行返回',
r7 && r7.text === '1.' && r7.completed === false,
JSON.stringify(r7));
// 没有数字时的点号 → 不被剥离避免「1.5 foo」里的「1.」误剥)
const r = parseImportLine('1.5 是版本号', { stripLeadingBullets: true });
check('「1.5」不被当列表符号(避免误剥小数点)',
r && r.text === '1.5 是版本号',
JSON.stringify(r));
// 中文行内包含英文点
const r2 = parseImportLine('版本 2.0 发布了', { stripLeadingBullets: true });
check('「2.0」版本号行内嵌入 → 不被剥离',
r2 && r2.text === '版本 2.0 发布了',
JSON.stringify(r2));
// 数字+空格+点(异常输入,如 `1 . foo`):因为正则要求 `数字[.)]`,中间有空格就不匹配
const r3 = parseImportLine('1 . foo', { stripLeadingBullets: true });
check('「1 . 」异常格式不剥离',
r3 && r3.text === '1 . foo',
JSON.stringify(r3));
// 带 checkbox 但又混着前后空白
const r4 = parseImportLine(' - [ ] 前后都有空白 ', { stripLeadingBullets: true });
check('带前后空白的 checkbox 行 → 正确解析',
r4 && r4.completed === false && r4.text === '前后都有空白',
JSON.stringify(r4));
// tab 分隔也算空白
const r5 = parseImportLine('\t- foo\t', { stripLeadingBullets: true });
check('带 tab 的列表行 → 正确解析',
r5 && r5.text === 'foo',
JSON.stringify(r5));
}
// ============================================================
// [8] importTasksToCategory —— 批量 emit性能关键
//
// 早期实现里 _appendTask 每次都 emit('change'),批量导入 N 条任务 = N 次 UI 重渲染。
// 修复后走批量路径无论导入多少条change 只触发 1 次。
// ============================================================
console.log('\n[8] 批量导入只触发一次 change 事件(避免 N 次 UI 重渲染)');
{
// 用全局 back-mode settingsnewTaskPosition 等)让 addTask 走末尾追加;
// 导入顺序使用默认 'order'(按入参顺序 + 插到最前面)。对「按出现顺序」的断言
// 同样成立order 模式下 unshift 整批line1 在最前、lineN 在最后 —— 与入参
// 顺序完全一致。注意:历史上这里用 importPosition='back',现已删除该模式,
// 改用默认 'order',行为对「按入参顺序」的断言不变。参见 [12] 验证两种模式。
const backSettings = {
_values: {
newTaskPosition: 'back',
completedPosition: 'back',
trashPosition: 'back'
},
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: backSettings });
const cat = store.getCategory('工作');
// 50 条任务:早期实现会触发 50 次 change批量路径必须只有 1 次
const N = 50;
const lines = [];
for (let i = 0; i < N; i++) lines.push(`任务 ${i + 1}`);
let changeCount = 0;
const onChange = () => { changeCount++; };
store.on('change', onChange);
const result = store.importTasksToCategory('工作', lines);
store.off('change', onChange);
check(`50 条任务全部导入added=${N}`, result.added === N);
check('change 事件只触发 1 次(不是 50 次)', changeCount === 1,
`实际 ${changeCount}`);
// dirty 也应只触发一次_markDirty 内部会 push autoSavedebounce 仍合并)
check('dirty 已被标脏(合并后 = true', store.dirty === true);
// 验证任务确实全部落地
check(`「工作」分类里有 ${N} 条任务`, cat.tasks.length === N);
check('任务按出现顺序排列order 模式 = 入参顺序)',
cat.tasks.map(t => t.text).join('|') === lines.join('|'),
cat.tasks.map(t => t.text).join('|'));
}
// ============================================================
// [9] importTasksToCategory —— 与 addTask 走两条路径不冲突
// ============================================================
console.log('\n[9] 批量路径与 addTask 路径各自 emit 正确次数');
{
const store = makeStore();
const cat = store.getCategory('工作');
// 1) addTask 单条1 次 emit
let changeCount = 0;
const onChange1 = () => { changeCount++; };
store.on('change', onChange1);
store.addTask('工作', '单条');
store.off('change', onChange1);
check('addTask 单条 → change 触发 1 次', changeCount === 1,
`实际 ${changeCount}`);
// 2) 批量导入1 次 emit
changeCount = 0;
const onChange2 = () => { changeCount++; };
store.on('change', onChange2);
store.importTasksToCategory('工作', ['批 1', '批 2', '批 3']);
store.off('change', onChange2);
check('importTasksToCategory 批量 3 条 → change 触发 1 次', changeCount === 1,
`实际 ${changeCount}`);
// 3) 0 条有效行的批量0 次 emit不是 1 次 —— 0 条不应该假装操作发生了)
changeCount = 0;
const onChange3 = () => { changeCount++; };
store.on('change', onChange3);
store.importTasksToCategory('工作', ['', ' ', '']);
store.off('change', onChange3);
check('0 条有效行 → change 触发 0 次', changeCount === 0,
`实际 ${changeCount}`);
}
// ============================================================
// [10] _createTask —— 单条 ID 生成器
//
// 抽出后给 _appendTask 和 importTasksToCategory 共用。
// 自检确保批量路径里的任务也带合法 ID。
// ============================================================
console.log('\n[10] _createTask ID 生成');
{
const store = makeStore();
const task = store._createTask('测试', false);
check('id 是字符串且非空', typeof task.id === 'string' && task.id.length > 0,
`实际 ${JSON.stringify(task)}`);
check('字段默认值正确',
task.text === '测试' && task.completed === false &&
task.important === false && task.current === false);
// completed=true 也要正常赋值
const task2 = store._createTask('已完成', true);
check('completed=true 被保留', task2.completed === true);
}
// ============================================================
// [11] 数据保真度inline 标记 [⭐] / [▶] 必须翻译成 important / current
//
// 早期 parseImportLine 只返回 { text, completed } —— 文本里的 [⭐] / [▶]
// 会被原样写进任务 text但 important / current 字段仍是 false。
// 后果UI 上看起来文字里「写着 [⭐] 重要」,但实际不算重要;下次保存后
// parseMarkdown 又把 [⭐] 抽走 —— 用户数据在「导入 → 保存 → 重载」之间
// 被悄悄改写。
//
// 修复parseImportLine 与 parseMarkdown 共用 extractInlineMarkers
// 保证两套入口行为完全一致。
// ============================================================
console.log('\n[11] 数据保真度:[⭐]/[▶] 标记翻译成 important/current');
{
// 11.1 复现原 bugcheckbox + inline important
// 直接用 store.importTasksToCategory 验证端到端
const store = makeStore();
const r = store.importTasksToCategory('工作', [
'- [ ] [⭐] 重要任务',
'- [ ] [▶] 当前任务',
'- [✓] [⭐] 已完成且重要',
'- [ ] [▶] [⭐] 两个都在行首(合法)',
'- [ ] 对比 [⭐] 和 [ ] 行内不应被剥', // 行内 [⭐] —— 故意不应被识别
'- [ ] 任务 [⭐]', // 末尾 [⭐](前有空格)也应被识别
'- [ ] 任务 [▶]',
'[⭐] 重要(无 checkbox',
'⭐ 裸星(前后空格)',
'▶ 裸三角'
], { stripLeadingBullets: true });
check('10 条全部接受(无 skipped', r.added === 10 && r.skipped === 0,
`added=${r.added}, skipped=${r.skipped}`);
// v3.4 数据归属不变量:- [✓] 已完成且重要 会立即被搬到「# 已完成任务」分类,
// 所以「工作」里只会有 9 条 pending 任务,已完成分类里有 1 条。
// 用 find() 按 text 查,避免依赖数组顺序(顺序会随 v3.4 重排)。
const tasks = store.getCategory('工作').tasks;
const completed = store.getCompletedCategory()?.tasks || [];
// 注意:[⭐] / [▶] 必须出现在行首(紧贴开头)或行尾(前有空格)才会被识别。
// 行内位置「对比 [⭐] 和 [ ]」中间的 [⭐] 故意不被识别 —— 这是设计:
// parser 用位置锚避免误中「用户在文本里写了 [⭐] 字面字符」的情况。
// 测试要反映这个事实,而不是测「应该识别中间位置」—— 那种实现会低门槛地损坏数据。
check('[⭐] 开头text="重要任务", important=true, current=false',
tasks.find(t => t.text === '重要任务')?.important === true &&
tasks.find(t => t.text === '重要任务')?.current === false);
check('[▶] 开头text="当前任务", important=false, current=true',
tasks.find(t => t.text === '当前任务')?.important === false &&
tasks.find(t => t.text === '当前任务')?.current === true);
// v3.4[✓] 任务搬到「# 已完成任务」,并在「工作」里看不到
check('[✓]+[⭐] 在已完成分类里 completed=true 且 important=true',
completed.find(t => t.text === '已完成且重要')?.completed === true &&
completed.find(t => t.text === '已完成且重要')?.important === true,
JSON.stringify(completed));
check('[✓]+[⭐] 不在「工作」分类里v3.4 不变量)',
!tasks.some(t => t.text === '已完成且重要'));
check('[✓]+[⭐] 带 originalCategory=工作(便于取消勾选归位)',
completed.find(t => t.text === '已完成且重要')?.originalCategory === '工作');
check('两个标记都紧贴行首 [▶] [⭐]important=true 且 current=true',
tasks.find(t => t.text === '两个都在行首(合法)')?.important === true &&
tasks.find(t => t.text === '两个都在行首(合法)')?.current === true);
// 关键:「对比 [⭐] 和 [ ]」里 [⭐] 出现在中间位置 —— parser 故意不识别
// 这是设计意图(避免误剥用户文本里的字面 [⭐] 字符),不是 bug。
check('行内 [⭐] 不被识别(保护用户文本里的字面字符)',
tasks.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥')?.important === false &&
tasks.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥')?.text === '对比 [⭐] 和 [ ] 行内不应被剥');
check('末尾 [⭐](前有空格):剥掉且 important=true',
tasks.find(t => t.text === '任务' && t.important === true) !== undefined);
check('末尾 [▶]:剥掉且 current=true',
tasks.find(t => t.text === '任务' && t.current === true) !== undefined);
check('[⭐] 开头(无 checkboximportant=true',
tasks.find(t => t.text === '重要(无 checkbox')?.important === true);
// 裸 ⭐ / ▶ 因为前后有空格extractInlineMarkers 会识别 —— 这是设计:
// 写文件时强制用 [⭐] / [▶] 这种方括号标记,裸字符仅用于向后兼容旧文件
check('裸 ⭐(前后空格)被识别为 important',
tasks.find(t => t.text === '裸星(前后空格)')?.important === true);
check('裸 ▶(前后空格)被识别为 current',
tasks.find(t => t.text === '裸三角')?.current === true);
check('「工作」分类只有 9 条未完成任务v3.4 后)',
tasks.length === 9, `实际 ${tasks.length}`);
check('「# 已完成任务」分类有 1 条已完成任务',
completed.length === 1, `实际 ${completed.length}`);
// 11.2 往返:序列化后再 parseMarkdown 解析,标记不应被「重复剥」或漏剥
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedCompleted = reloaded.find(c => c.kind === CategoryKind.COMPLETED);
check('重载后「工作」任务数 = 9[✓] 已在已完成分类)',
reloadedWork?.tasks?.length === 9,
`实际 ${reloadedWork?.tasks?.length}`);
check('重载后「# 已完成任务」任务数 = 1',
reloadedCompleted?.tasks?.length === 1,
`实际 ${reloadedCompleted?.tasks?.length}`);
check('重载后 [⭐] 重要任务仍标 important',
reloadedWork?.tasks?.find(t => t.text === '重要任务')?.important === true);
check('重载后 [▶] 当前任务仍标 current',
reloadedWork?.tasks?.find(t => t.text === '当前任务')?.current === true);
check('重载后 两个标记都在行首的任务仍标 important+current',
reloadedWork?.tasks?.find(t => t.text === '两个都在行首(合法)' &&
t.important === true && t.current === true) !== undefined);
// 「对比 [⭐] 和 [ ]」是行内位置,重载后 text 应保持原样([⭐] 不能被剥)
check('重载后行内 [⭐] 不会被破坏',
reloadedWork?.tasks?.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥') !== undefined);
}
// ============================================================
// [12] 数据保真度:纯 checkbox 行(无文本)不再被悄悄丢弃
//
// 早期实现:`- [x]` → null → skipped++
// 后果:用户手写的纯勾选行导入时莫名消失。
//
// 修复:与 parseMarkdown 行为对齐 —— 文本允许为空(空字符串 "")。
// 实际意义不大UI 上一条空文本任务看起来奇怪但保证「load → import」对称。
// ============================================================
console.log('\n[12] 数据保真度:纯 checkbox 行不被丢弃');
{
const store = makeStore();
// 混合输入:纯勾选行 + 正常任务
const r = store.importTasksToCategory('工作', [
'- [ ]',
'- [x]',
'- [✓]',
'- [ ] 正常任务'
], { stripLeadingBullets: true });
check('4 行全部接受(无 skipped', r.added === 4 && r.skipped === 0,
`added=${r.added}, skipped=${r.skipped}`);
// v3.4 数据归属不变量completed=true 的任务被搬到「# 已完成任务」分类,
// 「工作」里只剩 2 条 pending 任务(- [ ] 空任务 + - [ ] 正常任务)。
const tasks = store.getCategory('工作').tasks;
const completed = store.getCompletedCategory()?.tasks || [];
check('- [ ] 留在「工作」分类里 completed=false',
tasks.find(t => t.text === '' && t.completed === false) !== undefined);
check('正常任务在「工作」分类里',
tasks.find(t => t.text === '正常任务' && t.completed === false) !== undefined);
check('「工作」分类只有 2 条未完成任务v3.4 后)',
tasks.length === 2, `实际 ${tasks.length}`);
check('- [x] 搬到「# 已完成任务」 completed=true',
completed.find(t => t.completed === true) !== undefined);
check('- [✓] 搬到「# 已完成任务」 completed=true',
completed.filter(t => t.completed === true).length === 2,
`completed 实际 ${completed.length}`);
// 往返serialize → parseMarkdown4 条任务都还在
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedCompleted = reloaded.find(c => c.kind === CategoryKind.COMPLETED);
check('重载后「工作」有 2 条任务',
reloadedWork?.tasks?.length === 2,
`实际 ${reloadedWork?.tasks?.length}`);
check('重载后「# 已完成任务」有 2 条任务',
reloadedCompleted?.tasks?.length === 2,
`实际 ${reloadedCompleted?.tasks?.length}`);
check('重载后已完成任务仍 completed=true',
reloadedCompleted?.tasks?.every(t => t.completed === true));
}
// ============================================================
// [13] 数据保真度:导入对既有任务零干扰
//
// 回归测试 —— 之前发现批量路径会用 _appendTask 一次只追加一条,
// 可能污染既有任务的顺序 / ID / 状态。批量路径应只 push不修改其它字段。
//
// 注:本测试早期版本用 importPosition='back' 让既有任务「保持原位」、新导入
// 任务追加到末尾,从而验证既有任务的 id / 字段快照未被修改。importPosition
// 收成两个选项后不再有 'back',所以本测试改用「默认 order」语义导入任务按
// 入参顺序插到最前面,既有任务被自然推到列表下方。重点验证的「既有任务 id 与
// 字段不被改」这条不变量与排序模式无关 —— 无论用 order / front导入路径都
// 不能触碰既有任务对象的字段。
// ============================================================
console.log('\n[13] 数据保真度:批量导入对既有任务零干扰');
{
// 默认 settingsStore不注入importPosition 走 'order' 默认值。
// 同时显式把 *Position 系列全部设为 'front',避免测试受其它全局设置影响。
const settings = {
_values: {
newTaskPosition: 'front',
completedPosition: 'front',
uncompletePosition: 'front',
trashPosition: 'front',
restorePosition: 'front'
// importPosition 不设 → store 内部兜底为 'order'
},
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
// 既有任务:混合状态。
// 注意 addTask 走 newTaskPosition='front'unshift所以调用顺序「existing 1 → existing 2」
// 之后cat.tasks 里顺序是 [existing 2, existing 1]existing 2 插到最前)。
// 用「在 add 完之后按 add 顺序定位」的方式拿引用,避免依赖 cat.tasks 下标。
store.addTask('工作', 'existing 1');
const ex1 = cat.tasks.find(t => t.text === 'existing 1');
store.addTask('工作', 'existing 2');
const ex2 = cat.tasks.find(t => t.text === 'existing 2');
// 标记 ex2 为重要 + 完成 + 当前 —— 这是要保真的「既有任务 2」
ex2.important = true;
ex2.completed = true;
ex2.current = true;
const ex1Id = ex1.id;
const ex2Id = ex2.id;
const ex2Snapshot = JSON.stringify({
text: ex2.text, completed: ex2.completed, important: ex2.important, current: ex2.current
});
// 批量导入
store.importTasksToCategory('工作', [
'- [ ] new A',
'- [✓] [⭐] new B',
'new C'
]);
// 既有任务的 ID 必须不变order 模式下新导入任务插到最前,既有任务被推到下面)
// 先记录被推到列表下方后,既有任务的当前下标。
const ex1Idx = cat.tasks.findIndex(t => t.id === ex1Id);
const ex2Idx = cat.tasks.findIndex(t => t.id === ex2Id);
check('既有任务 1 的 id 仍在分类里', ex1Idx >= 0);
check('既有任务 2 的 id 仍在分类里', ex2Idx >= 0);
// 既有任务的状态字段快照必须完全一致(防止批量路径意外修改)
const ex2After = cat.tasks.find(t => t.id === ex2Id);
const ex2AfterSnapshot = JSON.stringify({
text: ex2After.text, completed: ex2After.completed,
important: ex2After.important, current: ex2After.current
});
check('既有任务 2 的所有字段未变', ex2AfterSnapshot === ex2Snapshot,
`before=${ex2Snapshot}, after=${ex2AfterSnapshot}`);
// 既有任务的 id 在导入前后必须完全一致 —— 这是「批量路径不修改既有任务对象」
// 的核心不变量,与 importPosition 是 order 还是 front 无关。
check('既有任务 1 的 id 未变', cat.tasks[ex1Idx].id === ex1Id);
check('既有任务 2 的 id 未变', cat.tasks[ex2Idx].id === ex2Id);
// 既有任务 2 的 text 也未变
check('既有任务 2 的 text 未变', cat.tasks[ex2Idx].text === 'existing 2');
// v3.4[✓] 任务被搬到「# 已完成任务」,所以「工作」里剩 4 条existing 1/2 + new A + new C
// 用 find() 按 text 查,不依赖下标(顺序由 importPosition 决定,这里不固定)。
check('「工作」分类追加新任务,共 4 条', cat.tasks.length === 4,
`实际 ${cat.tasks.length}`);
check('new A 按入参顺序order排在最前',
cat.tasks[0].text === 'new A',
JSON.stringify(cat.tasks.map(t => t.text)));
check('new C 按入参顺序排在 new A 之后',
cat.tasks[1].text === 'new C',
JSON.stringify(cat.tasks.map(t => t.text)));
check('既有任务 1 在 new A / new C 之后',
cat.tasks.find(t => t.text === 'existing 1') !== undefined);
check('既有任务 2 在 new A / new C 之后',
cat.tasks.find(t => t.text === 'existing 2') !== undefined);
check('new B 不在「工作」分类里v3.4 不变量)',
!cat.tasks.some(t => t.text === 'new B'));
// 新任务的 ID 与既有任务不冲突
const allIds = cat.tasks.map(t => t.id);
check('所有任务 ID 唯一', new Set(allIds).size === allIds.length);
// v3.4new B 进了「# 已完成任务」分类 —— 验证 inline 标记正确落地
const completed = store.getCompletedCategory()?.tasks || [];
const newB = completed.find(t => t.text === 'new B');
check('new B 在「# 已完成任务」里 completed=true 且 important=true',
newB?.completed === true && newB?.important === true,
JSON.stringify(newB));
check('new B 带 originalCategory=工作',
newB?.originalCategory === '工作');
}
// ============================================================
// [12] importPosition 两种模式order / front
//
// 早期实现直接复用 newTaskPosition = 'front'(默认),导致:
// - 旧版:「按入参顺序粘贴 3 行」 → 最后一行跑到列表最前面,与用户输入顺序相反。
// 现拆出独立的 importPositionsettingsStore.importPosition两种合法值
// - 'order'(默认):按入参顺序插到目标位置的最前面 —— 第一行排到最上。
// - 'front' :入参倒序插到目标位置的最前面 —— 最后一行排到最上(与 newTaskPosition='front' 同款)。
//
// 历史上曾有第三种 'back' 模式(按入参顺序追加到末尾),但与全局 newTaskPosition='back'
// 行为完全重叠 ——「追加到末尾」属于位置语义,归全局设置管;导入对话框只管「顺序」。
// 收成两个选项后这条测试用例被移除。
//
// 调用方还可以通过 options.importPosition 直接覆盖 settingsStore 的值,本测试
// 也验证这条覆盖路径settingsStore 没字段 / 字段非法时仍能拿到合法默认值)。
// ============================================================
console.log('\n[12] importPosition 两种模式(与 newTaskPosition 解耦)');
{
const baseSettings = {
_values: { newTaskPosition: 'front', completedPosition: 'front' },
get(k) { return this._values[k]; }
};
// 12.1 order 模式(默认)—— 不依赖 settingsStore 注入也走默认值
{
// 不注入 settingsStore验证 importPosition 默认 'order'(按入参顺序 + 插到最前面)
const store = makeStore();
const cat = store.getCategory('工作');
// 先放一条现有任务作为「锚点」—— 验证新导入任务真的插到了前面,而不是追加到末尾
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
const lines = ['第一行', '第二行', '第三行'];
store.importTasksToCategory('工作', lines);
const texts = cat.tasks.map(t => t.text);
check('order 模式:现有任务保留在最后',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
check('order 模式:导入任务按入参顺序插到最前面',
texts.slice(0, 3).join('|') === '第一行|第二行|第三行',
JSON.stringify(texts));
}
// 12.2 front 模式 —— 入参倒序,最后一行在最上
{
const settings = { _values: { ...baseSettings._values, importPosition: 'front' }, get(k) { return this._values[k]; } };
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
store.importTasksToCategory('工作', ['第一行', '第二行', '第三行']);
const texts = cat.tasks.map(t => t.text);
check('front 模式:现有任务保留在最后',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
check('front 模式:导入任务倒序插到最前面(最后一行在最上)',
texts.slice(0, 3).join('|') === '第三行|第二行|第一行',
JSON.stringify(texts));
}
// 12.3 解耦验证 —— 即便 newTaskPosition='back'importPosition 默认 'order'
// 也能确保导入任务插到前面。这是用户报告的「后面的内容排在前面」bug 的核心修复点。
{
const settings = {
// 模拟用户主设置里把「新增任务」设为「后面」(最下追加),
// 但导入对话框希望默认走 order —— 验证二者真的解耦。
_values: { newTaskPosition: 'back', completedPosition: 'back' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
store.importTasksToCategory('工作', ['A', 'B', 'C']);
const texts = cat.tasks.map(t => t.text);
check('与 newTaskPosition 解耦newTaskPosition=back 不影响导入顺序',
texts.slice(0, 3).join('|') === 'A|B|C',
JSON.stringify(texts));
check('与 newTaskPosition 解耦现有任务仍被推到末尾importPosition=order',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
}
// 12.5 options.importPosition 覆盖 settingsStore —— 导入对话框的选择优先级最高
{
const settings = {
_values: { importPosition: 'front' }, // settingsStore 说用 front
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
// 但 options.importPosition='order' 覆盖 → 应该按入参顺序插到最前面
store.importTasksToCategory('工作', ['A', 'B', 'C'], { importPosition: 'order' });
const texts = cat.tasks.map(t => t.text);
check('options.importPosition 覆盖 settingsStoreorder 优先于 front',
texts.join('|') === 'A|B|C',
JSON.stringify(texts));
}
// 12.6 非法值兜底 —— settingsStore 给了非法值时回退到 'order'
{
const settings = {
_values: { importPosition: 'bogus-value' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
store.importTasksToCategory('工作', ['A', 'B']);
const texts = cat.tasks.map(t => t.text);
check('非法 importPosition 值 → 兜底为 order按入参顺序 + 插到最前)',
texts.join('|') === 'A|B',
JSON.stringify(texts));
}
// 12.7 已完成任务也走同一个 importPosition —— pending / completed 行为对称
{
const settings = {
_values: { importPosition: 'order' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
// 先用一次空批导入触发「# 已完成任务」分类的创建_getOrCreateCompletedCategory
// 是惰性的,不导入 completed 任务时不创建),再把锚点放进去。
store.importTasksToCategory('工作', ['- [✓] placeholder']);
const completedCat = store.getCompletedCategory();
completedCat.tasks.push({ id: 'existing-c', text: '已有完成', completed: true, important: false, current: false });
// 混合导入:第 1 条 pending第 2 条 completed
store.importTasksToCategory('工作', ['pending-X', '- [✓] completed-Y']);
const workTasks = store.getCategory('工作').tasks.map(t => t.text);
const completedTasks = store.getCompletedCategory().tasks.map(t => t.text);
check('order 模式pending 按入参顺序插到工作分类前面',
workTasks[0] === 'pending-X',
JSON.stringify(workTasks));
check('order 模式completed 也按入参顺序插到前面(与 pending 对称)',
completedTasks[0] === 'completed-Y' &&
completedTasks[completedTasks.length - 1] === '已有完成',
JSON.stringify(completedTasks));
}
}
// ============================================================
// 总结
// ============================================================
console.log(`\n==========\n通过 ${summary.pass} / 失败 ${summary.fail}\n==========`);
process.exit(summary.fail === 0 ? 0 : 1);