// 全面 round-trip 一致性:UI 显示 ↔ markdown 完全对应 // // 用户要求:软件里看到的每条任务、分类、状态、顺序,都必须能在 markdown 文件里找到, // 反过来 markdown 里的每条任务、分类、状态、顺序也都必须能在 UI 里看到。 // // 本测试覆盖: // 1. 文本逐字符一致(含中文、emoji、Markdown 强调、代码、链接、特殊空白) // 2. 状态(⭐/▶/[✓])映射一致 // 3. 顺序一致 // 4. 分类名称一致(含空格、CJK、emoji) // 5. parser → writer → parser 幂等 // 6. writer → parser 不会丢任务 // 7. 空文本任务、纯空白任务、特殊字符任务保留 import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; import { writeMarkdown, createDefaultDoc } from '../src/markdown-writer.js'; import { check, summary, printSummary } from './_lib/check.mjs'; const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); console.log('\n[1] 文本逐字符一致:所有字符(包括 Markdown 强调、链接、emoji)原样保留'); { const cases = [ // 普通中文 '买菜', // emoji '🐛 fix login bug', '🎉 发布 v3', // Markdown 强调(不应被解析为语法,只作为普通文本) '**加粗**', '*斜体*', '`代码片段`', '~~删除线~~', // Markdown 链接 '看 [文档](https://example.com)', // 多种引号 '"英文双引号"', "'英文单引号'", '「中文双引号」', '『中文嵌套引号』', // 反引号、管道符 '`code`', 'a | b | c', // 数字、英文混合 'RTFM 101', // 多空格:解析器只 trim 首尾,不主动合并中间空格(除非遇到 ⭐/▶ 走 .replace(/\s+/g, ' ')) '前后 多空 格', // 中英混合 '完成 Q3 项目设计文档', // 反斜杠 '路径 C:\\Users\\test', // URL 'https://github.com/foo/bar?x=1&y=2#hash', // 数学符号 '计算 a² + b² = c²', // 特殊 CJK '한글 테스트', '日本語テスト', ]; for (const text of cases) { const md = `# 全部任务\n\n## 工作\n\n- [ ] ${text}\n`; const cats = parseMarkdown(md); const got = cats.find(c => c.name === '工作').tasks[0].text; // 解析器只 trim 首尾空白;只有 ⭐/▶ 标识走过的代码路径才会 .replace(/\s+/g, ' ') const expected = text.replace(/^\s+|\s+$/g, ''); check(`文本保留:${text.slice(0, 30)}${text.length > 30 ? '...' : ''}`, got === expected, `got=${JSON.stringify(got)} expected=${JSON.stringify(expected)}`); } } console.log('\n[2] 状态标识映射:UI 看到的 ↔ markdown 写的'); { const cases = [ { md: '- [ ] 普通任务', expected: { completed: false, important: false, current: false } }, { md: '- [✓] 已完成', expected: { completed: true, important: false, current: false } }, { md: '- [ ] [⭐] 重要', expected: { completed: false, important: true, current: false } }, { md: '- [ ] [▶] 当前', expected: { completed: false, important: false, current: true } }, { md: '- [ ] [▶] [⭐] 重要且当前', expected: { completed: false, important: true, current: true } }, { md: '- [✓] [▶] [⭐] 三状态', expected: { completed: true, important: true, current: true } }, // 兼容旧标记 { md: '- [ ] ⭐ 旧版重要', expected: { completed: false, important: true, current: false } }, { md: '- [ ] ▶ 旧版当前', expected: { completed: false, important: false, current: true } }, { md: '- [ ] @当前 旧版当前', expected: { completed: false, important: false, current: true } }, // [x] / [X] 也算完成 { md: '- [x] x 完成', expected: { completed: true, important: false, current: false } }, { md: '- [X] X 完成', expected: { completed: true, important: false, current: false } }, ]; for (const { md, expected } of cases) { const cats = parseMarkdown(`# 全部任务\n\n## 工作\n\n${md}\n`); const task = cats.find(c => c.name === '工作').tasks[0]; const got = { completed: task.completed, important: task.important, current: task.current }; check(`状态:${md}`, eq(got, expected), `got=${JSON.stringify(got)}`); } } console.log('\n[3] 顺序完全保留(无 sort)'); { // 用一些字母接近的文本,确认不会按字母排 const md = `# 全部任务\n\n## 工作\n\n- [ ] zebra\n- [ ] apple\n- [ ] mango\n- [ ] banana\n- [ ] cherry\n`; const cats = parseMarkdown(md); const texts = cats.find(c => c.name === '工作').tasks.map(t => t.text); check('文本顺序:zebra, apple, mango, banana, cherry', eq(texts, ['zebra', 'apple', 'mango', 'banana', 'cherry']), texts.join(',')); // 跨分类顺序也保留 const md2 = `# 全部任务\n\n## 工作\n\n- [ ] 工作 2\n- [ ] 工作 1\n\n## 学习\n\n- [ ] 学习 3\n- [ ] 学习 1\n- [ ] 学习 2\n`; const cats2 = parseMarkdown(md2); check('工作内顺序', eq(cats2.find(c => c.name === '工作').tasks.map(t => t.text), ['工作 2', '工作 1'])); check('学习内顺序', eq(cats2.find(c => c.name === '学习').tasks.map(t => t.text), ['学习 3', '学习 1', '学习 2'])); } console.log('\n[4] 分类名称完全保留(含空格、CJK、emoji、特殊字符)'); { const cases = [ '工作', '工作 学习', ' 带空格 ', // trim 后变「带空格」 '分类-A', '分类 A', '中文 / 子分类', '🐛 bugs', '🎉 发布', '123 数字', ]; for (const name of cases) { const md = `# 全部任务\n\n## ${name}\n\n- [ ] 任务\n`; const cats = parseMarkdown(md); const trimmed = name.replace(/^\s+|\s+$/g, ''); const found = cats.find(c => c.name === trimmed); check(`分类名「${name}」`, found !== undefined, cats.map(c => c.name).join(' | ')); } } console.log('\n[5] parser → writer → parser 幂等:往返不丢任何信息'); { const inputs = [ // 简单 `# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] b\n- [ ] [⭐] c\n- [ ] [▶] d\n`, // 多分类 `# 全部任务\n\n## 工作\n\n- [ ] 工作 1\n- [ ] 工作 2\n\n## 学习\n\n- [ ] 学习 1\n\n## 生活\n\n- [ ] 生活 1\n- [✓] 生活 2\n`, // 含空文本任务 `# 全部任务\n\n## 工作\n\n- [ ]\n- [ ] 有文本\n- [✓]\n`, // 含旧版标记 `# 全部任务\n\n## 工作\n\n- [ ] ⭐ 旧版\n- [ ] @当前 旧版\n`, // 含 emoji `# 全部任务\n\n## 工作\n\n- [ ] 🐛 fix bug\n- [ ] 🎉 release\n`, ]; for (const [i, input] of inputs.entries()) { const cats1 = parseMarkdown(input); const md1 = writeMarkdown(cats1); const cats2 = parseMarkdown(md1); const md2 = writeMarkdown(cats2); // markdown 完全一致(往返幂等) check(`[${i}] markdown 往返一致 (md1 === md2)`, md1 === md2, `md1=${JSON.stringify(md1)} md2=${JSON.stringify(md2)}`); // 任务数一致 const n1 = cats1.reduce((s, c) => s + c.tasks.length, 0); const n2 = cats2.reduce((s, c) => s + c.tasks.length, 0); check(`[${i}] 任务数不变`, n1 === n2, `${n1} → ${n2}`); // 任务 (text, completed, important, current) 元组完全一致 const flat1 = cats1.flatMap(c => c.tasks.map(t => ({t: t.text, c: t.completed, i: t.important, r: t.current}))); const flat2 = cats2.flatMap(c => c.tasks.map(t => ({t: t.text, c: t.completed, i: t.important, r: t.current}))); check(`[${i}] 任务元组一致`, eq(flat1, flat2), `\n flat1=${JSON.stringify(flat1)}\n flat2=${JSON.stringify(flat2)}`); } } console.log('\n[6] writer → parser 不丢任务:写出去再读回来,任务全在'); { // 起点:构造一份较复杂的 categories const initial = parseMarkdown(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] b\n- [ ] [⭐] c\n- [ ] [▶] d\n- [ ] [▶] [⭐] e\n\n## 学习\n\n- [ ] 旧标记 ⭐\n- [ ] 旧标记 @当前\n\n## 未分类\n\n- [ ] 孤儿任务\n`); const written = writeMarkdown(initial); // 写出去再读 const reloaded = parseMarkdown(written); // 验证:所有任务都在 const initialTexts = initial.flatMap(c => c.tasks.map(t => t.text)); const reloadedTexts = reloaded.flatMap(c => c.tasks.map(t => t.text)); check('所有任务文本都回来', initialTexts.every(t => reloadedTexts.includes(t)), `\n lost=${JSON.stringify(initialTexts.filter(t => !reloadedTexts.includes(t)))}`); check('任务数一致', initialTexts.length === reloadedTexts.length); // 验证:所有状态都回来(用 (分类名, 任务索引) 配对,避免同名任务撞) for (const cat of initial) { cat.tasks.forEach((t, i) => { const reloadedCat = reloaded.find(c => c.name === cat.name); const found = reloadedCat?.tasks[i]; check(`状态:${cat.name}[${i}]「${t.text}」`, found && found.text === t.text && found.completed === t.completed && found.important === t.important && found.current === t.current, found && `expected=${JSON.stringify({c:t.completed,i:t.important,r:t.current})} got=${JSON.stringify({c:found.completed,i:found.important,r:found.current})}`); }); } } console.log('\n[7] 空文本任务(合法手写)'); { // 软件允许「纯勾选无文本」的任务 const md = `# 全部任务\n\n## 工作\n\n- [ ]\n- [ ] 有文本\n- [✓]\n`; const cats = parseMarkdown(md); const tasks = cats.find(c => c.name === '工作').tasks; check('3 条任务都在', tasks.length === 3); check('第 1 条空文本', tasks[0].text === '' && tasks[0].completed === false); check('第 2 条文本', tasks[1].text === '有文本'); check('第 3 条已完成空文本', tasks[2].text === '' && tasks[2].completed === true); // 往返 const written = writeMarkdown(cats); const reloaded = parseMarkdown(written); const reloadedTasks = reloaded.find(c => c.name === '工作').tasks; check('往返后任务数仍 3', reloadedTasks.length === 3); check('往返后空文本保留', reloadedTasks[0].text === ''); check('往返后已完成保留', reloadedTasks[2].completed === true); } console.log('\n[8] 多行/换行处理:单行任务强制要求'); { // 任务文本中嵌入换行 → 写时器会压成单行,读时器会把「多出来的行」当新分类/任务 // 这是已知行为,但需要测试:UI 任务数 = markdown 任务数 const md = `# 全部任务\n\n## 工作\n\n- [ ] 任务 A\n- [ ] 任务 B\n`; const cats = parseMarkdown(md); check('基本多任务解析', cats.find(c => c.name === '工作').tasks.length === 2); } console.log('\n[9] BOM 处理:UTF-8 BOM 不影响解析'); { const md = '\uFEFF# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n'; const cats = parseMarkdown(md); check('BOM 后能正常解析', cats.find(c => c.name === '工作').tasks.length === 2, cats.map(c => `${c.name}(${c.tasks.length})`).join(' | ')); } console.log('\n[10] CRLF / LF / CR:换行符归一化'); { const lf = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`; const crlf = `# 全部任务\r\n\r\n## 工作\r\n\r\n- [ ] a\r\n- [ ] b\r\n`; const catsLf = parseMarkdown(lf); const catsCrlf = parseMarkdown(crlf); check('LF 解析', catsLf.find(c => c.name === '工作').tasks.length === 2); check('CRLF 解析', catsCrlf.find(c => c.name === '工作').tasks.length === 2); } console.log('\n[11] 分类 kind 一致性:UI 视角的「全部任务容器/子分类/回收站」kind 不变'); { const md = `# 全部任务\n\n## 工作\n\n- [ ] a\n\n# 回收站\n\n- [ ] 已删\n`; const cats = parseMarkdown(md); const container = cats.find(c => c.kind === CategoryKind.OTHER_TASKS); const sub = cats.find(c => c.parentOtherTasks); const trash = cats.find(c => c.kind === CategoryKind.TRASH); check('容器:全部任务', container && container.name === '全部任务'); check('子分类:工作', sub && sub.name === '工作'); check('回收站:回收站', trash && trash.name === '回收站'); check('子分类.parentOtherTasks=true', sub.parentOtherTasks === true); // 往返 const written = writeMarkdown(cats); const reloaded = parseMarkdown(written); check('往返后容器仍为容器', reloaded.some(c => c.kind === CategoryKind.OTHER_TASKS)); check('往返后子分类仍是子分类', reloaded.some(c => c.parentOtherTasks)); check('往返后回收站仍是回收站', reloaded.some(c => c.kind === CategoryKind.TRASH)); } console.log('\n[12] 创建默认文档:4 个子分类 + 容器'); { const cats = createDefaultDoc(); const container = cats.find(c => c.kind === CategoryKind.OTHER_TASKS); const subs = cats.filter(c => c.parentOtherTasks); check('容器名为「全部任务」', container && container.name === '全部任务'); check('默认 4 个子分类', subs.length === 4, `subs=${subs.map(s => s.name).join(',')}`); for (const name of ['工作', '学习', '生活', '未分类']) { check(`子分类 ${name} 存在`, subs.some(s => s.name === name)); } } console.log('\n[13] 重要 + 当前 → 智能视图聚合(v3 不写镜像段,但聚合正确)'); { // UI 上看「重要任务」智能视图:所有 important=true 的任务 // UI 上看「当前任务」智能视图:所有 current=true 的任务 // UI 上看「已完成任务」智能视图:所有 completed=true 的任务 const md = `# 全部任务\n\n## 工作\n\n- [ ] [⭐] a\n- [ ] b\n- [ ] [▶] c\n\n## 学习\n\n- [✓] [⭐] d\n- [ ] e\n- [✓] f\n`; const cats = parseMarkdown(md); const all = cats.flatMap(c => c.tasks); const important = all.filter(t => t.important).map(t => t.text).sort(); const current = all.filter(t => t.current).map(t => t.text).sort(); const completed = all.filter(t => t.completed).map(t => t.text).sort(); check('重要任务视图:a, d', eq(important, ['a', 'd']), important.join(',')); check('当前任务视图:c', eq(current, ['c']), current.join(',')); check('已完成任务视图:d, f', eq(completed, ['d', 'f']), completed.join(',')); } console.log('\n[14] 同一文本多份:每份都是独立任务'); { const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n- [ ] 买菜\n- [ ] 买菜\n`; const cats = parseMarkdown(md); const tasks = cats.find(c => c.name === '工作').tasks; check('3 条同名任务都在', tasks.length === 3); check('id 各不相同', new Set(tasks.map(t => t.id)).size === 3, tasks.map(t => t.id).join(',')); } console.log('\n[15] 行中行尾混合的 :行中保留为字面文本,行尾剥掉'); { // 行中:保留(用户可能写文档示例) // 行尾:剥掉(v3 不写,但旧文件兼容) const md = `# 全部任务\n\n## 工作\n\n- [ ] 看这里 中间内容 \n`; const cats = parseMarkdown(md); const t = cats.find(c => c.name === '工作').tasks[0]; // 行尾的剥掉,行中的保留 check('行中保留 + 行尾剥掉', t.text === '看这里 中间内容', JSON.stringify(t.text)); // 解析器对每个加载文件从 t1 开始,单任务就是 t1(运行时分配,非文件中的 t1) check('id 是「工作」分类第一条任务的运行时 id', t.id === 't1', `t.id=${t.id}`); // 写出去:行尾的 已被剥掉;行中的 是用户文本的一部分,保留 const written = writeMarkdown(cats); check('写出去无行尾 $/.test(written.split('\n').find(l => l.startsWith('- '))), written); // 行中那串 `` 仍在写出去的文本里(作为字面字符) check('行中 作为字面文本保留', written.includes(''), written); } console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); process.exit(summary.fail === 0 ? 0 : 1);