This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

341
scripts/smoke-ui-parity.mjs Normal file
View File

@@ -0,0 +1,341 @@
// 全面 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] 行中行尾混合的 <!-- id -->:行中保留为字面文本,行尾剥掉');
{
// 行中:保留(用户可能写文档示例)
// 行尾剥掉v3 不写,但旧文件兼容)
const md = `# 全部任务\n\n## 工作\n\n- [ ] 看这里 <!-- id:t1 --> 中间内容 <!-- id:t99 -->\n`;
const cats = parseMarkdown(md);
const t = cats.find(c => c.name === '工作').tasks[0];
// 行尾的剥掉,行中的保留
check('行中保留 + 行尾剥掉',
t.text === '看这里 <!-- id:t1 --> 中间内容',
JSON.stringify(t.text));
// 解析器对每个加载文件从 t1 开始,单任务就是 t1运行时分配非文件中的 t1
check('id 是「工作」分类第一条任务的运行时 id', t.id === 't1', `t.id=${t.id}`);
// 写出去:行尾的 <!-- id:t99 --> 已被剥掉;行中的 <!-- id:t1 --> 是用户文本的一部分,保留
const written = writeMarkdown(cats);
check('写出去无行尾 <!-- id:(行中是字面文本保留)',
!/<!--\s*id:[^\s>]+\s*-->$/.test(written.split('\n').find(l => l.startsWith('- '))),
written);
// 行中那串 `<!-- id:t1 -->` 仍在写出去的文本里(作为字面字符)
check('行中 <!-- id:t1 --> 作为字面文本保留', written.includes('<!-- id:t1 -->'), written);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);