Files
Todo-List/scripts/verify-reload-ui.mjs
2026-09-12 13:55:57 +08:00

238 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// 模拟用户场景:外部修改 → 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);