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

View File

@@ -0,0 +1,383 @@
// 全面 smoke test验证外部编辑 → 软件反映
import { TaskStore } from '../src/task-store.js';
import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js';
import { writeMarkdown } from '../src/markdown-writer.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 桩出 window.api让 TaskStore 不真去写文件
installApiStub();
// ============================================================
// [1] 外部编辑 → 重新加载 → 顺序保留
// ============================================================
console.log('\n[1] 外部编辑调整顺序 → 重新加载 → 顺序保留');
{
// 初始a, b, c
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`;
const store = makeStore(initial);
const work = store.getCategory('工作');
check('初始顺序 a/b/c', work.tasks.map(t => t.text).join(',') === 'a,b,c',
work.tasks.map(t => t.text).join(','));
// 用户在编辑器里改成 c, a, b保存 → fs.watch → 重新加载)
const reordered = `# 全部任务\n\n## 工作\n\n- [ ] c\n- [ ] a\n- [ ] b\n`;
store.loadFromContent(reordered, null);
const work2 = store.getCategory('工作');
check('重载后顺序变为 c/a/b', work2.tasks.map(t => t.text).join(',') === 'c,a,b',
work2.tasks.map(t => t.text).join(','));
// 跨分类移动:在工作里把 x 移到最前
const store2 = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] x\n- [ ] y\n- [ ] z\n\n## 学习\n\n- [ ] k1\n`);
const store3 = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] y\n- [ ] z\n- [ ] x\n\n## 学习\n\n- [ ] k1\n`);
check('工作分类 y/z/x 顺序保留',
store3.getCategory('工作').tasks.map(t => t.text).join(',') === 'y,z,x');
// 顺序往返parser → writer → parser
const md = `# 全部任务\n\n## 工作\n\n- [ ] 第一\n- [ ] 第二\n- [ ] 第三\n`;
const cats = parseMarkdown(md);
const written = writeMarkdown(cats);
const cats2 = parseMarkdown(written);
const work3 = cats2.find(c => c.name === '工作');
check('parser→writer→parser 顺序不变',
work3.tasks.map(t => t.text).join(',') === '第一,第二,第三');
}
// ============================================================
// [2] 外部编辑 → 重新加载 → 文本变更同步
// ============================================================
console.log('\n[2] 外部编辑文本 → 重新加载 → 文本同步');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] 原文本\n`;
const store = makeStore(initial);
// 用户改成新文本
const updated = `# 全部任务\n\n## 工作\n\n- [ ] 新文本\n`;
store.loadFromContent(updated, null);
const work = store.getCategory('工作');
check('文本被新内容覆盖', work.tasks[0].text === '新文本',
work.tasks[0].text);
check('仍是 1 条任务', work.tasks.length === 1);
}
// ============================================================
// [3] 外部编辑 → 重新加载 → 删除任务
// ============================================================
console.log('\n[3] 外部编辑删除任务 → 重新加载 → 删除生效');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`;
const store = makeStore(initial);
const removed = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] c\n`;
store.loadFromContent(removed, null);
const work = store.getCategory('工作');
check('删除中间那条,剩 a/c',
work.tasks.map(t => t.text).join(',') === 'a,c',
work.tasks.map(t => t.text).join(','));
}
// ============================================================
// [4] 外部编辑 → 重新加载 → 新增任务
// ============================================================
console.log('\n[4] 外部编辑新增任务 → 重新加载 → 新增生效');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n`;
const store = makeStore(initial);
const added = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] 新增的\n`;
store.loadFromContent(added, null);
const work = store.getCategory('工作');
check('新增后 a/新增的',
work.tasks.map(t => t.text).join(',') === 'a,新增的',
work.tasks.map(t =>t.text).join(','));
}
// ============================================================
// [5] 跨分类移动(外部编辑)
// ============================================================
console.log('\n[5] 跨分类移动 → 重新加载 → 位置同步');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] taskA\n\n## 学习\n\n- [ ] taskB\n`;
const store = makeStore(initial);
const moved = `# 全部任务\n\n## 工作\n\n\n## 学习\n\n- [ ] taskB\n- [ ] taskA\n`;
store.loadFromContent(moved, null);
const work = store.getCategory('工作');
const study = store.getCategory('学习');
check('taskA 移到学习分类末尾', study.tasks.map(t => t.text).join(',') === 'taskB,taskA');
check('工作分类变空', work.tasks.length === 0);
}
// ============================================================
// [6] id 运行时分配 + 重新加载重新分配
// ============================================================
console.log('\n[6] 跨重载task.id 重新分配');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const store1 = makeStore(initial);
const work1 = store1.getCategory('工作');
const ids1 = work1.tasks.map(t => t.id);
check('初次加载 id 是 t1/t2', ids1.join(',') === 't1,t2', ids1.join(','));
// 重新加载同样内容
store1.loadFromContent(initial, null);
const work2 = store1.getCategory('工作');
const ids2 = work2.tasks.map(t => t.id);
check('重新加载后 id 也是 t1/t2确定性', ids2.join(',') === 't1,t2', ids2.join(','));
check('id 字符串相等(基于位置)', ids1.join(',') === ids2.join(','));
}
// ============================================================
// [7] 同名任务在不同分类 → 各自独立 id
// ============================================================
console.log('\n[7] 同名任务在不同分类 → 各自独立');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n\n## 生活\n\n- [ ] 买菜\n`;
const cats = parseMarkdown(md);
const work = cats.find(c => c.name === '工作');
const life = cats.find(c => c.name === '生活');
check('工作和生活都有「买菜」', work.tasks[0].text === '买菜' && life.tasks[0].text === '买菜');
check('两个 id 不同', work.tasks[0].id !== life.tasks[0].id,
`工作=${work.tasks[0].id}, 生活=${life.tasks[0].id}`);
}
// ============================================================
// [8] 同名任务在同一分类 → 多个独立任务
// ============================================================
console.log('\n[8] 同名任务在同一分类 → 多个独立');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n- [ ] 买菜\n`;
const cats = parseMarkdown(md);
const work = cats.find(c => c.name === '工作');
check('两条「买菜」都在', work.tasks.length === 2);
check('两条 id 不同', work.tasks[0].id !== work.tasks[1].id,
`${work.tasks[0].id} vs ${work.tasks[1].id}`);
}
// ============================================================
// [9] 状态标识(⭐/▶/[✓])保留
// ============================================================
console.log('\n[9] 状态标识(⭐/▶/[✓])跨重载保留');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] [⭐] 重要任务\n- [ ] [▶] 当前任务\n- [✓] 已完成\n`;
const store = makeStore(initial);
const work = store.getCategory('工作');
check('重要标记', work.tasks[0].important === true);
check('当前标记', work.tasks[1].current === true);
// v3.4 起:[✓] 任务在加载时自动从源分类搬到「# 已完成任务」真实分类,
// 不再停留在原分类里 —— 数据归属明确,源分类里不再保留已完成的「鬼影」。
const completed = store.getCompletedCategory();
check('已完成任务被搬到「# 已完成任务」分类v3.4',
completed?.tasks?.some(t => t.text === '已完成' && t.completed === true),
completed?.tasks?.map(t => `${t.text}(${t.completed})`).join(' | '));
// 重新加载
store.loadFromContent(initial, null);
const work2 = store.getCategory('工作');
check('重载后重要标记保留', work2.tasks[0].important === true);
check('重载后当前标记保留', work2.tasks[1].current === true);
const completed2 = store.getCompletedCategory();
check('重载后已完成任务仍在「# 已完成任务」并 completed=true',
completed2?.tasks?.some(t => t.text === '已完成' && t.completed === true),
completed2?.tasks?.map(t => `${t.text}(${t.completed})`).join(' | '));
}
// ============================================================
// [10] writer 永远不会输出 <!-- id:
// ============================================================
console.log('\n[10] writer 永不输出 <!-- id:');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] [▶] b\n`;
const cats = parseMarkdown(md);
const written = writeMarkdown(cats);
check('写入器无 <!-- id', !written.includes('<!-- id:'),
written);
// 即使用 task.id 也是字符串writer 也不该写
cats[1].tasks[0].id = 'some-injected-id';
const written2 = writeMarkdown(cats);
check('即便 task.id 是任意字符串也不写', !written2.includes('<!-- id:'),
written2);
}
// ============================================================
// [11] 旧文件迁移(包含 <!-- id -->)→ 加载干净
// ============================================================
console.log('\n[11] 旧文件迁移:含 <!-- id -->');
{
const old = `# 全部任务\n\n## 工作\n\n- [ ] 旧任务 <!-- id:t99 -->\n- [ ] 普通任务\n`;
const cats = parseMarkdown(old);
const work = cats.find(c => c.name === '工作');
check('旧 id 注释被剥掉,文本干净', work.tasks.every(t => !t.text.includes('<!--')),
work.tasks.map(t => t.text).join(' | '));
check('id 由解析器分配(不再是 t99',
work.tasks.every(t => t.id && t.id !== 't99'),
work.tasks.map(t => t.id).join(','));
// 写回 → 不包含 <!-- id
const written = writeMarkdown(cats);
check('写回后无 <!-- id:', !written.includes('<!-- id:'));
}
// ============================================================
// [12] 删除/恢复仍然能工作(基于运行时 id
// ============================================================
console.log('\n[12] 删除→恢复(基于运行时 id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`);
const work = store.getCategory('工作');
const bId = work.tasks.find(t => t.text === 'b').id;
// 删 b
const ok = store.deleteTask('工作', bId);
check('deleteTask 返回 true', ok === true);
check('工作剩 a/c', store.getCategory('工作').tasks.map(t => t.text).join(',') === 'a,c');
// 从回收站恢复 —— v3.5.2+:默认按 originalCategory 归位(删除 b 时已记下"工作"
const restored = store.restoreTask(bId);
check('restoreTask 返回 { ok: true }', restored.ok === true);
const trash = store.getTrashCategory();
check('回收站空了', trash.tasks.length === 0);
// 默认归到「工作」deleteTask 时记下的原分类);本测试文档没有「未分类」子分类
check('b 按原分类归位到「工作」',
store.getCategory('工作').tasks.some(t => t.text === 'b'),
JSON.stringify(store.getCategory('工作').tasks.map(t => t.text)));
}
// ============================================================
// [13] 跨分类移动(基于 runtime id
// ============================================================
console.log('\n[13] 跨分类移动(基于 runtime id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n\n## 学习\n\n- [ ] c\n`);
const work = store.getCategory('工作');
const aId = work.tasks.find(t => t.text === 'a').id;
const ok = store.moveTask('工作', aId, '学习');
// moveTask 现在返回实际生效的目标分类名(防御性改投语义),不再是布尔。
// 普通路径:返回的就是用户传的目标分类。
check('moveTask 返回「学习"(普通路径无改投)', ok === '学习');
check('工作剩 b', store.getCategory('工作').tasks.map(t => t.text).join(',') === 'b');
// 默认 movePosition='front':移过来的任务落到目标分类最前面 → a,c
check('学习有 a/c默认 movePosition=front', store.getCategory('学习').tasks.map(t => t.text).join(',') === 'a,c');
}
// ============================================================
// [14] 拖拽重排序(基于 runtime id
// ============================================================
console.log('\n[14] 重排序(基于 runtime id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`);
const work = store.getCategory('工作');
const aId = work.tasks[0].id;
const bId = work.tasks[1].id;
// splice 语义toIndex 是「移除前」的位置。
// 起始 [a, b, c]from=0, to=2 → splice(0,1) 得 [b,c];再 splice(2,0,a) 得 [b,c,a]
const ok = store.reorderTask('工作', 0, 2);
check('reorderTask 返回 true', ok === true);
check('顺序变为 b/c/asplice 语义toIndex 是移除前位置)',
store.getCategory('工作').tasks.map(t => t.text).join(',') === 'b,c,a');
// 拖到末尾toIndex === length 也合法commit 中明确放行)
// 当前 [b, c, a],把 b位置 0拖到末尾位置 3
// splice(0,1) → [c,a]splice(3,0,b) → [c,a,b]
const ok2 = store.reorderTask('工作', 0, 3);
check('把 b位置 0拖到 length 位置 → 顺序变为 c/a/b',
ok2 === true &&
store.getCategory('工作').tasks.map(t => t.text).join(',') === 'c,a,b');
}
// ============================================================
// [15] 外部编辑文件时自动切回 SortBy.MANUAL用户原 bug 报告)
// ============================================================
console.log('\n[15] 外部编辑文件 → 自动切回 MANUAL让文件顺序生效');
{
// 用户在软件里切到了「按字母排序」
const initial = `# 全部任务\n\n## 工作\n\n- [ ] cherry\n- [ ] apple\n- [ ] banana\n`;
const store = makeStore(initial);
store.setSortBy('alphabet');
check('设置 sortBy=alphabet 后生效',
store.sortBy === 'alphabet');
// 用户在 markdown 里改成「按她想要的顺序」存盘 → fs.watch 触发 reload
const reordered = `# 全部任务\n\n## 工作\n\n- [ ] banana\n- [ ] cherry\n- [ ] apple\n`;
store.loadFromContent(reordered, null);
// 关键sortBy 必须切回 MANUAL否则 getVisibleTasks 会按字母重排
check('loadFromContent 后 sortBy 自动切回 manual',
store.sortBy === 'manual',
`sortBy=${store.sortBy}`);
// 现在 UI 渲染的顺序就是文件里的顺序
const visible = store.getVisibleTasks(store.getCategory('工作'));
check('UI 可见顺序按文件顺序banana/cherry/apple',
visible.map(t => t.text).join(',') === 'banana,cherry,apple',
visible.map(t => t.text).join(','));
}
// ============================================================
// [16] 默认 sortBy=MANUAL 时,外部编辑不会无谓触发 sort 事件
// ============================================================
console.log('\n[16] sortBy=MANUAL 时 reload 不发 sort 事件(避免误触发 UI');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const store = makeStore(initial);
let sortEventCount = 0;
store.on('sort', () => sortEventCount++);
store.loadFromContent(`# 全部任务\n\n## 工作\n\n- [ ] b\n- [ ] a\n`, null);
check('sortBy=MANUAL 时不触发 sort 事件',
sortEventCount === 0,
`sortEventCount=${sortEventCount}`);
}
// ============================================================
// [17] 自写入抑制:渲染端 readFile + serialize() 比对
// (用户原 bug外部编辑内容修改后软件没及时更新
// ============================================================
console.log('\n[17] 自写入抑制fs.watch → readFile → serialize() 比对');
{
// 用户原 bug1.5s 时间窗口会吞掉用户紧接着的外部保存。
// 修复方案主进程不过滤fs.watch 时序不可靠),所有事件发 IPC。
// 渲染端 readFile 后与 store.serialize() 比对 —— 完全相同视为
// 自己写入的产物静默;任何字节差异视为外部修改正常 reload + toast。
// (a) 模拟「autoSave 触发的写入」:文件内容 === serialize() → 静默
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const store = makeStore(initial);
// 模拟 autoSave 完成后的状态:内存中 a/b 的顺序serialize 输出与文件一致
const fileContentAfterAutoSave = store.serialize();
check('autoSave 后文件内容 === serialize()',
fileContentAfterAutoSave === initial,
`serialize=${JSON.stringify(fileContentAfterAutoSave)}`);
// (b) 模拟「用户在 markdown 里改了内容」:文件内容 !== serialize() → reload
const userEdited = `# 全部任务\n\n## 工作\n\n- [ ] b\n- [ ] a\n- [ ] user-added\n`;
check('用户编辑后内容 !== serialize() → 触发 reload',
userEdited !== store.serialize());
// (c) 模拟「外部修改文本」:任何字节差异都能识别
const tinyEdit = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b 修改\n`;
check('用户改一个字也能识别',
tinyEdit !== store.serialize());
// (d) 用户删除任务:内容差异极大
const deletedOne = `# 全部任务\n\n## 工作\n\n- [ ] a\n`;
check('用户删除一条任务也能识别',
deletedOne !== store.serialize());
// (e) 极端情况:用户外部保存后内容恰好 === serialize()(误判为自写入)
// 等价于「用户在外部编辑器里做了与内存状态完全一致的内容」—— 实际
// 等同于用户没改。这种情况下渲染端会静默(不弹 toast但用户也
// 确实没改东西,无副作用。可接受。
const sameAsSerialize = store.serialize();
check('误判条件(用户保存内容 === serialize可触发但等价于没改',
sameAsSerialize === store.serialize());
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);