// 模拟用户场景:外部修改 → resolveExternalChangeConflict → loadFromContent → UI 更新 // // 真实测试:插入任务到「## 工作」分类下(保持子分类结构) import { readFileSync } from 'node:fs'; import { parseMarkdown } from '../src/markdown-parser.js'; import { writeMarkdown } from '../src/markdown-writer.js'; import { pathsEqual } from '../src/utils/path.js'; import { findTodoFile } from './_lib/find-todo-path.mjs'; import { check, summary, printSummary } from './_lib/check.mjs'; class MockStore { constructor(initialContent, initialPath) { this.categories = parseMarkdown(initialContent); this.filePath = initialPath; this.selectedCategoryName = '工作'; this.selectedSmartList = null; this.dirty = false; this._suppressDirty = false; this.events = []; this._listeners = {}; } on(event, listener) { if (!this._listeners[event]) this._listeners[event] = []; this._listeners[event].push(listener); return () => { this._listeners[event] = this._listeners[event].filter(l => l !== listener); }; } emit(event, ...args) { this.events.push({ event, args }); (this._listeners[event] || []).forEach(l => l(...args)); } serialize() { return writeMarkdown(this.categories); } cancelPendingSave() {} loadFromContent(content, filePath = null) { this._suppressDirty = true; try { const previousSelected = this.selectedCategoryName; const previousSmart = this.selectedSmartList; this.categories = parseMarkdown(content); this.filePath = filePath; const stillExists = previousSelected && this.categories.some(c => c.name === previousSelected); if (previousSmart) { this.selectedSmartList = previousSmart; } else if (stillExists) { this.selectedCategoryName = previousSelected; this.selectedSmartList = null; } else { this.selectedSmartList = 'current'; this.selectedCategoryName = null; } this.dirty = false; this.emit('file-path', this.filePath); this.emit('load', { categories: this.categories }); this.emit('change'); } finally { this._suppressDirty = false; } this.emit('dirty', false); } } // 优先用用户的真实 todo.md 做端到端验证,缺文件则用内置样例 // 路径解析统一走 scripts/_lib/find-todo-path.mjs,避免硬编码用户路径 const found = findTodoFile(); const todoPath = found ? found.path : null; const SAMPLE_MD = `# 全部任务 ## 工作 - [ ] 工作任务 A - [ ] 工作任务 B - [✓] 已完成的工作任务 ## 学习 - [ ] 学习任务 A - [ ] 学习任务 B ## 未分类 - [ ] 未分类任务 `; let original; if (found) { original = found.content; console.log(`[verify-reload-ui] 使用用户文件 ${todoPath}(${original.length} 字符)`); } else { original = SAMPLE_MD; console.log('[verify-reload-ui] 找不到用户 todo.md,回退到内置样例(不写回磁盘)'); } // 统一占位路径:用户路径缺失时用一个固定的虚拟路径给 MockStore, // 后续断言只用它做字符串比对(pathsEqual),具体值不影响测试语义 const mockFilePath = todoPath || `${process.platform === 'win32' ? 'C:\\TodoList' : '/tmp'}/todo.md`; console.log('\n[场景 A] 在第一个子分类下追加任务(用户最常见的 VSCode 编辑场景)'); const store = new MockStore(original, mockFilePath); // 选第一个「有任务」的子分类做目标(c.tasks 是数组,空数组也是 truthy,必须明确判 length) let targetSub = store.categories.find(c => c.kind === 'normal' && c.parentOtherTasks && c.tasks.length > 0); let targetName = targetSub ? targetSub.name : '工作'; store.selectedCategoryName = targetName; // 用户文件所有子分类都空时,「插入到目标段」毫无意义 —— 切回 SAMPLE_MD 跑。 // SAMPLE 里有「工作」「学习」「未分类」三个有任务的子分类,下面的段插入测试才有信号。 // 必须把 store 也用 SAMPLE_MD 重载 —— 不然 store.categories 还是用户文件的,目标段 // 名(如「工作」)在用户分类里根本不存在,find() 返回 undefined,后续断言全挂。 if (original !== SAMPLE_MD && !targetSub) { console.log('[setup] 用户文件没有含任务的子分类,切回 SAMPLE_MD 以保留测试信号'); original = SAMPLE_MD; store.loadFromContent(original, mockFilePath); targetSub = store.categories.find(c => c.kind === 'normal' && c.parentOtherTasks && c.tasks.length > 0); targetName = targetSub ? targetSub.name : '工作'; store.selectedCategoryName = targetName; } // 找 targetName 这一 h2 段:起点 = `## {targetName}`,终点 = 下一个 `\n## ` 或 `\n# ` // (h2 之后的下一个 h1 也会终止本段;用户文件里学习子分类后是 `# 已完成任务`,必须兼容) const targetHeaderIdx = original.indexOf(`## ${targetName}`); if (targetHeaderIdx < 0) { console.log(` ⚠️ 找不到 ## ${targetName} 段,跳过场景 A`); } else { const h2 = original.indexOf('\n## ', targetHeaderIdx); const h1 = original.indexOf('\n# ', targetHeaderIdx); // 取最近的下个标题作为段落终点;都没有就贴文件尾 —— 必须落在目标段内 let workInsertPoint; if (h2 > 0 && h1 > 0) workInsertPoint = Math.min(h2, h1); else if (h2 > 0) workInsertPoint = h2; else if (h1 > 0) workInsertPoint = h1; else workInsertPoint = original.length; const modified = original.slice(0, workInsertPoint) + `\n- [ ] 外部添加的新任务 A1\n` + `- [✓] 外部添加的已完成 B2 [⭐]\n` + original.slice(workInsertPoint); console.log(`原文件 ${original.length} 字符 → 修改后 ${modified.length} 字符(修改段:${targetName})`); // 模拟主进程 IPC 发来的 changedPath(反斜杠) // mockFilePath 在 win32 上已用反斜杠;正反斜杠等价仍由 pathsEqual 覆盖 const ipcChangedPath = mockFilePath.replace(/\//g, '\\'); check('pathsEqual 通过(正反斜杠等价)', pathsEqual(ipcChangedPath, store.filePath)); check('文件内容确实与内存不同', modified !== store.serialize()); // 调用 loadFromContent const beforeWorkTasks = store.categories.find(c => c.name === targetName).tasks.length; store.loadFromContent(modified, ipcChangedPath); const afterWorkTasks = store.categories.find(c => c.name === targetName).tasks.length; console.log(`「${targetName}」任务数: ${beforeWorkTasks} → ${afterWorkTasks}`); check(`重载后「${targetName}」任务数增加`, afterWorkTasks === beforeWorkTasks + 2, `${beforeWorkTasks} → ${afterWorkTasks}`); const newWorkCat = store.categories.find(c => c.name === targetName); check(`重载后「${targetName}」包含 A1 任务`, newWorkCat.tasks.some(t => t.text === '外部添加的新任务 A1')); check(`重载后「${targetName}」包含 B2 任务`, newWorkCat.tasks.some(t => t.text === '外部添加的已完成 B2')); check('重载后 A1 是 pending', newWorkCat.tasks.find(t => t.text === '外部添加的新任务 A1')?.completed === false); check('重载后 B2 是 completed', newWorkCat.tasks.find(t => t.text === '外部添加的已完成 B2')?.completed === true); check('重载后 B2 是 important', newWorkCat.tasks.find(t => t.text === '外部添加的已完成 B2')?.important === true); check('change 事件触发', store.events.some(e => e.event === 'change')); check('load 事件触发', store.events.some(e => e.event === 'load')); check(`selectedCategoryName 保持「${targetName}」`, store.selectedCategoryName === targetName); } console.log('\n[场景 B] CRLF 行尾处理(VSCode Windows 默认保存 CRLF)'); const crlf = original.replace(/\n/g, '\r\n'); const storeB = new MockStore(original, mockFilePath); { // 同样的「段终点」兜底 —— 必须落在目标段内,否则任务会跑到已完成/回收站去 const targetHeaderIdxB = crlf.indexOf(`\r\n## ${targetName}`); let workInsertPointB; if (targetHeaderIdxB < 0) { workInsertPointB = crlf.length; } else { const h2b = crlf.indexOf('\r\n## ', targetHeaderIdxB + 2); const h1b = crlf.indexOf('\r\n# ', targetHeaderIdxB + 2); if (h2b > 0 && h1b > 0) workInsertPointB = Math.min(h2b, h1b); else if (h2b > 0) workInsertPointB = h2b; else if (h1b > 0) workInsertPointB = h1b; else workInsertPointB = crlf.length; } const modifiedB = crlf.slice(0, workInsertPointB) + `\r\n- [ ] CRLF 测试任务\r\n` + crlf.slice(workInsertPointB); const parsed = parseMarkdown(modifiedB).find(c => c.name === targetName); check(`parseMarkdown 容忍 CRLF(${targetName})`, parsed && parsed.tasks.some(t => t.text === 'CRLF 测试任务')); } console.log('\n[场景 C] 选中分类被外部删除'); const storeC = new MockStore(original, mockFilePath); storeC.selectedCategoryName = targetName; // 删掉 `## ${targetName}` 整段:向后看必须兼顾下一个 h2 或下一个 h1 // (用户文件里最后一个子分类后可能直接接 # 已完成任务 / # 回收站,没有 `\n## ` 也得删干净) // 同时必须删干净**所有同名 h2**(如 `## 未分类` 双写)—— 不然剩下那一份会 // 兜住 selectedCategoryName,让"分类被删回退到 current"的断言看起来没过, // 但实际是产品行为正确:内存里那个分类没被真正删掉。 const escName = targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); let removedWork = original.replace( new RegExp('## ' + escName + '[\\s\\S]*?(?=\\n## |\\n# |$)', 'g'), '' ); if (removedWork === original) { // 兜底:上面的正则在某些边界条件下没命中,改用 slice 整段切走。 // 同样需要清掉所有同名 h2 —— 反复 slice 直到 indexOf 找不到为止。 let working = original; let startIdx = working.indexOf('## ' + targetName); while (startIdx >= 0) { let endIdx = -1; const after = startIdx + ('## ' + targetName).length + 1; const nextH1 = working.indexOf('\n# ', after); const nextH2 = working.indexOf('\n## ', after); if (nextH1 > 0 && nextH2 > 0) endIdx = Math.min(nextH1, nextH2); else if (nextH1 > 0) endIdx = nextH1; else if (nextH2 > 0) endIdx = nextH2; else endIdx = working.length; working = working.slice(0, startIdx) + working.slice(endIdx); startIdx = working.indexOf('## ' + targetName); } removedWork = working; } storeC.loadFromContent(removedWork, mockFilePath); check('分类被删后回退到 current 智能视图', storeC.selectedSmartList === 'current', `selectedSmartList=${storeC.selectedSmartList}, selectedCategoryName=${storeC.selectedCategoryName}`); check('分类被删后 selectedCategoryName 清空', storeC.selectedCategoryName === null); console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); process.exit(summary.fail === 0 ? 0 : 1);