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

867 lines
35 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ↔ 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] 基础字段 roundtriptext / completed / important / current
// ============================================================
console.log('\n[1] 字段 roundtriptext / 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('&lt;'));
}
// ============================================================
// [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.1toggleTask 勾选时**不再**清 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);