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

65
scripts/_lib/api-stub.mjs Normal file
View File

@@ -0,0 +1,65 @@
// 跨脚本共享window.api 桩。
//
// 用途TaskStore / SettingsStore 等模块在模块加载时读 window.api.*,没有 IPC
// preload 环境时直接抛 undefined。之前 4+ 个脚本各自复制一份 6 行 stub
// 容易漏字段或拼写漂移。
//
// 用法:
// import { installApiStub } from './_lib/api-stub.mjs';
// installApiStub(); // 默认桩
// installApiStub({ // 自定义部分方法
// writeFile: async () => ({ ok: true }),
// });
// // 测试中要临时改某个方法:
// globalThis.window.api.readFile = async () => ({ ok: true, content: '...' });
/**
* 默认 stub所有方法返回「无害成功」值让 TaskStore / SettingsStore 不抛错。
*
* 默认实现按「最小可运行」原则写writeFile/readFile 返回 {ok:true}on* 返回
* noop unsubscribenotify* 返回 undefined。
* 需要断言具体调用的脚本可以覆盖。
*/
export function installApiStub(overrides = {}) {
globalThis.window = globalThis.window || {};
const api = {
// 文件 IO —— TaskStore._autoSave / file-* IPC 用
writeFile: async () => ({ ok: true }),
readFile: async () => ({ ok: true, content: '' }),
fileExists: async () => true,
createIfMissing: async () => ({ ok: true }),
createSnapshot: async () => ({ ok: true, snapshotPath: '' }),
// 事件订阅 —— on* 模式返回 unsubscribe
onFileExternalChange: () => () => {},
onMenuCommand: () => () => {},
onFlushPendingSave: () => () => {},
onAlwaysOnTopChanged: () => () => {},
onMaximizeStateChanged: () => () => {},
onDirtyChanged: () => () => {},
// 单向通知 —— preload 提供给 renderer 主动调
notifyDirtyChanged: () => {},
notifyFlushDone: () => {},
// 设置存储
getSettings: async () => ({}),
saveSettings: async () => ({}),
// 窗口控制
setAlwaysOnTop: async () => true,
minimizeWindow: async () => true,
toggleMaximizeWindow: async () => true,
closeWindow: async () => true,
// 应用信息
getDataDir: async () => '',
getDefaultDataDir: async () => '',
getVersion: async () => '0.0.0-test',
getElectronVersion: async () => null,
...overrides,
};
globalThis.window.api = api;
return api;
}

83
scripts/_lib/check.mjs Normal file
View File

@@ -0,0 +1,83 @@
// 跨脚本共享:统一的断言 / 计数 / 退出 helper。
//
// 之前散落在 ~25 个脚本里的断言模式有 3 种:
// 1) check(name, cond, detail='') + pass/fail 计数(大多数)
// 2) ok(name) / fail(name, msg) + failed 计数(少数)
// 3) 自定义 ok/bad + 自己的计数器settings / backup / status-meta
//
// 这里统一提供:
// - check(name, cond, detail?) —— 主流风格条件断言detail 是失败时的诊断
// - ok(name) / bad(name, msg) —— 风格 2 / 3 的兼容入口(适合「手动构造失败信息」)
// - printSummary(label) —— 打印「通过 X / 失败 Y」并按需 exit
// - summary 对象 —— 共享计数器,避免每个脚本各定义 pass/fail
//
// 用法:
// import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
// check('某断言', cond, '可选失败详情');
// ...
// printSummary('check-batch-ops');
export const summary = { pass: 0, fail: 0 };
/**
* 主流断言cond 为真则通过,否则打印失败 + 可选详情。
* @param {string} name
* @param {boolean} cond
* @param {string} [detail]
*/
export function check(name, cond, detail = '') {
if (cond) {
summary.pass++;
console.log(`${name}`);
} else {
summary.fail++;
const tail = detail ? `${detail}` : '';
console.log(`${name}${tail}`);
}
}
/**
* 显式 ok用于「不需要断言条件直接报告通过」的场景。
* 兼容旧脚本里的 ok() 风格。
* @param {string} name
*/
export function ok(name) {
summary.pass++;
console.log(`${name}`);
}
/**
* 显式 bad用于「手动构造失败信息」的场景错误细节比条件断言更复杂时
* 兼容旧脚本里的 fail()/bad() 风格。
* @param {string} name
* @param {string} [msg]
*/
export function bad(name, msg = '') {
summary.fail++;
const tail = msg ? `: ${msg}` : '';
console.log(`${name}${tail}`);
}
/**
* 打印「通过 X / 失败 Y」总结行。
* exitOnFail=true 时若有失败则以 exit 1 退出(脚本默认应这么用)。
*
* @param {string} [label] - 总结行前的标签,例如脚本名
* @param {boolean} [exitOnFail=true]
*/
export function printSummary(label = '', exitOnFail = true) {
const head = label ? `${label}` : '';
console.log(`\n${head}通过 ${summary.pass} / 失败 ${summary.fail}`);
if (exitOnFail && summary.fail > 0) {
process.exit(1);
}
}
/**
* 重置计数器 —— 脚本里想做多轮隔离断言时调用。
* 大多数脚本不需要:进程结束就 exitcounter 自然释放。
*/
export function resetSummary() {
summary.pass = 0;
summary.fail = 0;
}

View File

@@ -0,0 +1,79 @@
// 跨脚本共享:找用户的真实 todo.md 路径并读取内容。
//
// 解决 user memory 里反复提到的硬编码路径问题:
// - 之前 `scripts/check-*.mjs` / `verify-*.mjs` 直接写死 `c:/Users/guan/TodoList/todo.md`
// 在别人的机器 / CI 必挂;
// - 这套查找链按优先级收敛CLI `--file` → `TODO_LIST_FILE` 环境变量 → 平台约定
// (用户主目录下的 ~/TodoList/todo.md跨平台统一
//
// 用法:
// import { findTodoFile } from './_lib/find-todo-path.mjs';
// const { path: todoPath, content } = findTodoFile() ?? {};
// if (!todoPath) { /* 走内置 SAMPLE 兜底 */ }
//
// 返回 `null`(找不到任何候选)让调用方决定降级策略 —— 通常是回落到内置 SAMPLE 跑,
// 与「脚本不能因为路径在别人机器上不一样就硬挂」的回归策略一致。
import { readFileSync } from 'node:fs';
/**
* 找用户的真实 todo.md。
* @returns {{ path: string, content: string } | null}
*/
export function findTodoFile() {
// 1) 命令行 --file=xxx / --file xxx 优先CI / 调试覆盖)
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--file' && argv[i + 1]) {
const p = argv[i + 1];
try {
return { path: p, content: readFileSync(p, 'utf8') };
} catch {
return null;
}
}
if (a.startsWith('--file=')) {
const p = a.slice('--file='.length);
try {
return { path: p, content: readFileSync(p, 'utf8') };
} catch {
return null;
}
}
}
// 2) 环境变量(脚本间共享 —— e.g. CI 全局指定一个 todo.md
const envPath = process.env.TODO_LIST_FILE;
if (envPath) {
try {
return { path: envPath, content: readFileSync(envPath, 'utf8') };
} catch {
/* 文件被删 / 权限不足 —— 继续往下找 */
}
}
// 3) 平台约定:用户主目录下的 TodoList~/TodoList
//
// 注意:不要用 HOMEDRIVE 单拼 —— `HOMEDRIVE=C:` + 不带 HOMEPATH 会得到
// `C:\TodoList\todo.md`C: 盘根目录下的 TodoList完全不是用户的 home。
// 应该走 USERPROFILE / HOME 这类已经合并好 drive + path 的环境变量。
const home = process.env.USERPROFILE || process.env.HOME || '';
const candidates = [];
if (home) {
if (process.platform === 'win32') {
candidates.push(`${home}\\TodoList\\todo.md`, `${home}/TodoList/todo.md`);
} else {
candidates.push(`${home}/TodoList/todo.md`);
}
}
for (const p of candidates) {
try {
return { path: p, content: readFileSync(p, 'utf8') };
} catch {
/* 跳过不存在的候选 */
}
}
return null;
}

View File

@@ -0,0 +1,36 @@
// 跨脚本共享:构造 TaskStore 测试 fixture。
//
// 之前 ~20 个脚本里复制粘贴 makeStore(),三套签名(必填 md / 可选 md / 无参)
// 实质等价:建 store → loadDefault 或 loadFromContent → 禁掉 autoSave。
//
// 统一为 makeStore(markdown = null, options = {})
// - markdown = null/不传 = 走 loadDefault()(默认文档)
// - markdown = 字符串 = 走 loadFromContent(markdown, null)(自定义内容)
// - options.settingsStore = 一个 settingsStore 实例(桩或真),注入后 TaskStore
// 才能读到 *Position 偏好;不传等价于未注入 → TaskStore 走 'front' 默认值。
//
// autoSave 必须禁掉:脚本里没有真实的 preload/IPC 环境,让 autoSave 跑会触发
// 找不到 window.api 而抛错。所有脚本都做了相同的事——集中到 helper 里确保新人
// 不用手动记得这一步。
import { TaskStore } from '../../src/task-store.js';
/**
* 构造一个 TaskStore fixture。
*
* @param {string|null} [markdown=null] - 自定义 markdownnull = 用默认文档
* @param {{ settingsStore?: object }} [options={}] - 注入的 settingsStore可选
* @returns {TaskStore}
*/
export function makeStore(markdown = null, options = {}) {
const store = new TaskStore(options);
if (markdown !== null) {
store.loadFromContent(markdown, null);
} else {
store.loadDefault(null);
}
// 关掉 autoSave 的副作用(避免污染测试环境 / 触发 IPC
// 保留 .cancel() / .flush() 接口,调用方代码不需要做 null 检查。
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
return store;
}

48
scripts/_lib/tmp-todo.mjs Normal file
View File

@@ -0,0 +1,48 @@
// 跨脚本共享:临时 todo.md 文件的创建 + 清理。
//
// 用途polling-fallback / snapshot-creation / write-race 等脚本要在隔离目录
// 下模拟真实 todo.md 文件,每次都重复同样的 3 行 mkdtempSync+writeFileSync
// boilerplate且清理端 `fs.rmSync(tmpDir, { recursive, force })` 在 3 个脚本
// 各自复制一份 —— 任何一处改成 `fs.rm` 或漏 `{ force: true }` 就会在 CI 上
// 留下 tmp 目录。
//
// 用法:
// import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs';
// const { dir, file } = makeTmpTodoFile({ prefix: 'poll-test-' });
// // ... 测试 ...
// cleanupTmpTodo({ dir });
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// 默认 seed最小可工作的「全部任务」+「工作」分类 + 一条 a 任务。
// 覆盖路径:如果脚本要不同初始内容,传 `content` overridesnapshot-creation
// 用 `# todo\n- [ ] A\n- [ ] B\n`write-race 用 `## 工作\n\n- [ ] V0`)。
const DEFAULT_SEED = '# 全部任务\n\n## 工作\n\n- [ ] a\n';
/**
* 创建一个临时目录并写入 todo.md返回 { dir, file }。
*
* @param {object} [opts]
* @param {string} [opts.prefix='todo-test-'] — mkdtempSync 前缀,含连字符
* @param {string} [opts.content] — 初始 markdown 内容;不传走 DEFAULT_SEED
* @returns {{ dir: string, file: string }}
*/
export function makeTmpTodoFile({ prefix = 'todo-test-', content = DEFAULT_SEED } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const file = path.join(dir, 'todo.md');
fs.writeFileSync(file, content);
return { dir, file };
}
/**
* 清理临时目录。`force: true` 让目录不存在时也不抛 —— 测试中途崩了也能跑。
*
* @param {object} opts
* @param {string} opts.dir
*/
export function cleanupTmpTodo({ dir }) {
if (!dir) return;
fs.rmSync(dir, { recursive: true, force: true });
}