872 lines
43 KiB
JavaScript
872 lines
43 KiB
JavaScript
// 一次性验证脚本:内容导入功能(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 文本部分允许为空吗?早期实现返回 null(empty 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('「# 已完成任务」收到 [✓] 任务 B(v3.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 settings(newTaskPosition 等)让 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 autoSave,debounce 仍合并)
|
||
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 复现原 bug:checkbox + 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('[⭐] 开头(无 checkbox):important=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 → parseMarkdown,4 条任务都还在
|
||
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.4:new 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 行」 → 最后一行跑到列表最前面,与用户输入顺序相反。
|
||
// 现拆出独立的 importPosition(settingsStore.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 覆盖 settingsStore:order 优先于 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);
|