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

267 lines
12 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.
// 回归测试Markdown 解析器对内联标记的处理
//
// 关键场景:
// - [⭐] / [▶] 后直接接文字(无空格)必须被识别
// - 裸 ⭐ / 裸 ▶ 必须前后有空白避免吃掉行内字符如「⭐buy」不应被识别
// - 同一行 [⭐] 与 [▶] 同时出现要分别正确归位
// - 行尾的标记要被识别(位置不限)
// - 旧版标记 [▶] / ▶ / @当前 都要被识别
//
// 跑法node scripts/check-markers.mjs
import { parseMarkdown } from '../src/markdown-parser.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { writeMarkdown } from '../src/markdown-writer.js';
// 取出第一个被解析出来的任务
function firstTask(md) {
return parseMarkdown(md).flatMap(c => c.tasks)[0] || null;
}
console.log('\n[1] 方括号标记 [⭐] / [▶] 紧贴文字必须被识别');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐]buy milk');
check('[⭐]buy milk 标记为重要且文字干净', t1 && t1.important === true && t1.text === 'buy milk', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] [▶]follow up');
check('[▶]follow up 标记为当前且文字干净', t2 && t2.current === true && t2.text === 'follow up', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] [▶][⭐]task');
check('[▶][⭐]task 两个标记同时识别', t3 && t3.important === true && t3.current === true && t3.text === 'task', JSON.stringify(t3));
}
console.log('\n[2] 裸 ⭐ / 裸 ▶ / @当前 必须前后有空白');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] ⭐buy');
check('裸 ⭐buy 被拒绝(标记泄漏保留)', t1 && t1.important === false && t1.text === '⭐buy', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ▶task');
check('裸 ▶task 被拒绝', t2 && t2.current === false && t2.text === '▶task', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前task');
check('@当前task 被拒绝(多字符中文)', t3 && t3.current === false && t3.text === '@当前task', JSON.stringify(t3));
}
console.log('\n[3] 正常带空白的标记依然可用');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐] task');
check('[⭐] task 重要', t1 && t1.important === true && t1.text === 'task', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ⭐ task');
check('裸 ⭐ task 重要', t2 && t2.important === true && t2.text === 'task', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前 task');
check('@当前 task 当前', t3 && t3.current === true && t3.text === 'task', JSON.stringify(t3));
const t4 = firstTask('# X\n\n## A\n\n- [ ] task [⭐]');
check('task [⭐] 行尾标记重要', t4 && t4.important === true && t4.text === 'task', JSON.stringify(t4));
}
console.log('\n[4] 往返一致性:二次写出的 Markdown 等价');
{
const input = `# 全部任务
## 工作
- [ ] [⭐]紧贴任务
- [ ] [▶]紧贴任务
- [ ] [⭐] 带空格
- [ ] [▶] 带空格
- [ ] [▶][⭐]两个都有
- [ ] 普通任务
`;
const cats1 = parseMarkdown(input);
const md1 = writeMarkdown(cats1);
const cats2 = parseMarkdown(md1);
const md2 = writeMarkdown(cats2);
check('二次往返 markdown 完全一致', md1 === md2, `md1 != md2`);
check('二次往返任务数一致',
cats1.flatMap(c => c.tasks).length === cats2.flatMap(c => c.tasks).length);
// 重要/当前 状态也要在两次往返后保持
const tasks1 = cats1.flatMap(c => c.tasks);
const tasks2 = cats2.flatMap(c => c.tasks);
const states1 = tasks1.map(t => `${t.text}|${t.important ? 1 : 0}${t.current ? 1 : 0}`).sort();
const states2 = tasks2.map(t => `${t.text}|${t.important ? 1 : 0}${t.current ? 1 : 0}`).sort();
check('任务状态 (text,important,current) 完全一致', JSON.stringify(states1) === JSON.stringify(states2),
`\n states1=${JSON.stringify(states1)}\n states2=${JSON.stringify(states2)}`);
}
console.log('\n[5] 旧版标记仍被识别(向后兼容)');
{
// 这些标记在升级前的文件里可能出现;升级后仍要能加载
const t1 = firstTask('# X\n\n## A\n\n- [ ] ⭐ task');
check('裸 ⭐ 旧版仍识别', t1 && t1.important === true, JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ▶ task');
check('裸 ▶ 旧版仍识别', t2 && t2.current === true, JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前 task');
check('@当前 旧版仍识别', t3 && t3.current === true, JSON.stringify(t3));
}
console.log('\n[7] 文本中的特殊字符不被解析器误吃');
{
// TASK_LINE_RE 只锚 checkbox `[xxx]`,文本里的 `[1]` / `[abc]` 都是字面字符,
// 不会与 inline 标记 `[⭐]` / `[▶]` / `[原分类xxx]` 混淆:
// - `[⭐]` 等用的是 ✅ U+2B50 / ▶ U+25B6方括号 + 字面 unicode 字符
// - 文本里的 `[1]` 是 ASCII 数字 `1`regex `^\[([^\]]+)\]` 第一次匹配整行 checkbox
// 文本部分走 group(2),不会再触发 marker 抽取
// 验证「文本里含 ]」与「文本里含中文括号」都不会触发解析误判
const t1 = firstTask('# X\n\n## A\n\n- [ ] 任务 [1] 内容');
check('文本中的 [1] 保留为字面文本',
t1 && t1.text === '任务 [1] 内容' && !t1.important && !t1.current && !t1.originalCategory,
JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] 任务(中文括号)');
check('文本中的中文括号保留为字面文本',
t2 && t2.text === '任务(中文括号)' && !t2.important && !t2.current,
JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] a]b]c');
check('文本中含嵌套 ] 保留为字面文本(不会吃掉第二个]',
t3 && t3.text === 'a]b]c',
JSON.stringify(t3));
}
console.log('\n[8] [原分类xxx] 内含 ] 字符的边界(非贪婪匹配首个 ] 即终止)');
{
// `[原分类a]b]` 应解析为 originalCategory=`a`,文本保留 `b]`
// —— 因为 regex 是 `[^\]]+?` 非贪婪,首个 ] 即关闭标记。
// 同样适用于 [原分类xxx] 内含 → 等特殊字符
const md = `# 全部任务
## 工作
- [ ] 任务
- [✓] [原分类a]b] 任务
# 已完成任务
- [✓] [原分类:含→符号] 任务
`;
const cats = parseMarkdown(md);
const workCat = cats.find(c => c.name === '工作');
const workTasks = workCat?.tasks || [];
// 工作分类下应该有一条「[原分类a]b] 任务」→ originalCategory=`a`, text=`b] 任务`
const moved = workTasks.find(t => t.text.startsWith('b]'));
check('[原分类a]b] 在「工作」分类下解析为 originalCategory=a / text="b] 任务"',
moved && moved.originalCategory === 'a' && moved.text === 'b] 任务',
JSON.stringify(moved));
const completedCat = cats.find(c => c.kind === 'completed');
const completedTasks = completedCat?.tasks || [];
const sym = completedTasks.find(t => t.text === '任务');
check('[原分类:含→符号] 在「已完成任务」分类下解析为 originalCategory="含→符号"',
sym && sym.originalCategory === '含→符号' && sym.text === '任务',
JSON.stringify(sym));
}
console.log('\n[9] 空文本任务合法(手写 `- [ ]` / `- [✓]`');
{
// parser 必须接受空文本,否则会被 _ensureBaseStructure 当成纯文本忽略、丢失任务数据
const t1 = firstTask('# X\n\n## A\n\n- [ ]');
check('空文本未完成 task.text=""',
t1 && t1.text === '' && t1.completed === false,
JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [✓]');
check('空文本已完成 task.text="" / completed=true',
t2 && t2.text === '' && t2.completed === true,
JSON.stringify(t2));
// 纯空白文本 → trim 后为空文本,与空文本等价
const t3 = firstTask('# X\n\n## A\n\n- [ ] ');
check('纯空白文本 trim 后变空字符串',
t3 && t3.text === '',
JSON.stringify(t3));
}
console.log('\n[6] 行尾 <!-- id --> 注释被剥掉v3+ 写入器不再输出,仅用于旧文件迁移)');
{
// 锚定行尾之前的 bug正则 `/<!--\s*id:([^\s>]+)\s*-->/` 没有 $ 锚,
// 会从文本中间抠出第一个匹配,把用户的字面字符串删掉。例如:
// `- [ ] 在 markdown 里可以这样写注释:<!-- id:t1 -->示例`
// 旧实现会把 `<!-- id:t1 -->` 部分静默剥掉,留下「示例」。
// 锚定行尾后,只剥写时器挂在末尾的注释。
//
// v3+ 写入器不再写 id 注释,解析器也不再从文件中提取 id 作为任务身份
// task.id 改由末尾的统一计数器分配)。但行尾 id 注释的剥离仍要保留,
// 否则旧文件中的 `<!-- id:t99 -->` 会作为字面字符出现在 UI 文本里。
const t1 = firstTask('# X\n\n## A\n\n- [ ] 写注释示例 <!-- id:t1 -->中间内容');
check('文本中间的 <!-- id:xxx --> 保留为字面文本',
t1 && t1.text === '写注释示例 <!-- id:t1 -->中间内容',
JSON.stringify(t1));
// 行尾的 id 注释被剥掉id 由解析器统一分配(不再是文件里的值)
const t2 = firstTask('# X\n\n## A\n\n- [ ] 普通任务 <!-- id:t99 -->');
check('行尾的 id 注释被剥掉文本干净task.id 由解析器分配',
t2 && t2.text === '普通任务' && typeof t2.id === 'string' && t2.id !== 't99',
JSON.stringify(t2));
// 文本里同时含行中和行尾两个,剥尾留中(行中的也作为文本)
const t3 = firstTask('# X\n\n## A\n\n- [ ] 行中<!-- id:t1 -->也有<!-- id:t99 -->');
// 行尾锚 $ 匹配 <!-- id:t99 -->;行中的 <!-- id:t1 --> 保留
check('行中 + 行尾两个 id 注释:剥尾留中',
t3 && typeof t3.id === 'string' && t3.text === '行中<!-- id:t1 -->也有',
JSON.stringify(t3));
}
// ============================================================
// [N+1] 标记剥除后必须保留任务文本里的内部连续空格H8 回归)
// ============================================================
//
// 旧实现 extractInlineMarkers 末尾用 `text.replace(/\s+/g, ' ').trim()` 做"全局空白折叠"
// 触发条件是任务带了 `[⭐]` / `[▶]` / `[原分类xxx]` 任一标记 ——
// 把用户原本写的 `前后 多空 格`(保留 N 个空格)压成 `前后 多空 格`(每个间隔 1 个空格)。
// 数据损坏静默:保存时 text 已经丢空格,下次读回来还是丢。
//
// 修复:去掉全局折叠,只 trim 两端。
console.log('\n[N+1] 标记剥除后保留任务文本里的内部连续空格');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐]前后 多空 格');
check('[⭐] + 内部 2 空格保留',
t1 && t1.important === true && t1.text === '前后 多空 格',
JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] [▶] 任务 多空 文本 [⭐]');
check('[▶] head + [⭐] tail + 内部 2 空格保留',
t2 && t2.important === true && t2.current === true && t2.text === '任务 多空 文本',
JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] [原分类:工作] A B C');
check('[原分类xxx] + 内部 2 空格保留',
t3 && t3.originalCategory === '工作' && t3.text === 'A B C',
JSON.stringify(t3));
const t4 = firstTask('# X\n\n## A\n\n- [ ] foo bar baz [⭐]');
check('tail [⭐] + 内部 2 空格保留',
t4 && t4.important === true && t4.text === 'foo bar baz',
JSON.stringify(t4));
}
// ============================================================
// [N] writeMarkdown 在异常输入下必须抛错而不是静默吞掉
// ============================================================
{
console.log('\n[writer] 空 categories 必须抛错(防止静默清空磁盘文件)');
let threwEmpty = false;
try {
writeMarkdown([]);
} catch (e) {
threwEmpty = true;
}
check('空数组 throw不是返回空字符串', threwEmpty);
console.log('\n[writer] 非数组输入必须 throw');
let threwNonArray = false;
try {
writeMarkdown(null);
} catch (e) {
threwNonArray = true;
}
check('null throw', threwNonArray);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);