update
This commit is contained in:
867
scripts/check-md-ui-sync.mjs
Normal file
867
scripts/check-md-ui-sync.mjs
Normal file
@@ -0,0 +1,867 @@
|
||||
// 全面回归: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);
|
||||
Reference in New Issue
Block a user