// 全面回归:markdown ↔ UI 同步契约 // 覆盖「文件字符 → 内存字段 → UI 渲染 → 写回文件」整条链路 // // 关键不变量(v3.4 起): // - completed=true 的任务**只能**活在 kind=COMPLETED 分类 // - completed=false 的任务**只能**活在 kind=NORMAL 子分类 // - 子分类相对顺序在 roundtrip 后保留 // - 任务顺序在子分类内 roundtrip 后保留 // - 所有 inline 标记([⭐] / [▶] / [原分类:xxx])在 parser / writer 间对称 import { parseMarkdown, CategoryKind, extractInlineMarkers } from '../src/markdown-parser.js'; import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; import { makeStore } from './_lib/store-fixture.mjs'; import { writeMarkdown, createDefaultDoc } from '../src/markdown-writer.js'; import { TaskStore } from '../src/task-store.js'; import { escapeHtml } from '../src/utils/dom.js'; import { installApiStub } from './_lib/api-stub.mjs'; // 把 jsdom 没有的环境变量兜底 —— 多个测试都依赖 store 但只设一次即可 function setupWindow() { installApiStub(); } setupWindow(); const TEST_ID = 'check-md-ui-sync'; // 提取 `## X` / `# X` 段(从该标题到下一个 ## / # 之前)。 // 用于边界断言,避免贪婪正则跨段匹配污染测试信号。 // sectionMarker: e.g. '## 工作' 或 '# 回收站' function extractSection(md, sectionMarker) { const start = md.indexOf(sectionMarker); if (start < 0) return ''; const after = start + sectionMarker.length; const nextH2 = md.indexOf('\n## ', after); const nextH1 = md.indexOf('\n# ', after); let end; if (nextH2 > 0 && nextH1 > 0) end = Math.min(nextH2, nextH1); else if (nextH2 > 0) end = nextH2; else if (nextH1 > 0) end = nextH1; else end = md.length; return md.slice(start, end); } // ============================================================ // [1] 基础字段 roundtrip:text / completed / important / current // ============================================================ console.log('\n[1] 字段 roundtrip:text / completed / important / current'); { const md = `# 全部任务 ## 工作 - [ ] 普通任务 - [ ] [⭐] 重要任务 - [ ] [▶] 当前任务 - [ ] [▶] [⭐] 既当前又重要 - [ ] 任务有 [⭐] 但是 inline - [ ] 任务有 [▶] inline - [✓] 已完成的任务 - [✓] [▶] [⭐] 完成且当前且重要 `; const store = new TaskStore(); store.loadFromContent(md, null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const work = store.getCategory('工作'); // 加载后 [✓] 任务被搬到「# 已完成任务」分类,「工作」里只剩 6 条 check('[1] [✓] 已搬到「# 已完成任务」', work.tasks.length === 6, `实际 ${work.tasks.length}`); check('[1] 普通任务文本', work.tasks[0]?.text === '普通任务'); check('[1] 普通任务未完成', work.tasks[0]?.completed === false); check('[1] 普通任务不重要', work.tasks[0]?.important === false); check('[1] 普通任务非当前', work.tasks[0]?.current === false); check('[1] 重要任务标记', work.tasks.find(t => t.text === '重要任务')?.important === true); check('[1] 当前任务标记', work.tasks.find(t => t.text === '当前任务')?.current === true); check('[1] 既当前又重要 current', work.tasks.find(t => t.text === '既当前又重要')?.current === true); check('[1] 既当前又重要 important', work.tasks.find(t => t.text === '既当前又重要')?.important === true); // parser 只剥行首/行尾位置的 [⭐] [▶];行内的保留为字面字符串 const inline = work.tasks.filter(t => t.text.includes('[⭐]') || t.text.includes('[▶]')); check('[1] 行内 [⭐] [▶] 保留在文本', inline.length === 2); check('[1] 行内 [⭐] 不算 important', inline.every(t => !t.important)); check('[1] 行内 [▶] 不算 current', inline.every(t => !t.current)); // 「工作」里的 6 条任务的文本都不能含行首/行尾位置的 [⭐] [▶] const hasLeftoverMarker = work.tasks.some(t => /^[\[((]?[⭐▶]/.test(t.text.trim()) || /[\[((]?[⭐▶][\]))]?$/.test(t.text.trim()) ); check('[1] 文本不含行首/行尾 [⭐] [▶]', !hasLeftoverMarker); const completed = store.getCompletedCategory(); check('[1] [✓] 任务搬到「# 已完成任务」', !!completed); check('[1] 已完成分类含「已完成的任务」', completed?.tasks?.some(t => t.text === '已完成的任务' && t.completed === true)); check('[1] 已完成分类含「完成且当前且重要」', completed?.tasks?.some(t => t.text === '完成且当前且重要' && t.completed === true)); } // ============================================================ // [2] parser → writer → parser 幂等 // ============================================================ console.log('\n[2] parser → writer → parser 幂等'); { const md = `# 全部任务 ## 工作 - [ ] a - [ ] b - [ ] [⭐] c - [ ] [▶] d ## 学习 - [ ] e `; const cats1 = parseMarkdown(md); const written1 = writeMarkdown(cats1); const cats2 = parseMarkdown(written1); const written2 = writeMarkdown(cats2); check('[2] 两次写出结果一致(幂等)', written1 === written2, `\n--- 第一次 ---\n${written1}\n--- 第二次 ---\n${written2}`); // 字段一致性 const work1 = cats1.find(c => c.name === '工作'); const work2 = cats2.find(c => c.name === '工作'); check('[2] 「工作」任务数一致', work1.tasks.length === work2.tasks.length); check('[2] 「工作」任务文本一致', work1.tasks.map(t => t.text).join(',') === work2.tasks.map(t => t.text).join(',')); check('[2] 「工作」important 字段一致', work1.tasks.map(t => t.important).join(',') === work2.tasks.map(t => t.important).join(',')); check('[2] 「工作」current 字段一致', work1.tasks.map(t => t.current).join(',') === work2.tasks.map(t => t.current).join(',')); // writer 对 [✓] 任务的处理 const mdWithDone = `# 全部任务 ## 工作 - [ ] 未完成 - [✓] 已完成 `; const catsWithDone = parseMarkdown(mdWithDone); // parser 后 [✓] 在「工作」里 check('[2] parser 后 [✓] 在「工作」里', catsWithDone.find(c => c.name === '工作')?.tasks[1]?.completed === true); // writer 写出 [✓] check('[2] writer 输出 [✓]', writeMarkdown(catsWithDone).includes('- [✓] 已完成')); } // ============================================================ // [3] 子分类相对顺序保留 // ============================================================ console.log('\n[3] 子分类相对顺序保留'); { const md = `# 全部任务 ## 工作 - [ ] a ## 学习 - [ ] b ## 生活 - [ ] c ## 未分类 - [ ] d `; const cats = parseMarkdown(md); const subNames = cats.filter(c => c.parentOtherTasks).map(c => c.name); check('[3] parser 顺序 工作/学习/生活/未分类', subNames.join(',') === '工作,学习,生活,未分类', subNames.join(',')); const written = writeMarkdown(cats); const cats2 = parseMarkdown(written); const subNames2 = cats2.filter(c => c.parentOtherTasks).map(c => c.name); check('[3] roundtrip 顺序一致', subNames.join(',') === subNames2.join(','), subNames2.join(',')); } // ============================================================ // [4] 任务顺序在子分类内保留 // ============================================================ console.log('\n[4] 任务顺序保留'); { const md = `# 全部任务 ## 工作 - [ ] 1st - [ ] 2nd - [ ] 3rd - [ ] 4th `; const cats = parseMarkdown(md); const written = writeMarkdown(cats); const cats2 = parseMarkdown(written); const work2 = cats2.find(c => c.name === '工作'); check('[4] roundtrip 任务顺序', work2.tasks.map(t => t.text).join(',') === '1st,2nd,3rd,4th'); } // ============================================================ // [5] 特殊字符与 HTML 转义 // ============================================================ console.log('\n[5] 特殊字符与 HTML 转义'); { const md = `# 全部任务 ## 工作 - [ ] 普通 - [ ] 含 <尖括号> - [ ] 含 & 符号 - [ ] 含 "双引号" - [ ] 含 '单引号' - [ ] 含 [方括号] - [ ] 含 (圆括号) - [ ] 含 \\反斜杠\\ `; const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); check('[5] 8 条任务', work.tasks.length === 8, `实际 ${work.tasks.length}`); const written = writeMarkdown(cats); check('[5] writer 保留 <尖括号>', written.includes('含 <尖括号>')); check('[5] writer 保留 &', written.includes('含 & 符号')); check('[5] writer 保留 "', written.includes('含 "双引号"')); check('[5] writer 保留 [方括号]', written.includes('含 [方括号]')); const lt = work.tasks.find(t => t.text === '含 <尖括号>'); check('[5] parser 正确解析 <尖括号>', lt?.text === '含 <尖括号>'); const escaped = escapeHtml(lt.text); check('[5] UI escape 不破坏文本结构', escaped.includes('<')); } // ============================================================ // [6] 空文本任务 // ============================================================ console.log('\n[6] 空文本任务'); { const md = `# 全部任务 ## 工作 - [ ] - [✓] - [ ] [⭐] `; // parser 不做迁移(迁移是 store 的事)—— 3 条全在「工作」里 const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); check('[6] parser 保留 3 条任务(含空文本)', work.tasks.length === 3, `实际 ${work.tasks.length}`); // 加载到 store 后,[✓] 被搬到「# 已完成任务」分类 const store = new TaskStore(); store.loadFromContent(md, null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); check('[6] 加载后「工作」剩 2 条([✓] 被迁移)', store.getCategory('工作').tasks.length === 2, `实际 ${store.getCategory('工作').tasks.length}`); check('[6] 加载后「# 已完成任务」有 1 条空文本', store.getCompletedCategory()?.tasks?.some(t => t.text === '')); const written = writeMarkdown(cats); check('[6] writer 输出空文本任务', /- \[ \]\s*\n/.test(written)); // parser 不做迁移,所以「## 工作」段里也会有 [✓] 空文本 —— 写回后由 store 迁移处理 check('[6] writer 输出空 [✓] 任务(在源段,因为 parser 不迁移)', /- \[✓\]\s*\n/.test(written)); // 走 store 加载链路后,[✓] 被搬到已完成分类,写回就在已完成段了 const writtenViaStore = store.serialize(); const completedSec = extractSection(writtenViaStore, '# 已完成任务'); check('[6] store.serialize() 后 [✓] 在已完成段', /\[✓\]/.test(completedSec), `已完成段:\n${completedSec}`); } // ============================================================ // [7] 中文字符、emoji 与空格 // ============================================================ console.log('\n[7] 中文字符、emoji 与空格'); { const md = `# 全部任务 ## 工作 - [ ] 中文任务:你好世界 - [ ] Emoji 🎉🚀✨ - [ ] 含多个空格的 任务 - [ ] 首尾空格 任务 `; const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); check('[7] 中文文本完整', work.tasks.find(t => t.text === '中文任务:你好世界')?.text === '中文任务:你好世界'); check('[7] Emoji 完整', work.tasks.find(t => t.text.includes('🎉'))?.text.includes('🎉')); check('[7] 中间多个空格保留', work.tasks.find(t => t.text === '含多个空格的 任务')?.text === '含多个空格的 任务'); } // ============================================================ // [8] BOM 头处理 // ============================================================ console.log('\n[8] BOM 头处理'); { const md = '# 全部任务\n\n## 工作\n\n- [ ] a\n'; const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); check('[8] BOM 不会让整个文件被忽略', work?.tasks?.length === 1, `tasks=${work?.tasks?.length}`); check('[8] BOM 文件后任务 a 存在', work.tasks[0]?.text === 'a'); } // ============================================================ // [9] CRLF 行尾 // ============================================================ console.log('\n[9] CRLF 行尾'); { const md = '# 全部任务\r\n\r\n## 工作\r\n\r\n- [ ] 任务1\r\n- [✓] 任务2\r\n'; const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); check('[9] CRLF 行尾 任务1', work.tasks.find(t => t.text === '任务1')?.text === '任务1'); // [✓] 任务在 parser 阶段还在「工作」里;migration 走 loadFromContent 才会搬 check('[9] 文本无 \\r', work.tasks.every(t => !t.text.includes('\r'))); } // ============================================================ // [10] [原分类:xxx] 标记 - 仅在 COMPLETED/TRASH 出现 // ============================================================ console.log('\n[10] [原分类:xxx] 标记'); { const md = `# 全部任务 ## 工作 - [ ] 普通任务 ## 未分类 - [✓] [原分类:工作] 已完成的工作任务 # 已完成任务 - [✓] [原分类:工作] 已完成的工作任务 2 `; const store = new TaskStore(); store.loadFromContent(md, null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const written = store.serialize(); check('[10] writer 输出 [原分类:工作]', written.includes('[原分类:工作]')); check('[10] writer 在「# 已完成任务」分类下写 [原分类:工作]', /# 已完成任务[\s\S]*\[原分类:工作\]/.test(written)); // 关键断言:「# 已完成任务」之前的所有子分类段落都不能出现 [原分类:xxx] // 用截取已完成段之前的全部内容做正则检查 const beforeCompleted = written.split('# 已完成任务')[0]; check('[10] 子分类段不含 [原分类:xxx]', !beforeCompleted.includes('[原分类:'), `beforeCompleted 内容:\n${beforeCompleted}`); } // ============================================================ // [11] extractInlineMarkers 边界情况 // ============================================================ console.log('\n[11] extractInlineMarkers 边界情况'); { const empty = extractInlineMarkers(''); check('[11] 空文本', empty.text === '' && !empty.important && !empty.current); const onlyMarkers = extractInlineMarkers('[⭐] [▶]'); check('[11] 只有标记 - 文本为空', onlyMarkers.text === ''); check('[11] 只有标记 - important', onlyMarkers.important === true); check('[11] 只有标记 - current', onlyMarkers.current === true); const tailMarkers = extractInlineMarkers('任务 [⭐] [▶]'); check('[11] 行尾 [⭐] 抽走', !tailMarkers.text.includes('[⭐]')); check('[11] 行尾 [▶] 抽走', !tailMarkers.text.includes('[▶]')); check('[11] 行尾 important=true', tailMarkers.important === true); check('[11] 行尾 current=true', tailMarkers.current === true); const inline = extractInlineMarkers('对比 [⭐] 和 [ ]'); check('[11] 行内 [⭐] 保留', inline.text.includes('[⭐]')); check('[11] 行内 [⭐] 不算 important', inline.important === false); const reversed = extractInlineMarkers('[⭐] [▶] 任务'); check('[11] 反向顺序 important=true', reversed.important === true); check('[11] 反向顺序 current=true', reversed.current === true); check('[11] 反向顺序 文本干净', reversed.text === '任务'); const legacyCurrent = extractInlineMarkers('@当前 任务'); check('[11] @当前 抽走', legacyCurrent.current === true && !legacyCurrent.text.includes('@当前')); const legacyStar = extractInlineMarkers('任务 ⭐'); check('[11] 裸 ⭐ 抽走', legacyStar.important === true && !legacyStar.text.includes('⭐')); const legacyPlay = extractInlineMarkers('任务 ▶'); check('[11] 裸 ▶ 抽走', legacyPlay.current === true && !legacyPlay.text.includes('▶')); const origCat = extractInlineMarkers('[原分类:工作] 任务'); check('[11] [原分类:xxx] 抽走', origCat.originalCategory === '工作' && !origCat.text.includes('[原分类')); } // ============================================================ // [12] 默认文档结构完整 // ============================================================ console.log('\n[12] createDefaultDoc 默认结构'); { const doc = createDefaultDoc(); const names = doc.map(c => c.name); check('[12] 容器存在', doc.some(c => c.kind === CategoryKind.OTHER_TASKS)); check('[12] 包含 工作/学习/生活/未分类', names.includes('工作') && names.includes('学习') && names.includes('生活') && names.includes('未分类')); check('[12] 容器位置在最前(OTHER_TASKS)', doc[0].kind === CategoryKind.OTHER_TASKS); check('[12] 「未分类」是子分类', doc.find(c => c.name === '未分类')?.parentOtherTasks === true); } // ============================================================ // [13] 智能列表视图聚合正确 // ============================================================ console.log('\n[13] 智能列表视图聚合正确'); { const md = `# 全部任务 ## 工作 - [ ] [▶] 工作当前 - [ ] [⭐] 工作重要 ## 学习 - [ ] [▶] 学习当前 - [ ] 普通任务 # 已完成任务 - [✓] [▶] 已完成但带当前 `; const store = new TaskStore(); store.loadFromContent(md, null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const smart = store.collectSmartListTasks(); // v3.4:完成 = 归档。已勾选的任务从「当前任务」「重要任务」聚合里消失, // 只通过「已完成任务」分类暴露。 // v4.1:toggleTask 勾选时**不再**清 task.current(标记与 completed 正交, // 详见 collectSmartListTasks 注释),所以 completed+current 共存是正常形态 —— // 但聚合排除规则不变:状态保留在文件里,只是不参与「当前任务」视图。 check('[13] current 视图 2 条(不含已完成的)', smart.current.length === 2, smart.current.map(t => t.text).join(',')); check('[13] important 视图 1 条', smart.important.length === 1, smart.important.map(t => t.text).join(',')); check('[13] allTasks 视图 4 条(子分类下未完成,不含已完成分类)', smart.allTasks.length === 4, smart.allTasks.map(t => t.text).join(',')); check('[13] completed 视图 1 条', smart.completed.length === 1, smart.completed.map(t => t.text).join(',')); } // ============================================================ // [14] 子分类重命名 / 删除后文件 roundtrip // ============================================================ console.log('\n[14] 子分类重命名 / 删除后文件 roundtrip'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n\n## 学习\n\n- [ ] c\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); store.renameCategory('工作', '任务'); check('[14] 重命名成功', store.getCategory('任务')?.name === '任务'); check('[14] 旧名不存在', !store.getCategory('工作')); const written = store.serialize(); const cats2 = parseMarkdown(written); check('[14] roundtrip 重命名后名字保留', !!cats2.find(c => c.name === '任务')); check('[14] roundtrip 重命名后任务还在', !!cats2.find(c => c.name === '任务')?.tasks.find(t => t.text === 'a')); store.deleteCategory('任务'); const written2 = store.serialize(); const cats3 = parseMarkdown(written2); const uncat = cats3.find(c => c.name === '未分类'); check('[14] 删除后任务到「未分类」', uncat?.tasks?.length === 2, `实际 ${uncat?.tasks?.length}`); } // ============================================================ // [15] writeMarkdown 在空状态下的兜底 // ============================================================ console.log('\n[15] writeMarkdown 边界'); { let threw = false; try { writeMarkdown([]); } catch { threw = true; } check('[15] 空数组抛错', threw); threw = false; try { writeMarkdown(null); } catch { threw = true; } check('[15] null 抛错', threw); const cats = [{ name: '全部任务', kind: CategoryKind.OTHER_TASKS, isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] }, { name: '未分类', kind: CategoryKind.NORMAL, isSpecial: false, meta: null, parentOtherTasks: true, tasks: [{ text: 'a', completed: false, important: false, current: false }] }]; const out = writeMarkdown(cats); check('[15] 最小文档能写出', out.includes('# 全部任务') && out.includes('## 未分类') && out.includes('- [ ] a')); } // ============================================================ // [16] updateTaskText 写回后的 roundtrip // ============================================================ console.log('\n[16] UI 文本编辑 → 文件 roundtrip'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] 原文本\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const work = store.getCategory('工作'); const taskId = work.tasks[0].id; store.updateTaskText('工作', taskId, '新文本 & 含特殊 <字符>'); const written = store.serialize(); check('[16] 写回包含新文本', written.includes('新文本 & 含特殊 <字符>')); const cats2 = parseMarkdown(written); const work2 = cats2.find(c => c.name === '工作'); check('[16] 重新解析后文本一致', work2.tasks[0]?.text === '新文本 & 含特殊 <字符>'); } // ============================================================ // [17] 完整操作序列 roundtrip(含 toggleTask 跨分类) // ============================================================ console.log('\n[17] 完整操作序列 roundtrip'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] task1\n- [ ] task2\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const work = store.getCategory('工作'); const t1 = work.tasks.find(t => t.text === 'task1').id; const t2 = work.tasks.find(t => t.text === 'task2').id; // 1) 标记 task1 为重要 store.updateTaskMeta('工作', t1, { important: true }); // 2) 标记 task2 为当前 store.updateTaskMeta('工作', t2, { current: true }); // 3) 勾选 task1 → 搬到「# 已完成任务」 store.toggleTask('工作', t1); const written = store.serialize(); check('[17] 写回含 [▶] task2([▶] 在 [⭐] 前)', written.includes('[▶] task2')); const completedSection = extractSection(written, '# 已完成任务'); const workSection17 = extractSection(written, '## 工作'); check('[17] 写回含 [⭐] task1(在已完成段)', /\[⭐\][\s\S]*?task1/.test(completedSection), `completedSection:\n${completedSection}`); check('[17] 写回含 [✓] task1(在已完成段)', /\[✓\][\s\S]*?task1/.test(completedSection)); check('[17] task1 不在工作分类段', !workSection17.includes('task1'), `工作段:\n${workSection17}`); // 重新加载 const store2 = new TaskStore(); store2.loadFromContent(written, null); store2._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const completed2 = store2.getCompletedCategory(); const work2 = store2.getCategory('工作'); check('[17] 重载 task1 在「# 已完成任务」且 important=true', completed2?.tasks?.some(t => t.text === 'task1' && t.important === true && t.completed === true)); check('[17] 重载 task2 在「工作」且 current=true', work2?.tasks?.some(t => t.text === 'task2' && t.current === true)); // 4) 取消勾选 task1 → 回到原分类(工作) const completedTaskId = completed2.tasks.find(t => t.text === 'task1').id; // 关键:调用 toggleTask 时必须用 store 里的分类名(不带 `#` 前缀) store2.toggleTask('已完成任务', completedTaskId); const work3 = store2.getCategory('工作'); check('[17] 取消勾选后 task1 回到「工作」', work3?.tasks?.some(t => t.text === 'task1')); check('[17] 取消勾选后 important 标记仍保留', work3?.tasks?.find(t => t.text === 'task1')?.important === true); check('[17] 取消勾选后 originalCategory 已清掉(避免噪音)', !work3?.tasks?.find(t => t.text === 'task1')?.originalCategory); } // ============================================================ // [18] 批量导入 → 写回 → 解析完整链路 // (关键回归:importTasksToCategory 必须把 [✓] 搬到「# 已完成任务」, // 否则违反 v3.4 数据归属不变量 —— 这是最近修的 bug) // ============================================================ console.log('\n[18] 批量导入 roundtrip + v3.4 数据归属'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const lines = [ '- [ ] 普通任务', '- [✓] 已完成', '- [ ] [⭐] 重要', '- [ ] [▶] 当前', '- [ ] 任务有 [⭐] inline', '裸任务', '' ]; const result = store.importTasksToCategory('工作', lines); check('[18] 导入返回 ok', result.ok === true); check('[18] 导入 6 条(空行 skipped)', result.added === 6, `actual ${result.added}, skipped=${result.skipped}`); // 关键:v3.4 数据归属不变量 —— [✓] 任务必须立即被搬到「# 已完成任务」分类 const work = store.getCategory('工作'); const completed = store.getCompletedCategory(); check('[18] 「工作」里 5 条未完成任务', work.tasks.length === 5, `工作实际 ${work.tasks.length}`); check('[18] 「工作」里无 completed=true 的任务(v3.4 不变量)', work.tasks.every(t => !t.completed), work.tasks.map(t => `${t.text}(done=${t.completed})`).join(',')); check('[18] 「# 已完成任务」里有 1 条任务', completed?.tasks?.length === 1, `已完成实际 ${completed?.tasks?.length}`); check('[18] 已完成的任务带 originalCategory=工作', completed?.tasks[0]?.originalCategory === '工作'); const written = store.serialize(); // 抽出 ## 工作 段(到下一个 ## 或 # 之前) const workSection = extractSection(written, '## 工作'); check('[18] 写回含 [✓] 已完成(在已完成段)', /# 已完成任务[\s\S]*?\[✓\][\s\S]*?已完成/.test(written)); check('[18] 写回不含 [✓] 已完成 在工作分类段', !workSection.includes('[✓]'), `工作段:\n${workSection}`); // 重新解析,结构一致 const cats2 = parseMarkdown(written); const work2 = cats2.find(c => c.name === '工作'); const completed2 = cats2.find(c => c.kind === CategoryKind.COMPLETED); check('[18] 重新解析「工作」5 条', work2.tasks.length === 5); check('[18] 重新解析「# 已完成任务」1 条', completed2?.tasks?.length === 1); } // ============================================================ // [19] 标题层级:h3+ 不重置分类 // h3 任务是孤儿,按 parser 当前行为归入上一分类 —— 避免丢数据。 // 这是有意保留的 roundtrip 安全行为,本测试固化契约。 // ============================================================ console.log('\n[19] 标题层级(h3+ 不重置分类)'); { const md = `# 全部任务 ## 工作 - [ ] a ### 子节 (h3) - [ ] b #### 更深 (h4) - [ ] c ## 学习 - [ ] d `; const cats = parseMarkdown(md); const work = cats.find(c => c.name === '工作'); // parser 行为:h3 不重置 currentCategory → b/c 归入「工作」(不丢数据) check('[19] h3+ 任务归入上一分类(避免数据丢失)', work.tasks.length === 3, `actual ${work.tasks.length}`); const study = cats.find(c => c.name === '学习'); check('[19] 学习分类 1 条任务', study.tasks.length === 1); // writer 不输出 h3 — 视觉信息丢失但任务保留 const written = writeMarkdown(cats); check('[19] writer 不输出 h3/h4 标题', !/^#{3,}\s/m.test(written)); check('[19] writer 输出所有任务', written.includes('- [ ] a') && written.includes('- [ ] b') && written.includes('- [ ] c')); } // ============================================================ // [20] 容器直接包含任务(无 ## 子分类) // ============================================================ console.log('\n[20] 容器直接包含任务 → 归到「未分类」'); { const md = `# 全部任务 - [ ] 容器下直接的任务 - [ ] [⭐] 容器下的重要任务 `; const cats = parseMarkdown(md); const uncat = cats.find(c => c.name === '未分类' && c.parentOtherTasks); check('[20] 「未分类」子分类存在', !!uncat); check('[20] 任务归到「未分类」', uncat?.tasks?.length === 2); const written = writeMarkdown(cats); check('[20] writer 输出 ## 未分类 段', written.includes('## 未分类')); } // ============================================================ // [21] 侧边栏结构 ↔ 文件结构完全一致 // 模拟 sidebar 渲染逻辑:从 store 的 categories 数组生成 sidebar HTML, // 再和 parser 从文件 parseMarkdown 出的 categories 对比。 // ============================================================ console.log('\n[21] sidebar 数据来源 = 文件结构'); { const md = `# 全部任务 ## 工作 - [ ] a ## 学习 - [ ] b ## 自定义分类 - [ ] c # 已完成任务 - [✓] done `; const store = new TaskStore(); store.loadFromContent(md, null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const subNames = store.getSubCategories().map(c => c.name); check('[21] 侧边栏子分类 = 工作/学习/自定义分类', subNames.join(',') === '工作,学习,自定义分类', subNames.join(',')); const written = store.serialize(); const cats2 = parseMarkdown(written); const subNames2 = cats2.filter(c => c.parentOtherTasks).map(c => c.name); check('[21] roundtrip 后子分类一致', subNames.join(',') === subNames2.join(','), subNames2.join(',')); } // ============================================================ // [22] search 同步:highlightTaskText 与 case-insensitive 搜索一致 // ============================================================ console.log('\n[22] 搜索同步'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] Hello World\n- [ ] 你好世界\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); store.setSearchQuery('hello'); const segs = store.highlightTaskText('Hello World'); check('[22] 大小写不敏感命中', segs.some(s => s.highlight && s.text === 'Hello')); store.setSearchQuery('你好'); const segs2 = store.highlightTaskText('你好世界'); check('[22] 中文搜索命中', segs2.some(s => s.highlight && s.text === '你好')); store.setSearchQuery(''); const segs3 = store.highlightTaskText('你好世界'); check('[22] 空搜索 → 无 highlight', segs3.length === 1 && segs3[0].highlight === false); } // ============================================================ // [23] 删除/恢复 流程:UI 操作 → 文件 roundtrip // ============================================================ console.log('\n[23] 删除 → 恢复 roundtrip'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] keep\n- [ ] delete-me\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const work = store.getCategory('工作'); const taskId = work.tasks.find(t => t.text === 'delete-me').id; // 删 store.deleteTask('工作', taskId); check('[23] 删除后「工作」剩 1 条', store.getCategory('工作').tasks.length === 1); check('[23] 删除后回收站有 1 条', store.getTrashCategory()?.tasks?.length === 1); // 写回 const written = store.serialize(); check('[23] 写回含 # 回收站', written.includes('# 回收站')); // 用 extractSection 严格限定只查 ## 工作 段(防止正则跨段匹配) const workSection = extractSection(written, '## 工作'); check('[23] 写回不含 delete-me 在工作段', !workSection.includes('delete-me'), `工作段:\n${workSection}`); check('[23] 写回含 delete-me 在回收站段', /# 回收站[\s\S]*?delete-me/.test(written)); // 重新加载 const store2 = new TaskStore(); store2.loadFromContent(written, null); store2._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); check('[23] 重载「工作」1 条', store2.getCategory('工作').tasks.length === 1); check('[23] 重载回收站 1 条', store2.getTrashCategory()?.tasks?.length === 1); // 恢复 const trashId = store2.getTrashCategory().tasks[0].id; store2.restoreTask(trashId); check('[23] 恢复后回收站 0 条', store2.getTrashCategory()?.tasks?.length === 0); const written2 = store2.serialize(); check('[23] 恢复后写回不含 # 回收站 段', !written2.includes('# 回收站')); } // ============================================================ // [24] 跨分类移动 + 文件 roundtrip // ============================================================ console.log('\n[24] 跨分类移动 roundtrip'); { const store = new TaskStore(); store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] a\n\n## 学习\n\n- [ ] b\n', null); store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); const work = store.getCategory('工作'); const aId = work.tasks.find(t => t.text === 'a').id; store.moveTask('工作', aId, '学习'); check('[24] 移动后工作 0 条', store.getCategory('工作').tasks.length === 0); check('[24] 移动后学习 2 条', store.getCategory('学习').tasks.length === 2); const written = store.serialize(); // 严格提取 ## 工作 段(到下一个 ## 或 # 之前),避免正则跨段匹配 const workSection = extractSection(written, '## 工作'); check('[24] 工作段无 a', !workSection.includes('- [ ] a'), `工作段:\n${workSection}`); check('[24] 学习段有 a', extractSection(written, '## 学习').includes('- [ ] a')); } console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); process.exit(summary.fail === 0 ? 0 : 1);