This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
// tests/unit/i18n/locale-parity.test.ts
//
// 守卫:zh-CN.json / en-US.json 顶层键结构必须严格一致。
// 行为:把两文件扁平化到 dot 键(递归 object),两侧集合不同就 fail。
// 目的:防止漏译 / 拼错键名 —— 双 locale 同时被引,i18next 在缺的 key 上抛
// 警告并把 zh 当 fallback,renderer 看似正常但英文版回退中文,debug 极难。
//
// 已知 i18n stub:en-US 部分技术字面量(loadingDots '…')和 zh 相同是
// 预期 —— 不因此 fail。
//
// 历史:audit-r156 (2026-09-?) 起一直被守;r165-hotfix 收紧"两边 dict
// 完整性"语义后从 1 例失败拉到 1 例通过(去掉 en-US 9 个多余键)。
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, expect, it } from 'vitest';
interface LocaleJson {
[key: string]: unknown;
}
const LOCALES_DIR = resolve(__dirname, '../../../src/renderer/src/i18n/locales');
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
describe('i18n locale parity', () => {
const zhPath = resolve(LOCALES_DIR, 'zh-CN.json');
const enPath = resolve(LOCALES_DIR, 'en-US.json');
const zh = JSON.parse(readFileSync(zhPath, 'utf8')) as LocaleJson;
const en = JSON.parse(readFileSync(enPath, 'utf8')) as LocaleJson;
const zhKeys = new Set(flatKeys(zh));
const enKeys = new Set(flatKeys(en));
it('zh-CN.json 和 en-US.json 顶层都读得到且是合法 JSON', () => {
expect(typeof zh).toBe('object');
expect(typeof en).toBe('object');
});
it('两 locale 的扁平键集合严格一致', () => {
const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k)).sort();
const onlyEn = [...enKeys].filter((k) => !zhKeys.has(k)).sort();
expect({ onlyZh, onlyEn }).toEqual({ onlyZh: [], onlyEn: [] });
});
it('两 locale 都有 _meta.code 字段(自我描述)', () => {
const zhMeta = (zh as { _meta?: { code?: string } })._meta;
const enMeta = (en as { _meta?: { code?: string } })._meta;
expect(zhMeta?.code).toBe('zh-CN');
expect(enMeta?.code).toBe('en-US');
});
});

View File

@@ -0,0 +1,113 @@
// tests/unit/i18n/no-dead-keys.test.ts
//
// 守 r233「locale 键集合不得有死键」这条卫生契约。
//
// ── 为什么值得守 ─────────────────────────────────────────────────
// renderer 的 t() 是**字符串寻址**,TS 永远不报错:功能下线时谁也不会
// 删 locale 键 ⇒ 键集合只涨不缩,r206 主进程字典清过一次(90→77),
// renderer 字典从没清过。死键的代价:
// ① 误导维护者 —— 看到 `modal.renameLiterature` 以为还有这个弹窗,
// 实际只有 `renameLiteratureConfirm` 活着(r233 实测);
// ② 误导占位符契约 / locale parity 的排查面 —— 集合越大噪声越多;
// ③ 翻译成本 —— 每加一个 locale 就要为死键多付一遍翻译。
// r233 清掉的两条:`modal.renameLiterature`(ConfirmDialog 化后只剩
// Confirm 键活着)、`folders.moveOutOfFolder`(「移出文件夹」功能自
// disk-folder 改造后整段下线,只剩注释提到)。
//
// ── 扫描口径(先剥注释再匹配,注释提到 key 不算使用)───────────────
// 一个键算「活着」当且仅当 src/renderer 去注释源码里命中以下任一形态:
// 1. 字面量:t('ns.key') / t("ns.key") / t(`ns.key`);
// 2. 复数基:`notes.listCount_one/_other` 的基 `notes.listCount` 命中
// 字面量(i18next 按 count 自动拼后缀);
// 3. 前缀模板:源码出现 `t(`ns.${…}`)` ⇒ `ns.*` 整族视同 used
// (settings.platform / startup.phase 靠这条);
// 4. tWithFallback('ns', …) 第一参是命名空间字面量 ⇒ `ns.*` 整族
// 视同 used(ErrorFallback 的 errorBoundary.* / toast.* 靠这条,
// 口径与组件头注的「调用形态不变式」一致)。
// `_meta.*` 是结构字段(locale-parity 直接 JSON 读,不走 t()),豁免。
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_SRC = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[key: string]: unknown;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
/** 收集 src/renderer 全部 .ts/.tsx(排除 locales 目录本身)。 */
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (p.includes('locales')) continue;
out.push(...collectSourceFiles(p));
} else if (/\.(ts|tsx)$/.test(e.name)) {
out.push(p);
}
}
return out;
}
/** 剥掉注释 —— 注释里提到 key 不算使用(否则死键靠死注释「续命」)。 */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
}
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
/** 死键扫描:返回 src/renderer 里没有任何活引用的 locale 键。 */
export function findDeadLocaleKeys(locale: LocaleJson, strippedCorpus: string): string[] {
const keys = flatKeys(locale);
const hit = (s: string): boolean =>
strippedCorpus.includes(`'${s}'`) ||
strippedCorpus.includes(`"${s}"`) ||
strippedCorpus.includes('`' + s + '`');
const dead: string[] = [];
for (const k of keys) {
if (k.startsWith('_meta.')) continue;
const base = k.replace(PLURAL_SUFFIX, '');
if (hit(k)) continue;
if (base !== k && hit(base)) continue;
const ns = k.split('.').slice(0, -1).join('.');
if (ns && (strippedCorpus.includes('`' + ns + '.${') || strippedCorpus.includes(`'${ns}.\${`)))
continue;
if (ns && strippedCorpus.includes(`tWithFallback('${ns}'`)) continue;
dead.push(k);
}
return dead;
}
describe('renderer locale 无死键(r233)', () => {
const zh = JSON.parse(readFileSync(join(LOCALES_DIR, 'zh-CN.json'), 'utf8')) as LocaleJson;
const corpus = collectSourceFiles(RENDERER_SRC)
.map((f) => stripComments(readFileSync(f, 'utf8')))
.join('\n');
it('zh-CN 每个键都在 src/renderer 有活引用(死键 = 功能已下线但键没删)', () => {
const dead = findDeadLocaleKeys(zh, corpus);
expect(dead, `死键(功能下线后忘删):\n ${dead.join('\n ')}`).toEqual([]);
});
it('en-US 同样无死键(两 locale 键集合 parity 已有守卫,这里双保险)', () => {
const en = JSON.parse(readFileSync(join(LOCALES_DIR, 'en-US.json'), 'utf8')) as LocaleJson;
const dead = findDeadLocaleKeys(en, corpus);
expect(dead, `死键(功能下线后忘删):\n ${dead.join('\n ')}`).toEqual([]);
});
});

View File

@@ -0,0 +1,143 @@
// tests/unit/i18n/no-missing-keys.test.ts
//
// 守「**代码里用的 i18n 键必须真的存在**」这条契约(no-missing-keys)。
//
// ── 为什么值得守(与 no-dead-keys 是镜像的一对)────────────────────
// renderer 的 t() 是**字符串寻址**:写错一个字母,TS 不报错、eslint 不
// 报错、build 不报错 —— 只有真正渲染到那一处时,i18next 找不到 key,
// **把 key 本身当文案印出来**。用户看到的是 `notes.svaeSucess` 这种
// 点号串,而不是「保存成功」。
//
// 与已有守卫的分工:
// · locale-parity.test.ts → zh 集合 == en 集合(漏译)
// · no-dead-keys.test.ts → locale 里的键都被代码用(死键)
// · **本测** → 代码里用的键都在 locale 里(缺键)
// 主进程侧有同款守卫:tests/unit/main/menu-label-i18n-keys.test.ts
// (「调用点引用的 key 必须在 T 里」)。renderer 侧此前**没有**这面镜子:
// r206 只在主进程字典上补过,renderer 的 JSON 侧一直裸奔。
//
// ── 扫描口径(与 no-dead-keys 对齐,免得两套口径互相打脸)───────────
// 先剥注释(注释里提到 key 不算使用),再扫 src/renderer/**/*.ts(x):
// 1. 字面量:t('ns.key') / t("ns.key") / t(`ns.key`) —— 键必须存在;
// 2. 复数基:代码写 'notes.listCount',locale 里是
// 'notes.listCount_one' / '_other' ⇒ 按 i18next 的复数后缀解析,
// 基键 + 任意一个复数变体存在即算命中;
// 3. 前缀模板:t(`ns.${…}`) ⇒ 要求 ns.* 至少有一个键存在
// (settings.platform / startup.phase 走这条);
// 4. tWithFallback('ns', …) ⇒ 要求 ns.* 至少有一个键存在
// (ErrorFallback 的 errorBoundary.* / toast.* 走这条)。
// `_meta.*` 是结构字段(locale-parity 直接 JSON 读,不走 t()),不参与。
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_SRC = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[k: string]: unknown;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
/** 收集 src/renderer 全部 .ts/.tsx(排除 locales 目录本身)。 */
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (p.includes('locales')) continue;
out.push(...collectSourceFiles(p));
} else if (/\.(ts|tsx)$/.test(e.name)) {
out.push(p);
}
}
return out;
}
/** 剥掉注释 —— 注释里提到 key 不算使用。 */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
}
const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'];
/** 从源码里抽出「用到的字面量键」。 */
function findUsedKeys(corpus: string): string[] {
const out = new Set<string>();
// t('ns.key') / t("ns.key") / t(`ns.key`)
const lit = /\bt\(\s*['"`]([A-Za-z0-9_.-]+)['"`]/g;
let m: RegExpExecArray | null;
while ((m = lit.exec(corpus)) !== null) out.add(m[1]!);
return [...out];
}
/** 从源码里抽出「用到的模板命名空间」(t(`ns.${…}`) / tWithFallback('ns', …))。 */
function findUsedNamespaces(corpus: string): string[] {
const out = new Set<string>();
const tpl = /\bt\(\s*['"`]([A-Za-z0-9_-]+)\.\$\{/g;
let m: RegExpExecArray | null;
while ((m = tpl.exec(corpus)) !== null) out.add(m[1]!);
const fb = /\btWithFallback\(\s*['"`]([A-Za-z0-9_-]+)['"`]/g;
while ((m = fb.exec(corpus)) !== null) out.add(m[1]!);
return [...out];
}
/**
* 键是否存在 —— 复数基也算命中(i18next 按 count 拼 _one/_other 后缀)。
*/
function keyExists(keys: Set<string>, key: string): boolean {
if (keys.has(key)) return true;
return PLURAL_SUFFIXES.some((s) => keys.has(key + s));
}
export function findMissingKeys(locale: LocaleJson, usedKeys: string[], usedNamespaces: string[]): string[] {
const keys = new Set(flatKeys(locale));
const missing: string[] = [];
for (const k of usedKeys) {
if (k.startsWith('_meta.')) continue;
if (!keyExists(keys, k)) missing.push(k);
}
for (const ns of usedNamespaces) {
const hasAny = [...keys].some((k) => k.startsWith(`${ns}.`));
if (!hasAny) missing.push(`${ns}.* (模板命名空间无任何键)`);
}
return missing.sort();
}
describe('renderer i18n 无缺键(用了但 locale 里没有 ⇒ 界面印出裸 key)', () => {
const zh = JSON.parse(readFileSync(join(LOCALES_DIR, 'zh-CN.json'), 'utf8')) as LocaleJson;
const en = JSON.parse(readFileSync(join(LOCALES_DIR, 'en-US.json'), 'utf8')) as LocaleJson;
const corpus = collectSourceFiles(RENDERER_SRC)
.map((f) => stripComments(readFileSync(f, 'utf8')))
.join('\n');
const usedKeys = findUsedKeys(corpus);
const usedNamespaces = findUsedNamespaces(corpus);
it('扫描器非空转:至少扫到若干调用点与命名空间(防空转守卫)', () => {
expect(usedKeys.length).toBeGreaterThan(50);
expect(usedNamespaces.length).toBeGreaterThan(0);
});
it('zh-CN:代码里用到的每个键都存在(缺键 = 界面印出 key 本身)', () => {
const missing = findMissingKeys(zh, usedKeys, usedNamespaces);
expect(missing, `zh-CN 缺键:\n ${missing.join('\n ')}`).toEqual([]);
});
it('en-US:代码里用到的每个键都存在(缺键 = 英文界面印出 key 本身)', () => {
const missing = findMissingKeys(en, usedKeys, usedNamespaces);
expect(missing, `en-US 缺键:\n ${missing.join('\n ')}`).toEqual([]);
});
});

View File

@@ -0,0 +1,376 @@
// tests/unit/i18n/placeholder-contract.test.ts
//
// 守卫(r206,2026-10-20):**i18n 调用点 ↔ locale 模板的占位符契约**。
//
// 为什么这条是安全面(不是风格):占位符对不上时 i18next **不报错、不抛异常**
// —— 它把填不上的 `{xxx}` 原样留在渲染结果里。用户看到的是
// 「导入失败:{count} 个文件导入失败:
// {msgs}」这种半成品文案(2026-10-17 r73 的真实现场:
// 调用点传 `{ msg }`,而 locale 模板要 `{count}` / `{msgs}` —— 两个都没填上)。
// 而 TypeScript **完全看不见**这件事:react-i18next 的 t() 第二个参数是
// `Record<string, unknown>`,传错 key 名不报错;locale JSON 是普通字符串,
// 编译期也不校验。⇒ 只有一条**行为级**契约能拦。
//
// 三条不变式:
// 1. 同一 key 的 zh-CN / en-US 两份模板占位符集合**完全一致**
// (漏翻译一侧的占位符 = 切语言后文案少一段);
// 2. 同一 key 的复数变体(base / _one / _other)占位符集合完全一致
// (i18next 按 count 选变体,变体之间占位符不同 = 某些数量下少占位符);
// 3. 每个 t('key', {vars}) 调用点,vars 集合**恰好等于**模板占位符集合
// (少传 ⇒ 裸占位符露出;多传 ⇒ 填不进去,通常是 key 写错)。
//
// 判定方式:静态扫 src/renderer/src/** 的 t() 调用点 + 读两份 locale JSON。
// 扫之前先剥注释(注释里的 t('x') 不是调用点)。
//
// 覆盖边界(诚实声明):
// - 只认**字面量** key 与**字面量** vars 对象;`t(someVar)`、`t(`a.${b}`)`、
// `t('k', opts)`(opts 是变量)一律跳过 —— 静态分析看不见它们的 vars。
// - 只覆盖 src/renderer/src/**;主进程那套 T 字典由
// tests/unit/main/menu-label-i18n-keys.test.ts 守(结构不同:扁平的
// key → {zh-CN,en-US},不是嵌套 JSON)。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_DIR = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[key: string]: unknown;
}
function readLocale(name: string): LocaleJson {
return JSON.parse(readFileSync(join(LOCALES_DIR, `${name}.json`), 'utf8')) as LocaleJson;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
function flatValues(value: LocaleJson, prefix = ''): Map<string, string> {
const out = new Map<string, string>();
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
for (const [k2, v2] of flatValues(v as LocaleJson, key)) out.set(k2, v2);
} else {
out.set(key, String(v));
}
}
return out;
}
const PLACEHOLDER_RE = /\{([A-Za-z0-9_]+)\}/g;
function placeholdersOf(text: string): Set<string> {
const out = new Set<string>();
PLACEHOLDER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PLACEHOLDER_RE.exec(text)) !== null) out.add(m[1]);
return out;
}
// === 源码扫描 ===
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/**
* 剥掉注释(保留行数,便于报行号)。
*
* 自己写而不是用正则,是因为正则分不清 `//` 是注释还是字符串里的
* 'https://...'。这里做一个极小的词法扫描:单/双引号字符串整体跳过,
* 模板串按整段跳过(内部 ${} 的转义不深究 —— 仓库里没有含反引号的
* 嵌套模板串)。
*/
function stripComments(src: string): string {
const out: string[] = [];
let i = 0;
const n = src.length;
let inLine = false;
let inBlock = false;
let quote: string | null = null;
while (i < n) {
const c = src[i];
const c2 = src[i + 1];
if (inLine) {
if (c === '\n') {
inLine = false;
out.push(c);
} else {
out.push(' ');
}
i++;
continue;
}
if (inBlock) {
if (c === '*' && c2 === '/') {
inBlock = false;
out.push(' ');
i += 2;
continue;
}
out.push(c === '\n' ? '\n' : ' ');
i++;
continue;
}
if (quote) {
out.push(c);
if (c === '\\') {
if (i + 1 < n) out.push(src[i + 1]);
i += 2;
continue;
}
if (c === quote) quote = null;
i++;
continue;
}
if (c === '/' && c2 === '/') {
inLine = true;
out.push(' ');
i += 2;
continue;
}
if (c === '/' && c2 === '*') {
inBlock = true;
out.push(' ');
i += 2;
continue;
}
if (c === "'" || c === '"' || c === '`') {
quote = c;
out.push(c);
i++;
continue;
}
out.push(c);
i++;
}
return out.join('');
}
/** 从 openIdx(必须是 '(' 的位置)起取出成对括号内的内容 */
function argsOf(src: string, openIdx: number): string {
let depth = 0;
for (let i = openIdx; i < src.length; i++) {
const c = src[i];
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (depth === 0) return src.slice(openIdx + 1, i);
}
}
return src.slice(openIdx + 1);
}
/** 顶层逗号切分(跳过括号/引号内部) */
function splitTopLevel(args: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote: string | null = null;
let cur = '';
for (let i = 0; i < args.length; i++) {
const c = args[i];
if (quote) {
cur += c;
if (c === '\\') {
cur += args[i + 1] ?? '';
i++;
continue;
}
if (c === quote) quote = null;
continue;
}
if (c === "'" || c === '"' || c === '`') {
quote = c;
cur += c;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
if (c === ',' && depth === 0) {
parts.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
if (cur.trim()) parts.push(cur.trim());
return parts;
}
/** 对象字面量的键集合 —— 同时认 `k: v` 与简写 `{ k }` */
function objectKeys(obj: string): string[] | null {
const body = obj.trim();
if (!body.startsWith('{') || !body.endsWith('}')) return null;
const inner = body.slice(1, -1).trim();
if (!inner) return [];
const keys: string[] = [];
for (const part of splitTopLevel(inner)) {
const m = /^([A-Za-z_$][A-Za-z0-9_$]*)\s*(:|$)/.exec(part);
if (!m) return null; // 计算属性 / 展开 → 放弃分析这一处
keys.push(m[1]);
}
return keys;
}
interface CallSite {
file: string;
line: number;
key: string;
/** null = 调用点没传 vars(第二实参不是对象字面量) */
vars: string[] | null;
/** 第二实参存在但不是对象字面量(比如字符串默认值 / 变量) */
varsUnknown: boolean;
}
function collectCallSites(): CallSite[] {
const out: CallSite[] = [];
// 独立的标识符 t( —— 前一个字符不能是标识符字符/点(排除 .t( / obj.t( )
const re = /(?<![A-Za-z0-9_$.])t\s*\(/g;
for (const file of walkTs(RENDERER_DIR)) {
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
const raw = readFileSync(file, 'utf8');
const src = stripComments(raw);
for (const m of src.matchAll(re)) {
const openIdx = m.index + m[0].length - 1;
const args = splitTopLevel(argsOf(src, openIdx));
if (args.length === 0) continue;
const keyMatch = /^'([A-Za-z0-9_.]+)'$/.exec(args[0]);
if (!keyMatch) continue; // 动态 key / 模板串 → 跳过
const key = keyMatch[1];
const line = src.slice(0, m.index).split('\n').length;
if (args.length === 1) {
out.push({ file: rel, line, key, vars: null, varsUnknown: false });
continue;
}
const second = args[1];
const keys = objectKeys(second);
if (keys) {
out.push({ file: rel, line, key, vars: keys, varsUnknown: false });
} else {
out.push({ file: rel, line, key, vars: null, varsUnknown: true });
}
}
}
return out;
}
/** 复数变体:i18next 按 count 选 base / _one / _other */
function variantsOf(key: string, locale: Map<string, string>): string[] {
return [key, `${key}_one`, `${key}_other`].filter((k) => locale.has(k));
}
describe('i18n 占位符契约(r206)', () => {
const zh = readLocale('zh-CN');
const en = readLocale('en-US');
const zhVals = flatValues(zh);
const enVals = flatValues(en);
const callSites = collectCallSites();
it('同一 key 的 zh-CN / en-US 占位符集合一致', () => {
const bad: string[] = [];
for (const key of flatKeys(zh)) {
const a = placeholdersOf(zhVals.get(key) ?? '');
const b = placeholdersOf(enVals.get(key) ?? '');
if (a.size !== b.size || [...a].some((x) => !b.has(x))) {
bad.push(` ${key}: zh=[${[...a].sort()}] en=[${[...b].sort()}]`);
}
}
expect(bad.join('\n')).toBe('');
});
it('同一 key 的复数变体(base / _one / _other)占位符集合一致', () => {
const bad: string[] = [];
const all = new Set([...zhVals.keys(), ...enVals.keys()]);
for (const key of all) {
// 只看「某个复数变体存在」的 key
if (!key.endsWith('_one') && !key.endsWith('_other')) continue;
const base = key.replace(/_(one|other)$/, '');
const group = [base, `${base}_one`, `${base}_other`].filter((k) => zhVals.has(k));
const sets = group.map((k) => placeholdersOf(zhVals.get(k) ?? ''));
const first = sets[0];
if (sets.some((s) => s.size !== first.size || [...s].some((x) => !first.has(x)))) {
bad.push(
` ${base}: ` +
group.map((k) => `${k}=[${[...placeholdersOf(zhVals.get(k) ?? '')].sort()}]`).join(' '),
);
}
}
expect(bad.join('\n')).toBe('');
});
it('每个 t() 调用点传的 vars 恰好等于模板占位符集合', () => {
const bad: string[] = [];
for (const cs of callSites) {
if (cs.varsUnknown) continue; // 第二实参不是对象字面量 → 看不见
const variants = variantsOf(cs.key, zhVals);
if (variants.length === 0) {
bad.push(` ${cs.file}:${cs.line} → t('${cs.key}') 的 key 在 locale 里不存在`);
continue;
}
const need = placeholdersOf(zhVals.get(variants[0]) ?? '');
const given = new Set(cs.vars ?? []);
const missing = [...need].filter((x) => !given.has(x));
const extra = [...given].filter((x) => !need.has(x));
if (missing.length || extra.length) {
bad.push(
` ${cs.file}:${cs.line} → t('${cs.key}') 传=[${[...given].sort()}] ` +
`需=[${[...need].sort()}]` +
(missing.length ? ` 少传:${missing.join(',')}` : '') +
(extra.length ? ` 多传:${extra.join(',')}` : ''),
);
}
}
expect(bad.join('\n')).toBe('');
});
it('扫描器本身有效:能认出「少传 / 多传 / key 不存在」三种违规', () => {
// 探针:用真实的调用点解析器验证判定逻辑本身没写反。
const fake = [
"const a = t('k.one', { name: 1 });",
"const b = t('k.two', { wrong: 1 });",
"const c = t('k.missing', { name: 1 });",
].join('\n');
const parsed = [...fake.matchAll(/(?<![A-Za-z0-9_$.])t\s*\(/g)].map((m) => {
const openIdx = m.index + m[0].length - 1;
const args = splitTopLevel(argsOf(fake, openIdx));
return { key: /^'([A-Za-z0-9_.]+)'$/.exec(args[0])![1], vars: objectKeys(args[1]) };
});
expect(parsed).toEqual([
{ key: 'k.one', vars: ['name'] },
{ key: 'k.two', vars: ['wrong'] },
{ key: 'k.missing', vars: ['name'] },
]);
// 简写对象也要认出来(i18next 支持 `{ count }` 这种写法)
expect(objectKeys('{ count }')).toEqual(['count']);
expect(objectKeys('{ count: n, msgs: m }')).toEqual(['count', 'msgs']);
});
it('扫描器确实扫到了调用点(空集 = 扫描器坏了,不是契约没问题)', () => {
// 探针:防止上面几条因为「一个调用点都没扫到」而空过。
expect(callSites.length).toBeGreaterThan(20);
// 已知调用点:NotesList 的导入失败 toast(r73 的历史 bug 点)
const hit = callSites.find(
(c) => c.key === 'toast.importNoteFailed' && c.vars?.includes('msgs'),
);
expect(hit).toBeDefined();
});
});

317
tests/unit/layering.test.ts Normal file
View File

@@ -0,0 +1,317 @@
// tests/unit/layering.test.ts
//
// 守卫:进程分层 import 边界。renderer↔main 之间只能通过 preload(window.api)
// 桥,不能直接 import 对方代码。
//
// 规则:
// - src/main/**/*.{ts,tsx} 禁止 import renderer 代码
// - src/renderer/src/**/*.{ts,tsx} 禁止 import 主进程模块(走 window.api 桥)
// - src/preload/**/*.{ts,tsx} 允许 import main 的**类型**(IPC 类型 contract)
// 禁止 import renderer(否则把 renderer 逻辑拉进 main)
//
// ── audit-r254:守卫自身从「正则匹配源码文本」升级为「解析 import 并解析真实路径」──
//
// 旧实现有三处**假绿**(守卫绿着,却拦不住它声称要拦的东西)。以下每条
// 都做过**实证**(把违规探针塞进 src/,跑旧守卫看它是否转红):
//
// ① 跨语句吞并(P0)—— preload 那条规则用
// /import\s+(?:type\s+)?[\s\S]*?from\s*['"](?:@main\/|\.\.\/main\/…)/g
// `[\s\S]*?` 会把**两条相邻 import** 合成一条语句。于是
// import type { A } from '@shared/types'; ← 纯类型
// import { errMsg } from '../main/utils/error'; ← 运行时值(违规)
// 被并成一条,`isTypeOnlyImport` 看到行首的 `import type` ⇒ 判「纯类型」
// ⇒ **放行真正的违规**。
// 实证:旧守卫对这段 preload 代码 **绿**(违规在)。
//
// ② 别名漏网(P1)—— tsconfig paths 里 `@/*` 也指向 src/renderer/src,
// 而旧规则只认 `@renderer/` 与相对路径 ⇒ main 进程里写
// import { x } from '@/components/foo';
// **完全不被拦**。
// 实证:旧守卫对这段 main 代码 **绿**(违规在)。
//
// ③ 动态 import 漏网(P1)—— 旧规则只匹配 `from '…'` 与 `require('…')`,
// 不含 `import('…')` ⇒ renderer 里写
// const m = await import('@main/utils/error');
// **完全不被拦**。
// 实证:旧守卫对这段 renderer 代码 **绿**(违规在)。
//
// (另注:旧规则靠手写层级正则,`../../main` 是**子串**匹配 —— 幸运的是
// 它也能命中 `../../../main`(实测),所以相对路径这一维并没漏;但同样
// 的子串语义会误命中 `../../mainland` 这类目录名。改用真实路径解析后,
// 任意层级都对,且不再靠手写清单。)
//
// 新实现:先把源码里的注释剥掉(避免注释里的 import 字样误报/漏报),
// 再逐条**解析** import 语句拿到模块说明符,最后把说明符**解析成真实
// 绝对路径**并判定它落在哪一层 —— 别名(@main/ @renderer/ @shared/ @/)与
// 相对路径(任意层级)一律按解析结果判定,不再靠手写层级清单。
//
// 允许破例的清单(测试用例、源头已知安全)用 ALLOWED_REASON 注释说明。
import { readdirSync, readFileSync, statSync } from 'fs';
import { dirname, join, relative, resolve, sep } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...walkTs(full));
} else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) {
out.push(full);
}
}
return out;
}
interface Violation {
file: string;
line: number;
text: string;
}
type Layer = 'main' | 'renderer' | 'preload' | 'shared';
/**
* 剥掉注释,只留「代码」文本(逐行,行号不变)。
*
* 为什么必须剥:守卫扫的是**源码文本**,注释里写一句
* // 曾经写过:import { x } from '../main/utils/y';
* 就会把注释当成真 import(误报);反过来,注释里那句**真曾发生过**的
* 违规也不会被记录(漏报的「历史痕迹」不算问题,但不能反过来把真代码
* 也放过)。剥掉注释后,匹配到的就一定是真语句。
*
* 说明:模板字符串跨行时,本扫描器按「行内闭合」处理 —— 极端情况下会把
* 模板里的一行截断(只会造成漏报,不会误报)。
*/
function codeLines(src: string): string[] {
const out: string[] = [];
let inBlock = false;
for (const raw of src.split('\n')) {
let outLine = '';
let i = 0;
let quote: string | null = null;
while (i < raw.length) {
const c = raw[i]!;
const n = raw[i + 1];
if (inBlock) {
if (c === '*' && n === '/') {
inBlock = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (c === '\\') {
outLine += c + (n ?? '');
i += 2;
continue;
}
if (c === quote) quote = null;
outLine += c;
i++;
continue;
}
if (c === '/' && n === '*') {
inBlock = true;
i += 2;
continue;
}
if (c === '/' && n === '/') break; // 行注释 → 本行剩余丢弃
if (c === '"' || c === "'" || c === '`') {
quote = c;
outLine += c;
i++;
continue;
}
outLine += c;
i++;
}
out.push(outLine);
}
return out;
}
interface ImportHit {
/** 模块说明符(原样,如 '../main/utils/error') */
spec: string;
/** 命中的整条语句(归一化空白,便于报错展示) */
stmt: string;
/** 是否纯类型 import(不引入运行时值) */
typeOnly: boolean;
/** 1 基行号 */
line: number;
}
// import 语句:`import type X from 'y'` / `import { a, type b } from 'y'` /
// `import 'y'`(副作用) / `export ... from 'y'`(再导出)。
//
// 关键:`[^;'"]*?` 而不是 `[\s\S]*?` —— 前者**不允许**跨过引号与分号,
// 于是相邻两条 import **不会**被合成一条(旧实现的 P0-1)。
const IMPORT_FROM_RE = /\bimport\s+(type\s+)?([^;'"]*?)from\s*['"]([^'"]+)['"]/g;
const EXPORT_FROM_RE = /\bexport\s+([^;'"]*?)from\s*['"]([^'"]+)['"]/g;
const BARE_IMPORT_RE = /\bimport\s*['"]([^'"]+)['"]/g;
const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
const DYNAMIC_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
/** 判断一条 import 语句是否**纯类型**(不引入运行时值)。 */
function isTypeOnlyImport(stmt: string): boolean {
const t = stmt.trim();
if (/^import\s+type\b/.test(t)) return true;
const m = /import\s*\{([\s\S]*?)\}/.exec(t);
if (!m) return false; // default / namespace import ⇒ 运行时值
const specs = m[1]!
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return specs.length > 0 && specs.every((s) => s.startsWith('type '));
}
/** 收集一个文件里的所有 import / re-export / require / 动态 import。 */
function collectImports(src: string): ImportHit[] {
const lines = codeLines(src);
const hits: ImportHit[] = [];
const push = (spec: string, stmt: string, line: number): void => {
hits.push({ spec, stmt: stmt.replace(/\s+/g, ' ').trim(), typeOnly: isTypeOnlyImport(stmt), line });
};
lines.forEach((text, idx) => {
for (const m of text.matchAll(IMPORT_FROM_RE)) {
push(m[3]!, m[0], idx + 1);
}
for (const m of text.matchAll(EXPORT_FROM_RE)) {
push(m[2]!, m[0], idx + 1);
}
for (const m of text.matchAll(BARE_IMPORT_RE)) {
push(m[1]!, m[0], idx + 1);
}
for (const m of text.matchAll(REQUIRE_RE)) {
push(m[1]!, m[0], idx + 1);
}
for (const m of text.matchAll(DYNAMIC_RE)) {
push(m[1]!, m[0], idx + 1);
}
});
return hits;
}
/**
* 把模块说明符解析成「它落在哪一层」。
*
* 别名(tsconfig paths):
* @shared/* → src/shared (共享层,任何层都可引)
* @main/* → src/main
* @renderer/*→ src/renderer/src
* @/* → src/renderer/src
* 相对路径:按**导入文件所在目录**解析成绝对路径 —— 任意层级都对,
* 不再靠手写 `../../main` 这类层级清单(旧实现的 P0-2)。
* 裸包名:返回 null(第三方依赖,不在分层管辖内)。
*/
function layerOfSpecifier(spec: string, importerRel: string): Layer | null {
if (spec.startsWith('@shared/')) return 'shared';
if (spec.startsWith('@main/')) return 'main';
if (spec.startsWith('@renderer/') || spec.startsWith('@/')) return 'renderer';
if (!spec.startsWith('.')) return null; // 第三方包
const abs = resolve(ROOT, dirname(importerRel), spec);
const rel = relative(ROOT, abs).split(sep).join('/');
if (rel.startsWith('src/main/')) return 'main';
if (rel.startsWith('src/renderer/src/')) return 'renderer';
if (rel.startsWith('src/preload/')) return 'preload';
if (rel.startsWith('src/shared/')) return 'shared';
return null;
}
function detectLayer(relPath: string): Layer | null {
const rel = relPath.split(sep).join('/');
if (rel.startsWith('src/main/')) return 'main';
if (rel.startsWith('src/renderer/src/')) return 'renderer';
if (rel.startsWith('src/preload/')) return 'preload';
return null;
}
interface Rule {
layer: Exclude<Layer, 'shared'>;
/** 目标层:命中即越层 */
forbiddenTarget: Exclude<Layer, 'shared'>;
reason: string;
}
const RULES: Rule[] = [
{
layer: 'main',
forbiddenTarget: 'renderer',
reason: 'main 进程不应 import renderer 代码(IPC 是唯一通道)。',
},
{
layer: 'renderer',
forbiddenTarget: 'main',
reason: 'renderer 代码不应 import 主进程模块(走 window.api 桥)。',
},
{
layer: 'preload',
forbiddenTarget: 'renderer',
reason: 'preload 是 bridge,不应 import renderer(否则把 renderer 逻辑拉进 main)。',
},
];
describe('进程分层 import 边界', () => {
const sourceFiles = walkTs(join(ROOT, 'src'));
for (const rule of RULES) {
it(`${rule.layer}: ${rule.reason}`, () => {
const violations: Violation[] = [];
for (const file of sourceFiles) {
const rel = relative(ROOT, file);
if (detectLayer(rel) !== rule.layer) continue;
const src = readFileSync(file, 'utf8');
for (const hit of collectImports(src)) {
if (layerOfSpecifier(hit.spec, rel) !== rule.forbiddenTarget) continue;
violations.push({ file: rel, line: hit.line, text: hit.stmt });
}
}
if (violations.length > 0) {
// eslint-disable-next-line no-console
console.error('layering violations:', violations);
}
expect(violations).toEqual([]);
});
}
/**
* audit-r229:preload 只能 import main 的**类型**(IPC 类型 contract),
* 不得 import main 的**运行时值**。
*
* 事故形态:src/preload/index.ts 曾写
* `import { errMsg } from '../main/utils/error';`
* —— 这是**值** import(不是 import type),把 main 的模块图拖进 preload,
* 进而拖进 renderer 进程的启动路径。ARCHITECTURE「preload 只做桥、不含业务」
* 与本文件头注「允许 import @main/*(IPC 类型 contract)」都只授权**类型**。
* 同一文件里 r140 刚把 MAX_TOAST_MESSAGE_CHARS 从 main 搬到
* @shared/limits/ipc「让 preload 跨进程 import main 间接」—— 同一个文件里
* 一半搬走一半留下,自相矛盾。
*
* 修法(r229):errMsg 本体下沉到 @shared/display-string,preload 直连 shared。
* 本规则把「preload 不得运行时引 main」钉住,防止下一个 helper 又走老路。
*/
it('preload: 不得 import main 的运行时值(只允许类型)', () => {
const violations: Violation[] = [];
for (const file of sourceFiles) {
const rel = relative(ROOT, file);
if (detectLayer(rel) !== 'preload') continue;
const src = readFileSync(file, 'utf8');
for (const hit of collectImports(src)) {
if (layerOfSpecifier(hit.spec, rel) !== 'main') continue;
if (hit.typeOnly) continue;
violations.push({ file: rel, line: hit.line, text: hit.stmt });
}
}
if (violations.length > 0) {
// eslint-disable-next-line no-console
console.error('preload→main runtime import violations:', violations);
}
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,163 @@
// tests/unit/main/change-data-path-missing-settings.test.ts
//
// 守卫 r207:「更改数据目录」对**全新工作区**必然失败(ENOENT: settings.json)。
//
// ── 被守卫的缺陷(用户可见)──────────────────────────────────────────
// changeDataPath 复制完工作区内容后,还要把源根的 settings.json 搬过去
// (并把 rootPath / dataPath 改写成新根)。旧实现把 readFileCapped 的
// **ENOENT** 当成致命错 ⇒ 直接 throw ⇒ 整个迁移失败、目标目录被回滚删掉。
//
// 而「源根**没有** settings.json」是**正常状态**,不是异常:
// - r163 的「首启跳过写盘」优化:当前设置 == 全默认 ⇒ 不写盘
// ⇒ **空工作区的根下一直没有 settings.json**;
// - settings.json 被 quarantine 成 settings.json.corrupt-<ts>.json
// (r200 的损坏分支)后,那条分支同样**不写回** defaults。
//
// ⇒ 用户装好应用、笔记还没写几篇,点「更改数据目录」→ 迁移**必然失败**,
// 报的还是一句裸的
// ENOENT: no such file or directory, open 'D:\...\settings.json'
// (实测:目标目录里的 notes/ 被复制出来又被回滚删掉)。
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A1 源根**没有** settings.json → 迁移**成功**,源内容全部到位,
// 新根**也不写** settings.json(与源保持一致)。
// A2 源根有正常 settings.json → 迁移成功,且新根的 rootPath / dataPath
// 被改写成**新根**(既有行为,防回归)。
// A3 源根的 settings.json 内容**损坏** → 迁移成功,新根拿到**逐字**副本
// (「迁移不改变内容」优先)。
// A4 源根的 settings.json **读不到**(路径其实是个目录 → EISDIR)→
// 迁移 fail-closed 抛错,且目标目录**一个字节都没写**(不留半成品)。
// A5 任何一次迁移都不许动源根(它是用户的原件)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
vi.mock('electron', () => ({
app: {
getVersion: () => '9.9.9-test',
getPath: () => 'C:\\fake-user-data',
getName: () => 'rkms-test',
},
shell: {
trashItem: async () => undefined,
},
}));
// ⚠ 临时根必须**每次跑测唯一**(mkdtemp),不能用固定目录名 + 递归删。
// 固定名会让两次并发的跑测(CI 分片 / watch 重跑 / 两个 checkout 同时跑)
// 互删对方正在用的目录树 ⇒ 一片 ENOENT / ENOTEMPTY 假红。
// 守卫:tests/unit/main/lint/no-fixed-temp-dir.test.ts
const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-r207-'));
const ROOT = join(TMP, 'root');
const TARGET = join(TMP, 'target');
vi.mock('../../../src/main/location', () => ({
// 箭头函数体是**延迟**求值的 —— 常量在测试文件主体里定义也没关系
defaultDataPath: () => join(TMP, 'default'),
writeLocation: async () => {},
}));
import { changeDataPath } from '../../../src/main/settings/store';
import { AppError } from '../../../src/main/utils/error';
async function exists(p: string): Promise<boolean> {
return fs
.stat(p)
.then(() => true)
.catch(() => false);
}
/** 一个最小但真实的工作区:notes/ + literature/ */
async function makeWorkspace(): Promise<void> {
await fs.mkdir(join(ROOT, 'notes'), { recursive: true });
await fs.mkdir(join(ROOT, 'literature'), { recursive: true });
await fs.writeFile(join(ROOT, 'notes', 'a.md'), '# a', 'utf-8');
await fs.writeFile(join(ROOT, 'literature', 'paper.pdf'), 'pdf', 'utf-8');
}
describe('迁移数据目录:源没有 settings.json(r207)', () => {
beforeEach(async () => {
await fs.rm(TMP, { recursive: true, force: true });
await fs.mkdir(ROOT, { recursive: true });
});
afterEach(async () => {
await fs.rm(TMP, { recursive: true, force: true });
});
it('A1 源根没有 settings.json → 迁移成功,内容全到位,新根也不写 settings.json', async () => {
await makeWorkspace();
// 全新工作区:根下**没有** settings.json
await changeDataPath(TARGET, ROOT);
// 源内容全部搬过去了
expect(await fs.readFile(join(TARGET, 'notes', 'a.md'), 'utf-8')).toBe('# a');
expect(await fs.readFile(join(TARGET, 'literature', 'paper.pdf'), 'utf-8')).toBe('pdf');
// 新根**没有** settings.json(与源保持一致;新根的 initSettings 给默认值)
expect(await exists(join(TARGET, 'settings.json'))).toBe(false);
});
it('A2 源根有正常 settings.json → 新根的 rootPath / dataPath 被改写成新根', async () => {
await makeWorkspace();
await fs.writeFile(
join(ROOT, 'settings.json'),
JSON.stringify({ theme: 'dark', language: 'zh-CN', rootPath: ROOT, dataPath: ROOT }),
'utf-8',
);
await changeDataPath(TARGET, ROOT);
const written = JSON.parse(
await fs.readFile(join(TARGET, 'settings.json'), 'utf-8'),
) as Record<string, unknown>;
expect(written['rootPath']).toBe(TARGET);
expect(written['dataPath']).toBe(TARGET);
// 用户设置逐字保留
expect(written['theme']).toBe('dark');
expect(written['language']).toBe('zh-CN');
});
it('A3 源根的 settings.json 内容损坏 → 迁移成功,新根拿到逐字副本', async () => {
await makeWorkspace();
const corrupt = '{ this is not json';
await fs.writeFile(join(ROOT, 'settings.json'), corrupt, 'utf-8');
await changeDataPath(TARGET, ROOT);
// 逐字复制(「迁移不改变内容」优先;新根的 initSettings 会自己 quarantine)
expect(await fs.readFile(join(TARGET, 'settings.json'), 'utf-8')).toBe(corrupt);
// 内容照样搬过去了
expect(await fs.readFile(join(TARGET, 'notes', 'a.md'), 'utf-8')).toBe('# a');
});
it('A4 源根的 settings.json 读不到 → fail-closed,且目标目录一个字节都没写', async () => {
await makeWorkspace();
// 用「路径其实是个目录」制造读失败(Windows 上 open 成功、readFile 报 EISDIR)
await fs.mkdir(join(ROOT, 'settings.json'), { recursive: true });
const err = await changeDataPath(TARGET, ROOT).then(
() => null,
(e: unknown) => e,
);
// 抛的是**结构化错误**且带可自救的消息(不是裸的 ENOENT/EISDIR ——
// 裸错误里没有任何「怎么办」的指引)
expect(err).toBeInstanceOf(AppError);
expect((err as AppError).code).toBe('IO_ERROR');
expect((err as AppError).message).toContain(join(ROOT, 'settings.json'));
// fail-fast:目标目录**根本不存在**(没有被复制出半成品)
expect(await exists(TARGET)).toBe(false);
});
it('A5 迁移不许动源根(用户的原件)', async () => {
await makeWorkspace();
await changeDataPath(TARGET, ROOT);
expect(await fs.readFile(join(ROOT, 'notes', 'a.md'), 'utf-8')).toBe('# a');
expect(await fs.readFile(join(ROOT, 'literature', 'paper.pdf'), 'utf-8')).toBe('pdf');
expect(await exists(join(ROOT, 'settings.json'))).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
// tests/unit/main/cjk-mixed-script-search.test.ts
//
// 守 r208「中英混排查询 0 命中」契约。
//
// 现象(2026-10-20 内存库实测,真 SQLite + 真 FTS5):
// 文档正文:「本文介绍 CNN 图像分类模型 的基本原理。」
// 搜「CNN 模型」(带空格) → 1 条
// 搜「CNN模型」(不带空格) → **0 条**
// 同一篇文档,只是少打一个空格,结果从"搜得到"变成"搜不到"。
//
// 根因:r196 之后,含 CJK 的查询 token 一律被拼成**一个 FTS5 短语**
// (「CNN模型」→「"CNN 模 型"」)。FTS5 短语要求 token **相邻**,而
// 索引侧把每个汉字切成了独立 token ⇒ 文档里必须出现连续的
// 「CNN 模 型」才命中。文档写的是「CNN 图像分类模型」——中间隔着
// 「图像分类」四个 token ⇒ 短语不成立 ⇒ 0 结果。
// 而带空格的写法被切成两个 token(CNN* AND "模 型")⇒ 能命中。
//
// 修法:混排 token 按**文字系统**切 run,每个 run 一个子句,子句之间
// 用空格(= FTS5 隐式 AND)连接 ⇒「CNN模型」与「CNN 模型」产生
// 完全相同的查询串,带不带空格不再影响结果。
//
// 本测锁四件事:
// 1. 混排 token 与「带空格」写法产生**完全相同**的查询串(核心不变量);
// 2. 混排 token 不再被当成一个整体短语;
// 3. 纯中文 / 纯拉丁的既有行为**不变**(不顺手放宽);
// 4. 端到端(真 SQLite + 真 FTS5):不写空格也能搜到、缺一侧不命中
// (是 AND 不是 OR)、片段高亮正常且无 ZWSP 残留。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fileURLToPath } from 'node:url';
import type { NoteResource, ResourceId } from '../../../src/shared/types';
type Db = import('better-sqlite3').Database;
// 主进程 db 句柄由 sqlite.ts 的 getDB 提供 —— 测试里换成内存库。
const { dbRef } = vi.hoisted(() => ({ dbRef: { current: null as Db | null } }));
vi.mock('../../../src/main/db/sqlite', () => ({
getDB: () => {
if (!dbRef.current) throw new Error('test db not initialized');
return dbRef.current;
},
}));
const { ZWSP, splitScriptRuns } = await import('../../../src/main/db/cjk-segment');
const { sanitizeFtsQuery, upsertResource, searchResources } = await import(
'../../../src/main/db/resources-repo'
);
const { applyMigrations } = await import('../../../src/main/db/migrator');
const MIGRATIONS_DIR = fileURLToPath(
new URL('../../../src/main/db/migrations', import.meta.url),
);
async function openDb(): Promise<Db> {
const mod = (await import('better-sqlite3')) as unknown as { default: new (p: string) => Db };
return new mod.default(':memory:');
}
function note(id: string, title: string): NoteResource {
return {
kind: 'note',
id: id as ResourceId,
title,
relPath: `${id}.md`,
tags: [],
size: 1,
mtime: 1,
indexedAt: 1,
};
}
function insertNote(id: string, title: string, body: string): void {
upsertResource(note(id, title), body, `/abs/${id}.md`);
}
describe('按文字系统切 run(r208 纯函数)', () => {
it('连续汉字合成一个 run,汉字与拉丁各成一段', () => {
expect(splitScriptRuns('模型')).toEqual([{ cjk: true, text: '模型' }]);
expect(splitScriptRuns('CNN模型')).toEqual([
{ cjk: false, text: 'CNN' },
{ cjk: true, text: '模型' },
]);
expect(splitScriptRuns('模型CNN')).toEqual([
{ cjk: true, text: '模型' },
{ cjk: false, text: 'CNN' },
]);
expect(splitScriptRuns('Attention机制')).toEqual([
{ cjk: false, text: 'Attention' },
{ cjk: true, text: '机制' },
]);
});
it('纯拉丁 / 空串', () => {
expect(splitScriptRuns('transformer')).toEqual([{ cjk: false, text: 'transformer' }]);
expect(splitScriptRuns('')).toEqual([]);
});
});
describe('查询串构造(r208)', () => {
it('核心不变量:混排 token 与「带空格」写法产生完全相同的查询串', () => {
expect(sanitizeFtsQuery('CNN模型')).toBe(sanitizeFtsQuery('CNN 模型'));
expect(sanitizeFtsQuery('模型CNN')).toBe(sanitizeFtsQuery('模型 CNN'));
expect(sanitizeFtsQuery('Attention机制')).toBe(sanitizeFtsQuery('Attention 机制'));
});
it('混排 token 不再被当成一个整体短语(否则要求跨文字系统相邻)', () => {
expect(sanitizeFtsQuery('CNN模型')).not.toBe('"CNN 模 型"');
expect(sanitizeFtsQuery('CNN模型')).toBe('CNN* "模 型"');
});
it('纯中文仍是短语(r196 行为不变)', () => {
expect(sanitizeFtsQuery('学习')).toBe('"学 习"');
expect(sanitizeFtsQuery('注意力')).toBe('"注 意 力"');
});
it('纯拉丁仍是前缀通配(旧行为不变)', () => {
expect(sanitizeFtsQuery('transformer')).toBe('transformer*');
expect(sanitizeFtsQuery('v1.2')).toBe('"v1.2"');
});
it('token 之间的空格仍是 AND(r196 行为不变)', () => {
expect(sanitizeFtsQuery('学习 transformer')).toBe('"学 习" transformer*');
});
it('空查询返回空串', () => {
expect(sanitizeFtsQuery('')).toBe('');
expect(sanitizeFtsQuery(' ')).toBe('');
});
});
describe('端到端:真 SQLite + 真 FTS5(r208)', () => {
let db: Db;
beforeEach(async () => {
db = await openDb();
applyMigrations(db, MIGRATIONS_DIR);
dbRef.current = db;
});
afterEach(() => {
dbRef.current = null;
db.close();
});
it('不写空格也能搜到(旧实现 0 结果)', () => {
insertNote('note-1', '混合笔记', '本文介绍 CNN 图像分类模型 的基本原理。');
expect(searchResources('CNN模型').map((h) => h.resource.id)).toEqual(['note-1']);
// 带空格的写法一直能命中 —— 两者现在结果一致
expect(searchResources('CNN 模型').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('跨文字系统不相邻也能搜到(旧实现要求相邻)', () => {
insertNote('note-1', '注意力机制', 'Attention 的应用很广,机制 相当复杂。');
expect(searchResources('Attention机制').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('缺一侧不命中 —— 是 AND 不是 OR', () => {
insertNote('note-a', '卷积网络', 'CNN 是卷积神经网络的简称。');
insertNote('note-b', '抽象模型', '模型 是对事物的抽象。');
expect(searchResources('CNN模型')).toEqual([]);
});
it('片段带高亮标记,且不含 ZWSP 残留', () => {
insertNote('note-1', '混合笔记', '本文介绍 CNN 图像分类模型 的基本原理。');
const hit = searchResources('CNN模型')[0]!;
expect(hit.snippet).toContain('<mark>');
expect(hit.snippet).not.toContain(ZWSP);
});
});

View File

@@ -0,0 +1,275 @@
// tests/unit/main/cjk-search.test.ts
//
// 守 r196「中文全文搜索失效」契约。
//
// 现象:FTS5 的 unicode61 分词器把**连续的中文串**当成一个 token
// (unicode61 不区分文字系统 —— 汉字是 Unicode 字母)。
// 实测(内存库 + unicode61 remove_diacritics 2):
// 搜「学习」 → 0 行
// 搜「机制」 → 0 行
// 搜「注意力」→ 只有恰好在整串开头的那行能命中
// 也就是中文笔记的全文搜索基本不可用(英文搜索正常,掩盖了问题)。
//
// 修法(索引侧 + 查询侧对称):
// 索引侧:写 FTS 时在汉字之间插 ZWSP(U+200B)⇒ 每个汉字独立成 token;
// 查询侧:中文查询改用 FTS5 短语("学 习")⇒ 要求 token 相邻;
// 存量:迁移 0009 用 cjk_seg() 重分词。
//
// 本测锁四件事:
// 1. 分词纯函数行为(含幂等 —— 迁移重跑不能累积 ZWSP);
// 2. 查询串构造(拉丁行为**不变**,中文改短语);
// 3. 端到端:真 SQLite + 真 FTS5 —— 中文子串能搜到、片段干净、
// 拉丁前缀匹配没被改坏;
// 4. 迁移 0009:存量(未分词)行在迁移后**能**被中文搜到。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fileURLToPath } from 'node:url';
import type { NoteResource, ResourceId } from '../../../src/shared/types';
type Db = import('better-sqlite3').Database;
// 主进程 db 句柄由 sqlite.ts 的 getDB 提供 —— 测试里换成内存库。
const { dbRef } = vi.hoisted(() => ({ dbRef: { current: null as Db | null } }));
vi.mock('../../../src/main/db/sqlite', () => ({
getDB: () => {
if (!dbRef.current) throw new Error('test db not initialized');
return dbRef.current;
},
}));
const {
ZWSP,
segmentCjk,
stripZwsp,
hasCjk,
isCjkChar,
segmentQueryToken,
} = await import('../../../src/main/db/cjk-segment');
const { sanitizeFtsQuery, upsertResource, searchResources } = await import(
'../../../src/main/db/resources-repo'
);
const { applyMigrations } = await import('../../../src/main/db/migrator');
const MIGRATIONS_DIR = fileURLToPath(
new URL('../../../src/main/db/migrations', import.meta.url),
);
async function openDb(): Promise<Db> {
const mod = (await import('better-sqlite3')) as unknown as { default: new (p: string) => Db };
return new mod.default(':memory:');
}
function note(id: string, title: string): NoteResource {
return {
kind: 'note',
id: id as ResourceId,
title,
relPath: `${id}.md`,
tags: [],
size: 1,
mtime: 1,
indexedAt: 1,
};
}
/** 走真实写路径(upsertResource)插入一条中文笔记。 */
function insertNote(id: string, title: string, body: string): void {
upsertResource(note(id, title), body, `/abs/${id}.md`);
}
describe('CJK 分词纯函数(r196)', () => {
it('汉字之间插入 ZWSP', () => {
expect(segmentCjk('深度学习')).toBe(
['深', '度', '学', '习'].join(ZWSP),
);
});
it('CJK↔拉丁边界也断开(否则整串是一个 token,两边都搜不到)', () => {
expect(segmentCjk('Attention机制')).toBe(['Attention', '机', '制'].join(ZWSP));
});
it('纯拉丁文本不被改写', () => {
expect(segmentCjk('attention is all you need')).toBe('attention is all you need');
});
it('幂等:重复分词结果不变(迁移 0009 重跑不能累积 ZWSP)', () => {
const once = segmentCjk('深度学习 Attention 机制');
expect(segmentCjk(once)).toBe(once);
});
it('stripZwsp 能把分词文本还原成原文', () => {
expect(stripZwsp(segmentCjk('深度学习'))).toBe('深度学习');
});
it('空串安全', () => {
expect(segmentCjk('')).toBe('');
expect(stripZwsp('')).toBe('');
});
it('CJK 判定:汉字/假名/谚文是,拉丁与数字不是', () => {
expect(isCjkChar('学')).toBe(true);
expect(isCjkChar('あ')).toBe(true);
expect(isCjkChar('한')).toBe(true);
expect(isCjkChar('a')).toBe(false);
expect(isCjkChar('1')).toBe(false);
expect(hasCjk('attention 机制')).toBe(true);
expect(hasCjk('attention only')).toBe(false);
});
it('查询侧切词:汉字逐字,非 CJK 连续段整体保留', () => {
expect(segmentQueryToken('学习')).toEqual(['学', '习']);
expect(segmentQueryToken('机器学习')).toEqual(['机', '器', '学', '习']);
expect(segmentQueryToken('Attention机制')).toEqual(['Attention', '机', '制']);
});
});
describe('查询串构造(r196)', () => {
it('中文 → FTS5 短语(每个汉字一个 token)', () => {
expect(sanitizeFtsQuery('学习')).toBe('"学 习"');
expect(sanitizeFtsQuery('注意力')).toBe('"注 意 力"');
});
it('拉丁 → 前缀通配(旧行为不变)', () => {
expect(sanitizeFtsQuery('transformer')).toBe('transformer*');
});
it('含特殊字符的拉丁 token → 引号短语(旧行为不变)', () => {
expect(sanitizeFtsQuery('v1.2')).toBe('"v1.2"');
});
it('FTS5 保留字兜底不变', () => {
expect(sanitizeFtsQuery('and')).toBe('a*');
});
it('中英混排查询:中文短语 + 拉丁前缀', () => {
expect(sanitizeFtsQuery('学习 transformer')).toBe('"学 习" transformer*');
});
it('空查询返回空串', () => {
expect(sanitizeFtsQuery('')).toBe('');
expect(sanitizeFtsQuery(' ')).toBe('');
});
});
describe('端到端:真 SQLite + 真 FTS5(r196)', () => {
let db: Db;
beforeEach(async () => {
db = await openDb();
applyMigrations(db, MIGRATIONS_DIR);
dbRef.current = db;
});
afterEach(() => {
dbRef.current = null;
db.close();
});
it('中文词内子串能搜到(旧实现 0 结果)', () => {
insertNote('note-1', '深度学习综述', '本文介绍注意力机制的基本原理。');
expect(searchResources('注意力').map((h) => h.resource.id)).toEqual(['note-1']);
expect(searchResources('机制').map((h) => h.resource.id)).toEqual(['note-1']);
expect(searchResources('学习').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('片段带高亮标记,且不含 ZWSP 残留', () => {
insertNote('note-1', '深度学习综述', '本文介绍注意力机制的基本原理。');
const hit = searchResources('注意力')[0]!;
expect(hit.snippet).toContain('<mark>注意力</mark>');
expect(hit.snippet).not.toContain(ZWSP);
expect(hit.snippet).toBe('本文介绍<mark>注意力</mark>机制的基本原理。');
});
it('中文标题也能搜到', () => {
insertNote('note-1', '深度学习综述', '正文');
expect(searchResources('综述').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('中英混排:中文侧与英文侧各自可搜', () => {
insertNote('note-1', '注意力机制', 'Attention is all you need. 注意力机制的核心。');
expect(searchResources('注意力').map((h) => h.resource.id)).toEqual(['note-1']);
expect(searchResources('attention').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('拉丁前缀匹配未被改坏', () => {
insertNote('note-1', 'Transformer', 'transformer architectures are popular.');
expect(searchResources('transform').map((h) => h.resource.id)).toEqual(['note-1']);
expect(searchResources('architecture').map((h) => h.resource.id)).toEqual(['note-1']);
});
it('未分词的行搜不到 —— 证明分词是命中的关键(旧行为的对照)', () => {
// 直接塞未分词的 FTS 行(模拟 r196 之前写入的索引)
db.prepare(
`INSERT INTO resources (id, kind, title, rel_path, abs_path, size, mtime, indexed_at, extract_status, tags)
VALUES ('note-raw', 'note', '注意力机制', 'raw.md', '/abs/raw.md', 1, 1, 1, 'ok', '[]')`,
).run();
db.prepare(
`INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, '本文介绍注意力机制。' FROM resources WHERE id = 'note-raw'`,
).run();
expect(searchResources('注意力')).toEqual([]);
});
});
describe('迁移 0009:存量行重分词(r196)', () => {
let db: Db;
beforeEach(async () => {
db = await openDb();
// 先停在 v8(0009 之前)
applyMigrations(db, MIGRATIONS_DIR, 8);
dbRef.current = db;
});
afterEach(() => {
dbRef.current = null;
db.close();
});
it('迁移前:未分词的存量行搜不到中文子串', () => {
db.prepare(
`INSERT INTO resources (id, kind, title, rel_path, abs_path, size, mtime, indexed_at, extract_status, tags)
VALUES ('note-old', 'note', '注意力机制', 'old.md', '/abs/old.md', 1, 1, 1, 'ok', '[]')`,
).run();
db.prepare(
`INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, '本文介绍注意力机制。' FROM resources WHERE id = 'note-old'`,
).run();
expect(searchResources('注意力')).toEqual([]);
});
it('迁移后:存量行能被中文搜到,且版本号推进到 9', () => {
db.prepare(
`INSERT INTO resources (id, kind, title, rel_path, abs_path, size, mtime, indexed_at, extract_status, tags)
VALUES ('note-old', 'note', '注意力机制', 'old.md', '/abs/old.md', 1, 1, 1, 'ok', '[]')`,
).run();
db.prepare(
`INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, '本文介绍注意力机制。' FROM resources WHERE id = 'note-old'`,
).run();
applyMigrations(db, MIGRATIONS_DIR);
expect(db.pragma('user_version', { simple: true })).toBe(9);
const hits = searchResources('注意力');
expect(hits.map((h) => h.resource.id)).toEqual(['note-old']);
expect(hits[0]!.snippet).not.toContain(ZWSP);
});
it('迁移幂等:再跑一次不累积 ZWSP', () => {
db.prepare(
`INSERT INTO resources (id, kind, title, rel_path, abs_path, size, mtime, indexed_at, extract_status, tags)
VALUES ('note-old', 'note', '注意力机制', 'old.md', '/abs/old.md', 1, 1, 1, 'ok', '[]')`,
).run();
db.prepare(
`INSERT INTO resources_fts (rowid, id, kind, title, body)
SELECT rowid, id, kind, title, '本文介绍注意力机制。' FROM resources WHERE id = 'note-old'`,
).run();
applyMigrations(db, MIGRATIONS_DIR);
const after = db
.prepare(`SELECT body FROM resources_fts WHERE id = 'note-old'`)
.get() as { body: string };
// 强制重跑 0009:把 user_version 拨回去
db.pragma('user_version = 8');
applyMigrations(db, MIGRATIONS_DIR);
const again = db
.prepare(`SELECT body FROM resources_fts WHERE id = 'note-old'`)
.get() as { body: string };
expect(again.body).toBe(after.body);
});
});

View File

@@ -0,0 +1,267 @@
// tests/unit/main/data-path-switch-id-remap.test.ts
//
// 守卫 r204:切换数据目录后,文献 id 必须重算成新根下的形态。
//
// ── 被守卫的缺陷(用户可见)──────────────────────────────────────────
// 文献 id = 'lit:' + toPosix(absPath) —— 与绝对路径强绑定。切数据目录时:
// - changeDataPath 是**复制**(旧目录原样保留);
// - SQLite 库在 userData/index/index.sqlite(全局,不在数据根下)
// ⇒ 切换**不会**产生新库,DB 里旧根下的文献行全部存活。
// 之后 literature service 重绑 → reload() 重扫新根:
// - 新根的文件算出新 id → disk-only 分支插新行;
// - 旧行不在新根的 diskIdSet 里 → db-only 分支,而旧目录的文件**还在**
// ⇒ existsSync(旧 absPath) 为真 ⇒ 「保留」。
// ⇒ 同一个 PDF 在 DB 里两行:文献列表**每个 PDF 出现两次**、搜索**结果重复**、
// ★ 挂在旧 id 那条上(用户在新目录里看到的是没星的那条)。
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A. remapLiteratureIdsForRootSwitch:旧根下的文献行 → **一次**
// renameResource(旧 id → 新行),新 id / 新 absPath 都是新根下的形态;
// 不在旧根下的行、note 行**一律不动**。
// B. switchDataPath 必须真的调用它(否则功能只存在于一个没人调的函数里),
// 且顺序是:DB 重算 → favorites 重绑 → ★ 改写 → literature 重扫。
// ★ 改写**必须**在 favorites.start() 之后(否则改写落回旧目录的副本)。
//
// 边界:真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(调哪个 DB 原语、id 算成什么样、顺序对不对)全在 main 层。
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { LiteratureResource, NoteResource, ResourceId } from '../../../src/shared/types';
const state = vi.hoisted(() => ({
order: [] as string[],
listResources: vi.fn(() => [] as unknown[]),
renameResource: vi.fn(
(_prevId: string, _r: LiteratureResource, _body: string, _abs: string) => {
state.order.push('db.renameResource');
},
),
remapIds: vi.fn((_map: Map<string, string>, _pathMap?: Map<string, string>) => {
state.order.push('favorites.remapIds');
return 0;
}),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
listResources: state.listResources,
renameResource: state.renameResource,
}));
vi.mock('../../../src/main/settings/store', () => ({
changeDataPath: async () => ({ oldPath: 'old', newPath: 'new' }),
initSettings: async () => ({}),
resetSettings: () => ({ current: {}, path: '' }),
captureSettingsSnapshot: () => ({ current: {}, path: '' }),
}));
vi.mock('../../../src/main/db/sqlite', () => ({
closeDB: () => {},
initDB: () => {},
}));
vi.mock('../../../src/main/location', () => ({
writeLocation: async () => {},
}));
vi.mock('../../../src/main/notes/service', () => ({
getNotesService: () => ({
start: async () => {
state.order.push('notes.start');
},
stop: async () => {
state.order.push('notes.stop');
},
}),
}));
vi.mock('../../../src/main/workspace/favorites', () => ({
getFavoritesService: () => ({
start: async () => {
state.order.push('favorites.start');
},
stop: async () => {
state.order.push('favorites.stop');
},
remapIds: async (map: Map<string, string>, pathMap?: Map<string, string>) => {
state.remapIds(map, pathMap);
return 0;
},
}),
}));
vi.mock('../../../src/main/literature/literature-service', () => ({
getLiteratureService: () => ({
start: async () => {
state.order.push('literature.start');
},
stop: async () => {
state.order.push('literature.stop');
},
}),
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import {
remapLiteratureIdsForRootSwitch,
relUnderRoot,
} from '../../../src/main/settings/root-remap';
import { switchDataPath } from '../../../src/main/settings/migration';
// ⚠ 临时根必须**每次跑测唯一**(mkdtemp):固定目录名会让两次并发的跑测
// 互删对方正在用的目录树 ⇒ 一片 ENOENT / ENOTEMPTY 假红。
// 守卫:tests/unit/main/lint/no-fixed-temp-dir.test.ts
const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-r204-'));
const OLD_ROOT = join(TMP, 'old');
const NEW_ROOT = join(TMP, 'new');
function litId(absPath: string): string {
return 'lit:' + absPath.split('\\').join('/');
}
function litRow(absPath: string): LiteratureResource {
return {
id: litId(absPath) as ResourceId,
kind: 'literature',
title: 'paper.pdf',
mtime: 1,
size: 10,
indexedAt: 0,
absPath,
rootId: null,
extractStatus: 'filename-only',
};
}
function noteRow(id: string): NoteResource {
return {
id: id as ResourceId,
kind: 'note',
title: 'n.md',
mtime: 1,
size: 10,
indexedAt: 1,
relPath: 'n.md',
tags: [],
};
}
describe('切换数据目录 → 文献 id 重算(r204)', () => {
beforeEach(() => {
state.order.length = 0;
state.listResources.mockClear();
state.listResources.mockReturnValue([]);
state.renameResource.mockClear();
state.remapIds.mockClear();
});
afterEach(() => {
setRootPath(OLD_ROOT);
});
// 跑完把这次唯一的临时根删掉
afterAll(async () => {
await fs.rm(TMP, { recursive: true, force: true });
});
// === A. remapLiteratureIdsForRootSwitch 的单元行为 ===
describe('remapLiteratureIdsForRootSwitch', () => {
it('A1 旧根下的文献行 → 一次 renameResource,id / absPath 都是新根下的形态', () => {
const oldAbs = join(OLD_ROOT, 'literature', 'paper.pdf');
const newAbs = join(NEW_ROOT, 'literature', 'paper.pdf');
state.listResources.mockReturnValue([litRow(oldAbs)]);
const remaps = remapLiteratureIdsForRootSwitch(OLD_ROOT, NEW_ROOT);
expect(state.renameResource).toHaveBeenCalledTimes(1);
const [prevId, next, body, abs] = state.renameResource.mock.calls[0] as [
string,
LiteratureResource,
string,
string,
];
expect(prevId).toBe(litId(oldAbs));
expect(next.id).toBe(litId(newAbs));
expect(next.absPath).toBe(newAbs);
expect(body).toBe(''); // 文献的 FTS 正文一直是空的
expect(abs).toBe(newAbs);
// 返回的映射表供 favorites 改写 fileId / relPath
expect(remaps).toEqual([{ oldId: litId(oldAbs), newId: litId(newAbs), path: newAbs }]);
});
it('A2 不在旧根下的文献行 → 一行都不动(别把别的库的行也改了)', () => {
const elsewhere = join(TMP, 'other-root', 'literature', 'x.pdf');
state.listResources.mockReturnValue([litRow(elsewhere)]);
const remaps = remapLiteratureIdsForRootSwitch(OLD_ROOT, NEW_ROOT);
expect(state.renameResource).not.toHaveBeenCalled();
expect(remaps).toEqual([]);
});
it('A3 note 行 → 不动(note id 与 relPath 绑定,换根不变)', () => {
state.listResources.mockReturnValue([noteRow('note:abc')]);
const remaps = remapLiteratureIdsForRootSwitch(OLD_ROOT, NEW_ROOT);
expect(state.renameResource).not.toHaveBeenCalled();
expect(remaps).toEqual([]);
});
it('A4 旧根 === 新根 → 直接空转,连 DB 都不查', () => {
state.listResources.mockReturnValue([litRow(join(OLD_ROOT, 'literature', 'p.pdf'))]);
const remaps = remapLiteratureIdsForRootSwitch(OLD_ROOT, OLD_ROOT);
expect(remaps).toEqual([]);
expect(state.listResources).not.toHaveBeenCalled();
});
it('A5 relUnderRoot 边界:兄弟目录(名含 ".." 前缀)与根本身都不算「在根下」', () => {
expect(relUnderRoot(OLD_ROOT, join(OLD_ROOT, 'literature', 'p.pdf'))).toBe(
join('literature', 'p.pdf'),
);
expect(relUnderRoot(OLD_ROOT, OLD_ROOT)).toBeNull();
expect(relUnderRoot(OLD_ROOT, join(OLD_ROOT, '..', '..foo', 'p.pdf'))).toBeNull();
expect(relUnderRoot(OLD_ROOT, join(OLD_ROOT, '..', 'sibling', 'p.pdf'))).toBeNull();
});
});
// === B. switchDataPath 的接线与顺序 ===
describe('switchDataPath', () => {
it('B1 切换 → 文献 id 重算 + ★ 的 fileId / relPath 跟着改,且顺序正确', async () => {
const oldAbs = join(OLD_ROOT, 'literature', 'paper.pdf');
const newAbs = join(NEW_ROOT, 'literature', 'paper.pdf');
setRootPath(OLD_ROOT);
state.listResources.mockReturnValue([litRow(oldAbs)]);
await switchDataPath(NEW_ROOT);
// DB 重算发生了一次
expect(state.renameResource).toHaveBeenCalledTimes(1);
const [prevId, next] = state.renameResource.mock.calls[0] as unknown as [
string,
LiteratureResource,
];
expect(prevId).toBe(litId(oldAbs));
expect(next.id).toBe(litId(newAbs));
// ★ 的 fileId + relPath 一起改
expect(state.remapIds).toHaveBeenCalledTimes(1);
const [idMap, pathMap] = state.remapIds.mock.calls[0] as [
Map<string, string>,
Map<string, string>,
];
expect(idMap.get(litId(oldAbs))).toBe(litId(newAbs));
expect(pathMap.get(litId(oldAbs))).toBe(newAbs);
// 顺序:DB 重算 → favorites 重绑 → ★ 改写 → literature 重扫
const idx = (label: string): number => state.order.indexOf(label);
expect(idx('db.renameResource')).toBeGreaterThanOrEqual(0);
expect(idx('db.renameResource')).toBeLessThan(idx('favorites.start'));
expect(idx('favorites.start')).toBeLessThan(idx('favorites.remapIds'));
expect(idx('favorites.remapIds')).toBeLessThan(idx('literature.start'));
});
});
});

View File

@@ -0,0 +1,247 @@
// tests/unit/main/db-id-swap-atomicity.test.ts
//
// 守卫 r201:资源「换 id」的 DB 写入必须是**一次事务**,不是两次独立事务。
//
// 背景(文献侧的真实 bug,本轮修):
// 文献的 id = 'lit:' + toPosix(absPath) ⇒ 改名 / 搬移都会换 id,
// DB 里必须「删旧行 + 写新行」。旧实现是**两个独立事务**:
// deleteResource(旧 id); // 事务 1
// upsertResource(新行); // 事务 2
// 中间任何失败(DB 忙 / 磁盘满 / FTS 报错 / 进程被杀)都会留下**三态漂移**:
// 磁盘文件在新路径、DB 里旧行没了新行没进、内存 byId 还指着旧 id
// ⇒ 这条文献从列表和搜索里**凭空消失**,而磁盘没动
// ⇒ **没有任何 watcher 事件能触发自愈**(搬移期间 watcher 是被主动
// 释放的),要等下次启动 resync 差集才回来。
// 笔记侧一直用 `renameResource`(单事务「upsert 新行 + 删旧行」),文献侧
// 漏了 —— 两侧语义不一致。
//
// 本测锁的契约:
// A. 文献改名 / 搬移 / 目录搬移 → DB 只发生**一次** renameResource 调用,
// **不再**出现独立的 deleteResource / upsertResource;
// B. **DB 写失败时不得留下半状态**:renameResource 抛错 ⇒ 错误上抛给
// 调用方,且 deleteResource **从未**被调用(⇒ 旧行还在 DB 里,不是
// 「旧行已删、新行未写」)。这条在旧实现下**必红**:deleteResource 已经
// 先执行了。
// C. 笔记侧:改名 / 搬移的 DB 换键与 byId 换键走**同一份** helper
// (applyNoteIdSwap),不再各写一份 —— 旧实现是 renameFile / moveFile
// 两处逐字重复(只差 log 文案与 i18n key),改一边漏另一边 = 「重命名
// 同步了 DB、搬移没同步」。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(调用哪个 DB 原语、失败时留下什么)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { readFileSync } from 'fs';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// chokidar mock(audit-r229):真 watcher 会在测试写文件时异步发 'add'/'change',
// 排一次 reload ⇒ reload 走 upsertResource ⇒ 本文件「换 id 不得出现独立
// upsertResource」的断言变成**竞态**:全量跑测时机器负载高、awaitWriteFinish
// 的轮询被拖慢,reload 恰好落在 mockClear() 之后 ⇒ A3 偶发红
// (实测:单跑 6/6 绿,全量跑 A3 红,报 upsertResource 被调用 1 次,
// 参数是 import 阶段那一行)。mock 掉 watcher 让 reload 只由测试显式驱动 ——
// 与 tests/unit/main/literature-stat-refresh.test.ts 同一处置(r212 的教训)。
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('资源换 id 的 DB 写入必须同事务(r201)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-idswap-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === 文献侧(本轮修的真实 bug)===
describe('LiteratureService —— 换 id 走单事务', () => {
let lit: LiteratureService;
beforeEach(async () => {
lit = new LiteratureService();
await lit.start({ skipInitialLoad: true });
});
afterEach(async () => {
await lit.stop();
});
async function importPaper(name: string): Promise<string> {
const src = join(root, name);
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
return imported.id;
}
it('A1 改名 → DB 只发生一次 renameResource(旧 id → 新行),无独立 delete/upsert', async () => {
const id = await importPaper('paper.pdf');
// 导入本身会调 upsertResource 写新行 —— 清掉计数,只断言「改名这一步」
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const renamed = await lit.renameFile(id as never, 'thesis');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(repo.renameResource).toHaveBeenCalledWith(
id,
expect.objectContaining({ id: renamed.id }),
'',
expect.stringContaining('thesis.pdf'),
);
// 两个独立事务的调用**不许**再出现
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('A2 搬进子目录 → 同样只发生一次 renameResource', async () => {
const id = await importPaper('paper.pdf');
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const moved = await lit.moveFile(id as never, 'sub');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(repo.renameResource).toHaveBeenCalledWith(
id,
expect.objectContaining({ id: moved.id }),
'',
expect.stringContaining('sub'),
);
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('A3 目录搬移 → 子树里每个文献各一次 renameResource,idRemap 一一对应', async () => {
const id = await importPaper('paper.pdf');
// 先搬进子目录 —— 这一步已经换过一次 id,子树搬移要换的是**这个** id
const inSub = await lit.moveFile(id as never, 'sub');
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const res = await lit.moveDir('sub', 'moved');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(res.idRemap).toHaveLength(1);
expect(res.idRemap[0].oldId).toBe(inSub.id);
expect(res.idRemap[0].newId).not.toBe(inSub.id);
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('B1 DB 写失败 → 错误上抛,且 deleteResource 从未被调用(不留「旧行已删」的半状态)', async () => {
const id = await importPaper('paper.pdf');
repo.renameResource.mockImplementationOnce(() => {
throw new Error('DB busy');
});
await expect(lit.renameFile(id as never, 'thesis')).rejects.toThrow();
// 关键断言:旧行**还在** DB 里(没有先被单独删掉)——
// 两个独立事务的旧实现在这里 deleteResource 已经执行过了。
expect(repo.deleteResource).not.toHaveBeenCalled();
});
it('B2 目录搬移时 DB 写失败 → 错误上抛,子树里没有任何一行被单独删掉', async () => {
const id = await importPaper('paper.pdf');
await lit.moveFile(id as never, 'sub');
repo.renameResource.mockImplementationOnce(() => {
throw new Error('DB busy');
});
await expect(lit.moveDir('sub', 'moved')).rejects.toThrow();
expect(repo.deleteResource).not.toHaveBeenCalled();
});
});
// === 笔记侧(本轮把两份逐字重复收成一份 helper)===
describe('NotesService —— 换 id 的时序不变量单点化', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('C1 改名 / 搬移的 DB 换键 + byId 换键走同一份 helper(源码里只有一处 renameResource 调用)', async () => {
// 行为面:改名与搬移都只发生一次 DB 换键,且内存索引跟着换
const n = await svc.create('a.md');
repo.renameResource.mockClear();
const renamed = await svc.renameFile(n.id, 'b');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(svc.findById(n.id)).toBeNull();
expect(svc.findById(renamed.id)).not.toBeNull();
repo.renameResource.mockClear();
const moved = await svc.moveFile(renamed.id, 'sub');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(svc.findById(renamed.id)).toBeNull();
expect(svc.findById(moved.id)).not.toBeNull();
// 结构面:notes/service.ts 里 `renameResource(` 只应出现一次
// (在 applyNoteIdSwap 内)。旧实现是 renameFile / moveFile /
// moveDir / renameDir 各写一份 ⇒ 改一边漏另一边 = 「重命名同步了
// DB、搬移没同步」这类漂移。
// 注:先剥掉整行注释 —— 注释里提到 renameResource( 不算调用点。
const src = readFileSync(
join(process.cwd(), 'src', 'main', 'notes', 'service.ts'),
'utf-8',
);
const code = src
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n');
const calls = code.match(/\brenameResource\(/g) ?? [];
expect(calls).toHaveLength(1);
// 两个调用点都走 helper
expect(src).toMatch(/await this\.applyNoteIdSwap\(/);
});
});
});

View File

@@ -0,0 +1,108 @@
// tests/unit/main/db-update-resource-stat.test.ts
//
// 守 r211 新增的 `updateResourceStat` 契约:**只更 stat 两列,不碰 FTS**。
//
// 为什么需要它(而不是直接用现成的 upsertResource):
// upsertResource 的语义是「FTS DELETE → 主表 upsert → FTS INSERT」,
// body 由 caller 给。刷 stat 的 caller 手上只有 '' ⇒ 将来抽过正文
// (indexed_at ≠ 0)的文献,正文会被**清空** ⇒ 全文搜索再也搜不到。
// 刷 stat 是「文件元数据变了」,与索引面无关 ⇒ 必须有一条**只动主表**
// 的写路径。
//
// 本测(真 SQLite + 真 FTS5)锁三件事:
// 1. size / mtime 被更新;
// 2. FTS 里的正文**原样保留**(仍能搜到)—— 这是本函数存在的理由;
// 3. FTS 行数不变(不产生重复 / 孤儿索引行)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { fileURLToPath } from 'node:url';
import type { LiteratureResource, ResourceId } from '../../../src/shared/types';
type Db = import('better-sqlite3').Database;
// 主进程 db 句柄由 sqlite.ts 的 getDB 提供 —— 测试里换成内存库。
const { dbRef } = vi.hoisted(() => ({ dbRef: { current: null as Db | null } }));
vi.mock('../../../src/main/db/sqlite', () => ({
getDB: () => {
if (!dbRef.current) throw new Error('test db not initialized');
return dbRef.current;
},
}));
const { upsertResource, getResource, searchResources, updateResourceStat } = await import(
'../../../src/main/db/resources-repo'
);
const { applyMigrations } = await import('../../../src/main/db/migrator');
const MIGRATIONS_DIR = fileURLToPath(
new URL('../../../src/main/db/migrations', import.meta.url),
);
async function openDb(): Promise<Db> {
const mod = (await import('better-sqlite3')) as unknown as { default: new (p: string) => Db };
return new mod.default(':memory:');
}
const LIT_ID = 'lit:/tmp/paper.pdf' as ResourceId;
function litRow(): LiteratureResource {
return {
kind: 'literature',
id: LIT_ID,
title: '注意力机制',
mtime: 1,
size: 1,
indexedAt: 42,
absPath: '/tmp/paper.pdf',
rootId: null,
extractStatus: 'ok',
};
}
describe('updateResourceStat:只更 stat 两列,不碰 FTS(r211)', () => {
beforeEach(async () => {
dbRef.current = await openDb();
applyMigrations(dbRef.current, MIGRATIONS_DIR);
});
afterEach(() => {
dbRef.current?.close();
dbRef.current = null;
});
it('size / mtime 被更新', () => {
upsertResource(litRow(), 'transformer 注意力机制的正文', '/tmp/paper.pdf');
updateResourceStat(LIT_ID, 4096, 1_700_000_000_000);
const row = getResource(LIT_ID);
expect(row?.size).toBe(4096);
expect(row?.mtime).toBe(1_700_000_000_000);
});
it('FTS 正文原样保留(刷 stat 不许清索引面)', () => {
upsertResource(litRow(), 'transformer 注意力机制的正文', '/tmp/paper.pdf');
expect(searchResources('transformer')).toHaveLength(1);
updateResourceStat(LIT_ID, 4096, 1_700_000_000_000);
expect(searchResources('transformer')).toHaveLength(1);
// 「正文」只出现在 body 里(title 是「注意力机制」)⇒ 命中即证明 body 还在
expect(searchResources('正文')).toHaveLength(1);
});
it('FTS 行数不变(不产生重复 / 孤儿索引行)', () => {
upsertResource(litRow(), 'transformer 注意力机制的正文', '/tmp/paper.pdf');
updateResourceStat(LIT_ID, 4096, 1_700_000_000_000);
const db = dbRef.current!;
const count = db.prepare('SELECT count(*) AS c FROM resources_fts WHERE id = ?').get(LIT_ID) as {
c: number;
};
expect(count.c).toBe(1);
});
it('行不存在时静默无操作(不抛错)', () => {
expect(() => updateResourceStat('lit:/nope.pdf' as ResourceId, 1, 1)).not.toThrow();
});
});

View File

@@ -0,0 +1,201 @@
// tests/unit/main/dir-move-nested-subdir.test.ts
//
// 守 r188「Windows 上重命名 / 搬移一个**含子目录**的文件夹」契约。
//
// 根因(本机实证,chokidar 4 + Windows 11 —— 见 utils/watcher-release.ts 头注):
// chokidar 用 fs.watch(ReadDirectoryChangesW)递归监听目录树,对**每个**
// 被监听的目录持有一个 OS 句柄。Windows 拒绝重命名一个**内部存在打开
// 句柄**的目录 ⇒ 只要待搬动的目录里还有子目录(chokidar 正监听着它),
// fs.rename 必然 EPERM。
//
// 四格对照实验:
// 无 watcher + 扁平子结构 → rename OK
// 无 watcher + 含子目录 → rename OK
// 有 watcher + 扁平子结构 → rename OK
// 有 watcher + 含子目录 → rename **EPERM** ← 旧行为
//
// 既有退避重试(retryOnWindowsTransient,5 次 / ~750ms)救不了:句柄是
// **长期**的(chokidar 一直在监听那个子目录),不是 Defender 那种瞬时占用。
//
// 修法:物理搬目录前关掉 watcher(释放全部目录句柄),搬完重建。
//
// 本测锁:
// 1. notes 域:重命名一个含子目录的文件夹 → 成功,磁盘结构对,子树里
// 每个笔记的 id 跟着重算;
// 2. literature 域:重命名 + 跨目录搬移,同款;
// 3. **watcher 仍然活着** —— 搬完之后在**新位置**建文件,服务仍能感知
// (修 EPERM 靠的是「关掉再重建」,若重建失败/不重建,监听就永久
// 丢了 —— 外部改动再也进不来,而且没有任何报错);
// 4. 扁平目录(无子目录)仍然能改名 —— 不回归。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn() },
shell: {
trashItem: vi.fn(),
showItemInFolder: vi.fn(),
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { NotesService } from '../../../src/main/notes/service';
async function waitFor(
predicate: () => boolean,
timeoutMs = 8000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 50));
}
return predicate();
}
describe('含子目录的文件夹改名 / 搬移(r188)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirmove-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
lit = new LiteratureService();
notes = new NotesService();
await lit.start({ skipInitialLoad: true });
await notes.start({ skipInitialLoad: true });
});
afterEach(async () => {
await notes.stop();
await lit.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('notes:重命名含子目录的文件夹 → 成功,子树里每个笔记的 id 跟着重算', async () => {
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
const top = await notes.create('n.md');
await notes.moveFile(top.id, 'sub');
const deep = await notes.create('n2.md');
await notes.moveFile(deep.id, 'sub/deep');
const res = await notes.renameDir('sub', 'renamed');
expect(res.relPath).toBe('renamed');
// 磁盘结构:整棵子树跟着走
const entries = await fs.readdir(join(root, 'notes', 'renamed', 'deep'));
expect(entries).toContain('n2.md');
expect(await fs.stat(join(root, 'notes', 'renamed', 'n.md')).then(() => true)).toBe(true);
// 旧位置没了
expect(await fs.stat(join(root, 'notes', 'sub')).then(() => true, () => false)).toBe(false);
// 子树里两个笔记的 id 都重算了(路径绑定 id)
const relPaths = notes.list().map((n) => n.relPath).sort();
expect(relPaths).toEqual(['renamed/deep/n2.md', 'renamed/n.md']);
const ids = notes.list().map((n) => n.id);
expect(new Set(ids).size).toBe(2);
expect(ids).not.toContain(top.id);
expect(ids).not.toContain(deep.id);
});
it('notes:跨目录搬移含子目录的文件夹 → 成功', async () => {
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
await fs.mkdir(join(root, 'notes', 'arch'), { recursive: true });
const n = await notes.create('n.md');
await notes.moveFile(n.id, 'sub/deep');
const res = await notes.moveDir('sub', 'arch');
expect(res.relPath).toBe('arch/sub');
expect(
await fs.stat(join(root, 'notes', 'arch', 'sub', 'deep', 'n.md')).then(() => true),
).toBe(true);
expect(notes.list().map((x) => x.relPath)).toEqual(['arch/sub/deep/n.md']);
});
it('literature:重命名含子目录的文件夹 → 成功,子树里每个文献的 id 跟着重算', async () => {
await fs.mkdir(join(root, 'literature', 'sub', 'deep'), { recursive: true });
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
const moved = await lit.moveFile(imported.id, 'sub/deep');
const res = await lit.renameDir('sub', 'renamed');
expect(res.relPath).toBe('renamed');
expect(res.idRemap).toHaveLength(1);
expect(res.idRemap[0]!.oldId).toBe(moved.id);
expect(res.idRemap[0]!.newId).not.toBe(moved.id);
const entries = await fs.readdir(join(root, 'literature', 'renamed', 'deep'));
expect(entries).toContain('paper.pdf');
expect(lit.list().map((r) => r.relPath)).toEqual(['renamed/deep/paper.pdf']);
});
it('literature:跨目录搬移含子目录的文件夹 → 成功', async () => {
await fs.mkdir(join(root, 'literature', 'sub', 'deep'), { recursive: true });
await fs.mkdir(join(root, 'literature', 'arch'), { recursive: true });
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
await lit.moveFile(imported.id, 'sub/deep');
const res = await lit.moveDir('sub', 'arch');
expect(res.relPath).toBe('arch/sub');
expect(
await fs.stat(join(root, 'literature', 'arch', 'sub', 'deep', 'paper.pdf')).then(
() => true,
),
).toBe(true);
expect(lit.list().map((r) => r.relPath)).toEqual(['arch/sub/deep/paper.pdf']);
});
it('搬完之后 watcher 仍然活着:新位置里新建的文件能被感知', async () => {
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
const n = await notes.create('n.md');
await notes.moveFile(n.id, 'sub/deep');
await notes.renameDir('sub', 'renamed');
// 修 EPERM 靠的是「关掉 watcher 再重建」—— 若重建不生效,监听就
// 永久丢了:外部在**新位置**建文件,服务再也感知不到,而且**没有任何
// 报错**(静默失聪比报错更难查)。
await fs.writeFile(join(root, 'notes', 'renamed', 'deep', 'new.md'), '# new\n', 'utf-8');
const seen = await waitFor(() =>
notes.list().some((x) => x.relPath === 'renamed/deep/new.md'),
);
expect(seen, '重建后的 watcher 应仍能感知新位置的文件新增').toBe(true);
});
it('扁平目录(无子目录)仍然能改名 —— 不回归', async () => {
await fs.mkdir(join(root, 'notes', 'flat'), { recursive: true });
const n = await notes.create('n.md');
await notes.moveFile(n.id, 'flat');
const res = await notes.renameDir('flat', 'renamed');
expect(res.relPath).toBe('renamed');
expect(notes.list().map((x) => x.relPath)).toEqual(['renamed/n.md']);
});
});

View File

@@ -0,0 +1,114 @@
// tests/unit/main/document-csp.test.ts
//
// 守卫:`frame-ancestors` 只能、也只该出现在**应用主文档的响应头**里。
//
// 为什么要有这个守卫(audit r169,2026-10-19):
// 1. 该 directive 只在响应头生效 —— 放 <meta> 里 Chromium 忽略并每次
// 启动打一条 error(实测:启动 console 有
// "directive 'frame-ancestors' is ignored when delivered via a
// <meta> element")。历史上 renderer/index.html 的 meta CSP 里就挂着
// 一份,防护是空操作,却一直刷错。
// 2. 加在 **rkms:// 资源**上也不对 —— 实测 Chromium 只对 web 系 scheme
// 强制 frame-ancestors,自定义 scheme 不强制(加了也不生效);而一旦
// Chromium 改为强制,PDFReader / ImageReader 的 <iframe src="rkms://…">
// 会被自家 directive 打死。
// → 正确位置:主进程给主文档注入 CSP 响应头(src/main/document-csp.ts)。
//
// 本测守三件事:
// A. buildDocumentHeaders 纯函数行为(补头 / 覆盖大小写不同的旧 CSP /
// 保留其它响应头)。
// B. installDocumentCspHeaders 真的注册了 onHeadersReceived,且只对
// mainFrame 注入(subFrame 放行 —— 否则会误伤 rkms:// iframe)。
// C. source-text 锁:meta CSP 里不再有 frame-ancestors、protocol.ts 里
// 不再下发 Content-Security-Policy(防止回退到错误位置)。
import { readFileSync } from 'fs';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// vi.mock 工厂会被提升到 import 之前,必须用 vi.hoisted 拿到 mock 函数引用
const { onHeadersReceived } = vi.hoisted(() => ({ onHeadersReceived: vi.fn() }));
vi.mock('electron', () => ({
session: {
defaultSession: {
webRequest: { onHeadersReceived },
},
},
}));
const ROOT = process.cwd();
function read(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf8');
}
describe('document-csp', () => {
beforeEach(() => {
onHeadersReceived.mockReset();
});
it('A1 buildDocumentHeaders 补上 CSP 且保留其它响应头', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
const out = buildDocumentHeaders({ 'Content-Type': 'text/html', 'X-Test': '1' });
expect(out['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
expect(out['Content-Type']).toBe('text/html');
expect(out['X-Test']).toBe('1');
});
it('A2 buildDocumentHeaders 大小写不敏感地覆盖旧 CSP,不产生重复键', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
const out = buildDocumentHeaders({ 'content-security-policy': "default-src 'none'", 'X-Keep': 'y' });
const cspKeys = Object.keys(out).filter((k) => k.toLowerCase() === 'content-security-policy');
expect(cspKeys).toEqual(['Content-Security-Policy']);
expect(out['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
expect(out['X-Keep']).toBe('y');
});
it('A3 buildDocumentHeaders 接受 undefined 入参', async () => {
const { buildDocumentHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
expect(buildDocumentHeaders(undefined)['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
});
it('B1 installDocumentCspHeaders 注册 onHeadersReceived 且只对 mainFrame 注入', async () => {
const { installDocumentCspHeaders, APP_DOCUMENT_CSP } = await import('../../../src/main/document-csp');
installDocumentCspHeaders();
expect(onHeadersReceived).toHaveBeenCalledTimes(1);
const [filter, listener] = onHeadersReceived.mock.calls[0] as unknown as [
{ urls: string[] },
(
details: { resourceType: string; responseHeaders?: Record<string, string> },
cb: (r: { responseHeaders?: Record<string, string> }) => void
) => void,
];
// 过滤必须覆盖 prod(file://)与 dev(localhost)两种文档来源
expect(filter.urls).toContain('file://*/*');
expect(filter.urls.some((u) => u.includes('localhost'))).toBe(true);
// mainFrame → 注入
const mainCb = vi.fn();
listener({ resourceType: 'mainFrame', responseHeaders: { 'X-A': '1' } }, mainCb);
expect(mainCb).toHaveBeenCalledTimes(1);
expect(mainCb.mock.calls[0][0].responseHeaders?.['Content-Security-Policy']).toBe(APP_DOCUMENT_CSP);
// subFrame → 不注入(rkms:// iframe 等子帧不能被误伤)
const subCb = vi.fn();
listener({ resourceType: 'subFrame', responseHeaders: { 'X-A': '1' } }, subCb);
expect(subCb).toHaveBeenCalledTimes(1);
expect(subCb.mock.calls[0][0].responseHeaders).toBeUndefined();
});
it('C1 meta CSP 里不再有 frame-ancestors(Chromium 忽略 + 每次启动报错)', () => {
const html = read('src/renderer/index.html');
const metaTag = html.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]*)"/);
expect(metaTag).not.toBeNull();
expect(metaTag![1]).not.toContain('frame-ancestors');
// 其余 directive 必须在(meta CSP 仍然有效,别把防护整体删掉)
expect(metaTag![1]).toContain("script-src 'self'");
expect(metaTag![1]).toContain("object-src 'none'");
});
it('C2 protocol.ts 不再下发 Content-Security-Policy(自定义 scheme 上不生效且有风险)', () => {
const src = read('src/main/protocol.ts');
expect(src).not.toMatch(/headers\.set\(\s*['"]Content-Security-Policy['"]/);
});
});

View File

@@ -0,0 +1,202 @@
// tests/unit/main/favorites-delete-cleanup.test.ts
//
// 守 r198「文件被删除后,收藏(★)必须跟着走」契约 —— remapFavoriteIds 的
// **另一半**。
//
// 背景(用户诉求「文件操作也是对应的」的元数据一半):
// r182 补上了「id 变了 → ★ 跟着改」(改名 / 搬移 / 目录改名)。
// 但**删除**路径一直只动文件 + DB 行,没人管 favorites.md ⇒ 收藏条目
// 变成永远命中不了的孤儿,三个后果:
//
// 1. favorites.md 无限增长(每个删掉的 ★ 文献留一条死条目);
// 2. **★ 状态错位(用户可见)**:文献 id = 'lit:'+toPosix(absPath) ——
// 把同一个 PDF 从原路径再导入一次 ⇒ id 完全相同 ⇒ 新行**继承**旧 ★
// (用户从没点过,却显示已收藏);再点一下是「取消收藏」(toggle 语义),
// 用户更困惑;
// 3. notes 域同样会留孤儿(现在还没笔记 ★ 的 UI,但 favorites.md 设计上
// 可手工编辑,老版本 / 手改都可能留下笔记条目)。
//
// 本测锁(行为级,对着 favorites.md 的真实内容说话):
// 1. 文献删除 → 该 id 的收藏条目消失;
// 2. 笔记移入回收站 → 该 id 的收藏条目消失;
// 3. 删除**没收藏**的文件 → favorites.md 条目数不变(不空写盘),
// 别的收藏也不受影响;
// 4. 同路径再导入 → **不继承**旧 ★(toggle 返回 true = 加入收藏,
// 而不是 false = 取消收藏)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// favorites 走真 AtomicFile(临时 root 下的 workspace/favorites/favorites.md)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as unknown[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// electron 整体 mock:ipcMain.handle 捕获 handler(验「通道真的把清理
// 串到 favorites 上」),shell.trashItem 让 safeTrash 在 node 环境可调
// (把文件搬进 .trash 目录 —— 证明「文件还在、只是换了位置」= 可恢复)
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { NotesService } from '../../../src/main/notes/service';
import { registerLiteratureIPC } from '../../../src/main/ipc/literature';
import { registerNotesIPC } from '../../../src/main/ipc/notes';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { IPC } from '../../../src/shared/types';
describe('文件被删除后收藏(★)跟着清理(r198)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favdel-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
const fav = getFavoritesService();
await fav.start();
lit = new LiteratureService();
notes = new NotesService();
await lit.start({ skipInitialLoad: true });
await notes.start({ skipInitialLoad: true });
});
afterEach(async () => {
await notes.stop();
await lit.stop();
await getFavoritesService().stop();
// maxRetries/retryDelay:Windows 上 rm 递归删目录偶发 ENOTEMPTY
// (删除期间目录内容还在变 —— 本测的 shell.trashItem mock 会往 .trash
// 里 rename 文件)。Node 的 rm 对 EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM
// 会线性退避重试 ⇒ 加重试把「全量套件并行时偶发红」变成稳定绿。
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it('文献删除 → 该 id 的收藏条目消失(favorites.md 不留死条目)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
await fav.toggle(imported.id, { title: 'paper.pdf', relPath: imported.absPath });
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(1);
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_DELETE);
expect(handler).toBeTruthy();
const res = (await handler!({}, imported.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
it('笔记移入回收站 → 该 id 的收藏条目消失', async () => {
const fav = getFavoritesService();
const created = await notes.create('n.md');
await fav.toggle(created.id, { title: 'n.md', relPath: created.relPath });
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(1);
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_TRASH);
expect(handler).toBeTruthy();
const res = (await handler!({}, created.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
it('删除没收藏的文件 → favorites.md 不动(别的收藏也不受影响)', async () => {
const fav = getFavoritesService();
// 另一条**别的**收藏
const other = join(root, 'other.pdf');
await fs.writeFile(other, 'PDF-BYTES', 'utf-8');
const otherImp = await lit.importFile(other);
await fav.toggle(otherImp.id, { title: 'other.pdf', relPath: otherImp.absPath });
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// imported 没有被收藏
const before = fav.listEntries().length;
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_DELETE);
const res = (await handler!({}, imported.id)) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().length).toBe(before);
const files = fav.listEntries().filter((e) => e.kind === 'file');
expect(files).toHaveLength(1);
expect(files[0]?.fileId).toBe(otherImp.id);
});
it('同路径再导入 → 不继承旧 ★(toggle 是「加入收藏」而不是「取消收藏」)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const first = await lit.importFile(src);
await fav.toggle(first.id, { title: 'paper.pdf', relPath: first.absPath });
registerLiteratureIPC(lit);
const del = electronMock.handlers.get(IPC.LITERATURE_DELETE);
expect((await del!({}, first.id)) as { ok: boolean }).toMatchObject({ ok: true });
// 同一路径再导入 ⇒ id 相同(lit id = 'lit:'+posix absPath)
const src2 = join(root, 'src2');
await fs.mkdir(src2, { recursive: true });
const src2File = join(src2, 'paper.pdf');
await fs.writeFile(src2File, 'PDF-BYTES-2', 'utf-8');
const second = await lit.importFile(src2File);
expect(second.id).toBe(first.id);
// 收藏条目已被清掉 ⇒ 再点 ★ 是「加入收藏」(true),不是「取消收藏」(false)
const nowFavorited = await fav.toggle(second.id, {
title: 'paper.pdf',
relPath: second.absPath,
});
expect(nowFavorited).toBe(true);
});
});

View File

@@ -0,0 +1,170 @@
// tests/unit/main/favorites-id-remap.test.ts
//
// 守 r182「资源 id 变更后,收藏(★)必须跟着走」契约。
//
// 用户的收藏条目存在 favorites.md 里,主键是**资源 id**:
// - literature id = 'lit:' + toPosix(absPath) → 路径一变 id 就变;
// - note id = SHA-256(relPath) 前 16 位 → 路径一变 id 就变。
// 而 renderer 侧 ★ 的显示/过滤只看 id(favoritedFileIds Set)。
//
// 旧实现:改名 / 搬移 / 文件夹改名改完 id,**没人管 favorites.md** ——
// 收藏条目里留着旧 id,旧 id 再也不会命中任何一行 ⇒ 用户的 ★ 在操作成功的
// 瞬间静默消失(favorites.md 里那条记录变成永远点不亮的孤儿)。
//
// 本测锁:
// 1. 文献搬进文件夹(LITERATURE_MOVE)→ 收藏条目 fileId 跟着变;
// 2. 笔记改名(NOTES_RENAME_FILE)→ 收藏条目跟着变(notes 域 id 是裸 id);
// 3. 没有收藏指向该 id → 不动 favorites.md(不写盘、不空转);
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// favorites 走真 AtomicFile(临时 root 下的 workspace/favorites/favorites.md)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// electron 整体 mock:ipcMain.handle 捕获 handler(验「通道真的把 remap
// 串到 favorites 上」),shell.trashItem 让 safeTrash 在 node 环境可调
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { NotesService } from '../../../src/main/notes/service';
import { registerLiteratureIPC } from '../../../src/main/ipc/literature';
import { registerNotesIPC } from '../../../src/main/ipc/notes';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { IPC } from '../../../src/shared/types';
describe('id 变更后收藏(★)跟着走(r182)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favremap-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
const fav = getFavoritesService();
await fav.start();
lit = new LiteratureService();
notes = new NotesService();
await lit.start({ skipInitialLoad: true });
await notes.start({ skipInitialLoad: true });
});
afterEach(async () => {
await notes.stop();
await lit.stop();
await getFavoritesService().stop();
await fs.rm(root, { recursive: true, force: true });
});
it('文献搬进文件夹 → 收藏条目的 fileId 跟着变(★ 不丢)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// renderer 的 ★ toggle:fileId = 文献 id,relPath 存的是绝对路径
await fav.toggle(imported.id, { title: 'paper.pdf', relPath: imported.absPath });
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_MOVE);
expect(handler).toBeTruthy();
const res = (await handler!({}, imported.id, 'sub')) as {
ok: boolean;
data?: { absPath: string; id: string };
};
expect(res.ok).toBe(true);
const newId = res.data!.id;
expect(newId).not.toBe(imported.id);
// 收藏条目:fileId 已经是新 id,展示路径也是新路径
const entries = fav.listEntries().filter((e) => e.kind === 'file');
expect(entries).toHaveLength(1);
expect(entries[0]?.fileId).toBe(newId);
expect(entries[0]?.relPath).toContain('sub');
});
it('笔记改名 → 收藏条目跟着变(notes 域 id 是裸 id)', async () => {
const fav = getFavoritesService();
const created = await notes.create('n.md');
await fav.toggle(created.id, { title: 'n.md', relPath: created.relPath });
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_RENAME_FILE);
expect(handler).toBeTruthy();
const res = (await handler!({}, created.id, 'renamed.md')) as {
ok: boolean;
data?: { id: string; relPath: string };
};
expect(res.ok).toBe(true);
const newId = res.data!.id;
expect(newId).not.toBe(created.id);
const entries = fav.listEntries().filter((e) => e.kind === 'file');
expect(entries).toHaveLength(1);
expect(entries[0]?.fileId).toBe(newId);
expect(entries[0]?.relPath).toBe('renamed.md');
});
it('没有收藏指向该 id → 不动 favorites.md(不空写盘)', async () => {
const fav = getFavoritesService();
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
// 不 toggle —— 没有任何收藏指向这个 id
const before = fav.listEntries().length;
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_MOVE);
const res = (await handler!({}, imported.id, 'sub')) as { ok: boolean };
expect(res.ok).toBe(true);
expect(fav.listEntries().length).toBe(before);
expect(fav.listEntries().filter((e) => e.kind === 'file')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,132 @@
// tests/unit/main/favorites-malformed-entries.test.ts
//
// 守 r197「favorites.md 形态非法不得静默销毁用户收藏」契约。
//
// favorites.md 是**支持手编**的文件(文件头注明「人类可读优先」)。手编时
// 把 frontmatter.entries 写成映射 / 留空 / 写成字符串,是极常见的笔误。
//
// 旧实现(favorites.ts parse):
// const entries = Array.isArray(fm.entries) ? fm.entries : [];
// ⇒ 形态非法被降级成**空列表**,解析**不抛错** ⇒ AtomicFile.start() 的
// quarantine 分支不触发 ⇒ 下一次 ★ toggle 就拿空内存态 persist 覆盖整个
// 文件 ⇒ 用户的收藏**永久消失**,磁盘上没有 .corrupt 备份,日志里只有
// 一条 warn(用户视角:「收藏全没了」)。
//
// 本测锁:形态非法 → parse 抛错 → AtomicFile 走 quarantine(原文件被改名成
// `.corrupt-<ts>` 保留内容)→ 随后的 toggle 覆盖的是**新**文件,原数据仍在。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
vi.mock('electron', () => ({
ipcMain: { handle: () => undefined },
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { FavoritesService } from '../../../src/main/workspace/favorites';
import { WORKSPACE_DATA_LAYOUT } from '../../../src/shared/types';
describe('favorites.md 形态非法(r197)', () => {
let root: string;
let favPath: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favmal-'));
setRootPath(root);
favPath = join(root, WORKSPACE_DATA_LAYOUT.FAVORITES_FILE);
await fs.mkdir(join(favPath, '..'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it('entries 不是数组 → 原文件被 quarantine 成 .corrupt-*,内容不丢', async () => {
const original = [
'---',
'schemaVersion: 1',
'entries:',
' a: 1',
'---',
'',
'# 收藏',
'',
].join('\n');
await fs.writeFile(favPath, original, 'utf-8');
const fav = new FavoritesService();
await fav.start();
try {
// 内存态回落空列表(功能降级但**不崩**)
expect(fav.listEntries()).toEqual([]);
// 关键:原文件被改名保留,不是原地被覆盖
const siblings = await fs.readdir(join(favPath, '..'));
const quarantined = siblings.filter((n) => n.startsWith('favorites.md.corrupt-'));
expect(quarantined).toHaveLength(1);
const kept = await fs.readFile(join(favPath, '..', quarantined[0] as string), 'utf-8');
expect(kept).toBe(original);
} finally {
await fav.stop();
}
});
it('形态非法后再 toggle:覆盖的是新文件,quarantine 备份仍在', async () => {
await fs.writeFile(
favPath,
['---', 'schemaVersion: 1', 'entries:', ' a: 1', '---', '', 'x', ''].join('\n'),
'utf-8',
);
const fav = new FavoritesService();
await fav.start();
try {
await fav.toggle('lit:abc', { title: 'T', relPath: 'p.pdf' });
// 新文件被正常写入
const fresh = await fs.readFile(favPath, 'utf-8');
expect(fresh).toContain('lit:abc');
// 旧数据仍以 .corrupt-* 形式保留(用户还能手工抢救)
const siblings = await fs.readdir(join(favPath, '..'));
expect(siblings.filter((n) => n.startsWith('favorites.md.corrupt-'))).toHaveLength(1);
} finally {
await fav.stop();
}
});
it('正常数组 entries → 不 quarantine(契约不因本轮改动而松动)', async () => {
const good = [
'---',
'schemaVersion: 1',
'entries:',
' - kind: file',
' id: e1',
' fileId: lit:x',
' title: T',
' relPath: p.pdf',
' addedAt: 2024-01-01T00:00:00.000Z',
'---',
'',
'# 收藏',
'',
].join('\n');
await fs.writeFile(favPath, good, 'utf-8');
const fav = new FavoritesService();
await fav.start();
try {
expect(fav.listEntries()).toHaveLength(1);
const siblings = await fs.readdir(join(favPath, '..'));
expect(siblings.filter((n) => n.startsWith('favorites.md.corrupt-'))).toHaveLength(0);
} finally {
await fav.stop();
}
});
});

View File

@@ -0,0 +1,174 @@
// tests/unit/main/folder-dir-move.test.ts
//
// 守 r173「目录(子树)跨父目录搬移」契约 —— 侧栏「剪切文件夹 → 粘贴到
// 别的目录」「把文件夹拖到别的目录」「移到根目录」三条入口的落点。
//
// 用户报的「粘贴功能有问题」第 4 轮:文件夹维度全是死的 ——
// - renderer 的 pasteFolderInto / moveFolderNow 是 throw「not yet
// supported」的占位(UI 菜单项可见,点了必报错);
// - 文件夹拖拽的 dataTransfer 载荷写 ids: [],而 readMovePayload 要求
// ids 非空 → 载荷在读端被整条拒收,drop 端永远收不到。
//
// 本测在 service 层锁三件事:
// 1. 物理搬移真的发生(子树整体挪位,非 .md 文件也带走);
// 2. 子树里每个资源的 id 都重算(notes id 随 relPath、lit id 随
// absPath)且 byId 换键 —— 否则「刚搬过去的笔记」打不开;
// 3. 返回 idRemap(oldId → newId + 新路径),renderer 靠它把选中态跟过去。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(fs.rename + frontmatter 重钉 + byId 维护)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { computeFileId } from '../../../src/main/notes/path-mapping';
describe('目录(子树)跨父目录搬移(r173)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirmove-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === notes 域 ===
describe('NotesService.moveDir', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 每个笔记 id 重钉 + byId 换键 + 返回 idRemap', async () => {
const n = await svc.create('a.md');
// 先把笔记放进子目录 'sub'
const inSub = await svc.moveFile(n.id, 'sub');
expect(inSub.relPath).toBe('sub/a.md');
const oldId = inSub.id;
const moved = await svc.moveDir('sub', 'target');
// 1) 物理搬移:整棵子树挪到 target/sub,原位置不留残影
expect(moved.relPath).toBe('target/sub');
await expect(fs.stat(join(root, 'notes', 'target', 'sub', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'sub'))).rejects.toThrow();
// 2) id 重钉:笔记 id = SHA-256(relPath) 前 16 位 → 路径变 id 必变
expect(moved.idRemap.length).toBe(1);
const remap = moved.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.newId).toBe(computeFileId('target/sub/a.md'));
expect(remap.relPath).toBe('target/sub/a.md');
// 3) byId 换键:旧 id 查不到,新 id 查得到
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
expect(svc.findById(remap.newId)?.relPath).toBe('target/sub/a.md');
// 4) 文件里的 frontmatter id 也跟着重钉(否则重开应用索引对不上)
const raw = await fs.readFile(join(root, 'notes', 'target', 'sub', 'a.md'), 'utf-8');
expect(raw).toContain(remap.newId);
});
it('搬到根(目标父目录 = 空串)', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
const moved = await svc.moveDir('sub', '');
expect(moved.relPath).toBe('sub');
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
it('搬进自己的子树被拒(自我嵌套)', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
await expect(svc.moveDir('sub', 'sub')).rejects.toThrow();
// 目录还在原处(未发生半截搬移)
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
});
// === literature 域 ===
describe('LiteratureService.moveDir', () => {
let svc: LiteratureService;
beforeEach(async () => {
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 文献 id 随 absPath 变 + byId 换键 + 返回 idRemap', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const inSub = await svc.moveFile(imported.id, 'sub');
const oldId = inSub.id;
const moved = await svc.moveDir('sub', 'target');
expect(moved.relPath).toBe('target/sub');
await expect(fs.stat(join(root, 'literature', 'target', 'sub', 'paper.pdf'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'literature', 'sub'))).rejects.toThrow();
expect(moved.idRemap.length).toBe(1);
const remap = moved.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.absPath).toContain('target/sub/paper.pdf');
expect(remap.relPath).toBe('target/sub/paper.pdf');
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
});
it('搬进自己的子树被拒', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
await svc.moveFile(imported.id, 'sub');
await expect(svc.moveDir('sub', 'sub')).rejects.toThrow();
await expect(fs.stat(join(root, 'literature', 'sub', 'paper.pdf'))).resolves.toBeTruthy();
});
});
});

View File

@@ -0,0 +1,311 @@
// tests/unit/main/folder-dir-rename.test.ts
//
// 守 r175「应用内重命名文件夹」契约 —— 重命名后子树里每个资源的 id
// 必须跟着变,否则用户会丢数据。
//
// 旧实现(src/main/ipc/folders.ts 的 rename-dir handler)只有
// 「同级重名检测 + await fs.rename(oldAbs, newAbs)」:
// - **不重钉** 子树里每个笔记的 frontmatter id;
// - **不更新** DB 的 rel_path / absPath;
// - **不换** byId 的键;
// - **不返回** idRemap(旧契约只返 { relPath })。
//
// 笔记 id 与路径强绑定(fm.id = SHA-256(relPath) 前 16 位)⇒ 重命名目录
// 后 id 全变。旧实现下:文件里的 fm.id 还是旧路径的哈希,下一次 writeNote
// 无条件重烧 `id: computeFileId(relPath)` ⇒ id 突变 ⇒ renderer 手里的
// id 变**死 id** ⇒ 之后每次保存都 NOT_FOUND,用户新敲的字困在死 id
// 草稿里(重启即丢)—— 真实数据丢失路径。
//
// 本测锁四件事:
// 1. 物理搬移真的发生(子树整体挪位);
// 2. 子树里每个资源的 id 都重算(notes id 随 relPath、lit id 随
// absPath),文件里的 frontmatter id 也被重钉;
// 3. byId 换键 + DB 行跟着改(old id 查不到 / new id 查得到);
// 4. 返回 idRemap(oldId → newId + 新路径),renderer 靠它把选中态跟过去;
// 且 IPC 通道真的把 idRemap 交出去(旧契约只返 { relPath })。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(fs.rename + frontmatter 重钉 + byId 维护)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// electron 需整体 mock:ipcMain.handle 用来捕获 IPC handler(验「通道真的
// 把 idRemap 交出去」),shell.trashItem 让 safeTrash 在 node 环境可调。
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { registerFoldersIPC } from '../../../src/main/ipc/folders';
import { computeFileId } from '../../../src/main/notes/path-mapping';
import { IPC } from '../../../src/shared/types';
describe('应用内重命名文件夹(r175)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-dirrename-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
repo.renameResource.mockClear();
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === notes 域 ===
describe('NotesService.renameDir', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 每个笔记 id 重钉 + byId 换键 + 返回 idRemap', async () => {
const n = await svc.create('a.md');
const inSub = await svc.moveFile(n.id, 'sub');
expect(inSub.relPath).toBe('sub/a.md');
const oldId = inSub.id;
const renamed = await svc.renameDir('sub', 'renamed');
// 1) 物理搬移
expect(renamed.relPath).toBe('renamed');
await expect(fs.stat(join(root, 'notes', 'renamed', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'sub'))).rejects.toThrow();
// 2) id 重钉:笔记 id = SHA-256(relPath) 前 16 位 → 路径变 id 必变
expect(renamed.idRemap.length).toBe(1);
const remap = renamed.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.newId).toBe(computeFileId('renamed/a.md'));
expect(remap.relPath).toBe('renamed/a.md');
// 3) byId 换键:旧 id 查不到,新 id 查得到
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
expect(svc.findById(remap.newId)?.relPath).toBe('renamed/a.md');
// 4) 文件里的 frontmatter id 也被重钉 —— 否则下次写盘 id 突变,
// renderer 手里的 id 变死 id,之后每次保存都 NOT_FOUND
const raw = await fs.readFile(join(root, 'notes', 'renamed', 'a.md'), 'utf-8');
expect(raw).toContain(remap.newId);
expect(raw).not.toContain(oldId);
// 5) DB 的 rel_path 跟着改(最后一次 renameResource = 改名后的行)
expect(repo.renameResource).toHaveBeenCalled();
const lastCall = repo.renameResource.mock.calls.at(-1)!;
expect(lastCall[1].relPath).toBe('renamed/a.md');
expect(lastCall[3]).toContain('renamed');
});
it('嵌套目录改名保留目录内层级(a/b/n.md → c/n.md)', async () => {
const n = await svc.create('n.md');
const deep = await svc.moveFile(n.id, 'a/b');
expect(deep.relPath).toBe('a/b/n.md');
const renamed = await svc.renameDir('a/b', 'c');
expect(renamed.relPath).toBe('c');
await expect(fs.stat(join(root, 'notes', 'c', 'n.md'))).resolves.toBeTruthy();
expect(renamed.idRemap[0]?.relPath).toBe('c/n.md');
expect(renamed.idRemap[0]?.newId).toBe(computeFileId('c/n.md'));
});
it('目标已存在 → 拒绝,源目录内容完好(不丢数据)', async () => {
const a = await svc.create('a.md');
await svc.moveFile(a.id, 'x');
const b = await svc.create('b.md');
await svc.moveFile(b.id, 'y');
await expect(svc.renameDir('x', 'y')).rejects.toThrow();
// 源目录与它的笔记都还在(没发生半截搬移)
await expect(fs.stat(join(root, 'notes', 'x', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'y', 'b.md'))).resolves.toBeTruthy();
});
it('同名 → no-op,idRemap 为空', async () => {
const n = await svc.create('a.md');
await svc.moveFile(n.id, 'sub');
const renamed = await svc.renameDir('sub', 'sub');
expect(renamed.relPath).toBe('sub');
expect(renamed.idRemap).toEqual([]);
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
});
});
// === literature 域 ===
describe('LiteratureService.renameDir', () => {
let svc: LiteratureService;
beforeEach(async () => {
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子树整体挪位 + 文献 id 随 absPath 变 + byId 换键 + 返回 idRemap', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const inSub = await svc.moveFile(imported.id, 'sub');
const oldId = inSub.id;
const renamed = await svc.renameDir('sub', 'renamed');
expect(renamed.relPath).toBe('renamed');
await expect(
fs.stat(join(root, 'literature', 'renamed', 'paper.pdf')),
).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'literature', 'sub'))).rejects.toThrow();
expect(renamed.idRemap.length).toBe(1);
const remap = renamed.idRemap[0]!;
expect(remap.oldId).toBe(oldId);
expect(remap.newId).not.toBe(oldId);
expect(remap.absPath).toContain('renamed/paper.pdf');
expect(remap.relPath).toBe('renamed/paper.pdf');
expect(svc.findById(oldId)).toBeNull();
expect(svc.findById(remap.newId)).not.toBeNull();
});
it('目标已存在 → 拒绝,源文件完好', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
await svc.moveFile(imported.id, 'x');
await fs.mkdir(join(root, 'literature', 'y'), { recursive: true });
await expect(svc.renameDir('x', 'y')).rejects.toThrow();
await expect(
fs.stat(join(root, 'literature', 'x', 'paper.pdf')),
).resolves.toBeTruthy();
});
});
// === IPC 通道契约 ===
// 旧契约只返 { relPath } —— id 变化对 renderer 完全不可见 ⇒ 选中态
// 永远钉在死 id 上。这里锁「通道必须把 idRemap 交出去」。
describe('rename-dir IPC', () => {
it('NOTES_FOLDERS_RENAME_DIR 返回 idRemap', async () => {
const notes = new NotesService();
const lit = new LiteratureService();
await notes.start({ skipInitialLoad: true });
await lit.start({ skipInitialLoad: true });
try {
registerFoldersIPC(notes, lit);
const n = await notes.create('a.md');
const inSub = await notes.moveFile(n.id, 'sub');
const oldId = inSub.id;
const handler = electronMock.handlers.get(IPC.NOTES_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as {
ok: boolean;
data?: { relPath: string; idRemap: Array<{ oldId: string; newId: string }> };
};
expect(res.ok).toBe(true);
expect(res.data?.relPath).toBe('renamed');
expect(res.data?.idRemap.length).toBe(1);
expect(res.data?.idRemap[0]?.oldId).toBe(oldId);
expect(res.data?.idRemap[0]?.newId).toBe(computeFileId('renamed/a.md'));
} finally {
await notes.stop();
await lit.stop();
}
});
it('LITERATURE_FOLDERS_RENAME_DIR 返回 idRemap', async () => {
const notes = new NotesService();
const lit = new LiteratureService();
await notes.start({ skipInitialLoad: true });
await lit.start({ skipInitialLoad: true });
try {
registerFoldersIPC(notes, lit);
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
const inSub = await lit.moveFile(imported.id, 'sub');
const oldId = inSub.id;
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as {
ok: boolean;
data?: { relPath: string; idRemap: Array<{ oldId: string; newId: string }> };
};
expect(res.ok).toBe(true);
expect(res.data?.relPath).toBe('renamed');
expect(res.data?.idRemap.length).toBe(1);
expect(res.data?.idRemap[0]?.oldId).toBe(oldId);
} finally {
await notes.stop();
await lit.stop();
}
});
});
});

View File

@@ -0,0 +1,272 @@
// tests/unit/main/folder-favorites-remap.test.ts
//
// 守 r189「文件夹(子树)改名 / 跨目录搬移后,收藏(★)必须跟着走」契约。
//
// r182 只接了**单文件**的改名 / 搬移(ipc/notes.ts、ipc/literature.ts),
// 文件夹层的接线被 r182 的提交说明显式推迟,一直没补 ⇒ 用户可复现的 bug:
//
// 1. ★ 一个文件夹里的文献;
// 2. 右键该文件夹 → 重命名(或剪切 → 粘贴到别的目录);
// 3. 文件夹在树上改名/搬走了,但**★ 静默消失** —— favorites.md 里还留着
// 旧 fileId,而 id 是按路径算出来的
// (notes: fm.id = SHA-256(relPath) 前 16 位;
// literature: 'lit:' + POSIX 绝对路径)
// ⇒ 旧 id 再也不会命中任何一行,那条收藏变成永远点不亮的孤儿。
//
// 本测锁:
// 1. 文献文件夹改名 → 子树里被 ★ 的文献,收藏条目 fileId + 路径跟着走;
// 2. 文献文件夹跨目录搬移 → 同上;
// 3. 笔记文件夹改名 → 同上,且**路径形态按域**:
// notes 存相对 notes 根的 relPath,literature 存绝对路径 —— 写错形态
// ⇒ ★ 还在,但收藏面板里的路径指向不存在的文件;
// 4. 嵌套子树(夹里的夹里的文件)也一起跟着走;
// 5. 子树外/未收藏的文件 → 不动 favorites.md(不空写盘、不误改)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// favorites 走真 AtomicFile(临时 root 下的 workspace/favorites/favorites.md)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { NotesService } from '../../../src/main/notes/service';
import { registerFoldersIPC } from '../../../src/main/ipc/folders';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { IPC } from '../../../src/shared/types';
interface IpcOk<T> {
ok: boolean;
data?: T;
}
interface FolderResult {
relPath: string;
idRemap: Array<{ oldId: string; newId: string; relPath?: string; absPath?: string }>;
}
describe('文件夹(子树)改名 / 搬移后收藏(★)跟着走(r189)', () => {
let root: string;
let lit: LiteratureService;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-favfolder-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
const fav = getFavoritesService();
await fav.start();
lit = new LiteratureService();
notes = new NotesService();
await lit.start({ skipInitialLoad: true });
await notes.start({ skipInitialLoad: true });
});
afterEach(async () => {
await notes.stop();
await lit.stop();
await getFavoritesService().stop();
await fs.rm(root, { recursive: true, force: true });
});
/** 导入一个 pdf 到 literature 根,再搬进 <dir> —— 返回搬完后的 {id, absPath}。 */
async function importPdfInto(dir: string, name: string): Promise<{ id: string; absPath: string }> {
const src = join(root, name);
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
await fs.mkdir(join(root, 'literature', dir), { recursive: true });
const moved = await lit.moveFile(imported.id, dir);
return { id: moved.id, absPath: moved.absPath };
}
/** 建一个笔记并搬进 <dir> —— 返回搬完后的 {id, relPath}。 */
async function createNoteIn(dir: string, name: string): Promise<{ id: string; relPath: string }> {
const created = await notes.create(name);
await fs.mkdir(join(root, 'notes', dir), { recursive: true });
const moved = await notes.moveFile(created.id, dir);
return { id: moved.id, relPath: moved.relPath };
}
function favFiles(): { fileId: string; relPath: string }[] {
return getFavoritesService()
.listEntries()
.filter((e) => e.kind === 'file')
.map((e) => ({ fileId: (e as { fileId: string }).fileId, relPath: e.relPath }));
}
it('文献文件夹改名 → 子树里被 ★ 的文献,收藏条目的 id 与路径都跟着走', async () => {
const fav = getFavoritesService();
const file = await importPdfInto('sub', 'paper.pdf');
// renderer 的 ★ toggle:fileId = 文献 id,relPath 存的是**绝对路径**
await fav.toggle(file.id, { title: 'paper.pdf', relPath: file.absPath });
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
expect(res.data!.relPath).toBe('renamed');
expect(res.data!.idRemap).toHaveLength(1);
const newId = res.data!.idRemap[0]!.newId;
expect(newId).not.toBe(file.id);
// 收藏条目:fileId 已是新 id,展示路径也是新路径(文献域 = 绝对路径)
const entries = favFiles();
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(newId);
expect(entries[0]!.relPath).toContain('renamed');
expect(entries[0]!.relPath).toBe(res.data!.idRemap[0]!.absPath);
});
it('文献文件夹跨目录搬移 → 收藏条目跟着走', async () => {
const fav = getFavoritesService();
const file = await importPdfInto('sub', 'paper.pdf');
await fav.toggle(file.id, { title: 'paper.pdf', relPath: file.absPath });
await fs.mkdir(join(root, 'literature', 'arch'), { recursive: true });
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_MOVE_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'arch')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
expect(res.data!.relPath).toBe('arch/sub');
const newId = res.data!.idRemap[0]!.newId;
expect(newId).not.toBe(file.id);
const entries = favFiles();
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(newId);
expect(entries[0]!.relPath).toContain('arch');
});
it('笔记文件夹改名 → 收藏条目跟着走,且路径形态是 relPath(不是绝对路径)', async () => {
const fav = getFavoritesService();
const note = await createNoteIn('sub', 'n.md');
expect(note.relPath).toBe('sub/n.md');
// notes 域 toggle 存的是相对 notes 根的 relPath
await fav.toggle(note.id, { title: 'n.md', relPath: note.relPath });
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.NOTES_FOLDERS_RENAME_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'renamed')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
expect(res.data!.relPath).toBe('renamed');
const newId = res.data!.idRemap[0]!.newId;
expect(newId).not.toBe(note.id);
const entries = favFiles();
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(newId);
// 关键:notes 域的展示路径必须是 relPath —— 写绝对路径会让收藏面板
// 指向一个与用户心智(相对 notes 根)不一致的字符串
expect(entries[0]!.relPath).toBe('renamed/n.md');
});
it('笔记文件夹跨目录搬移 → 收藏条目跟着走', async () => {
const fav = getFavoritesService();
const note = await createNoteIn('sub', 'n.md');
await fav.toggle(note.id, { title: 'n.md', relPath: note.relPath });
await fs.mkdir(join(root, 'notes', 'arch'), { recursive: true });
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.NOTES_FOLDERS_MOVE_DIR);
expect(handler).toBeTruthy();
const res = (await handler!({}, 'sub', 'arch')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
expect(res.data!.relPath).toBe('arch/sub');
const newId = res.data!.idRemap[0]!.newId;
const entries = favFiles();
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(newId);
expect(entries[0]!.relPath).toBe('arch/sub/n.md');
});
it('嵌套子树(夹里的夹里的文件)也一起跟着走', async () => {
const fav = getFavoritesService();
const file = await importPdfInto('sub/deep', 'paper.pdf');
await fav.toggle(file.id, { title: 'paper.pdf', relPath: file.absPath });
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_RENAME_DIR);
const res = (await handler!({}, 'sub', 'renamed')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
expect(res.data!.idRemap).toHaveLength(1);
const entries = favFiles();
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(res.data!.idRemap[0]!.newId);
expect(entries[0]!.relPath).toContain('renamed');
});
it('子树里没有收藏 → 不动 favorites.md(不空写盘、不误改别的收藏)', async () => {
const fav = getFavoritesService();
// 一个**在子树外**的文献被收藏
const outside = await importPdfInto('other', 'outside.pdf');
await fav.toggle(outside.id, { title: 'outside.pdf', relPath: outside.absPath });
// 子树里的文献**不**收藏
await importPdfInto('sub', 'paper.pdf');
const before = favFiles();
expect(before).toHaveLength(1);
registerFoldersIPC(notes, lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_FOLDERS_RENAME_DIR);
const res = (await handler!({}, 'sub', 'renamed')) as IpcOk<FolderResult>;
expect(res.ok).toBe(true);
// 子树外的收藏条目:id 与路径都**不能**被改
const after = favFiles();
expect(after).toHaveLength(1);
expect(after[0]!.fileId).toBe(outside.id);
expect(after[0]!.relPath).toBe(outside.absPath);
});
});

View File

@@ -0,0 +1,122 @@
// tests/unit/main/folder-list-dirs.test.ts
//
// 守 r174「笔记侧栏一个文件夹都不显示」契约 —— listSubdirs 的物理目录名。
//
// 用户报「笔记中 好像没正常显示文件夹」。根因:listSubdirs 拿 **逻辑 kind**
// ('note' / 'literature')当磁盘目录名 join,notes 域于是去读
// <rootPath>/note —— 而笔记真实住在 <rootPath>/notes/(WORKSPACE_DATA_LAYOUT
// .NOTES_DIR)。readdir ENOENT 被当「空目录」静默吞掉,UI 侧文件夹行恒为
// 0 条,且**全程零报错**。literature 域侥幸:kind 'literature' 恰好等于
// 目录名 'literature',所以只有文献能显示文件夹。
//
// 本测锁三件事:
// 1. kind='note' 读 <root>/notes(不是 <root>/note);
// 2. kind='literature' 读 <root>/literature(回归保护);
// 3. 两域互不串:在错误的目录名(<root>/note)下造夹,不得被列出来。
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { listSubdirs } from '../../../src/main/utils/safe-write';
// 跨层(仅测试内允许,src/** 仍受 layering.test.ts 约束):renderer 侧纯函数
// —— 树形行合成 + 按 dirname(relPath) 分组。folder-tree.ts / folder-view.ts
// 只 import 类型,不带 React / DOM,node 环境下安全。
// r176:钻入式的 visibleFolderRows / visibleItemRows 已下线,行合成改由
// buildTreeRows(树)+ groupItemsByDir(分组)承担。
import {
buildTreeRows,
groupItemsByDir,
} from '../../../src/renderer/src/lib/folder-tree';
describe('listSubdirs 的物理目录名(r174)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-listdirs-'));
await fs.mkdir(join(root, 'notes', 'sub'), { recursive: true });
await fs.mkdir(join(root, 'literature', 'lit'), { recursive: true });
// 诱饵:错误的目录名 —— 修好之前 notes 域读的就是这里
await fs.mkdir(join(root, 'note', 'decoy'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
});
it("kind='note' 列出 <root>/notes 的子目录", async () => {
const dirs = await listSubdirs(root, 'note', null);
expect(dirs.map((d) => d.name)).toEqual(['sub']);
expect(dirs[0]?.relPath).toBe('sub');
expect(dirs[0]?.parentRelPath).toBeNull();
});
it("kind='literature' 列出 <root>/literature 的子目录", async () => {
const dirs = await listSubdirs(root, 'literature', null);
expect(dirs.map((d) => d.name)).toEqual(['lit']);
});
it('诱饵目录 <root>/note 不被列出(kind 与目录名不再混用)', async () => {
const notes = await listSubdirs(root, 'note', null);
const lit = await listSubdirs(root, 'literature', null);
expect(notes.map((d) => d.name)).not.toContain('decoy');
expect(lit.map((d) => d.name)).not.toContain('decoy');
});
it('二级钻入:parentRelPath 走同一套目录名', async () => {
await fs.mkdir(join(root, 'notes', 'sub', 'deep'), { recursive: true });
const dirs = await listSubdirs(root, 'note', 'sub');
expect(dirs.map((d) => d.relPath)).toEqual(['sub/deep']);
expect(dirs[0]?.parentRelPath).toBe('sub');
});
it('目录不存在时返回空数组(不抛)', async () => {
const dirs = await listSubdirs(root, 'note', 'nope');
expect(dirs).toEqual([]);
});
// === 跨层:磁盘 → renderer 文件夹行 / 笔记归属分组 ===
// 这条就是用户报的「笔记里没正常显示文件夹」的可观测形态:
// 磁盘上有目录 + 目录里有笔记,UI 既要出文件夹行,也要在**展开**后
// 显示目录里的笔记(且根视图不再重复显示它)。
// r176:树形态下「进入目录」= 展开,不再是钻入 —— 同一份磁盘数据
// 喂给 buildTreeRows,折叠时只出文件夹行,展开才带上目录内笔记。
it('跨层:磁盘目录 → 树行 + 目录内笔记归属(r174 / r176)', async () => {
await fs.writeFile(join(root, 'notes', 'sub', 'inner.md'), 'x');
await fs.writeFile(join(root, 'notes', 'top.md'), 'x');
const summaries = [
{ id: 'i1', relPath: 'sub/inner.md' },
{ id: 'i2', relPath: 'top.md' },
];
const rootDirs = await listSubdirs(root, 'note', null);
const subDirs = await listSubdirs(root, 'note', 'sub');
const childrenByDir = new Map<string, typeof rootDirs>([
['', rootDirs],
['sub', subDirs],
]);
const itemsByDir = groupItemsByDir(summaries);
// 折叠:只出文件夹行 + 根层笔记(sub 里的笔记不露头)
const collapsed = buildTreeRows<{ id: string; relPath: string }>({
childrenByDir,
itemsByDir,
expanded: new Set(),
targetRelPath: null,
searchActive: false,
});
expect(collapsed.map((r) => r.id)).toEqual(['folder:sub', 'i2']);
// 展开:子目录的笔记作为子行出现(depth 1),根层笔记仍在 depth 0
const expanded = buildTreeRows<{ id: string; relPath: string }>({
childrenByDir,
itemsByDir,
expanded: new Set(['sub']),
targetRelPath: null,
searchActive: false,
});
expect(expanded.map((r) => r.id)).toEqual(['folder:sub', 'i1', 'i2']);
const byId = new Map(expanded.map((r) => [r.id, r]));
expect(byId.get('i1')?.depth).toBe(1);
expect(byId.get('i2')?.depth).toBe(0);
});
});

View File

@@ -0,0 +1,177 @@
// tests/unit/main/folder-name-dead-folder.test.ts
//
// 守 audit 2026-10-21 P1-1:**死夹**(建得出、进不去笔记的文件夹)。
//
// 缺陷(修前):
// 文件夹名的创建策略(shared/rel-path.ts 的 validateFolderName)只查
// 「单段 + 长度 ≤ 100 + 核心段规则」,**一条 Win32 规则都没有**;
// 而 notes 域的**笔记文件名校验**对 relPath 的**每一段**都应用 Win32
// 规则(保留字符 / 控制字符 / 结尾带点 / 结尾带空格 / 保留设备名)。
// ⇒ 两套规则的差集就是「建得出、但里面放不了任何笔记」的夹:
//
// 'a.'(结尾带点) 'a '(结尾带空格) 'CON'(保留设备名)
// 'a:b' 'a*b' 'a?b' 'a"b' 'a|b'(Win32 保留字符)
//
// 用户在侧栏「新建文件夹」能建成这些夹(validateFolderName 放行);
// 但在这个夹里点「新建笔记」⇒ createNote 收 'a./Untitled 1.md' ⇒
// 段 'a.' 命中「结尾带点」⇒ ValidationError ⇒ **这个夹里永远建不了
// 笔记**(而笔记**搬得进去** —— move 走 assertTargetRelPath,只查核心
// 段规则)。'a:b' 这类更糟:连 fs.mkdir 都会失败 ⇒ 用户看到的是天书级
// IO 报错,而不是「这个名字不能用」。
//
// 本测锁三条:
// I3. 能用 name 建的夹,一定能放笔记(跨域不变量:notes 域最严);
// I3'. 反过来:被拒的名字,notes 域也拒(⇒ 不会造出装不了东西的夹);
// 单一真相源:Win32 规则只在 shared/win32-name.ts 写一次,三个调用点
// (notes 文件名 / literature 改名 / 文件夹名)都从那里取。
//
// 同时守住**反面**风险:path 语义(指向磁盘上已存在的东西)**不得**因此
// 收紧 —— 磁盘上可能有这些名字(用户用别的工具建的),应用必须能看见
// 并进入它们,否则又变成 r186 的「列得出来进不去」。
import { describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
vi.mock('electron', () => ({
shell: { trashItem: vi.fn(), showItemInFolder: vi.fn() },
app: { getPath: vi.fn(() => 'C:/tmp/rkms-dead-folder-test') },
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
deleteResource: vi.fn(),
listResources: vi.fn(() => []),
upsertResource: vi.fn(),
}));
import { requireFolderName, requireNewFolderRelPath } from '../../../src/main/ipc/folders';
import { normalizeAndValidateNoteFilename } from '../../../src/main/notes/filename-validator';
import { planLiteratureRename } from '../../../src/main/literature/rename-file';
import { validateFolderName, validateRelPath } from '../../../src/shared/rel-path';
/** 这些名字**建不出来 / 建了也没用** —— 必须在创建时被拒。 */
const DEAD_NAMES: { name: string; why: string }[] = [
{ name: 'a.', why: '结尾带点 —— Win32 静默截断' },
{ name: 'a ', why: '结尾带空格 —— Win32 静默截断' },
{ name: 'CON', why: 'Windows 保留设备名' },
{ name: 'con', why: 'Windows 保留设备名(大小写不敏感)' },
{ name: 'com1', why: 'Windows 保留设备名' },
{ name: 'lpt9', why: 'Windows 保留设备名' },
{ name: 'con.txt', why: '保留设备名 + 扩展名(按词干判)' },
{ name: 'a:b', why: 'Win32 保留字符(而且 mkdir 本身就会失败)' },
{ name: 'a*b', why: 'Win32 保留字符' },
{ name: 'a?b', why: 'Win32 保留字符' },
{ name: 'a"b', why: 'Win32 保留字符' },
{ name: 'a|b', why: 'Win32 保留字符' },
];
/** 这些名字**正常** —— 必须放行,且能装笔记。 */
const OK_NAMES: string[] = [
'foo',
'资料',
'会议纪要 2024',
'a..b',
'C++ 学习',
'v1.2 草稿',
"O'Brien 访谈",
'100%完成',
'结果=最终',
'A&B',
'子目录.子目录',
'COM10', // 保留设备名只到 com9
'con 夹', // 词干带空格 ⇒ 不是保留设备名
];
/** 文献**文件名**(不是夹名)里建不出来的名字。 */
const LIT_DEAD_FILES: string[] = [
'a<b.pdf',
'a>b.pdf',
'CON.pdf',
'con.txt.pdf',
'a?.pdf',
'a|.pdf',
'a*.pdf',
'a".pdf',
'a:b.pdf',
];
describe('文件夹名:死夹守卫(audit 2026-10-21 P1-1)', () => {
it('I3:能用 name 建的夹,一定能放笔记(notes 域最严 ⇒ 它过了就都过了)', () => {
for (const name of OK_NAMES) {
expect(validateFolderName(name), `名字 ${JSON.stringify(name)} 应当放行`).toBeNull();
// 在这个夹里新建笔记:createNote 收的是含目录的 relPath ⇒
// 文件名校验会逐段检查,**父目录段也在内** ⇒ 这里过了才叫「能放」。
expect(
() => normalizeAndValidateNoteFilename(`${name}/Untitled.md`, 'filename'),
`夹 ${JSON.stringify(name)} 里必须能建笔记`,
).not.toThrow();
}
});
it('死夹名单:创建时必须被拒(mkdir / rename-dir 两条入口一致)', () => {
for (const { name, why } of DEAD_NAMES) {
expect(validateFolderName(name), `${name}(${why})应当被拒`).not.toBeNull();
expect(() => requireFolderName('ch', name, 'name'), `${name}(${why})IPC 入口应拒`).toThrow();
// mkdir 的叶子段也走同一份策略
expect(
() => requireNewFolderRelPath('ch', `父/${name}`, 'relPath'),
`${name}(${why})mkdir 叶子段应拒`,
).toThrow();
}
});
it('I3 逆向:被拒的名字,notes 域也拒 ⇒ 不会造出装不了东西的夹', () => {
for (const { name } of DEAD_NAMES) {
expect(
() => normalizeAndValidateNoteFilename(`${name}/Untitled.md`, 'filename'),
`${name} 既然建不出来,notes 域也不该能建笔记`,
).toThrow();
}
});
it('文献域同款:文件改名进不去这些名字(文献侧不留半套规则)', () => {
for (const name of LIT_DEAD_FILES) {
expect(
() => planLiteratureRename('/tmp/old.pdf', name),
`文献改名 ${name} 应被拒`,
).toThrow();
}
// 对照:'a..pdf' 是**合法**文件名(点不在结尾)⇒ 不能连它一起拒。
expect(planLiteratureRename('/tmp/old.pdf', 'a..pdf').newFilename).toBe('a..pdf');
});
it('反面风险:path 语义**不得**收紧(磁盘上已有的这些名字必须进得去)', () => {
// r186 的不变量 I2:「列得出来的,一定能进得去」。磁盘上可能有
// 外部工具建的 'a.' / 'CON' 目录 ⇒ path 语义必须放行,否则它们
// 会从列表里消失(数据在、界面找不到 = 更糟的洞)。
for (const { name } of DEAD_NAMES) {
expect(
validateRelPath(name, { allowRoot: true }),
`path 语义必须放行 ${name}(只有**创建**策略才拒)`,
).toBeNull();
}
});
it('单一真相源:Win32 规则只在 shared/win32-name.ts 写一次', async () => {
const files = [
'../../../src/main/notes/filename-validator.ts',
'../../../src/main/literature/rename-file.ts',
'../../../src/shared/rel-path.ts',
];
for (const rel of files) {
const src = await fs.readFile(new URL(rel, import.meta.url), 'utf8');
// 规则一旦有人在自己文件里再抄一份,三处迟早分叉 ⇒ 又出现
// 「一个域能建、另一个域不能建」的洞(这次的坑就是抄漏第三处)。
expect(
src,
`${rel} 不得自抄 Win32 规则常量`,
).not.toMatch(/const\s+\w*(FORBIDDEN|RESERVED|DEVICE|CONTROL_CHARS)\w*\s*=/);
// 三处都得从 win32-name 取判据
expect(src, `${rel} 必须从 shared/win32-name 取判据`).toMatch(/win32-name/);
}
const win32 = await fs.readFile(
new URL('../../../src/shared/win32-name.ts', import.meta.url),
'utf8',
);
expect(win32).toMatch(/WIN32_FORBIDDEN_CHARS/);
expect(win32).toMatch(/RESERVED_DEVICE_NAME/);
});
});

View File

@@ -0,0 +1,249 @@
// tests/unit/main/folder-scoped-file-ops.test.ts
//
// 守 r175「文件操作必须作用在对应的那一项、且不丢数据」契约。
//
// 背景:用户诉求 =「左侧的文献和笔记列表应该和对应的文件夹中完全一致,
// 相关的文件操作也是对应的,不要有错误,或者造成数据丢失」。
// 本测锁 5 条会在「子目录里操作」时破掉的规则:
//
// 1. 重命名笔记**保留所在目录** —— 旧实现把整条 relPath 换成新文件名,
// 'sub/a.md' 重命名成 'b.md' 会把文件搬到 notes 根(当前文件夹视图
// 里那条笔记凭空消失 = 列表与文件夹不一致)。
// 2. 文献删除走**系统回收站** —— 旧实现是 fs.unlink 硬删(不可恢复),
// 与笔记域(回收站)语义相反;文献往往就是用户手里唯一一份 PDF。
// 3. 搬移**不覆盖**目标目录里已有的同名文件 —— 旧实现靠「fs.rename 会
// 抛 EEXIST」拦,但文件级 rename 在 Windows/POSIX 上都**静默替换**
// (MoveFileExW + MOVEFILE_REPLACE_EXISTING / rename(2)),
// 后果是目标文件被静默销毁 = 数据丢失。
// 4. 导入路径用正斜杠也能过 —— 旧 realpath 守卫在 Windows 上恒误判
// (realpathSync 返反斜杠),导致「被搬动过的文献再也复制粘贴不了」。
// 5. 搬移不改文件 mtime —— 旧实现写 Date.now(),把「2 小时前」改成
// 「刚刚」,并让列表顺序突变。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// electron shell 单独 mock:trashItem 把文件**移进一个临时回收站目录**
// (这样测试能证明「文件还在、只是换了位置」= 可恢复,而硬删会丢)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// electron shell.trashItem:真实现是把文件挪进回收站 —— 测试里挪进一个
// 临时目录,便于断言「文件内容仍在 = 可恢复」。
const electronMock = vi.hoisted(() => {
const state = { trashDir: '' };
return {
state,
shell: {
trashItem: async (target: string): Promise<void> => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
await rename(target, j(state.trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({ shell: electronMock.shell }));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import {
importLiteratureFile,
removeLiteratureFile,
} from '../../../src/main/literature/import';
describe('文件操作的作用域与不丢数据(r175)', () => {
let root: string;
let trashDir: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-folderscope-'));
trashDir = await fs.mkdtemp(join(tmpdir(), 'rkms-trash-'));
electronMock.state.trashDir = trashDir;
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(trashDir, { recursive: true, force: true });
});
// === 1. 重命名保留目录 ===
describe('NotesService.renameFile —— 重命名不搬家', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('子目录里的笔记重命名后仍在该子目录(旧实现把它搬到 notes 根)', async () => {
const n = await svc.create('a.md');
const inSub = await svc.moveFile(n.id, 'sub');
expect(inSub.relPath).toBe('sub/a.md');
const renamed = await svc.renameFile(inSub.id, 'b.md');
// 目录不变,只换文件名
expect(renamed.relPath).toBe('sub/b.md');
await expect(fs.stat(join(root, 'notes', 'sub', 'b.md'))).resolves.toBeTruthy();
// 旧文件没了
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).rejects.toThrow();
// ★ 关键:没有偷偷搬到 notes 根
await expect(fs.stat(join(root, 'notes', 'b.md'))).rejects.toThrow();
});
it('子目录里的仅大小写重命名可用(旧实现在根目录才走 case-only 分支)', async () => {
const n = await svc.create('a.md');
const inSub = await svc.moveFile(n.id, 'sub');
const renamed = await svc.renameFile(inSub.id, 'A.md');
expect(renamed.relPath).toBe('sub/A.md');
await expect(fs.stat(join(root, 'notes', 'sub', 'A.md'))).resolves.toBeTruthy();
});
it('重命名到与同目录既有笔记同名 → 报错且两个文件都在(不覆盖)', async () => {
const a = await svc.create('a.md');
const b = await svc.create('b.md');
await svc.moveFile(a.id, 'sub');
const bInSub = await svc.moveFile(b.id, 'sub');
await expect(svc.renameFile(bInSub.id, 'a.md')).rejects.toMatchObject({
code: 'DUPLICATE_NAME',
});
// 两个文件都还在,内容没被动过
await expect(fs.stat(join(root, 'notes', 'sub', 'a.md'))).resolves.toBeTruthy();
await expect(fs.stat(join(root, 'notes', 'sub', 'b.md'))).resolves.toBeTruthy();
});
});
// === 3. 搬移不覆盖(notes) ===
it('把笔记搬进已有同名文件的目录 → 报错,目标文件内容与源文件都不丢', async () => {
const svc = new NotesService();
await svc.start({ skipInitialLoad: true });
try {
const a = await svc.create('a.md');
await fs.mkdir(join(root, 'notes', 'sub'), { recursive: true });
await fs.writeFile(join(root, 'notes', 'sub', 'a.md'), 'PRECIOUS', 'utf-8');
await expect(svc.moveFile(a.id, 'sub')).rejects.toMatchObject({
code: 'DUPLICATE_NAME',
});
// 目标文件内容没被覆盖
expect(await fs.readFile(join(root, 'notes', 'sub', 'a.md'), 'utf-8')).toBe('PRECIOUS');
// 源文件也还在(没被 rename 走)
await expect(fs.stat(join(root, 'notes', 'a.md'))).resolves.toBeTruthy();
} finally {
await svc.stop();
}
});
// === 2 + 3 + 5(literature) ===
describe('LiteratureService', () => {
let svc: LiteratureService;
beforeEach(async () => {
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('删除文献走回收站(文件可恢复),不是硬删', async () => {
const target = join(root, 'literature', 'paper.pdf');
await fs.writeFile(target, 'PDF-BYTES', 'utf-8');
const removed = await removeLiteratureFile(target, root);
expect(removed).toBe(true);
// 原位置没了
await expect(fs.stat(target)).rejects.toThrow();
// ★ 但内容还在回收站里 —— 硬删会在这里丢
const trashed = await fs.readFile(join(trashDir, 'paper.pdf'), 'utf-8');
expect(trashed).toBe('PDF-BYTES');
});
it('删除文献(走 service.remove)同样可恢复', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
await svc.remove(imported.id);
await expect(fs.stat(join(root, 'literature', 'paper.pdf'))).rejects.toThrow();
const trashed = await fs.readFile(join(trashDir, 'paper.pdf'), 'utf-8');
expect(trashed).toBe('PDF-BYTES');
});
it('搬移不覆盖目标目录里已有的同名文件(旧实现静默销毁目标)', async () => {
const src = join(root, 'x.pdf');
await fs.writeFile(src, 'SOURCE', 'utf-8');
const imported = await svc.importFile(src);
await fs.mkdir(join(root, 'literature', 'A'), { recursive: true });
await fs.writeFile(join(root, 'literature', 'A', 'x.pdf'), 'PRECIOUS', 'utf-8');
await expect(svc.moveFile(imported.id, 'A')).rejects.toMatchObject({
code: 'VALIDATION',
});
// 目标文件没被覆盖
expect(await fs.readFile(join(root, 'literature', 'A', 'x.pdf'), 'utf-8')).toBe('PRECIOUS');
// 源文件也还在
await expect(fs.stat(join(root, 'literature', 'x.pdf'))).resolves.toBeTruthy();
});
it('搬移不改 mtime(旧实现写 Date.now(),把「2 小时前」改成「刚刚」)', async () => {
const src = join(root, 'y.pdf');
await fs.writeFile(src, 'BYTES', 'utf-8');
const imported = await svc.importFile(src);
const before = svc.findById(imported.id)!.mtime;
const moved = await svc.moveFile(imported.id, 'sub');
expect(svc.findById(moved.id)!.mtime).toBe(before);
});
});
// === 4. 导入路径形态(正斜杠)不再被误杀 ===
it('导入路径用正斜杠也能过(realpath 守卫旧实现恒误判 → 搬移过的文献复制粘贴全废)', async () => {
const src = join(root, 'native.txt');
await fs.writeFile(src, 'X', 'utf-8');
const posixPath = src.split('\\').join('/');
const imported = await importLiteratureFile(posixPath, root);
expect(imported.absPath).toContain('native.txt');
// 内容真的拷进来了
expect(await fs.readFile(imported.absPath, 'utf-8')).toBe('X');
});
});

View File

@@ -0,0 +1,93 @@
// tests/unit/main/fs-cache-negative.test.ts
//
// 守 fs-cache 的缓存契约(2026-10-20,r197):
//
// 1. 文件不存在(ENOENT)→ 返 null,且**不进缓存** —— 文件随后被创建时
// 必须能立刻读到。
// 2. 同一文件重复读 → parser 只跑一次(mtime/size/ino 三者一致才算命中)。
// 3. invalidateFile 后 → 下次读重新 parse。
// 4. win32/darwin 下大小写不同的路径 → 同一个缓存条目。
//
// 第 1 条锁的是 r197 删掉的「负缓存」:旧实现把 ENOENT 写进缓存
// (mtimeMs/size/ino 全 -1 + missing=true),但读路径**先 open 成功**才查
// 缓存 ⇒ 负缓存条目永远命中不了,`cached.missing` 分支恒 false —— 它只是
// 白占一个 FIFO 槽位(把真热条目挤出去),还让注释吹嘘的「避免重复 stat」
// 根本不成立。本测同时锁住「负缓存不得复活」这条线:文件先不存在、
// 后创建,第二次读必须拿到内容(若有人再把 missing 缓存回来且不带 TTL,
// 这条会红)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { cachedReadParse, invalidateFile } from '../../../src/main/utils/fs-cache';
describe('fs-cache 缓存契约(r197)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-fscache-'));
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it('文件不存在 → 返 null,不抛', async () => {
const res = await cachedReadParse(join(root, 'nope.md'), (raw) => raw);
expect(res).toBeNull();
});
it('文件先不存在、后创建 → 第二次读必须拿到内容(负缓存不得吞掉新文件)', async () => {
const p = join(root, 'late.md');
expect(await cachedReadParse(p, (raw) => raw)).toBeNull();
await fs.writeFile(p, '# hello', 'utf-8');
const res = await cachedReadParse(p, (raw) => raw);
expect(res).not.toBeNull();
expect(res?.value).toBe('# hello');
});
it('同一文件重复读 → parser 只跑一次', async () => {
const p = join(root, 'a.md');
await fs.writeFile(p, 'v1', 'utf-8');
const parser = vi.fn((raw: string) => raw);
const first = await cachedReadParse(p, parser);
const second = await cachedReadParse(p, parser);
expect(first?.value).toBe('v1');
expect(second?.value).toBe('v1');
expect(parser).toHaveBeenCalledTimes(1);
});
it('invalidateFile 后 → 重新 parse', async () => {
const p = join(root, 'b.md');
await fs.writeFile(p, 'v1', 'utf-8');
const parser = vi.fn((raw: string) => raw);
await cachedReadParse(p, parser);
invalidateFile(p);
await cachedReadParse(p, parser);
expect(parser).toHaveBeenCalledTimes(2);
});
it('内容改了(大小变化)→ 重新 parse,不会返回 stale', async () => {
const p = join(root, 'c.md');
await fs.writeFile(p, 'v1', 'utf-8');
const parser = vi.fn((raw: string) => raw);
const first = await cachedReadParse(p, parser);
await fs.writeFile(p, 'v2-longer', 'utf-8');
const second = await cachedReadParse(p, parser);
expect(first?.value).toBe('v1');
expect(second?.value).toBe('v2-longer');
expect(parser).toHaveBeenCalledTimes(2);
});
it('parser 主动返回 null(业务值)→ 缓存命中后仍是 null,不是「文件不存在」', async () => {
const p = join(root, 'd.md');
await fs.writeFile(p, 'x', 'utf-8');
const parser = vi.fn(() => null as string | null);
const first = await cachedReadParse(p, parser);
const second = await cachedReadParse(p, parser);
expect(first?.value).toBeNull();
expect(second?.value).toBeNull();
expect(parser).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,64 @@
// tests/unit/main/ipc/library-reader-not-initialized.test.ts
//
// 守 audit-r244「工作区未初始化 ≠ 路径越界」契约。
//
// 问题:ipc/library.ts 的 handler 把 assertInsideAllowedRoot 抛出的
// **所有**错误一律翻成 `AppError('EPERM', 'file is outside the workspace')`。
// 但 assertInsideAllowedRoot 会因为两种完全不同的原因抛错:
// ① 路径真的在工作区外 / 是 symlink ⇒ EPERM(该拒);
// ② 工作区还没初始化(getRootPath() 未 setRootPath)⇒ INTERNAL
// 「Root path not initialized. Call setRootPath() first.」
// 旧实现把 ② 也报成 ① ⇒ renderer 显示「文件在工作区外」,而真实原因是
// 启动期工作区还没就绪 —— 排查方向完全跑偏(而且这个提示对用户是**假的**:
// 文件其实可能就在工作区里)。
//
// 本测锁:工作区未初始化时,reader 通道回的是 INTERNAL(不是 EPERM)。
//
// 注意:本测要拿到「未初始化」的模块态,所以用 vi.resetModules() +
// 动态 import 拿一份**全新的** safe-write 模块(它的 currentDataPath 初值
// 为 null);同文件里不调 setRootPath。
import { beforeEach, describe, expect, it, vi } from 'vitest';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: { trashItem: vi.fn() },
}));
import { IPC } from '../../../../src/shared/types';
interface IpcRes {
ok: boolean;
code?: string;
message?: string;
}
describe('audit-r244:reader 通道在「工作区未初始化」时报 INTERNAL', () => {
beforeEach(() => {
vi.resetModules();
electronMock.handlers.clear();
});
it('未 setRootPath 时 → code=INTERNAL(不是 EPERM)', async () => {
const { registerLibraryIPC } = await import('../../../../src/main/ipc/library');
registerLibraryIPC();
const h = electronMock.handlers.get(IPC.LIBRARY_READER_FOR_FILE);
expect(h).toBeTruthy();
const res = (await h!({}, 'C:/whatever/inside-workspace.txt')) as IpcRes;
expect(res.ok).toBe(false);
expect(res.code).toBe('INTERNAL');
expect(res.code).not.toBe('EPERM');
});
});

View File

@@ -0,0 +1,68 @@
// tests/unit/main/ipc/paths-no-path-leak.test.ts
//
// 守 audit-r244「白名单拒绝消息不回显绝对路径」契约。
//
// 问题:ipc/wrap.ts 只对 **INTERNAL** 走 scrubInternal(把 rootPath **之内**
// 的路径替成占位符);已知 code(EPERM 等)是**原样透传**的。而
// `assertInsideAllowedRoot` 的 EPERM 消息里带着 `abs` —— 恰恰是
// rootPath **之外**的路径(scrubInternal 恰恰脱敏不到它)⇒ 家目录 /
// OneDrive 目录名随 IPC 错误回包进 renderer 与 telemetry。
//
// 口径已由 ipc/shell.ts(r229)、ipc/library.ts(r191)确立:消息不含路径,
// 完整路径只留 main 侧日志。paths.ts 是**源头**,之前靠三个调用点各自
// 改写文案才没漏 —— 属「运气」而非「设计」,新调用点一接就漏。
//
// 本测锁:白名单拒绝抛出的 AppError.message 里既没有越界路径,也没有
// 工作区根路径。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { setRootPath } from '../../../../src/main/utils/safe-write';
import { assertInsideAllowedRoot } from '../../../../src/main/ipc/paths';
import { AppError } from '../../../../src/main/utils/error';
describe('audit-r244:白名单拒绝消息不含绝对路径', () => {
let root: string;
let outside: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-pathleak-root-'));
outside = await fs.mkdtemp(join(tmpdir(), 'rkms-pathleak-out-'));
setRootPath(root);
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
it('工作区外的绝对路径 → EPERM,消息里既无越界路径也无根路径', () => {
const secret = join(outside, 'id_rsa');
let err: unknown;
try {
assertInsideAllowedRoot(secret);
} catch (e: unknown) {
err = e;
}
expect(err).toBeInstanceOf(AppError);
const appErr = err as AppError;
expect(appErr.code).toBe('EPERM');
expect(appErr.message).not.toContain(secret);
expect(appErr.message).not.toContain(outside);
expect(appErr.message).not.toContain(root);
});
it('工作区外的相对路径(基于 root 解析后越界)→ 同样不含路径', () => {
let err: unknown;
try {
assertInsideAllowedRoot('../escape-outside');
} catch (e: unknown) {
err = e;
}
expect(err).toBeInstanceOf(AppError);
expect((err as AppError).code).toBe('EPERM');
expect((err as AppError).message).not.toContain(root);
});
});

View File

@@ -0,0 +1,365 @@
// tests/unit/main/ipc/paths.test.ts
// 守 src/main/ipc/paths.ts 的「路径白名单 + symlink 防御」契约(r229 补齐 🔒 守卫缺失)。
//
// ── 被守卫的契约(退化后的用户可见后果)─────────────────────────────
// C1 isInsideAnyRoot —— 唯一一处「路径是否落在根集合之内」的判定。
// 退化(改回 startsWith 前缀比较)⇒ 工作区外的**同前缀兄弟目录**
// (如 <root>-evil)被误判成「在白名单内」⇒ 渲染层可经 rkms:// iframe /
// shell:open-path 打开工作区外的文件(读侧越界,用户隐私外泄)。
// C2 assertInsideAllowedRoot —— 相对路径以 rootPath 为基准解析、越界抛 EPERM。
// 退化(按 cwd 解析相对路径)⇒ 应用的启动目录一变,「打开笔记所在目录」
// 这类相对路径操作全部落到工作区之外 ⇒ 用户看到「路径不允许」而毫无线索。
// C3 assertNotSymlink —— 白名单内但**是 symlink** → EPERM
// (这条正是源码里那处 🔒 指针指向的行为)。
// 退化(去掉 lstat / realpath 两道防线)⇒ 攻击者把 <root>/notes/foo.md
// 换成指向 ~/.ssh/id_rsa 的 symlink,渲染层的 rkms:// iframe 就能读到
// 工作区外的任意文件;用户侧表现是「笔记内容莫名其妙变成了 SSH 私钥」。
// C4 assertNotSymlink 的 roots 参数 —— 防御对照的是**调用方自己**的白名单。
// 退化(写死 allowedRoots())⇒ 用了别的根集合的调用方要么被误杀、要么
// 放行本该拦住的路径。
//
// ── 变异思路(哪几处改动会红)─────────────────────────────────────
// M1 isInsideAnyRoot 的 rel 判定换成 startsWith 前缀比较 → A3 / A6 / D8 红。
// M2 isInsideAnyRoot 删掉 caseInsensitive 折叠 → A4 红。
// M3 isInsideAnyRoot 改成按 process.platform 在 posix/win32 之间切 →
// B 组(注入 impl)与 C 组(宿主语义)同时红。
// M4 assertInsideAllowedRoot 的相对路径改成按 cwd 解析 → D3 红。
// M5 assertInsideAllowedRoot 不再调用 assertNotSymlink → D10 / D11 红。
// M6 assertNotSymlink 去掉第一道 lstat 防线 → E3 红。
// M7 assertNotSymlink 去掉第二道 realpath 链防线 → E4 红。
// M8 assertNotSymlink 把 roots 写死成 allowedRoots() → F1 / F2 红。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join, posix, resolve, win32 } from 'path';
// safe-write.ts 顶层 import { shell } from 'electron' —— node 环境下 electron
// 包导出的是二进制路径字符串,不做 mock 会在 import 期就炸。
vi.mock('electron', () => ({
shell: { trashItem: vi.fn(), openPath: vi.fn() },
}));
import { setRootPath } from '../../../../src/main/utils/safe-write';
import { AppError, errnoCode } from '../../../../src/main/utils/error';
import {
allowedRoots,
assertInsideAllowedRoot,
assertNotSymlink,
buildWorkspacePaths,
isInsideAnyRoot,
} from '../../../../src/main/ipc/paths';
/** 注入用的 posix / win32 路径语义(生产路径用的是宿主默认那套) */
const POSIX = { relative: posix.relative, isAbsolute: posix.isAbsolute };
const WIN32 = { relative: win32.relative, isAbsolute: win32.isAbsolute };
let root: string;
let outside: string;
/**
* 造一个指向 target 的**目录链接**。
* Windows 上 junction 不需要管理员权限(普通 symlink 需要 Developer Mode /
* 管理员);POSIX 上 Node 忽略 type 参数 ⇒ 等价于普通符号链接。
*/
async function makeLink(target: string, link: string): Promise<void> {
try {
await fs.symlink(target, link, 'junction');
} catch (err: unknown) {
throw new Error(
`造不出 junction(${target} → ${link}): ${String(err)}\n` +
`若所在环境完全禁止创建符号链接,这一组用例需要改成 mock fs.lstatSync。`,
);
}
}
/** 断言 fn 抛 AppError 且 code === expected */
function expectAppError(fn: () => unknown, expected: string): void {
let thrown: unknown;
try {
fn();
} catch (err: unknown) {
thrown = err;
}
expect(thrown, `期望抛 AppError(${expected}),但没有抛`).toBeInstanceOf(AppError);
expect((thrown as AppError).code).toBe(expected);
// errnoCode 是 wrap.ts / protocol.ts 判错码用的同一个 helper —— 两处口径要一致
expect(errnoCode(thrown)).toBe(expected);
}
beforeEach(async () => {
// realpath:macOS 的 /var → /private/var 是符号链接,不归一化的话 realpath
// 链防线会把「合法路径」判成越界(那是测试自身的问题,不是被测代码的)。
root = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-root-')));
outside = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-out-')));
setRootPath(root);
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
describe('A. isInsideAnyRoot:注入 path 语义后的判定(纯函数)', () => {
it('A1 根自身算在内(rel === "")', () => {
expect(isInsideAnyRoot(['/data'], '/data', { caseInsensitive: false, impl: POSIX })).toBe(true);
});
it('A2 根的子目录算在内', () => {
expect(
isInsideAnyRoot(['/data'], '/data/notes/a.md', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
});
it('A3 同前缀兄弟目录不算在内(/data-evil ≠ /data)', () => {
// 这是本文件存在的头号理由:startsWith 前缀比较会把 /data-evil 放行
expect(
isInsideAnyRoot(['/data'], '/data-evil/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
it('A4 大小写:caseInsensitive 决定 /DATA 与 /data 是否同一目录', () => {
expect(
isInsideAnyRoot(['/data'], '/DATA/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
expect(
isInsideAnyRoot(['/data'], '/DATA/x', { caseInsensitive: true, impl: POSIX }),
).toBe(true);
});
it('A5 多根集合:落在任一根内即算在内', () => {
expect(
isInsideAnyRoot(['/a', '/b'], '/b/x', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
expect(
isInsideAnyRoot(['/a', '/b'], '/c/x', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
it('A6 空根集合 ⇒ 一律不在内', () => {
expect(isInsideAnyRoot([], '/data', { caseInsensitive: false, impl: POSIX })).toBe(false);
});
it('A7 win32 语义:同盘子目录在内,换盘在外', () => {
expect(
isInsideAnyRoot(['C:\\data'], 'C:\\data\\notes', { caseInsensitive: false, impl: WIN32 }),
).toBe(true);
// 换盘 ⇒ relative 返回绝对路径 ⇒ 判为不在内
expect(
isInsideAnyRoot(['C:\\data'], 'D:\\data\\notes', { caseInsensitive: false, impl: WIN32 }),
).toBe(false);
});
it('A8 ".." 片段由 relative 内部归一化 ⇒ 仍判在内', () => {
// path.relative 内部会 resolve 两侧 ⇒ '/a/b/../b/c' ≡ '/a/b/c' ⇒ 在 '/a/b' 内。
// (反过来 '/data-evil' 之所以被拒,是因为归一化后它真的落在根之外。)
expect(
isInsideAnyRoot(['/a/b'], '/a/b/../b/c', { caseInsensitive: false, impl: POSIX }),
).toBe(true);
expect(
isInsideAnyRoot(['/a/b'], '/a/b/../../etc/passwd', { caseInsensitive: false, impl: POSIX }),
).toBe(false);
});
});
describe('B. isInsideAnyRoot:默认 impl(宿主 path 语义)', () => {
it('B1 根自身 / 子目录在内,区外在外', () => {
expect(isInsideAnyRoot([root], root)).toBe(true);
expect(isInsideAnyRoot([root], join(root, 'notes', 'a.md'))).toBe(true);
expect(isInsideAnyRoot([root], outside)).toBe(false);
});
it('B2 同前缀兄弟目录不在内(宿主语义下同样成立)', () => {
// root 与 sibling 只差后缀 ⇒ 前缀比较会误放行
const sibling = `${root}-evil`;
expect(isInsideAnyRoot([root], join(sibling, 'x'))).toBe(false);
});
it('B3 默认 opts 省略时 caseInsensitive 视为 false', () => {
// 显式传 false 与不传 opts 语义一致(省略 opts ⇒ 默认 { caseInsensitive: false })
const rootUpper = root.toUpperCase();
expect(isInsideAnyRoot([root], join(rootUpper, 'x'))).toBe(
isInsideAnyRoot([root], join(rootUpper, 'x'), { caseInsensitive: false }),
);
});
});
describe('C. allowedRoots / buildWorkspacePaths', () => {
it('C1 allowedRoots() 只含当前 rootPath(库下线后白名单缩成单元素)', () => {
expect(allowedRoots()).toEqual([resolve(root)]);
});
it('C2 buildWorkspacePaths() 给出 root / notes / favorites 三个绝对路径', () => {
const paths = buildWorkspacePaths();
expect(paths.root).toBe(resolve(root));
expect(paths.notes).toBe(join(resolve(root), 'notes'));
expect(paths.favorites).toBe(join(resolve(root), 'workspace', 'favorites'));
});
});
describe('D. assertInsideAllowedRoot:白名单 + 相对路径基准', () => {
it('D1 空串 → EINVAL', () => {
expectAppError(() => assertInsideAllowedRoot(''), 'EINVAL');
});
it('D2 非字符串 → EINVAL', () => {
expectAppError(() => assertInsideAllowedRoot(42 as unknown as string), 'EINVAL');
});
it('D3 相对路径以 rootPath 为基准解析(不是 cwd)', () => {
const abs = assertInsideAllowedRoot('notes');
expect(abs).toBe(join(resolve(root), 'notes'));
});
it('D4 绝对路径(区内)原样返回', async () => {
const dir = join(root, 'notes');
await fs.mkdir(dir, { recursive: true });
expect(assertInsideAllowedRoot(dir)).toBe(dir);
});
it('D5 根自身允许(相对结果为空串)', () => {
expect(assertInsideAllowedRoot(root)).toBe(resolve(root));
});
it('D6 区外路径 → EPERM', () => {
expectAppError(() => assertInsideAllowedRoot(outside), 'EPERM');
});
it('D7 相对路径逃逸("..")→ EPERM', () => {
expectAppError(() => assertInsideAllowedRoot('../escape'), 'EPERM');
});
it('D8 同前缀兄弟目录 → EPERM', () => {
expectAppError(() => assertInsideAllowedRoot(`${root}-evil`), 'EPERM');
});
it('D9 区内但尚不存在的路径 → 放行(先校验再写)', async () => {
const missing = join(root, 'notes', 'not-created-yet.md');
expect(assertInsideAllowedRoot(missing)).toBe(missing);
});
it('D10 区内但本身是 symlink → EPERM', async () => {
const target = join(root, 'real-target');
await fs.mkdir(target, { recursive: true });
const link = join(root, 'link');
await makeLink(target, link);
expectAppError(() => assertInsideAllowedRoot(link), 'EPERM');
});
it('D11 中间目录段被换成 symlink 指向区外 → EPERM(realpath 链防线)', async () => {
const secret = join(outside, 'secret.txt');
await fs.writeFile(secret, 'SECRET', 'utf-8');
await makeLink(outside, join(root, 'sub'));
expectAppError(() => assertInsideAllowedRoot(join(root, 'sub', 'secret.txt')), 'EPERM');
});
});
describe('E. assertNotSymlink:两道防线', () => {
it('E1 普通文件放行', async () => {
const f = join(root, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expect(() => assertNotSymlink(f, [root])).not.toThrow();
});
it('E2 普通目录放行', async () => {
const dir = join(root, 'plain-dir');
await fs.mkdir(dir, { recursive: true });
expect(() => assertNotSymlink(dir, [root])).not.toThrow();
});
it('E3 不存在的路径放行(后续 fs.open 自己报 ENOENT)', () => {
const missing = join(root, 'never-created.md');
expect(() => assertNotSymlink(missing, [root])).not.toThrow();
});
it('E4 白名单内但 lstat 是 symlink → EPERM(源码指针指向的那条行为)', async () => {
// 目标**也在白名单内** —— 拦它的理由是「它是链接」,不是「它指向区外」
const target = join(root, 'real-target');
await fs.mkdir(target, { recursive: true });
const link = join(root, 'link');
await makeLink(target, link);
expectAppError(() => assertNotSymlink(link, [root]), 'EPERM');
});
it('E5 中间段 symlink 把落点带出白名单 → EPERM(realpath 链防线)', async () => {
const secret = join(outside, 'secret.txt');
await fs.writeFile(secret, 'SECRET', 'utf-8');
await makeLink(outside, join(root, 'sub'));
expectAppError(() => assertNotSymlink(join(root, 'sub', 'secret.txt'), [root]), 'EPERM');
});
});
describe('F. assertNotSymlink 的 roots 参数 = 调用方自己的白名单', () => {
it('F1 路径在 allowedRoots 内、但 roots 是别的集合 → EPERM', async () => {
// 写死 allowedRoots() 的实现会放行 ⇒ 这条红
const f = join(root, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expectAppError(() => assertNotSymlink(f, [outside]), 'EPERM');
});
it('F2 路径不在 allowedRoots 内、但 roots 包含它 → 放行', async () => {
const f = join(outside, 'plain.txt');
await fs.writeFile(f, 'hello', 'utf-8');
expect(() => assertNotSymlink(f, [outside])).not.toThrow();
});
});
/**
* G 组(audit-r229 补):根自身是 symlink / junction 时,区内合法文件
* **不得**被判成「realpath 链逃逸」。
*
* 复现形态(Windows 上最常见):数据目录放在 OneDrive / Dropbox(云盘目录
* 本身是 reparse point),或用户按 utils/path-check.ts 的指引用 `mklink /J`
* 把数据目录建成 junction。此时
* - 白名单存的是**明文根** resolve(junction) → C:\data
* - realpath(区内的文件) 解析到**物理位置** → C:\real\data\notes\a.md
* 两侧口径不一致 ⇒ relative() 算出 '..\real\data\...' ⇒ 旧实现抛 EPERM。
*
* 用户现象:shell:open-path 报「路径不允许」、rkms:// 协议对所有文件返 403
* (文献 / PDF / 图片全打不开),而磁盘上的数据好好躺着。
*
* 修法见 src/main/ipc/paths.ts 的 isInsideRootsRealpathAware:落点落在
* 「明文根」**或**「realpath 后的根」之内即放行(与 safe-write.ts 2026-10-01
* 的同源修法一致);真正的逃逸(root/notes → /etc)两侧都不满足,仍被拒。
*/
describe('G. 根自身是 symlink/junction(数据目录在 OneDrive / mklink /J 下)', () => {
it('G1 根是 junction 时,区内真实文件不得被判 EPERM', async () => {
const realRoot = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-real-')));
const linkRoot = join(tmpdir(), `rkms-paths-link-${process.pid}-${Date.now()}`);
await makeLink(realRoot, linkRoot);
try {
setRootPath(linkRoot);
const notes = join(linkRoot, 'notes');
await fs.mkdir(notes, { recursive: true });
const file = join(notes, 'a.md');
await fs.writeFile(file, '# a', 'utf-8');
// 修前实测:抛 AppError('EPERM', 'path resolves outside allowed roots ...')
expect(() => assertInsideAllowedRoot(file)).not.toThrow();
expect(() => assertNotSymlink(file, [resolve(linkRoot)])).not.toThrow();
} finally {
setRootPath(root);
await fs.rm(linkRoot, { recursive: true, force: true });
await fs.rm(realRoot, { recursive: true, force: true });
}
});
it('G2 根是 junction 时,逃逸 symlink 仍被拒(防线不放松)', async () => {
const realRoot = await fs.realpath(await fs.mkdtemp(join(tmpdir(), 'rkms-paths-real-')));
const linkRoot = join(tmpdir(), `rkms-paths-link2-${process.pid}-${Date.now()}`);
await makeLink(realRoot, linkRoot);
try {
setRootPath(linkRoot);
// 把 junction 下的 notes 换成指向区外的 symlink —— 逃逸必须仍被拦
const notes = join(linkRoot, 'notes');
await fs.mkdir(notes, { recursive: true });
await fs.rm(notes, { recursive: true, force: true });
await makeLink(outside, notes);
const file = join(notes, 'evil.txt');
await fs.writeFile(file, 'x', 'utf-8');
expectAppError(() => assertNotSymlink(file, [resolve(linkRoot)]), 'EPERM');
} finally {
setRootPath(root);
await fs.rm(linkRoot, { recursive: true, force: true });
await fs.rm(realRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,354 @@
// tests/unit/main/ipc/pull-snapshot-companion.test.ts
//
// 守 r54 I4「pull-snapshot 配对」这条 IPC 契约(🔒 标记自 r54 一直挂在这个
// 路径上,r211 补上守卫本体)。
//
// ── 被守卫的缺陷(不是风格问题,是契约空洞)──────────────────────────
// r54 I4 的设计:每个 push 通道配一条 GET_STATE invoke,让 renderer 在
// mount 时主动拉一份 baseline,补「主进程 push 已先于 listener 注册」的
// race。配套的注释在五层里都写得很认真:
//
// src/shared/types.ts 「每个配对 push 都对应一条 GET_STATE invoke」
// src/preload/index.ts 「防『主进程 push 已先于 renderer listener』竞态」
// src/main/ipc/workspace.ts 「renderer remount 时若 listener 还没注册会错过」
// src/renderer/.../literature.ts 「pull-snapshot 由 literatureGetState 兜底」
//
// 但 LITERATURE_GET_STATE 与 FAVORITES_GET_STATE **从没有任何 renderer
// 调用点**(实测:src/renderer/ 全域 grep 零命中,只有注释提到它们)。
// 而两者的 handler 又与同域的 LIST 通道同源同形:
// literature:get-state → svc.list() ≡ literature:list
// favorites:get-state → listEntries()+filter ≡ favorites:list
// ⇒ 它们是两条**空壳 API**:既骗注释(宣称兜底一个并不存在的 race),
// 又是五层(enum / preload / api-types / main handler / schema)里的死代码。
//
// 那个 race 的真实修法是「**先订阅再 load**」,不是额外一次 pull:
// favorites: useSyncStores 的 useEffect —— 先 api.onFavoritesChanged(…),
// 再 useFavoritesStore.getState().load()(2026-09-10 的修法)
// literature: store 模块顶层 _ensureInit() 订阅(严格早于任何组件挂载),
// LiteratureList 的 useEffect 里首次 load()
// 订阅严格早于首次拉取 ⇒ 不存在漏事件窗口 ⇒ 额外 pull 是多余的。
//
// r211 的处置:两条空壳配对整对下线(enum / preload / api-types / main /
// schema 全清),配对表只剩启动进度一条(STARTUP_PROGRESS →
// STARTUP_GET_STATE,它的 renderer 调用方是 useSyncStores 的
// api.getStartupState())。
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A. 配对表与 IPC 枚举**完全一致**:
// A1 枚举里每个 *_GET_STATE 通道都在配对表里
// (表外的 *_GET_STATE 必然是空壳 —— 那正是本轮清掉的两条)
// A2/A3 配对表里每个通道都还在枚举里(表不许写得过时)
// B. 每一对的 **push** 通道五层齐:preload 有 ipcRenderer.on 订阅方法 →
// api-types 的 Api 接口声明了它 → renderer 真的调用了它 → 主进程有
// 真实 send 点(且不在 ipc/ handler 层里)
// C. 每一对的 **get_state** 通道五层齐:preload 有 invokeGuard 方法 →
// api-types 声明 → renderer **真的调用了它** → src/main/ipc 有 handler
// —— C 里的「renderer 真的调用了它」是本守卫的核心:少了这一条,
// 空壳配对就能一路混过五层而不被发现(当初就是这么混过去的)
// D. 已退役的 LITERATURE_GET_STATE / FAVORITES_GET_STATE 不得复活
// (enum / preload / api-types / main / schemas 任一处出现都红)
//
// ── 判定方式 ───────────────────────────────────────────────────────
// 纯静态扫源码(不 import electron / 不起进程):枚举、正则切方法体、
// 递归找调用点。注释先剥掉,免得「注释里提到通道名」被当成接线。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
function read(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf-8');
}
/** 剥掉块注释与行注释(行注释的 `//` 前不许是 `:` —— 免得砍掉 https://) */
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
function relOf(abs: string): string {
return relative(ROOT, abs).split('\\').join('/');
}
// ── IPC 枚举 ────────────────────────────────────────────────────────
/** src/shared/types.ts 里 IPC 常量的成员名 */
function ipcChannelKeys(): string[] {
const src = read('src/shared/types.ts');
const start = src.indexOf('export const IPC = {');
const end = src.indexOf('} as const;', start);
expect(start, 'src/shared/types.ts 里找不到 `export const IPC = {`').toBeGreaterThan(-1);
expect(end, 'src/shared/types.ts 里找不到 `} as const;`').toBeGreaterThan(-1);
const body = src.slice(start, end);
const keys: string[] = [];
for (const m of body.matchAll(/^\s{2}([A-Z][A-Z0-9_]*):/gm)) keys.push(m[1]);
return keys;
}
// ── preload ─────────────────────────────────────────────────────────
/**
* preload 顶层 `const api: Api = { … }` 的方法名 → 方法体(注释已剥)。
* 方法体 = 下一个顶层方法起点之前的全部文本。
*/
function preloadMethodBodies(): Map<string, string> {
const src = stripComments(read('src/preload/index.ts'));
const marks: Array<{ name: string; start: number }> = [];
for (const m of src.matchAll(/^ {2}([A-Za-z_$][\w$]*):/gm)) {
marks.push({ name: m[1], start: m.index });
}
const bodies = new Map<string, string>();
marks.forEach((mk, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : src.length;
bodies.set(mk.name, src.slice(mk.start, end));
});
return bodies;
}
/** preload 里「订阅了 IPC.<channel>」的方法名 */
function preloadOnMethod(channel: string): string | null {
for (const [name, body] of preloadMethodBodies()) {
if (body.includes(`ipcRenderer.on(IPC.${channel}`)) return name;
}
return null;
}
/** preload 里「invoke 了 IPC.<channel>」的方法名 */
function preloadInvokeMethod(channel: string): string | null {
for (const [name, body] of preloadMethodBodies()) {
if (!body.includes(`IPC.${channel}`)) continue;
if (!body.includes('invokeGuard')) continue;
return name;
}
return null;
}
// ── api-types(Api 接口 = preload 的类型真相)────────────────────────
/** shared/api-types.ts 的 `Api` 接口体(注释已剥) */
function apiInterfaceBody(): string {
const src = stripComments(read('src/shared/api-types.ts'));
const start = src.indexOf('export interface Api {');
expect(start, 'src/shared/api-types.ts 里找不到 `export interface Api {`').toBeGreaterThan(-1);
const end = src.indexOf('\n}', start);
expect(end, 'Api 接口没有闭合的 `\\n}`').toBeGreaterThan(-1);
return src.slice(start, end);
}
function apiTypesDeclares(method: string): boolean {
return new RegExp(`^\\s{2}${method}\\s*\\(`, 'm').test(apiInterfaceBody());
}
// ── renderer ────────────────────────────────────────────────────────
/** renderer 里调用了 `.<method>(` 的文件(相对路径) */
function rendererCallers(method: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'renderer'))) {
const src = read(relOf(file));
if (new RegExp(`\\.${method}\\s*\\(`).test(src)) hits.push(relOf(file));
}
return hits;
}
// ── main ────────────────────────────────────────────────────────────
/** main 侧 IPC.<channel> 的 send 点(不在 ipc/ handler 层里) */
function mainSendPoints(channel: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'main'))) {
const rel = relOf(file);
if (rel.startsWith('src/main/ipc/')) continue; // handler 层不算 send 点
for (const line of read(rel).split('\n')) {
if (!line.includes(`IPC.${channel}`)) continue;
if (!/send|broadcast|postMessage/i.test(line)) continue;
hits.push(rel);
}
}
return hits;
}
/** main/ipc 层里注册了 IPC.<channel> 的文件 */
function mainHandlerFiles(channel: string): string[] {
const hits: string[] = [];
for (const file of walk(join(ROOT, 'src', 'main', 'ipc'))) {
if (read(relOf(file)).includes(`IPC.${channel}`)) hits.push(relOf(file));
}
return hits;
}
// ── 配对表 ──────────────────────────────────────────────────────────
/**
* 现存的 pull-snapshot 配对。改这张表 = 改 IPC 契约,必须同时改
* enum / preload / api-types / main handler / renderer 调用点。
*/
const PAIRS: ReadonlyArray<{ push: string; getState: string }> = [
{ push: 'STARTUP_PROGRESS', getState: 'STARTUP_GET_STATE' },
];
/** r211 整对下线的通道 —— 不得复活(复活即空壳) */
const RETIRED_GET_STATE = ['LITERATURE_GET_STATE', 'FAVORITES_GET_STATE'];
describe('pull-snapshot 配对契约(r54 I4 / r211)', () => {
const channels = ipcChannelKeys();
describe('A. 配对表与 IPC 枚举一致', () => {
it('A1 枚举里每个 *_GET_STATE 通道都在配对表里(表外的必然是空壳)', () => {
const inEnum = channels.filter((c) => c.endsWith('_GET_STATE'));
const table = new Set(PAIRS.map((p) => p.getState));
const orphans = inEnum.filter((c) => !table.has(c));
expect(
orphans,
orphans.length > 0
? `这些 *_GET_STATE 通道不在配对表里:\n` +
orphans.map((c) => ` IPC.${c}`).join('\n') +
`\n空壳通道的典型症状 = 五层都齐(enum/preload/api-types/main)、` +
`但 renderer 从不调用它。要么接上真实调用方,要么整对下线。`
: '',
).toEqual([]);
});
it('A2 配对表里每个 get_state 通道都还在枚举里', () => {
const missing = PAIRS.map((p) => p.getState).filter((c) => !channels.includes(c));
expect(missing, `配对表里的通道不在 IPC 枚举里: ${missing.join(', ')}`).toEqual([]);
});
it('A3 配对表里每个 push 通道都还在枚举里', () => {
const missing = PAIRS.map((p) => p.push).filter((c) => !channels.includes(c));
expect(missing, `配对表里的通道不在 IPC 枚举里: ${missing.join(', ')}`).toEqual([]);
});
});
describe('B. 每对的 push 通道五层齐', () => {
for (const pair of PAIRS) {
it(`B ${pair.push}:preload 订阅 → api-types 声明 → renderer 调用 → main 有 send 点`, () => {
const onMethod = preloadOnMethod(pair.push);
expect(onMethod, `preload 里找不到 ipcRenderer.on(IPC.${pair.push}, …)`).not.toBeNull();
expect(
apiTypesDeclares(onMethod as string),
`api-types 的 Api 接口没声明 preload 的订阅方法 ${onMethod}`,
).toBe(true);
const callers = rendererCallers(onMethod as string);
expect(
callers,
`renderer 里没人调用 ${onMethod}(…) ⇒ 这条 push 通道根本没人听`,
).not.toEqual([]);
const sends = mainSendPoints(pair.push);
expect(
sends,
`主进程里找不到 IPC.${pair.push} 的 send 点(要有一个含 send/broadcast 的调用)`,
).not.toEqual([]);
});
}
});
describe('C. 每对的 get_state 通道五层齐(含 renderer 真实调用方)', () => {
for (const pair of PAIRS) {
it(`C ${pair.getState}:preload invoke → api-types 声明 → renderer 调用 → main handler`, () => {
const invokeMethod = preloadInvokeMethod(pair.getState);
expect(
invokeMethod,
`preload 里找不到 invokeGuard(… IPC.${pair.getState} …)`,
).not.toBeNull();
expect(
apiTypesDeclares(invokeMethod as string),
`api-types 的 Api 接口没声明 preload 的 invoke 方法 ${invokeMethod}`,
).toBe(true);
const callers = rendererCallers(invokeMethod as string);
expect(
callers,
`renderer 里没人调用 ${invokeMethod}(…) ⇒ 这条 GET_STATE 是空壳\n` +
`(五层都齐,但没人用 —— r211 清掉的 LITERATURE/FAVORITES 两对就是\n` +
` 这个形状:与同域 LIST 通道同源同形,注释还宣称它兜底 race。\n` +
` 修法二选一:① 接上真实调用方(并在注释里说明为什么需要它);\n` +
` ② 整对下线(enum/preload/api-types/main/schema 全清),\n` +
` 把「先订阅再 load」的顺序写成真正的兜底说明。)`,
).not.toEqual([]);
const handlers = mainHandlerFiles(pair.getState);
expect(handlers, `src/main/ipc 下没有注册 IPC.${pair.getState} 的 handler`).not.toEqual([]);
});
}
});
describe('D. 已退役的 GET_STATE 通道不得复活', () => {
for (const channel of RETIRED_GET_STATE) {
it(`D ${channel}:enum / preload / api-types / main / schemas 全域不得出现`, () => {
expect(
channels,
`IPC 枚举里复活了 ${channel}(r211 已把它整对下线 —— 它是空壳:` +
`与同域 LIST 通道同源同形,且 renderer 从来没调用过)`,
).not.toContain(channel);
// 只看**代码**,不看注释:退役说明本身要在注释里提到通道名(那是
// 给人看的历史记录),把注释也算进「复活」会让这条守卫没法用。
const base = channel.replace(/_GET_STATE$/, '');
const pascal = base.charAt(0) + base.slice(1).toLowerCase();
const patterns = [`IPC.${channel}`, `Zod${pascal}GetState`];
const scanned: Array<[string, string]> = [
['src/preload/index.ts', stripComments(read('src/preload/index.ts'))],
['src/shared/api-types.ts', stripComments(read('src/shared/api-types.ts'))],
['src/shared/schemas/ipc.ts', stripComments(read('src/shared/schemas/ipc.ts'))],
];
for (const file of walk(join(ROOT, 'src', 'main'))) {
scanned.push([relOf(file), stripComments(read(relOf(file)))]);
}
const alive = scanned
.filter(([, src]) => patterns.some((p) => src.includes(p)))
.map(([f]) => f);
expect(
alive,
`${channel} 在这些文件里复活了:\n` + alive.map((f) => ` ${f}`).join('\n'),
).toEqual([]);
});
}
});
describe('E. 守卫自身有效(变异对照)', () => {
it('E1 方法体切分能把「注释里提到通道名」与「真的订阅了」区分开', () => {
// 造一段假的 preload:方法 a 的注释里提到 IPC.FAKE_PUSH,方法 b 才真的订阅
const fake = [
'const api = {',
' // 说明文字:方法 a 的注释里提到 IPC.FAKE_PUSH',
' a: () => {',
' // IPC.FAKE_PUSH',
' },',
' b: () => {',
' ipcRenderer.on(IPC.FAKE_PUSH, listener);',
' },',
'};',
].join('\n');
const marks: Array<{ name: string; start: number }> = [];
for (const m of fake.matchAll(/^ {2}([A-Za-z_$][\w$]*):/gm)) {
marks.push({ name: m[1], start: m.index });
}
const bodies = new Map<string, string>();
marks.forEach((mk, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : fake.length;
bodies.set(mk.name, fake.slice(mk.start, end));
});
const found = [...bodies].find(([, body]) => body.includes('ipcRenderer.on(IPC.FAKE_PUSH'));
expect(found?.[0]).toBe('b');
});
it('E2 真实仓库里 preload 的 STARTUP 订阅方法查得到、且不是被注释骗到', () => {
const method = preloadOnMethod('STARTUP_PROGRESS');
expect(method).toBe('onStartupProgress');
expect(apiTypesDeclares(method as string)).toBe(true);
expect(rendererCallers(method as string).length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,119 @@
// tests/unit/main/ipc/settings-update-whitelist.test.ts
//
// 守 audit-r244「SETTINGS_UPDATE 只能改 UI 偏好字段」契约。
//
// 漏洞(本轮实证):`src/main/ipc/settings.ts` 的 handler 把 renderer 传来的
// patch 直接交给 `sanitizeSettingsPatch` —— 而它只是 `sanitizeAppSettings`
// (settings/validate.ts,同一个**加载侧**净化器)的别名,接受任何
// 「绝对 + ≤4096 字符」的 rootPath / dataPath。
//
// 而主进程 `settings/store.ts` 的 updateSettings 是
// `const next = { ...base, ...patch }; await saveSettings(next);`
// ⇒ 被劫持 / XSS 的 renderer 调
// api.updateSettings({ rootPath: 'C:\\Windows' })
// 就能把伪造的 rootPath **落盘到 settings.json**。
//
// 后果(不是数据丢失 —— 没有文件 IO 跟着伪造值走,但 UI 全错):
// - 设置页渲染错的根;renderer 侧 deriveNotesPath(settings.rootPath)
// 指向工作区**之外**的目录;
// - 「打开数据目录」走 SHELL_OPEN_PATH → assertInsideAllowedRoot 对
// **真实**根校验 → EPERM ⇒ 按钮变死;
// - 脏值要等到下次启动 reconcileRootPath 才自愈。
//
// 本测锁:
// 1. rootPath / dataPath 被剥掉(**不落盘**);
// 2. theme / language / workspaceLayout 等 UI 偏好字段照常更新;
// 3. 未知字段(未来新增的、还没进白名单的)也被剥掉(默认拒绝,而不是
// 默认放行)—— 白名单语义。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
dialog: { showOpenDialog: vi.fn() },
BrowserWindow: { fromWebContents: vi.fn() },
}));
const storeMock = vi.hoisted(() => ({
getSettings: vi.fn(() => ({})),
updateSettings: vi.fn(async (patch: unknown) => patch),
}));
vi.mock('../../../../src/main/settings/store', () => storeMock);
vi.mock('../../../../src/main/settings/migration', () => ({
switchDataPath: vi.fn(),
restoreDefaultDataPath: vi.fn(),
}));
import { registerSettingsIPC } from '../../../../src/main/ipc/settings';
import { IPC } from '../../../../src/shared/types';
describe('audit-r244:SETTINGS_UPDATE 只允许 UI 偏好字段', () => {
beforeEach(() => {
electronMock.handlers.clear();
storeMock.updateSettings.mockClear();
registerSettingsIPC();
});
afterEach(() => {
vi.restoreAllMocks();
});
function handler(): (event: unknown, ...args: unknown[]) => unknown {
const h = electronMock.handlers.get(IPC.SETTINGS_UPDATE);
expect(h).toBeTruthy();
return h!;
}
it('rootPath / dataPath 被剥掉,不进 updateSettings', async () => {
await handler()({}, {
rootPath: 'C:\\Windows',
dataPath: 'C:\\Windows',
theme: 'dark',
});
expect(storeMock.updateSettings).toHaveBeenCalledTimes(1);
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.rootPath).toBeUndefined();
expect(patch.dataPath).toBeUndefined();
expect(patch.theme).toBe('dark');
});
it('非法 theme 的 patch 被净化层剥掉后仍是空 patch(不写盘)', async () => {
await handler()({}, { theme: 'not-a-theme', rootPath: 'C:\\Windows' });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(Object.keys(patch)).toEqual([]);
});
it('UI 偏好字段照常更新(theme / language / workspaceLayout)', async () => {
await handler()({}, {
theme: 'system',
language: 'en-US',
workspaceLayout: { fontFamily: 'serif' },
});
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.theme).toBe('system');
expect(patch.language).toBe('en-US');
expect(patch.workspaceLayout).toEqual({ fontFamily: 'serif' });
});
it('未知字段被剥掉(白名单语义 = 默认拒绝)', async () => {
await handler()({}, { enabledExtensions: { evil: true } });
const patch = storeMock.updateSettings.mock.calls[0]![0] as Record<string, unknown>;
expect(patch.enabledExtensions).toBeUndefined();
});
});

View File

@@ -0,0 +1,133 @@
// tests/unit/main/library-reader-root-guard.test.ts
//
// 守 r191「library:readFileForReader 必须过工作区白名单」契约。
//
// 漏洞(本轮实证):`src/main/ipc/library.ts` 的 handler 只查
// `requireAbsolutePath`(是不是绝对路径),**不查是否落在工作区内**
// ⇒ renderer 拿到任意绝对路径就能把字节读回来。
//
// 同威胁模型的两条兄弟路径**都有**白名单,唯独 reader 这条漏了:
// - rkms:// 协议(protocol.ts)→ assertInsideAllowedRoot → 403
// (注释原文:「防止 renderer 拿到任意路径都能读(iframe 内
// fetch().text() 可外泄 ~/.ssh/id_rsa)」)
// - shell.open-path(ipc/shell.ts)→ assertInsideAllowedRoot → EPERM
// ⇒ 读侧比 iframe 侧**更松**:一个被注入的 renderer 脚本可以读
// ~/.ssh/id_rsa / 浏览器 cookie / 任何用户文件,并把字节发出去。
//
// 注:protocol.ts 的 import 注释写着「protocol.ts / shell.ts / reader.ts
// 三处独立写…」—— 以为 reader.ts 也在用同一份 assertInsideAllowedRoot,
// 实际 reader.ts 根本没接 ⇒ 注释与实现的漂移正是本漏洞的来源。
//
// 本测锁:
// 1. 工作区**外**的绝对路径 → 拒绝(ok:false / EPERM),且**不返回任何字节**;
// 2. 工作区**内**的文件 → 照常读出字节(不回归);
// 3. 工作区内的 symlink 指向区外 → 拒绝(白名单的 lstat + realpath 链防线)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: { trashItem: vi.fn() },
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { registerLibraryIPC } from '../../../src/main/ipc/library';
import { IPC } from '../../../src/shared/types';
interface IpcOk<T> {
ok: boolean;
data?: T;
code?: string;
message?: string;
}
/** ReadFileForReaderResult —— data / size / mtime(字节在 .data 里)。 */
interface ReadPayload {
data: Uint8Array;
size: number;
mtime: number;
}
describe('library:readFileForReader 的工作区白名单(r191)', () => {
let root: string;
let outside: string;
beforeEach(async () => {
// 两个**互不相干**的临时目录 —— outside 不在 root 之下
root = await fs.mkdtemp(join(tmpdir(), 'rkms-reader-root-'));
outside = await fs.mkdtemp(join(tmpdir(), 'rkms-reader-out-'));
setRootPath(root);
electronMock.handlers.clear();
registerLibraryIPC();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
function handler(): (event: unknown, ...args: unknown[]) => unknown {
const h = electronMock.handlers.get(IPC.LIBRARY_READER_FOR_FILE);
expect(h).toBeTruthy();
return h!;
}
it('工作区外的文件 → 拒绝,不返回任何字节', async () => {
const secret = join(outside, 'id_rsa');
await fs.writeFile(secret, 'PRIVATE-KEY-MATERIAL', 'utf-8');
const res = (await handler()({}, secret)) as IpcOk<ReadPayload>;
expect(res.ok).toBe(false);
expect(res.code).toBe('EPERM');
// 关键:失败时**不能**带 data(否则报错路径也漏字节)
expect(res.data).toBeUndefined();
expect(JSON.stringify(res)).not.toContain('PRIVATE-KEY-MATERIAL');
});
it('工作区内的文件 → 照常读出字节(不回归)', async () => {
const litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
const file = join(litDir, 'note.txt');
await fs.writeFile(file, 'hello workspace', 'utf-8');
const res = (await handler()({}, file)) as IpcOk<ReadPayload>;
expect(res.ok).toBe(true);
expect(Buffer.from(res.data!.data).toString('utf-8')).toBe('hello workspace');
expect(res.data!.size).toBe('hello workspace'.length);
});
it('工作区内的 symlink 指向区外 → 拒绝', async () => {
const litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
const secret = join(outside, 'id_rsa');
await fs.writeFile(secret, 'PRIVATE-KEY-MATERIAL', 'utf-8');
const link = join(litDir, 'link.txt');
try {
// Windows 上建 symlink 需要特权(Developer Mode / 管理员)—— 建不出来
// 就跳过这一条(其余两条已经锁住主契约)。
await fs.symlink(secret, link, 'file');
} catch {
return;
}
const res = (await handler()({}, link)) as IpcOk<ReadPayload>;
expect(res.ok).toBe(false);
expect(res.code).toBe('EPERM');
expect(JSON.stringify(res)).not.toContain('PRIVATE-KEY-MATERIAL');
});
});

View File

@@ -0,0 +1,135 @@
// tests/unit/main/lint/no-direct-body-read.test.ts
//
// 守「笔记 / Markdown body 的读必须走单一入口」这条契约(2026-10-20,r203):
//
// src/main/utils/body-cap.ts:readBodyWithCap —— 单 fd stat + read,
// 5MB 上限,超限抛 TOO_LARGE。
//
// 为什么这条是安全面(不是风格):fs.stat + fs.readFile 两步走法在 stat 与
// read 之间有 TOCTOU 窗口 —— AV 隔离 / OneDrive 重水合 / 用户 atomic-rename
// 都可能把文件换成 1GB+ 大文件,5MB 上限失效,主进程 OOM。走单一入口才有
// 单 fd 的 size 保护。
//
// 判定方式:静态扫 src/main/** 里的直接读调用(`readFile(` / `readFileSync(`),
// 路径参数**看起来像 .md / note / markdown** 的一律违规 —— 白名单只有两个:
// - src/main/utils/body-cap.ts (就是单一入口本身)
// - src/main/notes/standalone.ts (scan hot path 特化:块读 + 字节累计
// + 早 abort,比通用入口更严)
//
// 覆盖边界(诚实声明):静态判定只能看出路径**字面**像 .md 的调用。
// 变量名完全不沾 md/note 的直接读(例如 `readFile(join(dir, entry))`,
// entry 来自 readdir)本守卫**看不见** —— 那类代码要靠 code review。
// 守卫的价值是拦住最常见的一手写法:`readFile(join(dir, 'x.md'))`。
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
/** 唯一允许直接读 body 的文件(相对仓库根) */
const WHITELIST = new Set([
'src/main/utils/body-cap.ts',
'src/main/notes/standalone.ts',
]);
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/** 取出 `name(` 之后的成对括号内容 */
function argsOf(src: string, openIdx: number): string {
let depth = 0;
for (let i = openIdx; i < src.length; i++) {
const c = src[i];
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
depth--;
if (depth === 0) return src.slice(openIdx + 1, i);
}
}
return src.slice(openIdx + 1);
}
/** 第一个实参(顶层逗号切分) */
function firstArg(args: string): string {
let depth = 0;
let quote: string | null = null;
for (let i = 0; i < args.length; i++) {
const c = args[i];
if (quote) {
if (c === quote) quote = null;
continue;
}
if (c === "'" || c === '"' || c === '`') {
quote = c;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === ',' && depth === 0) return args.slice(0, i);
}
return args;
}
/** 路径参数是否"看起来像" .md / note / markdown */
function looksLikeNoteBody(arg: string): boolean {
if (/\.md\b/i.test(arg)) return true;
if (/\b(md|note|notes|markdown)[A-Za-z]*\b/i.test(arg)) return true;
return false;
}
interface Hit {
file: string;
line: number;
snippet: string;
}
function collectHits(): Hit[] {
const hits: Hit[] = [];
for (const file of walkTs(join(ROOT, 'src/main'))) {
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
if (WHITELIST.has(rel)) continue;
const src = readFileSync(file, 'utf8');
// 只认独立的 readFile( / readFileSync( —— readFileForReader( 不算
const re = /(?<![A-Za-z0-9_$])readFile(?:Sync)?\s*\(/g;
for (const m of src.matchAll(re)) {
const openIdx = m.index! + m[0].lastIndexOf('(');
const arg = firstArg(argsOf(src, openIdx));
if (!looksLikeNoteBody(arg)) continue;
const line = src.slice(0, m.index).split('\n').length;
hits.push({ file: rel, line, snippet: m[0].trim() + arg.trim().slice(0, 60) });
}
}
return hits;
}
describe('笔记 body 单一入口守卫(r203)', () => {
it('src/main/** 没有绕开 body-cap 直接读 .md', () => {
const hits = collectHits();
const detail = hits.map((h) => ` ${h.file}:${h.line} → ${h.snippet}`).join('\n');
expect(
hits,
`以下位置疑似直接读笔记 body(应改走 utils/body-cap.ts:readBodyWithCap):\n${detail}`,
).toEqual([]);
});
it('白名单里的两个文件都还在(守卫不能指向不存在的例外)', () => {
for (const w of WHITELIST) {
expect(() => statSync(join(ROOT, w)), `白名单文件不存在:${w}`).not.toThrow();
}
});
it('检测器本身能认出「.md 路径」与「无关路径」', () => {
expect(looksLikeNoteBody("join(dir, 'note.md')")).toBe(true);
expect(looksLikeNoteBody('notePath')).toBe(true);
expect(looksLikeNoteBody('markdownFile')).toBe(true);
expect(looksLikeNoteBody("join(def, 'settings.json')")).toBe(false);
expect(looksLikeNoteBody('locationPath()')).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
// tests/unit/main/lint/no-fixed-temp-dir.test.ts
//
// 守「测试的临时目录必须每次跑测唯一」这条约定(r209)。
//
// ── 被守卫的缺陷(不是风格问题,是并发跑测时的假红)────────────────────
// 约定:临时目录用 `fs.mkdtemp(join(tmpdir(), 'prefix-'))` —— 名字带
// 随机后缀,每次跑测唯一。仓库里 34 处测试都这么写。
//
// 偏离:少数几个测试文件用**固定**目录名 + 递归删:
// const TMP = join(tmpdir(), 'rkms-r205');
// beforeEach(async () => { await fs.rm(TMP, { recursive: true, force: true }); … })
//
// 后果:两次**并发**的跑测(CI 分片 / watch 模式重跑 / 两个 checkout 同时
// 跑 / 一条命令跑全量 + 另一条命令单跑某个文件)会用**同一个**目录,
// 于是一个进程的 `fs.rm -rf` 把另一个进程**正在用**的目录树删掉 ⇒
// 一片 ENOENT / ENOTEMPTY 假红,而两个测试**单跑都是绿的**。
//
// 实测(2026-10-20,真机):一条命令跑全量(81 文件)、另一条并行单跑
// rkms-r205 那个文件 ⇒ 该文件 4 例红:
// ENOENT: chmod '…\rkms-r205\default\notes' (fs.cp 刚建好的目录没了)
// ENOENT: scandir '…\rkms-r205\current'
// ENOENT: mkdir '…\rkms-r205\current'
// ENOTEMPTY: rmdir '…\rkms-r205\default'
// 两条命令**各自单跑**都 7/7 绿 ⇒ 竞争,不是被测代码的缺陷。
// 这类假红最贵:它看起来像「代码被改坏了」,会让人去改**没错**的产品代码。
//
// ── 判定方式 ───────────────────────────────────────────────────────
// 静态扫 tests/**:凡是 `join(tmpdir(), '<字面量>')` 形式的**固定**路径,
// 必须紧跟在 `mkdtemp(` 之后(即由 mkdtemp 生成唯一名)。
// - `mkdtemp(join(tmpdir(), 'rkms-x-'))` → 放行
// - `join(tmpdir(), 'rkms-x')` → 违规(固定名)
//
// 覆盖边界(诚实声明):静态判定只认**字面量**路径。写成
// `join(tmpdir(), \`rkms-${Date.now()}\`)` 的动态名**放行**(它每次都不同),
// 但那种写法不是原子唯一的 —— 两个进程在同一毫秒起跑仍可能撞名,
// 所以约定仍然是 mkdtemp。
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const TESTS_DIR = join(ROOT, 'tests');
/**
* 本守卫自己 exempt:它的源码里必然出现「固定 tmpdir 路径」的字面写法
* (注释与正则),扫到自己会误报。
*/
const SELF = 'tests/unit/main/lint/no-fixed-temp-dir.test.ts';
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walk(full));
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
/** `join(tmpdir(), '…')` / `join(os.tmpdir(), '…')` 里的字面量参数 */
const FIXED_TMP_PATH_RE = /join\(\s*(?:os\.)?tmpdir\(\)\s*,\s*(['"`])([^'"`]*)\1\s*\)/g;
interface Violation {
file: string;
line: number;
snippet: string;
}
function scanFile(abs: string): Violation[] {
const src = readFileSync(abs, 'utf-8');
const rel = abs.slice(ROOT.length + 1).split('\\').join('/');
if (rel === SELF) return [];
const out: Violation[] = [];
for (const m of src.matchAll(FIXED_TMP_PATH_RE)) {
const literal = m[2] ?? '';
// 动态名(带插值)每次都不同 —— 放行(但约定上仍该用 mkdtemp)
if (literal.includes('${')) continue;
const before = src.slice(0, m.index);
// 紧跟在 mkdtemp( 之后 ⇒ 由 mkdtemp 生成唯一名 ⇒ 放行
if (/mkdtemp\(\s*$/.test(before)) continue;
const line = src.slice(0, m.index).split('\n').length;
out.push({ file: rel, line, snippet: m[0].replace(/\s+/g, ' ') });
}
return out;
}
describe('临时目录必须每次跑测唯一(静态守卫)', () => {
it('tests/ 下不得出现固定的 tmpdir 路径(必须是 mkdtemp)', () => {
const violations = walk(TESTS_DIR).flatMap(scanFile);
expect(
violations,
violations.length > 0
? `固定临时目录名会让并发的两轮跑测互删对方的目录树(实测假红):\n` +
violations.map((v) => ` ${v.file}:${v.line} ${v.snippet}`).join('\n') +
`\n改法:const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-xxx-'));`
: '',
).toEqual([]);
});
it('守卫本身是有效的(拿一个已知的违规写法做对照)', () => {
const fake = [
"const TMP = join(tmpdir(), 'rkms-fake');",
"const OK = await fs.mkdtemp(join(tmpdir(), 'rkms-fake-'));",
"const ALSO_OK = join(tmpdir(), `rkms-${Date.now()}`);",
].join('\n');
const src = fake;
const hits: string[] = [];
for (const m of src.matchAll(FIXED_TMP_PATH_RE)) {
const literal = m[2] ?? '';
if (literal.includes('${')) continue;
const before = src.slice(0, m.index);
if (/mkdtemp\(\s*$/.test(before)) continue;
hits.push(m[0]);
}
// 第一条(固定名)被抓到,后两条(mkdtemp / 动态名)放行
expect(hits).toHaveLength(1);
expect(hits[0]).toContain('rkms-fake');
});
});

View File

@@ -0,0 +1,169 @@
// tests/unit/main/lint/stale-test-reference.test.ts
//
// 守卫(r180):源码注释里引用的**测试文件路径必须真实存在**,否则要么
// 明确标注为「守卫缺失」。
//
// 为什么需要这条:2026-10-19 的 docs 大下线把整棵 tests/ 目录删掉了
// (CHANGELOG 记录曾达 2501 例),但源码注释里 45 处「行为锁:xxx.test.ts」
// 的指针还指着已经不存在的文件。后果不是编译错误,而是**更坏的**:
// - 后来的人 grep 那个测试名 → 找不到 → 以为「这行为没被测过」→
// 重写一遍(白干),或者
// - 以为「有测试兜着」→ 不写测试就改行为 → 回归无人拦。
// 注释里的测试指针是**契约的一部分**,指针死了必须显式说「死了」。
//
// 约定:引用一个已不存在的测试文件时,同一行必须带 🔒 标记。
// 现状:45 处已按约定标注(逐条改成 `路径 🔒(守卫缺失)`)。
// 这条测试只拦**未标注**的死指针 —— 标注过的允许存在(它们是历史记录,
// 补回测试时把 🔒 去掉即可,这条测试会继续守着)。
//
// audit-r203 补上**反向**方向:🔒 标记会**过期** —— 守卫测试补齐后,
// 源码里的 `路径 🔒(守卫缺失)` 就不再成立,读者仍会以为「这块没测试兜着」
// 而重复造轮子(或反过来,以为「🔒 = 有问题」而不敢动)。约定是
// 「补回测试就把 🔒 去掉」,那就让机器来查有没有人忘了去掉。
import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const MARKER = '🔒';
// 只在源码目录里扫(测试文件之间互相引用少见,且会让本文件自指)。
const SCAN_DIRS = ['src'];
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
// 匹配注释 / 字符串里出现的「测试文件路径」字面量。
// 写成拼接形式,避免本文件自身被自己的正则命中。
const TEST_PATH_RE = new RegExp('tests[/\\\\][A-Za-z0-9_\\\\/-]+?\\.test\\.tsx?', 'g');
interface Violation {
file: string;
line: number;
target: string;
text: string;
}
/** 取出该行引用的全部测试路径(去重) */
function targetsIn(text: string): string[] {
TEST_PATH_RE.lastIndex = 0;
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = TEST_PATH_RE.exec(text)) !== null) {
const t = m[0].replaceAll('\\', '/');
if (!out.includes(t)) out.push(t);
}
return out;
}
function collectViolations(): Violation[] {
const violations: Violation[] = [];
for (const dir of SCAN_DIRS) {
for (const file of walkTs(join(ROOT, dir))) {
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((text, i) => {
TEST_PATH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TEST_PATH_RE.exec(text)) !== null) {
const target = m[0].replaceAll('\\', '/');
if (existsSync(join(ROOT, target))) continue;
if (text.includes(MARKER)) continue; // 已显式标注「守卫缺失」
violations.push({
file: file.slice(ROOT.length + 1),
line: i + 1,
target,
text: text.trim().slice(0, 120),
});
}
});
}
}
return violations;
}
/**
* 收集**过期**的 🔒 标记:某行带 🔒,且它引用的测试文件**全部**都已存在
* ⇒ 这条 🔒 已经过期,应当去掉。
*
* 判据用「全部存在」而不是「任一存在」:一行里可能同时引用多个测试
* (有的存在、有的仍缺),此时 🔒 仍然必要(还指着缺失的那个)。
*/
function collectStaleMarkers(): Violation[] {
const violations: Violation[] = [];
for (const dir of SCAN_DIRS) {
for (const file of walkTs(join(ROOT, dir))) {
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((text, i) => {
if (!text.includes(MARKER)) return;
const targets = targetsIn(text);
if (targets.length === 0) return;
if (!targets.every((t) => existsSync(join(ROOT, t)))) return;
violations.push({
file: file.slice(ROOT.length + 1),
line: i + 1,
target: targets.join(', '),
text: text.trim().slice(0, 120),
});
});
}
}
return violations;
}
describe('源码注释里的测试指针(r180)', () => {
it('引用的测试文件必须存在,或已在同一行标注 🔒 守卫缺失', () => {
const violations = collectViolations();
const detail = violations
.map((v) => ` ${v.file}:${v.line} → ${v.target}\n ${v.text}`)
.join('\n');
expect(
violations,
`以下注释引用了不存在的测试文件,且没有 🔒(守卫缺失)标注:\n${detail}`,
).toEqual([]);
});
it('扫描器本身能认出「已标注」与「未标注」两种状态', () => {
// 探针:确认正则与标注约定真的生效(否则上面那条可能因为正则失配而空过)
const sample = [
'// 行为锁:tests/unit/definitely/absent.test.ts',
'// 行为锁:tests/unit/definitely/absent.test.ts 🔒(守卫缺失)',
];
const hits = sample.filter((line) => {
TEST_PATH_RE.lastIndex = 0;
const m = TEST_PATH_RE.exec(line);
return m !== null && !line.includes(MARKER);
});
expect(hits).toHaveLength(1);
});
});
describe('🔒(守卫缺失)标记的时效性(r203)', () => {
it('守卫测试已补齐的行,不许还留着 🔒 标记', () => {
const stale = collectStaleMarkers();
const detail = stale.map((v) => ` ${v.file}:${v.line} → ${v.target}\n ${v.text}`).join('\n');
expect(
stale,
`以下行的测试已存在,🔒(守卫缺失)标记已过期,请去掉:\n${detail}`,
).toEqual([]);
});
it('扫描器能区分「过期标记」与「仍然有效的标记」', () => {
// 探针:一个引用真实存在文件的 🔒 行 → 过期;一个引用不存在文件的 🔒
// 行 → 仍然有效。两条判据都跑一遍,防止上面那条因为逻辑写反而空过。
const realFile = 'tests/unit/main/lint/stale-test-reference.test.ts';
const absentFile = 'tests/unit/definitely/absent.test.ts';
const realTargets = targetsIn(`// ${realFile} ${MARKER}`);
const absentTargets = targetsIn(`// ${absentFile} ${MARKER}`);
expect(realTargets).toEqual([realFile]);
expect(existsSync(join(ROOT, realTargets[0]))).toBe(true);
expect(existsSync(join(ROOT, absentTargets[0]))).toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
// tests/unit/main/literature-db-failure-selfheal.test.ts
//
// 行为锁(audit-r243,2026-10-21):**DB 写失败时不得留下「盘上有、库里没有」
// 的半成品**,且**必须显式触发一次自愈 reload**。
//
// ── 缺陷 1:导入后 DB 写失败 ⇒ 孤儿文件 ──────────────────────────────
// LiteratureService.importFile 的拷贝(importLiteratureFile)在 writeLock
// **之外**先落盘,DB 行在锁内写。DB 写失败(SQLITE_BUSY / 磁盘满)⇒
// 抛错给用户,但那份**已经拷进 literature/ 的文件**留在盘上:既没有
// resources 行、也没有 byId 条目 ⇒ 用户重试一次就多出「name (1).ext」,
// 列表里堆一堆「导入了但搜不到、也不在收藏里」的孤儿文件。
// 修法:DB 写失败 ⇒ 删掉刚拷出来的那份(回滚到「没导过」),错误照抛。
//
// ── 缺陷 2:目录搬移/改名后 DB 失败 ⇒ 没人自愈 ──────────────────────
// moveDir / renameDir 的注释都写着「失败 throw 且 byId 保持原状,
// chokidar 事件触发的 reload 自愈」—— 但**那个事件不会来**:
// 目录搬移走 withWatcherReleased(Windows 上 chokidar 持目录句柄 ⇒
// 改名必 EPERM),watcher 在 fs.rename 期间是关着的,重启又带
// ignoreInitial: true ⇒ 搬移产生的 unlink / add 事件一个都收不到。
// ⇒ DB 中途失败时:磁盘已搬到新位置,DB 里一半行还是旧 absPath/id,
// byId 也停在旧键 —— 三态漂移且**没有自愈触发器**。
// 修法:失败时**显式**排一次 reload(按磁盘真值重建 byId + disk↔DB 差集)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
renameResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
renameResource: repo.renameResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('literature DB 失败的自愈(audit-r243)', () => {
let root: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litdb-'));
setRootPath(root);
await fs.mkdir(join(root, 'literature'), { recursive: true });
repo.upsertResource.mockReset();
repo.renameResource.mockReset();
svc = new LiteratureService();
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('导入后 DB 写失败 ⇒ 刚拷进来的文件被回收(不留孤儿)', async () => {
await svc.start({ skipInitialLoad: true });
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
repo.upsertResource.mockImplementationOnce(() => {
throw new Error('SQLITE_BUSY: database is locked');
});
await expect(svc.importFile(src)).rejects.toThrow();
const left = await fs.readdir(join(root, 'literature'));
expect(left, `literature/ 里留下孤儿文件:${left.join(', ')}`).toEqual([]);
// 源文件当然不能被动
expect((await fs.stat(src)).isFile()).toBe(true);
});
it('目录搬移后 DB 失败 ⇒ 显式排一次 reload(chokidar 事件被 watcher 释放吞掉了)', async () => {
await fs.mkdir(join(root, 'literature', 'sub'), { recursive: true });
await fs.writeFile(join(root, 'literature', 'sub', 'p.pdf'), 'PDF-BYTES', 'utf-8');
await svc.start(); // 不 skipInitialLoad:需要 byId 里有子树行
expect(svc.list().length).toBeGreaterThan(0);
const reloadSpy = vi.spyOn(
svc as unknown as { scheduleReload: () => void },
'scheduleReload',
);
repo.renameResource.mockImplementationOnce(() => {
throw new Error('SQLITE_BUSY: database is locked');
});
await expect(svc.moveDir('sub', 'moved')).rejects.toThrow();
expect(
reloadSpy.mock.calls.length,
'DB 失败后没排 reload ⇒ 三态漂移(盘已搬 / DB 旧 id / byId 旧键)永远没人收',
).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,254 @@
// tests/unit/main/literature-db-only-gone-guard.test.ts
//
// 守 r219「db-only 清理把**还在磁盘上**的文献从索引里删掉」契约。
//
// 现象(与 notes 域 r210 同一个 bug 类,第四次出现):
// literature 有两条「db-only ⇒ 删索引行」路径,判「文件在不在」都用裸
// `existsSync(r.absPath)`:
// ① reload step 5:DB 有、磁盘扫不到的行,`!existsSync` ⇒ deleteResource;
// ② resyncExternal(60s 周期):byId 里 absPath 落在 literature/ 之外的
// 旧库迁移行,`existsSync` false ⇒ deleteResource + byId.delete。
// 而 `existsSync` 把「文件不存在」与「文件**读不到**」压成同一个 false
// (Defender 扫描 / OneDrive placeholder / 同步盘瞬时占用 ⇒ EACCES /
// EPERM / EBUSY)⇒ 一次**瞬时**错误就把还在磁盘上的文献从索引里删掉:
// 列表 / 搜索里消失(本轮不会补回),用户以为文件被删了。
// ②的 try/catch 包 existsSync 还是**死代码** —— existsSync 自己吞掉所有
// 错误返回 false,catch 永远进不去。
//
// 修法(r219):两条路都换成 `utils/file-check.ts` 的 `isReallyGone()` ——
// **只有 ENOENT 才算「没了」**,其余错误 fail-safe 当作「还在」(代价
// 不对等:误判「还在」= 留一条可自愈的 ghost 行;误判「没了」= 删掉
// 用户的数据可见性)。notes 侧 r209/r210 的两处本地实现一并上收到
// file-check.ts 单一真相源,两个域不再各自维护孪生实现。
//
// 本测锁四件事:
// A. reload:db-only 行 stat 报 EPERM ⇒ 行**必须留下**(缺陷本体,修前红);
// B. reload:db-only 行真 ENOENT ⇒ 行**仍被清理**(回归锚,守卫不许把
// 真 ghost 留下,修前绿);
// C. resyncExternal:外部行 stat 报 EPERM ⇒ 行留下 + **不 emit changed**
// (修前红);
// D. resyncExternal:外部行真 ENOENT ⇒ 删 + emit changed 一次(修前绿)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map;chokidar mock 掉(真 watcher 会被测试写文件触发,异步排额外
// reload ⇒ emit 计数竞态);「读不到」用 fs.promises.stat 按路径关键字
// ('locked')注入 EPERM —— 比 chmod 可靠(Windows 上 POSIX 权限位不可靠)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const delIds: string[] = [];
return { rows, delIds };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.delIds.push(id);
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
// chokidar mock:让 reload 只由测试显式驱动(真 watcher 会被测试写文件
// 异步触发 ⇒ 额外排 reload ⇒ emit 计数竞态)。
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
// 「读不到」注入:开关打开后,路径含 'locked' 的:
// - `promises.stat` reject(EPERM)—— isReallyGone 走这条路,被分类成
// 「读不到 ⇒ 还在」;
// - `existsSync` 返回 false —— **这正是 existsSync 在 EPERM 下的真实行为**
// (它吞掉所有错误返回 false)。不能只 mock statSync:Node 的 existsSync
// 绑的是内部 statSync 引用,模块命名空间的 statSync mock 拦不到它。
// 用开关而不是「路径即开关」—— 有的用例需要先让文件正常进 byId,再注入。
const fsFail = vi.hoisted(() => ({ statLocked: false }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const realStat = actual.promises.stat;
const realExistsSync = actual.existsSync;
const isLocked = (path: unknown): path is string =>
fsFail.statLocked && typeof path === 'string' && path.includes('locked');
return {
...actual,
default: actual,
existsSync: (path: unknown) => {
if (isLocked(path)) return false; // existsSync 在 EPERM/EACCES 下的真实行为
return realExistsSync(path as never);
},
promises: {
...actual.promises,
stat: (path: unknown, opts: unknown) => {
if (isLocked(path)) {
const err = Object.assign(new Error(`EPERM: operation not permitted, stat '${path}'`), {
code: 'EPERM',
});
return Promise.reject(err);
}
return realStat(path as never, opts as never);
},
},
};
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { litResourceId } from '../../../src/shared/resource-id';
import { toPosix } from '../../../src/main/notes/path-mapping';
function seedRow(absPath: string): FakeRow {
const id = litResourceId(toPosix(absPath)) as string;
const row: FakeRow = {
id,
kind: 'literature',
title: 'paper',
mtime: 1000,
size: 4,
indexedAt: 1,
absPath,
rootId: null,
extractStatus: 'done',
body: '',
};
db.rows.set(id, row);
return row;
}
/** 调私有 resyncExternal(60s 周期等不起;行为锁直接打方法)。 */
async function runResyncExternal(svc: LiteratureService): Promise<void> {
await (svc as unknown as { resyncExternal(): Promise<void> }).resyncExternal();
}
describe('db-only 清理只删「真的没了」的文献行(r219)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litghost-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
db.delIds.length = 0;
fsFail.statLocked = false;
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
fsFail.statLocked = false;
await fs.rm(root, { recursive: true, force: true });
});
it('A reload:db-only 行 stat 报 EPERM(读不到)⇒ 行必须留下', async () => {
// 文件从未写过盘 ⇒ 磁盘扫不到 ⇒ db-only;stat 被注入 EPERM。
const row = seedRow(join(litDir, 'locked-paper.pdf'));
fsFail.statLocked = true;
await svc.load();
expect(
db.rows.has(row.id),
'stat 读不到(EPERM)被当成「文件没了」⇒ 还在磁盘上的文献被从索引里删掉',
).toBe(true);
expect(db.delIds).not.toContain(row.id);
expect(svc.list().map((r) => r.id)).toContain(row.id);
});
it('B reload:db-only 行真 ENOENT ⇒ 行仍被清理(回归锚)', async () => {
// 文件从未写过盘,stat 走真 fs ⇒ ENOENT ⇒ 真 ghost,必须清。
const row = seedRow(join(litDir, 'gone-paper.pdf'));
await svc.load();
expect(db.rows.has(row.id), '真 ghost 行被守卫误留').toBe(false);
expect(db.delIds).toContain(row.id);
});
it('C resyncExternal:外部行 stat 报 EPERM ⇒ 行留下 + 不 emit changed', async () => {
// 旧库迁移行:absPath 在 literature/ 之外。先写真实文件让它进 byId。
const extDir = join(root, 'old-lib');
await fs.mkdir(extDir, { recursive: true });
const extPath = join(extDir, 'locked-ext.pdf');
await fs.writeFile(extPath, 'AAAA', 'utf-8');
const row = seedRow(extPath);
await svc.load();
expect(svc.list().map((r) => r.id)).toContain(row.id);
const changed = vi.fn();
svc.on('changed', changed);
fsFail.statLocked = true;
await runResyncExternal(svc);
expect(
db.rows.has(row.id),
'stat 读不到(EPERM)被当成「文件没了」⇒ 旧库文献被从索引里删掉',
).toBe(true);
expect(db.delIds).not.toContain(row.id);
expect(svc.list().map((r) => r.id)).toContain(row.id);
expect(changed, '没有真实删除却发了 changed ⇒ 渲染层被拖一遍全量拉').not.toHaveBeenCalled();
});
it('D resyncExternal:外部行真 ENOENT ⇒ 删 + emit changed 一次(回归锚)', async () => {
const extDir = join(root, 'old-lib');
await fs.mkdir(extDir, { recursive: true });
const extPath = join(extDir, 'gone-ext.pdf');
await fs.writeFile(extPath, 'AAAA', 'utf-8');
const row = seedRow(extPath);
await svc.load();
expect(svc.list().map((r) => r.id)).toContain(row.id);
await fs.rm(extPath); // 真被外部删掉
const changed = vi.fn();
svc.on('changed', changed);
await runResyncExternal(svc);
expect(db.rows.has(row.id), '真 ghost 行被守卫误留').toBe(false);
expect(db.delIds).toContain(row.id);
expect(svc.list().map((r) => r.id)).not.toContain(row.id);
expect(changed).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,154 @@
// tests/unit/main/literature-dir-structure-signal.test.ts
//
// 守 r215「文献域磁盘**目录结构**变了,主进程必须发信号」契约。
//
// ── 被守卫的缺陷(孪生实现漏一边)────────────────────────────────────
// notes 域 r184 就修过同一条:watcher 只监听 add / change / unlink
// (全是**文件级**事件)⇒ 用户在资源管理器里**建/删一个空文件夹**时
// 主进程一个事件都收不到 ⇒ 左侧栏文件夹树与磁盘永久对不上,要等下次
// 启动。literature 域**漏了**同一条(r215 前)。
//
// 而且 literature 域比 notes **多一层坑**:它的 reload 有 emit 指纹
// (r136 M1:指纹不变不 emit),指纹原来只看**文件行**
// `${id}|${mtime}|${size}` —— 空文件夹的增删不改变任何文件行
// ⇒ 就算接了 addDir/unlinkDir,reload 跑完指纹恒定 ⇒ 照样不 emit
// ⇒ 树还是不刷新。所以修法是**两件事一起**:
// ① watcher 接 addDir / unlinkDir(事件要进得来);
// ② 目录集进 emit 指纹(变化要能变成 emit)。
// 本测的端到端断言(外部建空文件夹 ⇒ 收到 changed)对**只修一半**
// 的两种形态都是红的。
//
// ── 与 notes 侧守卫的关系 ────────────────────────────────────────────
// notes 侧同契约由 notes-dir-structure-signal.test.ts 锁(r184);
// 本文件是 literature 域的镜像(r215)。renderer 侧「收到 changed →
// 树缓存失效重拉」由 folder-tree-external-refresh.test.ts 锁
// (useDiskFolderSidebar 对两个域同一条订阅,useDiskFolderSidebar.ts:366)。
//
// 真 chokidar(被测的就是「事件有没有被订阅」,mock 掉就没得测);
// resources-repo mock 成内存 Map(真库要 electron app.getPath)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
/** 等到 predicate 成立(或超时)。chokidar 事件 → 100ms 防抖 → reload。 */
async function waitForEvent(predicate: () => boolean, timeoutMs = 8000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 50));
}
return predicate();
}
describe('literature 目录结构变化必须发信号(r215)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
let changedCount: number;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litdir-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
changedCount = 0;
svc.on('changed', () => {
changedCount += 1;
});
// chokidar 的初始扫描是异步的 —— watcher 还没 ready 时建的目录收不到
// addDir 事件(ignoreInitial: true ⇒ 只报 watcher 就绪之后的变化)。
await new Promise((r) => setTimeout(r, 600));
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('外部新建空文件夹 → 发 changed(旧实现没接 addDir,永远不发;只接事件不进指纹也不发)', { timeout: 20000 }, async () => {
await fs.mkdir(join(litDir, 'empty-folder'), { recursive: true });
const fired = await waitForEvent(() => changedCount > 0);
expect(fired).toBe(true);
});
it('外部删掉空文件夹 → 发 changed', { timeout: 20000 }, async () => {
const dir = join(litDir, 'gone-folder');
await fs.mkdir(dir, { recursive: true });
await waitForEvent(() => changedCount > 0);
const before = changedCount;
await fs.rmdir(dir);
const fired = await waitForEvent(() => changedCount > before);
expect(fired).toBe(true);
});
it('外部给空文件夹改名 → 发 changed(addDir + unlinkDir 各一,至少一次)', { timeout: 20000 }, async () => {
const dir = join(litDir, 'old-name');
await fs.mkdir(dir, { recursive: true });
await waitForEvent(() => changedCount > 0);
const before = changedCount;
await fs.rename(dir, join(litDir, 'new-name'));
const fired = await waitForEvent(() => changedCount > before);
expect(fired).toBe(true);
});
it('对照:纯文件行无变化的重复 reload 不重复 emit(指纹机制没被 r215 破坏)', async () => {
// 目录结构稳定时,显式 load() 两次:第二次指纹必须命中「没变」分支。
// (指纹机制是 r136 M1 的防风暴闸,r215 只是在指纹里**加了一维**,
// 不是把闸拆了。)
await svc.load();
const before = changedCount;
await svc.load();
expect(changedCount).toBe(before);
});
});

View File

@@ -0,0 +1,104 @@
// tests/unit/main/literature-folder-membership.test.ts
//
// 守 literature 域的「文件夹归属」(r172)。
//
// 用户报「粘贴功能有问题」的文献侧第三层根因:文献行**没有 relPath**。
// renderer 的 visibleItemRows 按 dirname(relPath) 分组(根 = ''),没有 relPath
// ⇒ dirname 恒为 '' ⇒ 文献行永远只在根视图可见,子目录视图恒空。
// 于是「复制/粘贴进文件夹」「OS 拖进文件夹」文件确实搬到子目录了,列表却
// 还显示在根 —— 用户视角就是「粘贴没生效」。
//
// 旧代码的注释写着「Phase 1 临时把 absPath 当 relPath 走」,但**只做了
// type cast,没有任何映射** —— 注释是假的,行为是坏的。本用例锁住真实行为:
// 1. 导入到根 → relPath = 文件名
// 2. 搬进子目录 → relPath = 'sub/x.pdf'(且 id 随 absPath 变)
// 3. 直接丢在磁盘子目录里的文件(不经 UI)→ reload 后也能被发现并带上
// 正确的 relPath(递归扫描,旧实现只看顶层)
// 4. 越界文件(不在 literature 子树内)→ relPath = '' 归到根视图
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(relPath 计算 + 递归扫描 + byId 维护)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
// r196:文献 id 换行走**单事务** renameResource(upsert 新行 + 删旧行),
// 不再是 delete + upsert 两个独立事务 —— 见 literature-rename.test.ts。
renameResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
renameResource: repo.renameResource,
listResources: repo.listResources,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService, litRelPathOf } from '../../../src/main/literature/literature-service';
describe('LiteratureService 文件夹归属(relPath,r172)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-lit-rel-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('导入到根 → relPath = 文件名(根视图)', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const row = svc.findById(imported.id);
expect(row?.relPath).toBe('paper.pdf');
});
it('搬进子目录 → relPath 变成 sub/x.pdf(旧实现没有 relPath,列表永远在根)', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const moved = await svc.moveFile(imported.id, 'sub');
const row = svc.findById(moved.id);
expect(row?.relPath).toBe('sub/paper.pdf');
expect(moved.id).not.toBe(imported.id);
});
it('直接丢在磁盘子目录里的文件 → reload 后被发现并带正确 relPath', async () => {
await fs.mkdir(join(litDir, 'sub', 'deep'), { recursive: true });
await fs.writeFile(join(litDir, 'sub', 'deep', 'manual.pdf'), 'PDF', 'utf-8');
await svc.load();
const rows = svc.list();
expect(rows).toHaveLength(1);
expect(rows[0]?.relPath).toBe('sub/deep/manual.pdf');
});
it('reload 仍能扫到根目录的文件(递归改造没把顶层漏掉)', async () => {
await fs.writeFile(join(litDir, 'top.pdf'), 'PDF', 'utf-8');
await svc.load();
const rows = svc.list();
expect(rows).toHaveLength(1);
expect(rows[0]?.relPath).toBe('top.pdf');
});
it('litRelPathOf:越界路径(不在 literature 子树内)→ 空串归根', () => {
expect(litRelPathOf(litDir, join(litDir, 'sub', 'x.pdf'))).toBe('sub/x.pdf');
expect(litRelPathOf(litDir, join(root, 'elsewhere', 'x.pdf'))).toBe('');
expect(litRelPathOf(litDir, litDir)).toBe('');
});
});

View File

@@ -0,0 +1,87 @@
// tests/unit/main/literature-import-move.test.ts
//
// 守 literature 域「导入 → 立刻搬进子目录」链路(r170)。
//
// 用户报「粘贴功能有问题」的文献侧根因:旧实现在 IPC handler 里直调
// importLiteratureFile + upsertResource,**没更新 LiteratureService.byId**
// —— 导入后紧跟的 svc.moveFile(id, target) 走 byId.get(id) 必 miss →
// NOT_FOUND。两条用户可见路径同时失效:
// - 「复制 → 粘贴」进文件夹(store/literature.ts copy 分支)
// - 「OS 拖文件进文件夹」(LiteratureList 拖放 handler)
//
// 本用例在 service 层锁契约:importFile 之后 byId 立即可见,moveFile 不再
// NOT_FOUND,且搬移后 id 变化(absPath 变 → 'lit:'+absPath 变)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(文件拷贝 + byId 维护 + moveFile 的 fs.rename)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
// r196:文献 id 换行走**单事务** renameResource(upsert 新行 + 删旧行),
// 不再是 delete + upsert 两个独立事务 —— 见 literature-rename.test.ts。
renameResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
renameResource: repo.renameResource,
listResources: repo.listResources,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('LiteratureService 导入 → 搬进子目录(r170)', () => {
let root: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-lit-'));
setRootPath(root);
await fs.mkdir(join(root, 'literature'), { recursive: true });
svc = new LiteratureService();
// skipInitialLoad: 只关心 import → move 这条链,不引入 reload 的磁盘扫描
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('importFile 之后 byId 立即可见(旧实现只写 DB 行,byId 缺 → moveFile NOT_FOUND)', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
// 内存索引已含新行 —— 搬移立刻能找到它
expect(svc.findById(imported.id)).not.toBeNull();
// DB 行也写了
expect(repo.upsertResource).toHaveBeenCalled();
});
it('导入后立刻搬进子目录成功,id 随 absPath 变化', async () => {
const src = join(root, 'paper.pdf');
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await svc.importFile(src);
const moved = await svc.moveFile(imported.id, 'sub');
expect(moved.id).not.toBe(imported.id);
expect(moved.absPath).toContain('sub');
// 旧 id 已被换掉(内存索引与 DB 同步)
expect(svc.findById(imported.id)).toBeNull();
expect(svc.findById(moved.id)).not.toBeNull();
});
it('搬不存在的 id 仍抛 NOT_FOUND(契约不因本轮改动而松动)', async () => {
const ghost = 'lit:no-such' as unknown as Parameters<LiteratureService['moveFile']>[0];
await expect(svc.moveFile(ghost, 'sub')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
});

View File

@@ -0,0 +1,78 @@
// tests/unit/main/literature-import-partial-copy.test.ts
//
// 行为锁(audit-r243,2026-10-21):**导入失败时不得在 literature/ 里留下
// 半截文件**。
//
// ── 缺陷 ────────────────────────────────────────────────────────────
// import.ts 的拷贝走 fs.copyFile(src, dst, COPYFILE_EXCL)。copyFile 先
// 创建/截断 dst 再拷内容,中途失败(ENOSPC / EIO / 断电)会留下一个
// **截断的** dst。旧实现在「非 EEXIST」分支直接抛错,没清 dst。
// ⇒ 那半截文件留在 literature/ 里 ⇒ chokidar 'add' ⇒ reload 把它当
// 正常文献收进 byId + resources 表 ⇒ 用户列表里出现一个打不开的
// 「文献」,还占着磁盘空间。
// (同函数的「拷贝后 size 校验」分支是会 rm dst 的,但 copy 失败这条
// 路径根本走不到那里。)
//
// ── 修法 ────────────────────────────────────────────────────────────
// 非 EEXIST 失败 ⇒ 先 rm(dst) 再抛。EEXIST 分支**不能**删(dst 是别人
// 已有的文件,下面要换名重试)。
import { mkdtemp, writeFile, rm, readdir } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// mock 'fs':copyFile 造出「目标端半份残留」后抛 ENOSPC
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const real = actual.promises;
// audit-r253 (2026-10-21):`typeof import('fs')` 里**没有** default 属性
// (@types/node 的 fs 是 CJS 导出,default 由 esModuleInterop 合成,
// 不体现在模块命名空间的类型上)⇒ 直接写 actual.default 是 TS2339。
// 这里显式把「可能有 default」的形状加回去,运行时语义不变。
const withDefault = actual as typeof actual & { default?: typeof actual };
return {
...actual,
default: withDefault.default ?? actual,
promises: {
...real,
copyFile: async (src: string, dest: string) => {
await real.writeFile(dest, 'PARTIAL', 'utf8');
const err = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException;
err.code = 'ENOSPC';
void src;
throw err;
},
},
};
});
describe('文献导入:拷贝失败不留半截文件(audit-r243)', () => {
let root: string;
let srcFile: string;
beforeEach(async () => {
vi.resetModules();
root = await mkdtemp(join(tmpdir(), 'rkms-litimport-'));
srcFile = join(root, 'paper.pdf');
await writeFile(srcFile, 'REAL PDF CONTENT', 'utf8');
});
afterEach(async () => {
vi.resetModules();
await rm(root, { recursive: true, force: true });
});
it('copyFile 中途失败 ⇒ 抛错 + literature/ 里不残留半截文件', async () => {
const { importLiteratureFile, literatureDir } = await import(
'../../../src/main/literature/import'
);
await expect(importLiteratureFile(srcFile, root)).rejects.toThrow();
const dir = literatureDir(root);
const leftovers = await readdir(dir).catch(() => [] as string[]);
expect(
leftovers,
`literature/ 里残留了半截文件:${leftovers.join(', ')} ⇒ 会被 reload 当成正常文献收进索引`,
).toEqual([]);
});
});

View File

@@ -0,0 +1,268 @@
// tests/unit/main/literature-reload-emit-fingerprint.test.ts
//
// 守 r257「文献 reload 的 emit 幂等指纹」契约 —— 并锁住「指纹不许再拼
// 成一根大字符串」的性能契约。
//
// ── 为什么这一轮是「补守卫」而不是「补功能」 ─────────────────────────
// 文献域的 emit 指纹自 r136 M1 起就有(byId 内容没变 ⇒ 不 emit),
// notes 域自 r223 起也补上了(r223 的守卫:notes-reload-emit-fingerprint
// .test.ts)。但**文献域这半边一直没有守卫** —— tests/unit/main/ 下的
// literature-* 守卫覆盖的是导入 / 改名 / 移动 / stat 刷新 / 目录信号,
// **没有一个**盯 emit 指纹本身。r136 M1 之后这块代码被动过至少四次
// (r172 relPath / r197 O(N×M) / r211 双因子 / r254 单遍 stat),每一次
// 都可能把「指纹维度」悄悄改掉 —— 而没有守卫会发现。
// notes 侧守的是 r223 契约;文献侧现在补齐同一份契约(孪生实现口径
// 应当一致 —— 这正是 r223 那条注释里说的「第八次应验」)。
//
// ── 指纹的三个维度(缺一个就有一类用户可见的错) ──────────────────────
// ① 文件集 + 每行的 (mtime, size) —— 内容变了要 emit;
// ② 目录集(r215)—— 空文件夹增删不改任何文件行;
// ③ 首轮必 emit(r136 M1:初始 null)—— 否则首屏拿不到数据。
//
// ── 本测锁七件事 ───────────────────────────────────────────────────
// A. 磁盘零变化 → 第二次 load **不再 emit**(缺陷本体;文献侧此前无
// 守卫 ⇒ 这条是本轮新增的回归网);
// B. 外部改内容(mtime + size 都变)→ load 仍 emit(真变化不许被吞);
// C. 外部建**空文件夹** → load 仍 emit(r215 维度);
// D. 内容变长 + **mtime 保留**(cp -p / 备份还原 / 同步盘按内容去重的
// 真实形态)→ load 仍 emit(r211 的 size 维度必须参与指纹);
// F. 内容长度不变但 mtime 变(外部 touch / no-op 写)→ load 仍 emit
// (mtime 维度必须参与指纹);
// G. 首轮 load 必须 emit(r136 M1:初始基线为空也要把初始状态推出去);
// E. 指纹表示:**每行状态落在 Map 里**(不是拼成一根大字符串)——
// 这是本轮的性能契约(旧实现每轮拼 N 个 `${id}|${mtime}|${size}`
// 字符串 + 排序 + join:lit id 是绝对路径(平均 108 字符)⇒ 每轮
// 额外分配 ≈ 250KB 字符串、实测 ≈ 0.8ms/2000 行纯主线程 CPU;新
// 实现只做 O(n) 逐项比对 ≈ 0.13ms)。
//
// chokidar mock 掉(本测要的是「reload 之后」的判定,不是「事件有没有
// 被订阅」—— 后者由 literature-dir-structure-signal.test.ts 守);
// resources-repo mock 成内存 Map(真库要 electron app.getPath)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('文献 reload 的 emit 幂等指纹(r257)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litfp-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
async function seed(name: string, content = 'AAAA'): Promise<string> {
const p = join(litDir, name);
await fs.writeFile(p, content, 'utf-8');
return p;
}
it('A 磁盘零变化 → 第二次 load 不再 emit(缺陷本体)', async () => {
await seed('a.pdf');
await svc.load(); // 指纹落基线
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
await svc.load();
expect(
changed,
'byId 一个字没变也 emit ⇒ 同步盘 no-op 触碰每次都拖 renderer 全量重拉',
).not.toHaveBeenCalled();
});
it('B 外部改内容(mtime + size 都变)→ load 仍 emit(真变化不许被吞)', async () => {
const p = await seed('b.pdf');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.writeFile(p, 'AAAAAAAAAAAA', 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
expect(changed).toHaveBeenCalledTimes(1);
expect(svc.list().find((r) => r.absPath === p)?.size).toBe(12);
});
it('C 外部建空文件夹 → load 仍 emit(r215 维度:目录集参与指纹)', async () => {
await seed('c.pdf');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.mkdir(join(litDir, 'empty-folder'));
await svc.load();
expect(
changed,
'目录结构变化必须 emit —— 只看文件行会把空文件夹的增删判成「没变」',
).toHaveBeenCalledTimes(1);
});
it('D 内容变长 + mtime 保留 → load 仍 emit(r211 的 size 维度)', async () => {
// mtime 从**第一次落盘起**就钉死(不是「改完再写回」—— 那条路会把
// 亚毫秒精度截掉,于是「mtime 保留」其实没保住,size 维度就被
// mtime 维度的假差掩盖了)。这里两轮都是同一个整秒 mtime。
const p = join(litDir, 'd.pdf');
const fixed = new Date(1_700_000_000_000);
await fs.writeFile(p, 'AAAA', 'utf-8');
await fs.utimes(p, fixed, fixed);
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
// 内容变长但 mtime 钉死(备份还原 / cp -p / 同步盘按内容去重)
await fs.writeFile(p, 'AAAAAAAAAAAA', 'utf-8');
await fs.utimes(p, fixed, fixed);
await svc.load();
expect(
changed,
'mtime 被保留但 size 变了 ⇒ 指纹必须变(只看 mtime 会静默不刷)',
).toHaveBeenCalledTimes(1);
});
it('F 内容长度不变但 mtime 变(外部 touch / no-op 写)→ load 仍 emit(mtime 维度)', async () => {
const p = await seed('f.pdf', 'AAAA');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
expect(
changed,
'mtime 变了就必须 emit —— 指纹的 mtime 维度不许被删(否则外部改动静默不刷)',
).toHaveBeenCalledTimes(1);
});
it('G 首轮 load 必须 emit(初始基线为空 ⇒ 必须把初始状态推给 renderer)', async () => {
await seed('g.pdf');
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(
changed,
'首轮不 emit ⇒ renderer 永远拿不到初始列表(r136 M1 的「初始 null ⇒ 必 emit」)',
).toHaveBeenCalledTimes(1);
});
it('E 指纹表示:每行状态落在 Map 里(不是拼成一根大字符串)', async () => {
await seed('e1.pdf');
await seed('e2.pdf');
await seed('e3.pdf');
// 观测点:Map.prototype.set 的调用记录。reload 期间出现的 Map 里,
// 只有 per-row 状态表的**值**是纯 {mtime, size}:
// - db.rows / rowsById / refreshed / byId 的值是资源行(带 id);
// - diskStats 的值是扫描期 stat(带 mtimeMs,不是 mtime);
// 所以「值只含 mtime+size」这一条就能把状态表单独摘出来。
const seen: Array<Map<unknown, unknown>> = [];
const origSet = Map.prototype.set;
const spy = vi
.spyOn(Map.prototype, 'set')
.mockImplementation(function (this: Map<unknown, unknown>, key: unknown, value: unknown) {
seen.push(this);
return origSet.call(this, key, value);
});
try {
await svc.load();
} finally {
spy.mockRestore();
}
const tables = new Set(
seen.filter((m) => {
const vals = [...m.values()];
return (
vals.length > 0 &&
vals.every((v) => {
const o = v as Record<string, unknown>;
return typeof o.mtime === 'number' && typeof o.size === 'number' && !('id' in o);
})
);
}),
);
expect(
tables.size,
'reload 必须有一张纯 {mtime,size} 的 per-row 状态表(旧实现只有一根字符串)',
).toBe(1);
expect([...tables][0]!.size, '状态表的条数必须等于文件行数').toBe(3);
});
});

View File

@@ -0,0 +1,212 @@
// tests/unit/main/literature-rename.test.ts
//
// 守 r187「文献单文件重命名」契约。
//
// 背景(用户诉求:「相关的文件操作也是对应的」):
// 左侧两个列表的操作集此前**不对应** —— 笔记能右键改名(notes:rename-file),
// 文献不能(literature 域根本没有重命名通道)。用户只能去资源管理器改,
// 改完 app 里还是旧名(直到手动刷新),两侧行为不一致。
//
// 本测锁的契约:
// 1. 改名落盘:文件出现在新路径、旧路径消失、id 随路径变、内存索引换键;
// 2. **撞名不覆盖**(数据丢失红线):Windows 的 fs.rename 在目标已存在时
// 静默替换目标文件(MoveFileExW + MOVEFILE_REPLACE_EXISTING)—— 直接
// rename 等于把用户另一份同名 PDF 抹掉;
// 3. 不写扩展名 → 沿用原扩展名(pdf/epub/... 多种格式,不能一律 .md);
// 4. 非法文件名(带路径分隔符)→ 拒收,文件原地不动;
// 5. 仅大小写重命名 → 允许(Win32 大小写不敏感,资源管理器能做);
// 6. 改名后收藏(★)跟着走(lit id = 'lit:'+absPath,改名即换 id)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// electron 也 mock(ipcMain.handle 捕获 handler 以验 IPC 接线,shell 兜底)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
import { registerLiteratureIPC } from '../../../src/main/ipc/literature';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { IPC } from '../../../src/shared/types';
describe('文献单文件重命名(r187)', () => {
let root: string;
let lit: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litrename-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
electronMock.handlers.clear();
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
lit = new LiteratureService();
await lit.start({ skipInitialLoad: true });
});
afterEach(async () => {
await lit.stop();
await fs.rm(root, { recursive: true, force: true });
});
async function importPaper(name: string, bytes = 'PDF-BYTES'): Promise<string> {
const src = join(root, name);
await fs.writeFile(src, bytes, 'utf-8');
const imported = await lit.importFile(src);
return imported.id;
}
/** absPath 在库里可能是 OS 原生形态(导入)也可能是 POSIX 形态(搬移)——
* 断言时统一成正斜杠再比。 */
const norm = (p: string): string => p.replace(/\\/g, '/');
it('改名落盘:新路径有文件、旧路径消失、id 变、内存索引换键', async () => {
const id = await importPaper('paper.pdf');
// 导入那一步本身会调 upsertResource(写新行)—— 先清掉调用计数,
// 下面只断言「改名这一步」的 DB 调用。
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const renamed = await lit.renameFile(id as never, 'thesis');
const litDir = join(root, 'literature');
expect(await fs.readFile(join(litDir, 'thesis.pdf'), 'utf-8')).toBe('PDF-BYTES');
await expect(fs.lstat(join(litDir, 'paper.pdf'))).rejects.toThrow();
// id 随路径变(lit id = 'lit:'+posix absPath)
expect(renamed.id).not.toBe(id);
expect(norm(renamed.absPath)).toBe(norm(join(litDir, 'thesis.pdf')));
// 内存索引换键
expect(lit.findById(id)).toBeNull();
expect(lit.findById(renamed.id)).not.toBeNull();
// DB:r196 契约 —— 「upsert 新行 + 删旧行」必须**同事务**
// (renameResource),不再是 deleteResource + upsertResource 两个独立
// 事务。理由:两个事务中间失败 ⇒ DB 里旧行没了、新行没进 ⇒ 这条文献
// 从列表/搜索里凭空消失,而磁盘没动 ⇒ 无 watcher 事件可自愈。
expect(repo.renameResource).toHaveBeenCalledWith(
id,
expect.objectContaining({ id: renamed.id }),
'',
expect.stringContaining('thesis.pdf'),
);
// 旧的两个独立事务调用**不许**再出现
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('撞名不覆盖:目标已存在 → VALIDATION,目标文件内容原封不动', async () => {
const id = await importPaper('paper.pdf', 'MINE');
// 同目录再放一个同名目标(直接写盘,绕开 import 的撞名自动改名)
const litDir = join(root, 'literature');
await fs.writeFile(join(litDir, 'other.pdf'), 'THEIRS', 'utf-8');
await expect(lit.renameFile(id as never, 'other')).rejects.toMatchObject({
code: 'VALIDATION',
});
// 目标文件没被覆盖(数据丢失红线)
expect(await fs.readFile(join(litDir, 'other.pdf'), 'utf-8')).toBe('THEIRS');
// 源文件也还在原地
expect(await fs.readFile(join(litDir, 'paper.pdf'), 'utf-8')).toBe('MINE');
});
it('不写扩展名 → 沿用原扩展名(literature 有 pdf/epub/... 多种格式)', async () => {
const id = await importPaper('paper.pdf');
const renamed = await lit.renameFile(id as never, 'thesis');
expect(norm(renamed.absPath).endsWith('/thesis.pdf')).toBe(true);
});
it('文件名带路径分隔符 → 拒收,文件原地不动(重命名不改位置)', async () => {
const id = await importPaper('paper.pdf');
await expect(lit.renameFile(id as never, 'sub/paper.pdf')).rejects.toMatchObject({
code: 'VALIDATION',
});
const litDir = join(root, 'literature');
expect(await fs.readFile(join(litDir, 'paper.pdf'), 'utf-8')).toBe('PDF-BYTES');
});
it('仅大小写重命名 → 允许(Win32 大小写不敏感,资源管理器能做)', async () => {
const id = await importPaper('paper.pdf');
const renamed = await lit.renameFile(id as never, 'Paper');
expect(norm(renamed.absPath).toLowerCase().endsWith('/paper.pdf')).toBe(true);
expect(lit.findById(renamed.id)).not.toBeNull();
});
it('不存在的 id → NOT_FOUND(契约不因本轮改动而松动)', async () => {
const ghost = 'lit:no-such' as never;
await expect(lit.renameFile(ghost, 'x')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
it('IPC 通道 literature:rename-file 存在,且改名后收藏(★)跟着走', async () => {
const fav = getFavoritesService();
await fav.start();
try {
const id = await importPaper('paper.pdf');
await fav.toggle(id, { title: 'paper.pdf', relPath: join(root, 'literature', 'paper.pdf') });
registerLiteratureIPC(lit);
const handler = electronMock.handlers.get(IPC.LITERATURE_RENAME_FILE);
expect(handler).toBeTruthy();
const res = (await handler!({}, id, 'thesis')) as {
ok: boolean;
data?: { id: string; absPath: string; oldAbsPath: string };
};
expect(res.ok).toBe(true);
expect(res.data!.id).not.toBe(id);
expect(res.data!.oldAbsPath).toContain('paper.pdf');
const entries = fav.listEntries().filter((e) => e.kind === 'file');
expect(entries).toHaveLength(1);
expect(entries[0]?.fileId).toBe(res.data!.id);
expect(entries[0]?.relPath).toContain('thesis.pdf');
} finally {
await getFavoritesService().stop();
}
});
});

View File

@@ -0,0 +1,186 @@
// tests/unit/main/literature-single-stat-pass.test.ts
//
// 守 r254「文献 reload 每轮把整棵子树 stat 两遍」的修复契约 ——
// 一轮 reload 对每个磁盘文件只允许有**一次** stat(lstat)调用。
//
// 背景(用户诉求「使用流畅,不要有卡顿」):
// 文献 reload 是 chokidar 100ms 防抖的**高频**路径(用户往库里丢一批
// PDF / 同步盘落一批文件 ⇒ 连续几轮 reload)。旧实现每轮做两遍 stat:
// ① 扫描期 collectLiteratureFiles:每文件一次 lstat —— 只为了 symlink
// 防御,拿到的 mtimeMs / size 被**直接丢掉**;
// ② 刷新期 refreshDiskStats:对每个已在库的文件再来一次 fs.stat。
// 实测(2000 个文献文件 / 20 个子目录):①≈40ms + ②≈35ms = 75ms/轮,
// 而 ① 的结果本来就在手上 ⇒ 合并后只剩一次。
//
// 本测锁两件事:
// A. **调用次数**:N 个文件 ⇒ lstat 恰好 N 次、stat **0 次**
// (变异:把 refreshDiskStats 改回 fs.stat ⇒ stat 次数 = N ⇒ 变红);
// B. **语义不丢**:合并后行的 size / mtime 仍须是磁盘真值,且文件变了
// 仍要刷(否则「省了 stat 却把 r211 的修复弄丢了」—— 列表里的
// 「2.3 MB · 2h ago」会变回假的)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
// 计数 fs 的 stat / lstat 调用 —— 本测的**唯一**观测点。
const fsCalls = vi.hoisted(() => ({ stat: 0, lstat: 0, reset: () => { fsCalls.stat = 0; fsCalls.lstat = 0; } }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const realStat = actual.promises.stat;
const realLstat = actual.promises.lstat;
return {
...actual,
default: actual,
promises: {
...actual.promises,
stat: (path: unknown, opts: unknown) => {
fsCalls.stat++;
return realStat(path as never, opts as never);
},
lstat: (path: unknown, opts: unknown) => {
fsCalls.lstat++;
return realLstat(path as never, opts as never);
},
},
};
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
const N = 5;
describe('文献 reload 每轮只做一遍 stat(r254)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litstat1-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
fsCalls.reset();
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
async function seed(): Promise<string[]> {
const paths: string[] = [];
for (let i = 0; i < N; i++) {
const p = join(litDir, `paper${i}.pdf`);
await fs.writeFile(p, 'AAAA', 'utf-8');
paths.push(p);
}
return paths;
}
it('N 个文件 ⇒ lstat N 次、stat 0 次(不能有第二遍 stat)', async () => {
await seed();
fsCalls.reset();
await svc.load();
expect(fsCalls.lstat, '扫描期每个文件一次 lstat').toBe(N);
expect(fsCalls.stat, 'reload 不得再对每个文件 stat 一遍').toBe(0);
});
it('合并后行的 size / mtime 仍是磁盘真值(语义没丢)', async () => {
await seed();
await svc.load();
const rows = svc.list();
expect(rows).toHaveLength(N);
for (const r of rows) {
const disk = await fs.stat(r.absPath);
expect(r.size, `${r.absPath} 的 size 必须是磁盘真值`).toBe(disk.size);
expect(r.mtime, `${r.absPath} 的 mtime 必须是磁盘真值`).toBe(disk.mtimeMs);
}
});
it('文件被替换 ⇒ 行照样刷(省 stat 不能把 r211 的修复弄丢)', async () => {
const paths = await seed();
await svc.load();
const before = svc.list().find((r) => r.absPath === paths[0]);
expect(before?.size).toBe(4);
await fs.writeFile(paths[0]!, 'AAAAAAAAAA', 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(paths[0]!, t, t);
await svc.load();
const after = svc.list().find((r) => r.absPath === paths[0]);
expect(after?.size, '内容变了必须刷出新 size').toBe(10);
expect(after?.mtime).toBe(t.getTime());
});
it('第二轮 reload(文件没变)同样零 stat', async () => {
await seed();
await svc.load();
fsCalls.reset();
await svc.load();
expect(fsCalls.lstat).toBe(N);
expect(fsCalls.stat).toBe(0);
});
});

View File

@@ -0,0 +1,294 @@
// tests/unit/main/literature-stat-refresh.test.ts
//
// 守 r211「文献行的 mtime / size 永远停在导入那一刻」契约。
//
// 现象(文献域与笔记域的口径差):
// 笔记侧 syncDbWithDisk(r180)每次 reload 都重读文件 ⇒ 行的 mtime 跟着磁盘走。
// 文献侧 reload() 只对 **disk-only**(新出现)的文件 stat,已经在库里的行
// **一次都不 stat** ⇒ 行上的 mtime/size 永远停在导入那一刻。
//
// 连带三处用户可见的错:
// 1. 列表里「2.3 MB · 2h ago」是假的 —— 文件早就被重下 / 被同步盘覆盖;
// 2. 列表按 mtime 倒序 ⇒ 顺序也停在导入那一刻(新下载的文献排不上去);
// 3. emit 指纹是 `${id}|${mtime}` ⇒ 指纹恒定 ⇒ **连 'changed' 事件都
// 不发** ⇒ 渲染层永远收不到刷新通知(手动点「刷新」拉回来的还是旧值)。
//
// 本测锁四件事:
// A. 文件在磁盘上被替换 ⇒ reload 后行的 size/mtime 是**磁盘真值**;
// B. svc.list()(渲染层拿的就是它)也必须给新值;
// C. 变化 ⇒ 必须 emit('changed');没变化 ⇒ **不许** emit(否则每次 reload
// 都把渲染层拖一遍全量拉);
// D. stat 失败(EPERM / EBUSY)⇒ 行**保留**旧值,不崩、不当成「文件没了」
// (那是 db-only 分支的职责)。
//
// 另加一条「内容变但 mtime 被保留」(备份还原 / cp -p)⇒ 指纹必须**也**看
// size,否则这种情况仍然是「静默无变化」。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map;被测行为(什么时候 stat、什么时候写库、什么时候 emit)全在
// service 内。chokidar 也 mock 掉 —— 真 watcher 会在测试写文件时异步发
// 'add'/'change' ⇒ 额外排一次 reload ⇒ emit 计数变成竞态。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
mtime: number;
size: number;
indexedAt: number;
absPath: string;
rootId: string | null;
extractStatus: string;
body: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const calls = { upsert: 0, del: 0, stat: 0 };
return { rows, calls };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.calls.upsert++;
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.calls.del++;
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
// r211 新增:只更 size/mtime 两列(**不碰 FTS**)。
updateResourceStat: vi.fn((id: string, size: number, mtime: number) => {
const r = db.rows.get(id);
if (r) {
r.size = size;
r.mtime = mtime;
}
}),
}));
// chokidar mock:真 watcher 会在测试写文件时异步发 'add'/'change',
// 排一次 reload ⇒ emit 计数变竞态。mock 掉让 reload 只由测试显式驱动。
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
// 「某个文件 stat 不出来」(Defender 锁 / 同步盘占位 / 权限翻转):
// fsFail.statLocked 打开后,路径含 'locked' 的 stat / lstat 直接 reject(EPERM)。
// 用开关而不是「路径即开关」—— 首轮 reload 需要把这个文件**正常插进来**
// (它得先在 DB 里才有「刷不动」这回事)。
//
// perf r254:stat **与** lstat 一起挡 —— 旧实现里「刷 stat」走的是独立的
// fs.stat 一轮,所以只挡 stat 就能造出「拿不到 stat」的场景;r254 把
// 刷 stat 改成**复用扫描期那次 lstat**(见 literature-service.ts 的
// LitDiskStat 头注),「拿不到 stat 信息」的第一个落点变成**扫描期**:
// lstat 失败 ⇒ 该文件不进 diskPaths ⇒ 落进 db-only 分支 ⇒
// isReallyGone 只在 ENOENT 时才判「没了」,EPERM 一律 fail-safe 当
// 「还在」⇒ 行保留旧值。**可观察行为不变**(行保留旧值 / 不删行 /
// 列表里还在),只是「拿不到」发生在哪一 syscall 上换了。
const fsFail = vi.hoisted(() => ({ statLocked: false }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const realStat = actual.promises.stat;
const realLstat = actual.promises.lstat;
const blocked = (path: unknown, op: string): Error | null => {
if (fsFail.statLocked && typeof path === 'string' && path.includes('locked')) {
return Object.assign(new Error(`EPERM: operation not permitted, ${op} '${path}'`), {
code: 'EPERM',
});
}
return null;
};
return {
...actual,
default: actual,
promises: {
...actual.promises,
stat: (path: unknown, opts: unknown) => {
const err = blocked(path, 'stat');
if (err) return Promise.reject(err);
return realStat(path as never, opts as never);
},
lstat: (path: unknown, opts: unknown) => {
const err = blocked(path, 'lstat');
if (err) return Promise.reject(err);
return realLstat(path as never, opts as never);
},
},
};
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('文献行的 stat 必须跟着磁盘刷新(r211)', () => {
let root: string;
let litDir: string;
let svc: LiteratureService;
/** 当前 DB 里唯一的文献行(测试里始终只有一篇)。 */
function onlyRow(): FakeRow {
const rows = [...db.rows.values()];
expect(rows).toHaveLength(1);
return rows[0]!;
}
/** 把文件内容换掉并把 mtime 推到未来(模拟「重下 / 同步盘覆盖」)。 */
async function replaceFile(p: string, content: string): Promise<void> {
await fs.writeFile(p, content, 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
}
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litstat-'));
setRootPath(root);
litDir = join(root, 'literature');
await fs.mkdir(litDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
db.calls.del = 0;
fsFail.statLocked = false;
svc = new LiteratureService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('磁盘上的文件被替换 ⇒ reload 后行的 size/mtime 是磁盘真值', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const before = onlyRow();
expect(before.size).toBe(4);
// 取快照(memory mock 的 updateResourceStat 是**原地改**行对象,
// 直接留引用会让「改之前」的值也跟着变 ⇒ 断言失真)
const beforeMtime = before.mtime;
const beforeSize = before.size;
await replaceFile(p, 'AAAAAAAAAA'); // 10 字节 + 未来 mtime
const disk = await fs.stat(p);
await svc.load();
const after = onlyRow();
expect(after.size).toBe(10);
expect(after.mtime).toBe(disk.mtimeMs);
expect(after.size).not.toBe(beforeSize);
expect(after.mtime).toBeGreaterThan(beforeMtime);
});
it('svc.list()(渲染层拿的就是它)必须给新值', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
await replaceFile(p, 'AAAAAAAAAA');
await svc.load();
const row = svc.list()[0];
expect(row?.size).toBe(10);
expect(row?.mtime).toBe((await fs.stat(p)).mtimeMs);
});
it('文件被替换 ⇒ 必须 emit changed(否则渲染层永远看不到变化)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load(); // 第一次:指纹 null → 有值,这次 emit 不计入
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await replaceFile(p, 'BBBBBBBB');
await svc.load();
expect(changedCount).toBe(1);
});
it('文件没变 ⇒ 不许 emit changed(否则每次 reload 都把渲染层拖一遍)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await svc.load();
await svc.load();
expect(changedCount).toBe(0);
});
it('内容变了但 mtime 被保留(备份还原 / cp -p)⇒ 也要刷(指纹必须看 size)', async () => {
const p = join(litDir, 'paper.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const orig = await fs.stat(p);
let changedCount = 0;
svc.on('changed', () => {
changedCount++;
});
await fs.writeFile(p, 'AAAAAAAAAA', 'utf-8');
await fs.utimes(p, orig.atime, orig.mtime); // mtime 还原成原值
await svc.load();
expect(onlyRow().size).toBe(10);
expect(changedCount).toBe(1);
});
it('stat 失败(EPERM)⇒ 行保留旧值,不崩、不当成「文件没了」', async () => {
const p = join(litDir, 'locked.pdf');
await fs.writeFile(p, 'AAAA', 'utf-8');
await svc.load();
const before = onlyRow();
const beforeSize = before.size;
const beforeMtime = before.mtime;
// 从这里起 stat 拿不到(文件还在,只是读不出 stat)
fsFail.statLocked = true;
// 换成更大内容 + 未来 mtime
await fs.writeFile(p, 'AAAAAAAAAA', 'utf-8');
const t = new Date(Date.now() + 60_000);
await fs.utimes(p, t, t);
await svc.load();
fsFail.statLocked = false;
const after = onlyRow();
expect(after.id).toBe(before.id);
expect(after.size).toBe(beforeSize); // 刷不动就保持旧值
expect(after.mtime).toBe(beforeMtime);
expect(db.calls.del).toBe(0); // 绝不能把「读不到」当成「没了」
expect(svc.list()).toHaveLength(1);
});
});

View File

@@ -0,0 +1,98 @@
// tests/unit/main/location-relative-datapath.test.ts
//
// 守 r197「相对 dataPath 不得被 mkdir + 持久化」契约。
//
// location.ts 的 resolveDataPath() 有 9 条分支,其中**三条**会「拿一个
// 候选路径 → fs.mkdir → writeLocation 持久化」:
// - 第 1 步 location.json → 有 isAbsolute 守卫(:110)
// - 第 5 步 merged settings → 有 isAbsolute 守卫(:264,2026-10-01 补)
// - 第 7 步 userData settings → r197 补上同名守卫(defense-in-depth)
//
// 后果:userData 下残留的旧版 settings.json 写着 dataPath: "notes",
// 启动时工作区被建在 process.cwd()/notes(dev = 项目根、打包后 = asar
// 目录),并被写进 location.json 长期固化 —— 用户的工作区落在磁盘角落。
//
// 说明(可达性,别被误导):第 7 步在**当前**流程里其实走不到 ——
// userData settings 里带 dataPath 的情形已被第 5 步的 withPath 分支截住
// (且那里已有 isAbsolute 校验)。r197 在第 7 步补的是「将来流程调整时
// 不会静默改回去」的保险。本测是**行为级**守卫:不管最终由哪条分支兜住,
// 「相对 dataPath 必须落回默认、且不得在 cwd 下建目录」这条不变式都要成立。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const paths = vi.hoisted(() => ({ userData: '', documents: '' }));
vi.mock('electron', () => ({
app: {
getPath: (name: string): string => {
if (name === 'userData') return paths.userData;
if (name === 'documents') return paths.documents;
return tmpdir();
},
},
}));
import { resolveDataPath } from '../../../src/main/location';
import { WORKSPACE_DATA_LAYOUT } from '../../../src/shared/types';
describe('resolveDataPath 相对 dataPath(r197)', () => {
let root: string;
let userData: string;
let documents: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-loc-'));
userData = join(root, 'userData');
documents = join(root, 'Documents');
paths.userData = userData;
paths.documents = documents;
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(documents, { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it('userData settings.json 的相对 dataPath → 被忽略,落回默认路径', async () => {
await fs.writeFile(
join(userData, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE),
JSON.stringify({ dataPath: 'relative-notes' }),
'utf-8',
);
const res = await resolveDataPath();
const def = join(documents, 'ResearchHub', 'data');
expect(res.path).toBe(def);
// 关键:不得在 cwd 下把「相对路径」当工作区建出来
expect(existsSync(join(process.cwd(), 'relative-notes'))).toBe(false);
});
it('相对 dataPath 不得被持久化进 location.json', async () => {
await fs.writeFile(
join(userData, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE),
JSON.stringify({ dataPath: 'relative-notes' }),
'utf-8',
);
await resolveDataPath();
const locPath = join(userData, 'location.json');
if (existsSync(locPath)) {
const raw = await fs.readFile(locPath, 'utf-8');
expect(raw).not.toContain('relative-notes');
}
});
it('绝对 dataPath 仍然照常采纳(契约不因本轮改动而松动)', async () => {
const abs = join(root, 'workspace-abs');
await fs.writeFile(
join(userData, WORKSPACE_DATA_LAYOUT.SETTINGS_FILE),
JSON.stringify({ dataPath: abs }),
'utf-8',
);
const res = await resolveDataPath();
expect(res.path).toBe(abs);
expect(existsSync(abs)).toBe(true);
});
});

View File

@@ -0,0 +1,167 @@
// tests/unit/main/menu-label-i18n-keys.test.ts
//
// 守卫(r206,2026-10-20):**主进程 i18n 字典 T 的完整性 + 无死键**。
//
// 背景:主进程有一套**独立于 renderer** 的最小 i18n ——
// src/main/i18n-dict.ts 里的 `T: { key: { 'zh-CN':…, 'en-US':… } }`,
// 由 src/main/i18n.ts 的 t(key, lang) / tr(key) 解析。
// 它跟 renderer 那套 i18next(JSON 文件)是**两套不相干的系统**,各有各的坑:
//
// ① **key 拼错 / 漏建 ⇒ 菜单栏露出裸 key**。resolveT 查不到就 `return key`,
// 所以 `t('menu.file.newNotee', lang)` 会把 "menu.file.newNotee" 直接
// 印在应用菜单上 —— 用户看到一串点号,不是文案。TS 不会报:key 是
// 纯 string。
// ② **双语缺一侧 ⇒ 切语言后文案不换**(fallback zh-CN)。
// ③ **两侧占位符不一致 ⇒ 切语言后少一段**(与 renderer 侧同款问题,
// 参见 tests/unit/i18n/placeholder-contract.test.ts)。
// ④ **死键堆积** —— 特性下线后没人删的文案条目,躺在字典里误导后来人
// 「这个 key 好像还在用」。r206 实测 T 有 **13 条零引用**条目:
// 虚拟文件夹域 folder.*(7,随 migration 0008 drop_folders 退役)、
// diskFolder.{notFound,moveFailed,invalidRelPath}(3)、
// library.fileNotFoundPath / notes.dbSyncFailed /
// common.menuActionFailed(后者与 renderer locale 同名 = 第二个真相源)。
// 本轮已删,并加本守卫防复涨。
//
// 与 renderer 侧守卫的分工:renderer 守「JSON 嵌套 locale + 复数变体」,
// 这里守「主进程扁平 T 字典」。两套系统结构不同,守卫也分开。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import { T, resolveT } from '@main/i18n-dict';
const ROOT = process.cwd();
const MAIN_DIR = join(ROOT, 'src/main');
const DICT_FILE = 'src/main/i18n-dict.ts';
const LANGS = ['zh-CN', 'en-US'] as const;
const PLACEHOLDER_RE = /\{([A-Za-z0-9_]+)\}/g;
function placeholdersOf(text: string): Set<string> {
const out = new Set<string>();
PLACEHOLDER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PLACEHOLDER_RE.exec(text)) !== null) out.add(m[1]);
return out;
}
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/** src/main 下(除字典文件本身)出现过的所有字符串字面量 */
function stringLiteralsInMain(): Set<string> {
const out = new Set<string>();
for (const file of walkTs(MAIN_DIR)) {
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
if (rel === DICT_FILE) continue;
const src = readFileSync(file, 'utf8');
for (const m of src.matchAll(/'([A-Za-z0-9_.]+)'/g)) out.add(m[1]);
for (const m of src.matchAll(/"([A-Za-z0-9_.]+)"/g)) out.add(m[1]);
}
return out;
}
/** 找出 t('x', lang) / tr('x') 形式引用了但 T 里没有的 key */
function missingKeysInCallSites(): string[] {
const bad: string[] = [];
const re = /\b(?:t|tr)\(\s*'([A-Za-z0-9_.]+)'/g;
for (const file of walkTs(MAIN_DIR)) {
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
if (rel === DICT_FILE) continue;
const src = readFileSync(file, 'utf8');
src.split('\n').forEach((line, i) => {
for (const m of line.matchAll(re)) {
const key = m[1];
if (!(key in T)) bad.push(` ${rel}:${i + 1} → ${key}`);
}
});
}
return bad;
}
describe('主进程 i18n 字典(r206)', () => {
const dictKeys = Object.keys(T);
it('字典非空,且每个条目两种语言都有值', () => {
expect(dictKeys.length).toBeGreaterThan(0);
const bad: string[] = [];
for (const [key, entry] of Object.entries(T)) {
for (const lang of LANGS) {
const v = entry[lang];
if (typeof v !== 'string' || v.length === 0) {
bad.push(` ${key} 缺 ${lang}`);
}
}
}
expect(bad.join('\n')).toBe('');
});
it('同一 key 的 zh-CN / en-US 占位符集合一致', () => {
const bad: string[] = [];
for (const [key, entry] of Object.entries(T)) {
const a = placeholdersOf(entry['zh-CN'] ?? '');
const b = placeholdersOf(entry['en-US'] ?? '');
if (a.size !== b.size || [...a].some((x) => !b.has(x))) {
bad.push(` ${key}: zh=[${[...a].sort()}] en=[${[...b].sort()}]`);
}
}
expect(bad.join('\n')).toBe('');
});
it('调用点引用的 key 必须在 T 里(否则菜单/对话框露出裸 key)', () => {
const bad = missingKeysInCallSites();
expect(bad.join('\n')).toBe('');
});
it('T 里不许出现零引用死键', () => {
const literals = stringLiteralsInMain();
const dead = dictKeys.filter((k) => !literals.has(k));
expect(
dead,
`以下字典条目在 src/main 里零引用(特性下线后的遗留),请删除或接线:\n${dead.join('\n')}`,
).toEqual([]);
});
it('resolveT 行为:未知 key 原样返回 key 自身', () => {
// 这条是上面「菜单露出裸 key」风险的**根因** —— 显式钉住,
// 免得有人把 resolveT 改成抛错/返回空串,把风险从"难看"变成"崩"。
expect(resolveT('definitely.not.a.key', 'zh-CN')).toBe('definitely.not.a.key');
expect(resolveT('definitely.not.a.key', 'en-US')).toBe('definitely.not.a.key');
});
it('resolveT 行为:占位符按 params 替换', () => {
expect(resolveT('validation.noteFilenameInvalidChars', 'zh-CN', { name: 'a/b' })).toContain(
'a/b',
);
expect(resolveT('validation.noteFilenameInvalidChars', 'zh-CN', { name: 'a/b' })).not.toContain(
'{name}',
);
// 少传参数 ⇒ 占位符原样留着(这正是调用点契约要拦的现象)
expect(resolveT('validation.noteFilenameInvalidChars', 'zh-CN')).toContain('{name}');
});
it('menu.ts 里用到的每个 label key 都在 T 里', () => {
const src = readFileSync(join(MAIN_DIR, 'menu.ts'), 'utf8');
const bad: string[] = [];
for (const m of src.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'\s*,\s*lang\s*\)/g)) {
const key = m[1];
if (!(key in T)) bad.push(` ${key}`);
}
expect(bad.join('\n')).toBe('');
});
it('扫描器非空转:至少扫到若干调用点与字典条目', () => {
// 探针:防止上面几条因为「正则一个都没匹配上」而空过。
const src = readFileSync(join(MAIN_DIR, 'menu.ts'), 'utf8');
const hits = [...src.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'\s*,\s*lang\s*\)/g)].map((m) => m[1]);
expect(hits.length).toBeGreaterThan(3);
expect(hits.every((k) => k in T)).toBe(true);
expect(dictKeys.length).toBeGreaterThan(50);
});
});

View File

@@ -0,0 +1,97 @@
// tests/unit/main/native-module.test.ts
//
// 守卫:当前测试运行器下 better-sqlite3 原生模块必须真的能加载并读写。
//
// 为什么要有这条(2026-10-19 实地复现):
// better-sqlite3 不是 N-API,一个 .node 只对应一个 ABI —— Electron 30.5 用
// ABI 123,系统 Node 24 用 ABI 137,两者共用同一个
// node_modules/better-sqlite3/build/Release/better_sqlite3.node。
// 旧 pretest 钩子为了跑测试会**破坏性重建**这份模块(把 app 那份覆盖成
// Node 版),Windows 上文件被运行中的 electron 进程锁住 → EBUSY →
// 整条 `npm run test:run` 中止,哪怕测试本身全绿。
//
// 现在 scripts/run-tests.cjs 改成自适应:能用系统 Node 加载就用系统 Node,
// 否则用 Electron 自带 Node 跑(ELECTRON_RUN_AS_NODE=1),测试与 app 共用
// 同一份原生模块,不再有互相覆盖。
//
// 这条测试锁住「跑测试的那条路线下原生模块可用」这个前提 ——
// 将来谁再把 pretest 换回 `npm rebuild better-sqlite3`,或让 CI 换回
// 裸 `npx vitest run`,这条会以「模块加载失败 + 该用哪条命令」的形式立刻
// 暴露,而不是变成一堆莫名其妙的 SQLITE_CANTOPEN。
//
// 兼容性:两条路线下本用例都应通过 ——
// - 系统 Node 路线:模块是 Node ABI 版(或 napi 通用版),直接可加载
// - Electron 路线:模块是 Electron ABI 版,Electron 自带 Node 可加载
import { describe, expect, it } from 'vitest';
interface TestDb {
exec(sql: string): void;
close(): void;
prepare(sql: string): {
run(...args: unknown[]): { changes: number };
get(...args: unknown[]): unknown;
};
pragma(sql: string): unknown;
}
type DatabaseCtor = new (path: string) => TestDb;
const ABI_HINT = [
'[native-module] 当前测试运行器加载不了 better-sqlite3 原生模块。',
' better-sqlite3 不是 N-API,一个 .node 只对应一个 ABI(Electron / 系统 Node 各一份),',
' 两份共用 node_modules/better-sqlite3/build/Release/better_sqlite3.node。',
' 请用 `npm run test:run` —— scripts/run-tests.cjs 会自动挑能加载的那条路线',
' (系统 Node 直跑,或 Electron 自带 Node 跑 ELECTRON_RUN_AS_NODE=1);',
' 裸 `npx vitest run` 会用系统 Node,而此刻那份 .node 可能是 Electron ABI 的。',
].join('\n');
/**
* 打开内存库。注意:**原生 binding 是构造时才加载的**(require 只是拿到
* JS 包装),所以 import 成功不代表 ABI 匹配 —— 必须在 new Database() 外面
* 兜 try/catch,否则报的是难懂的 "NODE_MODULE_VERSION 123 vs 137"。
*/
async function openMemoryDb(): Promise<TestDb> {
let ctor: DatabaseCtor | undefined;
try {
const mod = (await import('better-sqlite3')) as unknown as { default: DatabaseCtor };
ctor = mod.default;
return new ctor(':memory:');
} catch (err) {
expect.fail(
`${ABI_HINT}\n 原始错误:${err instanceof Error ? err.message : String(err)}`,
);
throw err; // eslint-disable-line no-unreachable -- expect.fail 已抛,仅为满足类型
}
}
describe('better-sqlite3 原生模块', () => {
it('能打开内存库、建表、写入并读回', async () => {
const db = await openMemoryDb();
try {
db.exec('create table t (a integer primary key, b text not null)');
const ins = db.prepare('insert into t (b) values (?)');
const res = ins.run('hello');
expect(res.changes).toBe(1);
const row = db.prepare('select a, b from t where a = ?').get(1) as {
a: number;
b: string;
};
expect(row).toEqual({ a: 1, b: 'hello' });
} finally {
db.close();
}
});
it('pragma 可用(主进程依赖 journal_mode / foreign_keys 的行为)', async () => {
const db = await openMemoryDb();
try {
// journal_mode 在内存库上恒为 memory,这里只断言 pragma 调用本身可用
expect(() => db.pragma('journal_mode')).not.toThrow();
expect(() => db.pragma('foreign_keys = ON')).not.toThrow();
const fk = db.pragma('foreign_keys') as Array<{ foreign_keys: number }>;
expect(fk[0]?.foreign_keys).toBe(1);
} finally {
db.close();
}
});
});

View File

@@ -0,0 +1,77 @@
// tests/unit/main/notes-create-title-no-dir-prefix.test.ts
//
// 守 r214「新建笔记的标题只取文件名,不带目录前缀」契约。
//
// ── 被守卫的缺陷(用户可见)──────────────────────────────────────────
// `createNote(dataPath, filename)` 收到的 `filename` 是**含目录前缀**的
// relPath(在子文件夹里点「+ 新建」时,渲染端传 'sub/Untitled 1.md',
// 见 r175 的 notes-create-in-folder 契约)。旧实现直接
// `title = stripExt(fn)`,而 `stripExt` 的契约是**保留目录前缀**
// (path-utils.ts:它只剥末段的扩展名,不动前面的段)⇒
// frontmatter.title 变成 'sub/Untitled 1'。
//
// 后果:列表(NotesList)与编辑器标题框显示 'sub/Untitled 1' 而不是
// 'Untitled 1';该 title 同时进 DB 与 FTS ⇒ 搜索结果也带路径。
// 复制粘贴副本走同一形态,同样命中。
//
// 同文件里**三处**「从 relPath 推标题」的写法,口径必须一致:
// :314 扫描回退 cleanTitle(fm) ?? stripExt(filename) ← filename 已是末段
// :944 rename 回退 stripExt(filenameOf(relPath))
// :489 createNote stripExt(fn) ← 唯一漏了 filenameOf 的一处(r214 修)
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A. 在子文件夹里新建('sub/x.md')→ 标题是 'x',不含 'sub/' 前缀;
// B. 在根目录新建('x.md')→ 标题仍是 'x'(根目录行为不变,防回归);
// C. 多级目录('a/b/c.md')→ 标题是 'c'(只取末段);
// D. 文件名本身带空格 / 中文 → 标题只剥扩展名,不动其它字符。
//
// 真库(electron app.getPath)在测试环境不可用 → setRootPath 指到 mkdtemp,
// 直调 standalone.ts 的 createNote(不碰 DB,无需 mock resources-repo)。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import matter from 'gray-matter';
import { setRootPath } from '../../../src/main/utils/safe-write';
import { createNote } from '../../../src/main/notes/standalone';
describe('新建笔记标题不带目录前缀(r214)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notetitle-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
/** 读回刚创建文件的 frontmatter.title(用户最终看到的标题来源) */
async function readBackTitle(relPath: string): Promise<unknown> {
const raw = await fs.readFile(join(root, 'notes', relPath), 'utf-8');
return matter(raw).data['title'];
}
it('A 子文件夹里新建 → 标题是文件名,不含目录前缀', async () => {
await createNote(root, 'sub/Untitled 1.md');
expect(await readBackTitle('sub/Untitled 1.md')).toBe('Untitled 1');
});
it('B 根目录新建 → 标题是文件名(根目录行为不变,防回归)', async () => {
await createNote(root, 'a.md');
expect(await readBackTitle('a.md')).toBe('a');
});
it('C 多级目录 → 标题只取末段', async () => {
await createNote(root, 'a/b/c.md');
expect(await readBackTitle('a/b/c.md')).toBe('c');
});
it('D 中文 / 带空格文件名 → 标题只剥扩展名,不动其它字符', async () => {
await createNote(root, 'sub/深度学习 综述.md');
expect(await readBackTitle('sub/深度学习 综述.md')).toBe('深度学习 综述');
});
});

View File

@@ -0,0 +1,114 @@
// tests/unit/main/notes-dir-structure-signal.test.ts
//
// 守 r184「磁盘**目录结构**变了,主进程必须发信号」契约。
//
// 旧实现:notes 的 chokidar 只监听 add / change / unlink(全是**文件级**
// 事件)⇒ 用户在资源管理器里建了一个**空文件夹**(没有任何文件)时,
// 主进程一个事件都收不到 ⇒ reload 不跑 ⇒ 'changed' 不发 ⇒ renderer
// 完全不知道磁盘的目录结构变了。
//
// 后果(用户诉求「列表应该和对应的文件夹完全一致」被打破):
// - 外部新建的文件夹在左侧栏永远不出现;
// - 外部删掉的文件夹永远还在(点进去是空的);
// - 外部改名的文件夹显示旧名,里面文件的行从当前视图里「消失」。
//
// 本测锁:notes 服务在**只有目录变、没有文件变**时也必须发 'changed'。
// (renderer 侧「收到 changed → 树缓存失效并重拉」由
// tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts 锁。)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
/** 等到 predicate 成立(或超时)。chokidar 事件 → 100ms 防抖 → reload。 */
async function waitForEvent(
svc: NotesService,
predicate: () => boolean,
timeoutMs = 8000,
): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 50));
}
return predicate();
}
describe('notes 目录结构变化必须发信号(r184)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
let changedCount: number;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notedir-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
changedCount = 0;
svc.on('changed', () => {
changedCount += 1;
});
// chokidar 的初始扫描是异步的 —— watcher 还没 ready 时建的目录收不到
// addDir 事件(ignoreInitial: true ⇒ 只报 watcher 就绪之后的变化)。
await new Promise((r) => setTimeout(r, 600));
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('外部新建空文件夹 → 发 changed(旧实现只监听文件级事件,永远不发)', { timeout: 20000 }, async () => {
await fs.mkdir(join(notesDir, 'empty-folder'), { recursive: true });
const fired = await waitForEvent(svc, () => changedCount > 0);
expect(fired).toBe(true);
});
it('外部删掉空文件夹 → 发 changed', { timeout: 20000 }, async () => {
const dir = join(notesDir, 'gone-folder');
await fs.mkdir(dir, { recursive: true });
await waitForEvent(svc, () => changedCount > 0);
const before = changedCount;
await fs.rmdir(dir);
const fired = await waitForEvent(svc, () => changedCount > before);
expect(fired).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
// tests/unit/main/notes-filename-validator.test.ts
//
// 守 note 文件名校验契约 —— audit-r170 把圆括号放行(副本默认名
// 「X (copy).md」/「X (copy 2).md」是 Windows 资源管理器同款约定),
// 旧 regex 不收 '(' ')' → 每一次「复制 → 粘贴」都抛 ValidationError。
// 本文件锁住:
// - 圆括号(中段 / 段首)合法
// - 路径穿越形态('..'、绝对路径)仍被拒
// - 保留设备名 / 超长名仍被拒
// - .md / .markdown 两后缀都收
import { describe, expect, it } from 'vitest';
import {
normalizeAndValidateNoteFilename,
NOTE_FILENAME_MAX_LENGTH,
} from '../../../src/main/notes/filename-validator';
import { ValidationError } from '../../../src/main/utils/error';
describe('notes filename-validator', () => {
it('r170:圆括号副本名合法(中段)', () => {
expect(normalizeAndValidateNoteFilename('foo (copy).md', 'filename')).toBe('foo (copy).md');
expect(normalizeAndValidateNoteFilename('foo (copy 2).md', 'filename')).toBe('foo (copy 2).md');
expect(normalizeAndValidateNoteFilename('会议纪要 (副本).md', 'filename')).toBe(
'会议纪要 (副本).md',
);
});
it('r170:圆括号在段首也合法(OS 拖入「(1) 会议纪要.md」)', () => {
expect(normalizeAndValidateNoteFilename('(1) 会议纪要.md', 'filename')).toBe(
'(1) 会议纪要.md',
);
});
it('r170:嵌套路径(带目录前缀)仍合法 —— 副本直接建在目标目录靠它', () => {
expect(normalizeAndValidateNoteFilename('sub/foo (copy).md', 'filename')).toBe(
'sub/foo (copy).md',
);
});
it('路径穿越形态仍被拒', () => {
expect(() => normalizeAndValidateNoteFilename('../evil.md', 'filename')).toThrow(ValidationError);
expect(() => normalizeAndValidateNoteFilename('a/../../evil.md', 'filename')).toThrow(
ValidationError,
);
});
it('保留设备名仍被拒(每段都查)', () => {
expect(() => normalizeAndValidateNoteFilename('con.md', 'filename')).toThrow(ValidationError);
expect(() => normalizeAndValidateNoteFilename('sub/con.md', 'filename')).toThrow(
ValidationError,
);
});
it('超长名仍被拒', () => {
const long = `${'a'.repeat(NOTE_FILENAME_MAX_LENGTH + 1)}.md`;
expect(() => normalizeAndValidateNoteFilename(long, 'filename')).toThrow(ValidationError);
});
it('.md / .markdown 两后缀都收,缺后缀自动补 .md', () => {
expect(normalizeAndValidateNoteFilename('foo', 'filename')).toBe('foo.md');
expect(normalizeAndValidateNoteFilename('foo.markdown', 'filename')).toBe('foo.markdown');
});
});

View File

@@ -0,0 +1,192 @@
// tests/unit/main/notes-id-drift.test.ts
//
// 守 r192「笔记 id 漂移」在**主进程**这一侧的契约。
//
// id 的两套真相(这是漂移的根源):
// - 读:extractNoteId(note-id.ts)优先取**磁盘上的 frontmatter id**
// (兼容旧文件 / 手写 / 从别的机器拷来的 .md);
// - 写:writeNote(standalone.ts)把 fm.id **重钉**为 computeFileId(relPath)
// (r124「副本重映射」规则)⇒ 只要磁盘上的 fm.id 与路径哈希不一致,
// 保存之后 id 就变了。
//
// 现实触发源(不是理论):
// - 用户在资源管理器里改了文件名 / 移动了文件(fm.id 还是旧路径的哈希);
// - 从别的机器 / 别的工具拷进来的 .md(自带别的路径的 fm.id);
// - 手改过 frontmatter。
//
// 本测锁(主进程侧的三条事实):
// 1. 漂移**确实会发生**:磁盘 fm.id ≠ 路径哈希时,write 返回的 id 是**新 id**;
// 2. 旧 id **真的死了**(byId 里查不到)⇒ renderer 若还拿着旧 id,下一次
// 自动保存必撞 NOT_FOUND(正文只进内存草稿 ⇒ 重启即丢 = 数据丢失);
// 3. 收藏(★)跟着改:旧 fileId 变孤儿 ⇒ ★ 静默消失(r189 同款,触发点是
// 「保存」)。
//
// renderer 侧的重映射(选中态 / 多选 / 正在编辑的那篇)由
// tests/unit/renderer/store/notes-save-id-drift.test.ts 守。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
const electronMock = vi.hoisted(() => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: (channel: string, fn: (event: unknown, ...args: unknown[]) => unknown) => {
handlers.set(channel, fn);
},
},
shell: {
trashItem: vi.fn(),
showItemInFolder: vi.fn(),
},
};
});
vi.mock('electron', () => ({
ipcMain: electronMock.ipcMain,
shell: electronMock.shell,
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { registerNotesIPC } from '../../../src/main/ipc/notes';
import { getFavoritesService } from '../../../src/main/workspace/favorites';
import { computeFileId } from '../../../src/main/notes/path-mapping';
import { IPC } from '../../../src/shared/types';
interface IpcOk<T> {
ok: boolean;
data?: T;
code?: string;
}
const STALE_FM_ID = 'aaaaaaaaaaaaaaaa';
describe('笔记 id 漂移(保存时 fm.id 被重钉)(r192)', () => {
let root: string;
let notes: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-iddrift-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
electronMock.handlers.clear();
await getFavoritesService().start();
notes = new NotesService();
});
afterEach(async () => {
await notes.stop();
await getFavoritesService().stop();
await fs.rm(root, { recursive: true, force: true });
});
/**
* 落一个 frontmatter id 与路径哈希**不一致**的笔记(模拟「从别的机器
* 拷来的 .md」/「用户在资源管理器里改过文件名」)。
*/
async function writeStaleNote(filename: string, body: string): Promise<void> {
await fs.writeFile(
join(root, 'notes', filename),
`---\nid: ${STALE_FM_ID}\ntitle: T\n---\n\n${body}\n`,
'utf-8',
);
}
it('磁盘 fm.id ≠ 路径哈希 ⇒ 扫描时按 fm.id 载入,保存后返回**新 id**,且旧 id 真的死了', async () => {
await writeStaleNote('n.md', 'v1');
await notes.start(); // 全量扫描
// 扫描后:按磁盘上的 fm.id 载入
expect(notes.findById(STALE_FM_ID)).toBeTruthy();
const pathId = computeFileId('n.md');
expect(pathId).not.toBe(STALE_FM_ID);
// 保存 ⇒ 主进程把 fm.id 重钉为路径哈希 ⇒ 返回新 id
const written = await notes.write(STALE_FM_ID, 'v2');
expect(written.id).toBe(pathId);
expect(written.id).not.toBe(STALE_FM_ID);
// 旧 id **真的死了** —— renderer 若还拿着它,下一次自动保存必 NOT_FOUND
expect(notes.findById(STALE_FM_ID)).toBeNull();
expect(notes.findById(pathId)).toBeTruthy();
// 磁盘上的 fm.id 也已经是新 id(下次读不会又漂一次)
const raw = await fs.readFile(join(root, 'notes', 'n.md'), 'utf-8');
expect(raw).toContain(`id: ${pathId}`);
});
it('走 IPC 保存(真实链路)⇒ 收藏(★)跟着改,不会在保存成功的瞬间静默消失', async () => {
await writeStaleNote('n.md', 'v1');
await notes.start();
const fav = getFavoritesService();
// 用户给这篇点了 ★(renderer 存的是当前 id = 磁盘 fm.id)
await fav.toggle(STALE_FM_ID, { title: 'n.md', relPath: 'n.md' });
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_WRITE);
expect(handler).toBeTruthy();
const res = (await handler!(
{},
STALE_FM_ID,
'v2',
undefined,
undefined,
)) as IpcOk<{ id: string; relPath: string }>;
expect(res.ok).toBe(true);
const newId = res.data!.id;
expect(newId).not.toBe(STALE_FM_ID);
// 收藏条目:fileId 已是新 id,路径形态仍是 relPath(notes 域)
const entries = fav
.listEntries()
.filter((e) => e.kind === 'file')
.map((e) => ({ fileId: (e as { fileId: string }).fileId, relPath: e.relPath }));
expect(entries).toHaveLength(1);
expect(entries[0]!.fileId).toBe(newId);
expect(entries[0]!.relPath).toBe('n.md');
});
it('没有漂移(fm.id 本来就是路径哈希)⇒ id 不变,收藏也不该被无谓改写', async () => {
await notes.start();
const created = await notes.create('fresh.md');
const fav = getFavoritesService();
await fav.toggle(created.id, { title: 'fresh.md', relPath: 'fresh.md' });
registerNotesIPC(notes);
const handler = electronMock.handlers.get(IPC.NOTES_WRITE);
const res = (await handler!({}, created.id, 'v2', undefined, undefined)) as IpcOk<{
id: string;
}>;
expect(res.ok).toBe(true);
expect(res.data!.id).toBe(created.id);
const entries = fav
.listEntries()
.filter((e) => e.kind === 'file')
.map((e) => (e as { fileId: string }).fileId);
expect(entries).toEqual([created.id]);
});
});

View File

@@ -0,0 +1,122 @@
// tests/unit/main/notes-import-frontmatter.test.ts
//
// 守 r197「笔记导入不许丢元数据 / 不许因路径形态被静默跳过」契约。
//
// 背景(两个独立缺口,都在 notes/import.ts):
//
// 缺口①**元数据丢失**:旧实现把源 frontmatter 收窄成只剩 title + tags
// 两个键就写盘 ⇒ 源文件里的 doi / year / author / citekey / status
// 全部**静默丢弃**。用户从 Obsidian / Zotero 插件导出的研究笔记,
// 那些元数据进应用就没了(正文没有、frontmatter 也没有 = 永久丢失)。
// 同一个契约在**保存**路径上 2026-10-04 已经修过(writeNote 用
// existingFm 垫底),导入是这条契约的**入口缺口**。
//
// 缺口②**路径形态被误判为 symlink**:旧实现用
// `realpathSync(sourcePath) !== sourcePath` 判「文件被 symlink 重定向」,
// 而 realpathSync 在 Windows 上**永远返反斜杠**形态 ⇒ 只要传进来的
// 路径是正斜杠形态(笔记被搬动过一次后 relPath 就是 POSIX 形态),
// 就恒不相等 ⇒ **静默 skip**(用户看到「拖进来没反应」)。
// 文献侧 r175 已查清并改成 inode/dev 比对,笔记侧这次跟上。
//
// 真库(electron app.getPath)在测试环境不可用 → 本用例只碰 notes/import.ts
// 与 standalone.ts(都不碰 DB),故无需 mock resources-repo。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import matter from 'gray-matter';
import { setRootPath } from '../../../src/main/utils/safe-write';
import { importNoteFromFile } from '../../../src/main/notes/import';
describe('笔记导入(r197)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-noteimport-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it('源 frontmatter 的研究元字段(doi/year/author/citekey/status)必须原样保留', async () => {
const src = join(root, 'paper.md');
await fs.writeFile(
src,
[
'---',
'title: 深度学习综述',
'tags: [ml, review]',
'doi: 10.1234/abc',
'year: 2024',
'author: 张三',
'citekey: zhang2024dl',
'status: reading',
'---',
'',
'正文内容',
].join('\n'),
'utf-8',
);
const res = await importNoteFromFile(src, root);
expect(res.copied).toBe(1);
const relPath = res.createdRelPaths?.[0];
expect(relPath).toBeTruthy();
const raw = await fs.readFile(join(root, 'notes', relPath as string), 'utf-8');
const fm = matter(raw).data as Record<string, unknown>;
// 旧实现只剩 title / tags —— 这五个键全丢
expect(fm['doi']).toBe('10.1234/abc');
expect(fm['year']).toBe(2024);
expect(fm['author']).toBe('张三');
expect(fm['citekey']).toBe('zhang2024dl');
expect(fm['status']).toBe('reading');
// 已有键不受影响
expect(fm['title']).toBe('深度学习综述');
expect(fm['tags']).toEqual(['ml', 'review']);
});
it('源 frontmatter 的 id 必须被重钉成「按 relPath 算的 id」(不能沿用源 id)', async () => {
const src = join(root, 'with-id.md');
await fs.writeFile(
src,
['---', 'id: stale-source-id', 'title: T', '---', '', 'body'].join('\n'),
'utf-8',
);
const res = await importNoteFromFile(src, root);
expect(res.copied).toBe(1);
const relPath = res.createdRelPaths?.[0] as string;
const raw = await fs.readFile(join(root, 'notes', relPath), 'utf-8');
const fm = matter(raw).data as Record<string, unknown>;
expect(fm['id']).not.toBe('stale-source-id');
expect(String(fm['id'])).toMatch(/^[0-9a-f]{16}$/);
});
it('正斜杠形态的源路径必须能导入(旧 realpath 比对恒为真 ⇒ 静默 skip)', async () => {
const srcNative = join(root, 'posix.md');
await fs.writeFile(srcNative, ['---', 'title: POSIX', '---', '', 'body'].join('\n'), 'utf-8');
// 同一文件,路径写成正斜杠形态(Windows 上 realpathSync 返反斜杠 ⇒
// 旧实现恒判「symlink 分流」⇒ skipped=1)
const srcPosix = srcNative.replace(/\\/g, '/');
expect(srcPosix).not.toBe(srcNative);
const res = await importNoteFromFile(srcPosix, root);
expect(res.copied).toBe(1);
expect(res.skipped).toBe(0);
const relPath = res.createdRelPaths?.[0] as string;
const raw = await fs.readFile(join(root, 'notes', relPath), 'utf-8');
expect(matter(raw).data['title']).toBe('POSIX');
});
it('非 .md 后缀仍拒收(契约不因本轮改动而松动)', async () => {
const src = join(root, 'note.txt');
await fs.writeFile(src, 'hello', 'utf-8');
await expect(importNoteFromFile(src, root)).rejects.toMatchObject({
code: 'IO_ERROR',
});
});
});

View File

@@ -0,0 +1,115 @@
// tests/unit/main/notes-name-manageable.test.ts
//
// 守 r195「侧栏列得出来的笔记,应用也得管得了」—— 字符集这一层的
// 不变式 I2(与 r186 在文件夹层修的同一个洞)。
//
// 现象(用户可复现):
// 1. 用户从 Obsidian / 网盘同步 / 同事的机器上拷来一批 .md,名字里带
// '&' '+' '=' '%' "'" '#' '.' 之类(「C++ 学习.md」「v1.2 草稿.md」
// 「A&B.md」「100%完成.md」……);
// 2. 放进工作区的 notes/ 目录 → 侧栏**照常列出来**(collectNotePaths
// 只按后缀收,不做字符过滤);
// 3. 但把**同样的文件**拖进应用导入 → ValidationError「文件名包含非法
// 字符」⇒ **文件根本进不来**;想把笔记改名成这种名字 → 同样被拒。
// ⇒ 「看得见、管不了」—— 与用户诉求「列表 == 文件夹、文件操作要对得上」
// 直接冲突。
//
// 根因:notes 域的文件名校验是一条**白名单 regex**(只收字母/数字/下划线/
// 连字符/空格/圆括号/中文),而文献域(literature/rename-file.ts)只拒
// Win32 真正不能用的 `< > : " | ? *` + 控制字符 ⇒ 同一个字符,一边行
// 一边不行(两侧不对齐)。
//
// 本测锁(行为级,直接对着磁盘事实说话):
// 1. 这些真实存在的文件名**必须被 scanNotes 列出来**(列表侧);
// 2. 同一个名字**必须能通过 validator**(⇒ 可导入 / 可改名)—— 两边一致;
// 3. 真的建不出来的形态(Win32 保留字符 / 控制字符 / '..' 段 / 隐藏段 /
// 保留设备名 / 超长)**仍然被拒**(不能因为放宽就把洞打开)。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { scanNotes } from '../../../src/main/notes/standalone';
import { normalizeAndValidateNoteFilename } from '../../../src/main/notes/filename-validator';
import { WORKSPACE_DATA_LAYOUT } from '../../../src/shared/types';
import { stripExt } from '../../../src/shared/path-utils';
// 真实世界里极常见的学术笔记名 —— 旧白名单 regex 一个都过不了。
const TRICKY_NAMES = [
'A&B.md',
'C++ 学习.md',
'v1.2 草稿.md',
'100%完成.md',
"O'Brien 访谈.md",
'结果=最终.md',
'2024-06-01 会议 #3.md',
'数据+分析.md',
'Q&A 记录.md',
'论文(终稿).md',
];
describe('笔记文件名:列得出来就得管得了(r195)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notes-name-'));
await fs.mkdir(join(root, WORKSPACE_DATA_LAYOUT.NOTES_DIR), { recursive: true });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it('列在侧栏里的名字,必须同时能通过导入 / 改名校验', async () => {
for (const name of TRICKY_NAMES) {
await fs.writeFile(join(root, WORKSPACE_DATA_LAYOUT.NOTES_DIR, name), '# body\n', 'utf-8');
}
// 1) 列表侧:全部列出来
const notes = await scanNotes(root);
const listed = new Set(notes.map((n) => n.filename));
for (const name of TRICKY_NAMES) {
expect(listed.has(name), `${name} 应当被 scanNotes 列出来`).toBe(true);
}
// 2) 操作侧:同一个名字必须能过 validator(导入走 field='filename',
// 改名走 field='newFilename' —— 两个入口都要过)
for (const name of TRICKY_NAMES) {
const stem = stripExt(name);
expect(() => normalizeAndValidateNoteFilename(stem, 'filename')).not.toThrow();
expect(() => normalizeAndValidateNoteFilename(stem, 'newFilename')).not.toThrow();
}
});
it('建不出来的形态仍然被拒(放宽字符集不能把洞打开)', () => {
const bad: Array<[string, 'filename' | 'newFilename']> = [
['a:b.md', 'filename'], // Win32 保留字符
['a*b.md', 'filename'],
['a?b.md', 'filename'],
['a<b.md', 'filename'],
['a>b.md', 'filename'],
['a|b.md', 'filename'],
['a"b.md', 'filename'],
['a\\b.md', 'filename'], // 反斜杠 = 路径分隔符
['a\u0001b.md', 'filename'], // 控制字符
['../evil.md', 'filename'], // 路径穿越
['a/../evil.md', 'filename'],
['a//b.md', 'filename'], // 空段
['.hidden.md', 'filename'], // 隐藏项(collectNotePaths 也跳过)
['sub./a.md', 'filename'], // Win32 静默截断
['sub /a.md', 'filename'],
['con.md', 'filename'], // Windows 保留设备名
['con.txt.md', 'filename'], // 词干是 con
['com1.md', 'filename'],
];
for (const [name, field] of bad) {
expect(() => normalizeAndValidateNoteFilename(name, field), name).toThrow();
}
});
it('改名只改文件名、不改位置(r175 不变式):名字里不许带路径分隔符', () => {
expect(() => normalizeAndValidateNoteFilename('sub/x.md', 'newFilename')).toThrow();
// 新建到子目录仍然允许 '/' (create 路径靠它)
expect(normalizeAndValidateNoteFilename('sub/x.md', 'filename')).toBe('sub/x.md');
});
});

View File

@@ -0,0 +1,236 @@
// tests/unit/main/notes-reload-body-bytes.test.ts
//
// 守 perf r255「笔记正文的 UTF-8 字节数不许每轮 reload 重算」的契约。
//
// ── 被守卫的优化 ───────────────────────────────────────────────────
// notes reload 的两处判定都要「这篇笔记的 body 有多少字节」:
// ① emit 幂等指纹(r223):`${id}|${mtime}|${byteLength(body)}`
// ② DB 同步(r220):`row.size === byteLength(body)` 判「变了没」
// 旧实现两处都现场 `Buffer.byteLength(body,'utf8')` —— 而 byteLength
// 是**一次全量 UTF-8 扫描**:实测 2000 篇 × 8KB = 12.7ms/遍,两处
// = 25.4ms 的**主线程纯 CPU**(不是 I/O 等待 ⇒ 直接推迟同期的其它
// IPC 响应 ⇒ 用户侧「卡一下」)。稳态(文件没变)下这 25ms 是纯白烧。
//
// 修法:字节数跟着解析结果一起缓存(stat 门控解析缓存,r239),
// 命中即复用 ⇒ 稳态零计算。
//
// ── 本测锁四件事 ───────────────────────────────────────────────────
// A. **字节数维度必须被看见**:正文变长 + **mtime 保留**(备份还原 /
// cp -p / 同步盘按内容去重的真实形态)⇒ 必须 emit('changed') 且
// DB 行的 size 跟着更新。
// —— 这是唯一能把「字节数」与「mtime」两个维度分开的构造:若实现
// 复用了**陈旧**的字节数,指纹不变 ⇒ 不 emit;DB 判定相等 ⇒ 不
// upsert ⇒ 两处都红。
// B. **稳态不白写 DB**:文件没变 ⇒ 二次 reload **零 upsert**
// (字节数口径与写库口径若不一致 ⇒ 每轮都重写 ⇒ 红)。
// C. **字节数是 UTF-8 字节数,不是字符数**:中文正文的字节数必须
// > 字符串长度,且 DB 行的 size == Buffer.byteLength(body)。
// D. **缓存不得陈旧**:文件改了 ⇒ 下一轮拿到的是**新**字节数
// (解析缓存的 stat 门控命中判据变了 ⇒ 必须重算)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体
// mock 成内存 Map(与 notes-reload-emit-fingerprint.test.ts 同款 harness)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
size: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const calls = { upsert: 0 };
return { rows, calls };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.calls.upsert++;
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { scanNotes, clearNoteParseCache } from '../../../src/main/notes/standalone';
import { noteResourceId } from '../../../src/shared/resource-id';
/** 固定 mtime(整秒 ⇒ mtimeMs 无小数,断言可以严格相等)。 */
const FIXED_MTIME = new Date(1_700_000_000_000);
describe('笔记正文字节数的复用(r255)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
/**
* 落盘后的 body 期望字节数。
* 文件形态 = `---
title: "T"
---
<text>
` ⇒ gray-matter 的
* content = `
<text>
` ⇒ cleanBody 只收尾的换行 ⇒ body = `
<text>`
* (前导那个空行是 frontmatter 之后的换行,属于正文)。
*/
function expectedBytes(text: string): number {
return Buffer.byteLength(`
${text}`, 'utf8');
}
/** 落一个 .md 并把 mtime 钉到 FIXED_MTIME。 */
async function writeNote(name: string, title: string, body: string): Promise<string> {
const p = join(notesDir, name);
await fs.writeFile(p, `---\ntitle: "${title}"\n---\n\n${body}\n`, 'utf-8');
await fs.utimes(p, FIXED_MTIME, FIXED_MTIME);
return p;
}
beforeEach(async () => {
clearNoteParseCache();
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notebytes-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
clearNoteParseCache();
await fs.rm(root, { recursive: true, force: true });
});
it('A 正文变长 + mtime 保留 ⇒ 必须 emit 且 DB 行的 size 更新(字节数维度要能看见)', async () => {
await writeNote('a.md', 'A', '短正文');
await svc.load();
const rowId = noteResourceId(svc.findById(svc.list()[0]!.id)!.id);
const before = db.rows.get(rowId)!;
expect(before.size).toBe(expectedBytes('短正文'));
// 外部改写:正文变长,mtime **保留**(cp -p / 备份还原的形态)
const p = join(notesDir, 'a.md');
await fs.writeFile(p, `---\ntitle: "A"\n---\n\n短正文而且更长了\n`, 'utf-8');
await fs.utimes(p, FIXED_MTIME, FIXED_MTIME);
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(changed, 'mtime 保留但正文变长 ⇒ 指纹必须变(否则 renderer 看不到更新)').toHaveBeenCalledTimes(1);
const after = db.rows.get(rowId)!;
expect(after.size, 'DB 行的 size 必须跟着正文长度更新').toBe(
expectedBytes('短正文而且更长了'),
);
expect(after.size).not.toBe(before.size);
});
it('B 文件没变 ⇒ 二次 reload 零 upsert(不白写 DB)', async () => {
await writeNote('b.md', 'B', '正文 B');
await svc.load();
const upsertsAfterFirst = db.calls.upsert;
expect(upsertsAfterFirst).toBeGreaterThan(0);
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
await svc.load();
expect(db.calls.upsert, '内容没变却每轮重写 DB ⇒ FTS 被反复重建').toBe(upsertsAfterFirst);
expect(changed).not.toHaveBeenCalled();
});
it('C 中文正文的 size 是 UTF-8 字节数(不是字符数)', async () => {
await writeNote('c.md', 'C', '注意力机制');
await svc.load();
const note = svc.list()[0]!;
const row = db.rows.get(noteResourceId(note.id))!;
const body = svc.findById(note.id)!.body;
expect(Buffer.byteLength(body, 'utf8'), '本用例必须真的含多字节字符(否则测不出维度)').toBeGreaterThan(
body.length,
);
expect(row.size).toBe(Buffer.byteLength(body, 'utf8'));
});
it('D 文件改了 ⇒ 下一轮拿到的是新字节数(缓存不许陈旧)', async () => {
await writeNote('d.md', 'D', '第一版');
const first = new Map<string, number>();
await scanNotes(root, { bodyBytesOut: first });
expect(first.get('d.md')).toBe(expectedBytes('第一版'));
const p = join(notesDir, 'd.md');
await fs.writeFile(p, `---\ntitle: "D"\n---\n\n第二版更长了\n`, 'utf-8');
const second = new Map<string, number>();
await scanNotes(root, { bodyBytesOut: second });
expect(second.get('d.md'), '文件变了 ⇒ 字节数必须重算,不能拿缓存里的旧值').toBe(
expectedBytes('第二版更长了'),
);
});
it('E 稳态 reload 不再算字节数(零 byteLength 调用 —— 这就是本轮的性能契约)', async () => {
await writeNote('e.md', 'E', '正文 E');
// 首轮:解析期算一次(缓存回填)
await svc.load();
const spy = vi.spyOn(Buffer, 'byteLength');
try {
await svc.load();
expect(
spy.mock.calls.length,
'文件没变 ⇒ 字节数应直接取解析缓存,不该再全量扫一遍正文',
).toBe(0);
} finally {
spy.mockRestore();
}
});
});

View File

@@ -0,0 +1,201 @@
// tests/unit/main/notes-reload-db-sync.test.ts
//
// 守 r180「笔记域 reload 必须把 DB 拉齐到磁盘」契约。
//
// 旧实现:src/main/notes/service.ts 的 reload() 只重建内存 byId ——
// const notes = await scanNotes(...); this.byId = next; this.emit('changed');
// —— **从不碰 DB**。磁盘差集逻辑只存在于 resyncIfNeeded(),而它只在
// 启动期跑一次(r156 起的调用点)。
//
// 于是会话运行期间出现三个「列表与文件夹对不上」的洞:
// 1. 用户从 OS 拖进 <rootPath>/notes/ 的 .md:侧栏能看见(byId 重建了),
// 但 resources 表里**没有这一行** ⇒ 全局搜索 / FTS 永远搜不到它
// (literature 域 r136 起每次 reload 都做差集,两个域行为相反);
// 2. 用户用外部编辑器改过的 .md:侧栏标题是新的,DB 里的 body 还是旧的
// ⇒ 搜索命中旧正文,正文与文件内容不一致;
// 3. 用户在 OS 删掉的 .md:DB 留下 ghost 行 ⇒ 搜索还能搜到一篇磁盘上
// 已经不存在的东西(点进去 NOT_FOUND)。
//
// 本测锁四件事:
// 1. OS 拖入的 .md → reload 后进 DB(带 body ⇒ FTS 可搜);
// 2. 外部改写过的 .md → reload 后 DB 正文跟着更新(mtime 变 ⇒ 重写);
// 3. OS 删掉的 .md → reload 后 ghost 行被清理;
// 4. 磁盘没变 → reload **不重复写 DB**(否则每次防抖都全量重写,
// indexed_at 被刷掉,「indexed_at=0 = body 未填」这个标记就失真了)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成一个内存 Map;被测行为(差集判定 + upsert/删除时机)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const calls = { upsert: 0, del: 0 };
return { rows, calls };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.calls.upsert++;
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.calls.del++;
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { noteResourceId } from '../../../src/shared/resource-id';
describe('笔记 reload ↔ DB 差集(r180)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notereload-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
db.calls.del = 0;
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('OS 拖入的 .md → reload 后进 DB(带正文 ⇒ FTS 能搜到)', async () => {
// 模拟用户在资源管理器里把 a.md 丢进 <root>/notes/
await fs.writeFile(join(notesDir, 'external.md'), '# 外部笔记\n\n正文内容\n', 'utf-8');
await svc.load();
expect(svc.list().map((n) => n.relPath)).toEqual(['external.md']);
const rows = [...db.rows.values()];
expect(rows).toHaveLength(1);
expect(rows[0]?.relPath).toBe('external.md');
expect(rows[0]?.kind).toBe('note');
// 正文进了 DB(否则 FTS 索引里没有它,全局搜索搜不到)
expect(rows[0]?.body).toContain('外部笔记');
});
it('外部编辑器改过的 .md → reload 后 DB 正文跟着更新', async () => {
const created = await svc.create('edited.md');
const file = join(notesDir, 'edited.md');
const rowId = noteResourceId(created.id);
const before = db.rows.get(rowId);
expect(before?.body ?? '').not.toContain('外部新增的一行');
// 外部改写正文(保留 frontmatter,只动 body)+ 显式推 mtime ——
// 不推的话同毫秒内两次写会拿到相同 mtimeMs,断言会随机红。
await fs.appendFile(file, '\n外部新增的一行\n', 'utf-8');
const future = new Date(Date.now() + 5000);
await fs.utimes(file, future, future);
await svc.load();
const after = db.rows.get(rowId);
expect(after?.body).toContain('外部新增的一行');
});
it('OS 删掉的 .md → reload 后 ghost 行被清理(文件没了,索引也不能留)', async () => {
const created = await svc.create('gone.md');
const rowId = noteResourceId(created.id);
expect(db.rows.has(rowId)).toBe(true);
await fs.rm(join(notesDir, 'gone.md'), { force: true });
await svc.load();
expect(db.rows.has(rowId)).toBe(false);
expect(db.rows.size).toBe(0);
});
it('内容变但 mtime 被保留(备份还原 / cp -p / 同步盘)⇒ DB 正文仍跟着更新(r220 size 双因子)', async () => {
const created = await svc.create('restored.md');
const file = join(notesDir, 'restored.md');
const rowId = noteResourceId(created.id);
await svc.load(); // 先收敛(建文件那刻与落盘后的 mtime 差补写一次)
const upsertsBefore = db.calls.upsert;
// 外部改写正文(字节数变),然后把 DB 行的 mtime 拨成**当前磁盘
// mtime** —— 等价于「备份还原 / cp -p 保留了 mtime ⇒ DB 行的 mtime
// 与磁盘一致,但内容早就不一样了」。直接拨行而不是 fs.utimes:
// utimes 的 sub-ms 精度在 float64 秒往返里会丢,断言会随机红;
// 这里要钉的语义是「row.mtime === disk mtime 且内容不同」,
// 与 mtime 的绝对值无关。
await fs.appendFile(file, '\n还原回来的新内容\n', 'utf-8');
const diskMtime = (await fs.stat(file)).mtimeMs;
const row = db.rows.get(rowId);
expect(row).toBeDefined();
row!.mtime = diskMtime;
await svc.load();
const after = db.rows.get(rowId);
expect(
after?.body,
'只看 mtime 的旧判定把这次变更当成「没变」跳过 ⇒ FTS 永久停在旧正文',
).toContain('还原回来的新内容');
expect(db.calls.upsert).toBeGreaterThan(upsertsBefore);
});
it('磁盘没变 → reload 不重复写 DB(不无脑刷新 indexed_at)', async () => {
await svc.create('stable.md');
// 第一次 load 可能因「建文件那一刻的 mtime 与落盘后的 mtime 不同」而补写
// 一次 —— 先让它收敛,再量「稳态下不写」
await svc.load();
const before = db.calls.upsert;
await svc.load();
await svc.load();
expect(db.calls.upsert).toBe(before);
});
});

View File

@@ -0,0 +1,174 @@
// tests/unit/main/notes-reload-emit-fingerprint.test.ts
//
// 守 r223「notes reload 的 emit 幂等指纹」契约(镜像 literature r136 M1)。
//
// ── 被守卫的缺陷 ───────────────────────────────────────────────────
// literature reload 自 r136 M1 起有 emit 幂等指纹(byId 内容没变 ⇒
// 不 emit),notes reload 却**无条件** `this.emit('changed')` ——
// 孪生实现口径不一致第八次应验。后果:chokidar 对 no-op 触碰(同步盘
// 周期 touch / 元数据抖动)也触发 scheduleReload → emit ⇒ renderer
// 每次都全量 listNotes IPC + setState(100+ 行重渲),内容一个字没变;
// 高频时正是 perf(2026-09-04) 系列一直在压的卡顿源。
//
// 修法(r223):指纹 = `${id}|${mtime}|${size}` 排序 join(r211/r220
// 双因子口径)+ **目录集**(r215 的教训:空文件夹增/删/改名不改变
// 任何文件行,只看文件指纹会把目录结构变化判成「没变」;r184 起
// notes watcher 就监听 addDir/unlinkDir,指纹必须接住这一类)。
// 指纹只管 emit;syncDbWithDisk **无条件跑**(DB 自愈不依赖 renderer
// 是否需要刷新)。
//
// ── 本测锁四件事 ───────────────────────────────────────────────────
// A. 磁盘零变化 → 第二次 load **不再 emit**(缺陷本体,修前红);
// B. 外部改内容 → load 仍 emit(回归锚:真变化不许被指纹吞掉);
// C. 外部建**空文件夹** → load 仍 emit(回归锚,r215 类:文件行没变,
// 目录集变 —— 没有目录维度的「修复」会把这类判成没变);
// D. DB stale 行(indexed_at=0)→ load 仍回填(syncDbWithDisk 无条件
// 跑)但**不 emit**(修前红:HEAD unconditional emit 多打一次)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map(与 notes-reload-db-sync.test.ts 同款 harness)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
size: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { noteResourceId } from '../../../src/shared/resource-id';
describe('notes reload 的 emit 幂等指纹(r223)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notefp-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('A 磁盘零变化 → 第二次 load 不再 emit(修前红:HEAD 无条件 emit)', async () => {
await svc.create('a.md');
await svc.load(); // 指纹落基线
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
await svc.load();
expect(
changed,
'byId 一个字没变也 emit ⇒ 同步盘 no-op 触碰每次都拖 renderer 全量重拉',
).not.toHaveBeenCalled();
});
it('B 外部改内容 → load 仍 emit(真变化不许被指纹吞掉)', async () => {
const created = await svc.create('b.md');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.appendFile(join(notesDir, 'b.md'), '\n外部新增内容\n', 'utf-8');
// mtime 与 size 都变 ⇒ 指纹必变
await svc.load();
expect(changed).toHaveBeenCalledTimes(1);
expect(svc.findById(created.id)?.body).toContain('外部新增内容');
});
it('C 外部建空文件夹 → load 仍 emit(r215 类:文件行没变,目录集变)', async () => {
await svc.create('c.md');
await svc.load();
const changed = vi.fn();
svc.on('changed', changed);
await fs.mkdir(join(notesDir, 'empty-folder'));
await svc.load();
expect(
changed,
'目录结构变化必须 emit —— 只看文件指纹会把空文件夹的增删判成「没变」',
).toHaveBeenCalledTimes(1);
});
it('D DB stale 行(indexed_at=0)→ load 仍回填但不 emit(修前红)', async () => {
const created = await svc.create('d.md');
await svc.load();
const rowId = noteResourceId(created.id);
const row = db.rows.get(rowId);
expect(row).toBeDefined();
// 模拟 0005 迁移标记:FTS 只有 title 种子,等回填
row!.indexedAt = 0;
const changed = vi.fn();
svc.on('changed', changed);
await svc.load();
expect(
db.rows.get(rowId)?.indexedAt,
'stale 回填被指纹跳过 ⇒ indexed_at=0 行永久残废(syncDbWithDisk 必须无条件跑)',
).not.toBe(0);
expect(changed, 'byId 没变却为 DB 回填多打一次 emit').not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,236 @@
// tests/unit/main/notes-resync-ghost-guard.test.ts
//
// 守 r209「启动期 resync 把**还在磁盘上**的笔记整片删掉」契约。
//
// 现象:应用启动时(NotesService.resyncIfNeeded)会做 disk ↔ DB 差集,
// 把「DB 有、磁盘上扫不到」的笔记行当 ghost 删掉。这个判定**只信
// collectNotePaths 的扫描结果**,而扫描结果**可能是残缺的**:
// collectNotePaths 对单个子目录的 readdir 失败是「log warn 后只丢这一支」
// 的(standalone.ts 的 per-branch try/catch,注释里点名了 Defender 锁目录 /
// OneDrive placeholder / 权限翻转这些真实场景)⇒ 失败时缺的是**一整棵
// 子树**,不是「这些文件真被删了」。
//
// ⇒ 旧实现把那一整棵子树里的笔记**全部**判成 ghost 删掉:文件还在磁盘上,
// 索引行(DB + FTS)却没了 ⇒ 侧栏列表与全文搜索里这些笔记消失,而且
// **本轮不会补回**(插入阶段先于删除阶段跑,且它也看不见这些文件)⇒ 要等
// 下次启动才可能回来。
//
// 旧注释写的「走到这里 collectNotePaths 已完整成功(扫描 throw 会让整个
// resync reject,不会带着残缺 diskRelSet 进删除分支)」**不成立** ——
// collectNotePaths 从不 throw。
//
// 修法:与同文件**运行期**路径 syncDbWithDisk(r180)的双重守卫对齐 ——
// diskRelSet 之外再用「文件是不是**真的**没了」兜一次;且判据只在
// **ENOENT** 时算「没了」,其余错误(EACCES / EBUSY …)fail-safe 当作
// 「还在」(把「读不到」当成「没了」,代价是删掉用户的笔记)。
//
// 本测锁四件事:
// 1. 扫描盲点(子目录 readdir 失败)⇒ 那棵子树里的笔记行**必须留下**;
// 2. 扫描盲点(深度 > 16 的深层笔记)⇒ 同样必须留下;
// 3. 真的被外部删掉的文件 ⇒ ghost 行**仍然**被清理(守卫不许把真 ghost 留下);
// 4. 文件「读不到」(stat 报 EACCES)⇒ 行留下(fail-safe,不许误删)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成一个内存 Map;被测行为(差集判定 + 删/留时机)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
const calls = { upsert: 0, del: 0 };
return { rows, calls };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.calls.upsert++;
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.calls.del++;
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
// 模拟「某个子目录读不出来」(Defender 锁目录 / OneDrive placeholder / 权限翻转):
// fs.promises.readdir 对路径含 'locked' 的目录直接 reject(EPERM)。
// collectNotePaths 对这种失败是「log warn 后只丢这一支」⇒ 扫描结果缺一整棵子树。
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const realReaddir = actual.promises.readdir;
const realStat = actual.promises.stat;
return {
...actual,
default: actual,
promises: {
...actual.promises,
readdir: (path: unknown, opts: unknown) => {
if (typeof path === 'string' && path.includes('locked')) {
const err = Object.assign(new Error(`EPERM: operation not permitted, scandir '${path}'`), {
code: 'EPERM',
});
return Promise.reject(err);
}
return realReaddir(path as never, opts as never);
},
stat: (path: unknown, opts: unknown) => {
if (typeof path === 'string' && path.includes('unreadable')) {
const err = Object.assign(new Error(`EACCES: permission denied, stat '${path}'`), {
code: 'EACCES',
});
return Promise.reject(err);
}
return realStat(path as never, opts as never);
},
},
};
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { noteResourceId } from '../../../src/shared/resource-id';
describe('启动期 resync 的 ghost 清理守卫(r209)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
/** 造一条「DB 里有、磁盘上也有」的笔记行(模拟上一次会话留下的索引行)。 */
function seedRow(relPath: string, id: string): string {
const rowId = noteResourceId(id);
db.rows.set(rowId, {
id: rowId,
kind: 'note',
title: id,
relPath,
mtime: 1,
indexedAt: 1,
body: `# ${id}`,
absPath: join(root, 'notes', relPath),
});
return rowId;
}
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-resyncguard-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
db.calls.upsert = 0;
db.calls.del = 0;
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('子目录读不出来 ⇒ 那棵子树里的笔记行必须留下(文件还在磁盘上)', async () => {
const sub = join(notesDir, 'locked');
await fs.mkdir(sub, { recursive: true });
await fs.writeFile(join(sub, 'a.md'), '# A\n\n正文\n', 'utf-8');
const rowId = seedRow('locked/a.md', 'locked-a');
await svc.resyncIfNeeded();
expect(db.rows.has(rowId)).toBe(true);
});
it('深度 > 16 的深层笔记 ⇒ 行必须留下(collectNotePaths 的深度上限盲点)', async () => {
// collectNotePaths 的 MAX_COLLECT_DEPTH = 16:第 17 层目录**不读**
// ⇒ 里面的 .md 扫不到。运行期路径 syncDbWithDisk(r180)早就用
// existsSync 兜住了这一类,启动期路径以前没有。
const deepRel = Array.from({ length: 17 }, (_, i) => `L${i + 1}`).join('/');
const dir = join(notesDir, ...deepRel.split('/'));
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(join(dir, 'deep.md'), '# Deep\n\n正文\n', 'utf-8');
const rowId = seedRow(`${deepRel}/deep.md`, 'deep-note');
await svc.resyncIfNeeded();
expect(db.rows.has(rowId)).toBe(true);
});
it('运行期路径与启动期路径口径一致(同一份文件,两条路都留下)', async () => {
const sub = join(notesDir, 'locked');
await fs.mkdir(sub, { recursive: true });
await fs.writeFile(join(sub, 'a.md'), '# A\n\n正文\n', 'utf-8');
const rowId = seedRow('locked/a.md', 'locked-a');
await svc.load(); // 运行期路径(r180)
expect(db.rows.has(rowId)).toBe(true);
await svc.resyncIfNeeded(); // 启动期路径(r209)
expect(db.rows.has(rowId)).toBe(true);
});
it('真被外部删掉的文件 ⇒ ghost 行仍然被清理(守卫不许把真 ghost 留下)', async () => {
const rel = 'gone.md';
await fs.writeFile(join(notesDir, rel), '# Gone\n\n正文\n', 'utf-8');
const rowId = seedRow(rel, 'gone-note');
// 文件真的没了
await fs.rm(join(notesDir, rel), { force: true });
await svc.resyncIfNeeded();
expect(db.rows.has(rowId)).toBe(false);
});
it('文件「读不到」(stat 报 EACCES)⇒ 行留下(fail-safe:不许把读不到当成没了)', async () => {
// 两个故障叠在一起才算真正走到 fail-safe 分支:
// ① 文件在 'locked' 子树里 ⇒ 扫描看不见它(否则它压根不进 dbOnly 分支);
// ② stat 报 EACCES ⇒ existsSync 会返回 false(把「读不到」压成
// 「没了」)—— 判据必须是「只有 ENOENT 才算没了」。
const sub = join(notesDir, 'locked');
await fs.mkdir(sub, { recursive: true });
const rel = 'locked/unreadable.md';
await fs.writeFile(join(notesDir, ...rel.split('/')), '# Unreadable\n\n正文\n', 'utf-8');
const rowId = seedRow(rel, 'unreadable-note');
await svc.resyncIfNeeded();
expect(db.rows.has(rowId)).toBe(true);
});
});

View File

@@ -0,0 +1,99 @@
// tests/unit/main/notes-scan-parse-cache.test.ts
//
// 守 perf r239「scanNotes 的 stat 门控解析缓存」契约。
//
// 背景(用户诉求「使用流畅,不要有卡顿」):scanNotes 每次都把每个 .md
// open+read+gray-matter 解析一遍。实测(本机,8KB/篇):
// 500 篇 → 124ms;2000 篇 → 410ms。
// 成本拆解(read-only 373ms vs matter 解析 20.7ms @ N=500)证明瓶颈是
// **每文件的 open/read syscall** ⇒ 解法是「先 stat,没变就复用上次解析
// 结果」:500 篇 readdir+stat 全量 ≈ 15ms;2000 篇 ≈ 60ms。
//
// 命中判据 = mtime + size + ino 三因子(与 utils/fs-cache 的 readNote
// 缓存、DB 同步 r220 的双因子口径同源)。
//
// 本测锁三件事:
// 1. 文件没变 ⇒ 第二次扫描**复用同一批解析结果**(不重读磁盘);
// 2. 文件变了 ⇒ 重新解析(拿到新正文)—— 正确性方向,比性能更重要;
// 3. 文件被删 ⇒ 结果里没有它,且缓存条目被剪掉(不留悬挂引用)。
//
// 已知取舍(不锁,锁了就是把 bug 写成契约):「内容变但 mtime 保留且长度
// 相同」判成「没变」。这是全项目一致的双因子盲区(备份还原 / cp -p /
// 同步盘按内容去重),要兜只能全文 hash,代价是每次全量读回 —— 正是
// 本轮要消掉的成本。
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
scanNotes,
clearNoteParseCache,
noteParseCacheSize,
} from '../../../src/main/notes/standalone';
const fm = (title: string, body: string) => `---\ntitle: "${title}"\n---\n\n${body}\n`;
describe('scanNotes 的 stat 门控解析缓存(r239)', () => {
let root: string;
let notesDir: string;
beforeEach(async () => {
clearNoteParseCache();
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notecache-'));
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
});
afterEach(async () => {
clearNoteParseCache();
await fs.rm(root, { recursive: true, force: true });
});
it('文件没变 ⇒ 第二次扫描复用同一批解析结果(不重读磁盘)', async () => {
await fs.writeFile(join(notesDir, 'a.md'), fm('A', 'body A'), 'utf8');
await fs.writeFile(join(notesDir, 'b.md'), fm('B', 'body B'), 'utf8');
const first = await scanNotes(root);
expect(first).toHaveLength(2);
expect(noteParseCacheSize()).toBe(2);
const second = await scanNotes(root);
expect(second).toHaveLength(2);
// 同一批**对象**(命中缓存 ⇒ 没有重新读盘 + 重新解析)
const byRel = new Map(first.map((n) => [n.relPath, n]));
for (const n of second) {
expect(n).toBe(byRel.get(n.relPath));
}
});
it('文件变了 ⇒ 重新解析(拿到新正文,不能拿缓存里的旧值)', async () => {
const p = join(notesDir, 'a.md');
await fs.writeFile(p, fm('A', 'body A'), 'utf8');
const first = await scanNotes(root);
expect(first[0]!.body).toContain('body A');
// 长度也变(size 是命中判据之一)⇒ 必然 cache miss
await fs.writeFile(p, fm('A edited', 'body A edited and longer'), 'utf8');
const second = await scanNotes(root);
expect(second).toHaveLength(1);
expect(second[0]).not.toBe(first[0]);
expect(second[0]!.body).toContain('body A edited and longer');
expect(second[0]!.title).toBe('A edited');
});
it('文件被删 ⇒ 结果里没有它,缓存条目被剪掉(不留悬挂引用)', async () => {
const a = join(notesDir, 'a.md');
const b = join(notesDir, 'b.md');
await fs.writeFile(a, fm('A', 'body A'), 'utf8');
await fs.writeFile(b, fm('B', 'body B'), 'utf8');
await scanNotes(root);
expect(noteParseCacheSize()).toBe(2);
await fs.rm(a);
const second = await scanNotes(root);
expect(second.map((n) => n.relPath)).toEqual(['b.md']);
// 剪枝:被删文件的条目不再占位
expect(noteParseCacheSize()).toBe(1);
});
});

View File

@@ -0,0 +1,196 @@
// tests/unit/main/notes-self-write-echo.test.ts
//
// 守 perf r239「notes 域的自写回声不触发全量重扫」契约。
//
// 问题(用户诉求「使用流畅,不要有卡顿」的直接来源):
// autosave 每 1.5s 落一次盘 → chokidar 'change' → scheduleReload(100ms
// 防抖)→ **全量重扫**(scanNotes 把每个 .md 都 open+read+gray-matter
// 解析一遍)。实测(本机,8KB/篇):500 篇 → 124ms;2000 篇 → 410ms。
// 主进程被占住 ⇒ 整个应用卡顿(不只是列表)。
// 而这次重扫是**纯浪费**:write() 自己已经做完 byId.set + syncResource
// (DB/FTS) + emit('changed'),watcher 的回声只是把同样的状态重新从
// 磁盘读一遍。
//
// 顺带修掉的第二个浪费:原子写的 tmp 文件(`<target>.rkms-a<rand>.tmp`)
// 也会命中 chokidar 'add' ⇒ 旧实现**每次 autosave 多触发一次全量重扫**。
// tmp 后缀不是 .md/.markdown ⇒ 按后缀过滤天然滤掉。
//
// 本测锁三条行为:
// A. 应用自己写盘 ⇒ watcher 回声**不**触发全量重扫。
// B. 外部改文件 ⇒ **照常**触发全量重扫(不能被误吞)。
// C. DB 同步失败时 ⇒ 回声**仍然**触发重扫(自愈不能被优化掉)。
//
// 「有没有发生全量重扫」的可观测口径:scanNotes 的调用次数
// (重扫 = scanNotes,这是 reload() 的唯一入口)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
failUpsert: false,
upsertResource: vi.fn(() => {
if (repo.failUpsert) throw new Error('boom: db write failed');
}),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// 计数「全量重扫」次数 —— 部分 mock:其余导出原样透传。
const scan = vi.hoisted(() => ({ calls: 0 }));
vi.mock('../../../src/main/notes/standalone', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/main/notes/standalone')>();
return {
...actual,
scanNotes: async (...args: Parameters<typeof actual.scanNotes>) => {
scan.calls += 1;
return actual.scanNotes(...args);
},
};
});
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
/** 等到 predicate 成立(或超时)。 */
async function waitFor(predicate: () => boolean, timeoutMs = 8000): Promise<boolean> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 50));
}
return predicate();
}
const NOTE_FM = (title: string) => `---\nid: "x"\ntitle: "${title}"\n---\n\nbody of ${title}\n`;
describe('notes 自写回声不触发全量重扫(r239)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
/** 落一个笔记文件到磁盘(不经过 service ⇒ 算「外部」内容)。 */
async function putFile(name: string, title: string): Promise<string> {
const p = join(notesDir, name);
await fs.writeFile(p, NOTE_FM(title), 'utf8');
return p;
}
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-selfecho-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
await putFile('a.md', 'A');
await putFile('b.md', 'B');
svc = new NotesService();
await svc.start();
// chokidar 的初始扫描是异步的 —— watcher 还没 ready 时发生的变化收不到事件
await new Promise((r) => setTimeout(r, 700));
scan.calls = 0;
repo.failUpsert = false;
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('A. 应用自己写盘 ⇒ 回声不触发全量重扫', { timeout: 20000 }, async () => {
const note = svc.list()[0]!;
await svc.write(note.id, 'new body from the app', 'A2');
// 落盘 → chokidar(awaitWriteFinish 50ms)→ 事件 → 100ms 防抖 → reload
// ⇒ 给足时间,确保「如果没被抑制,重扫一定已经发生」
await new Promise((r) => setTimeout(r, 2500));
expect(scan.calls).toBe(0);
// 写本身必须成功(否则上面的 0 只是因为压根没写成功)
const onDisk = await fs.readFile(join(notesDir, note.relPath), 'utf8');
expect(onDisk).toContain('new body from the app');
});
it('B. 外部改文件 ⇒ 照常触发全量重扫(不能被误吞)', { timeout: 20000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B edited outside'), 'utf8');
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
// 重扫之后内存里的内容必须是外部改后的
await waitFor(() => svc.findById(target.id)?.body.includes('B edited outside') === true);
expect(svc.findById(target.id)?.body).toContain('B edited outside');
});
it('C. DB 同步失败 ⇒ 回声仍触发重扫(自愈不被优化掉)', { timeout: 20000 }, async () => {
const note = svc.list()[0]!;
repo.failUpsert = true;
await svc.write(note.id, 'body that fails to reach the db', 'A3');
// DB 写失败 ⇒ 不打自写标记 ⇒ watcher 回声照常触发全量重扫
// (否则这条笔记的 resources 行永远停在旧正文,搜索搜不到新内容)
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
});
it('D. 往 notes/ 里丢非笔记文件(图片等)⇒ 不触发全量重扫', { timeout: 20000 }, async () => {
await fs.writeFile(join(notesDir, 'screenshot.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
// 等足够久(chokidar 事件 + 100ms 防抖 + reload)
await new Promise((r) => setTimeout(r, 2500));
expect(scan.calls).toBe(0);
// 对照:同目录下的 .md 改动**必须**照常触发(不能被过滤误伤)
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B edited again'), 'utf8');
const fired = await waitFor(() => scan.calls > 0);
expect(fired).toBe(true);
});
it('E. app 写盘后**立刻**被外部改 ⇒ 外部内容必须可见(不得被自写回声吞掉)', { timeout: 30000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
// 1) app 自己写盘 → 记下「写完时的文件指纹」
await svc.write(target.id, 'body written by the APP', 'B-app');
// 2) 紧接着外部工具(Obsidian / 同步盘)改**同一个**文件
await fs.writeFile(join(notesDir, 'b.md'), NOTE_FM('B EXTERNAL EDIT'), 'utf8');
// 3) 等 watcher 事件 + 去抖 + reload 全部发生
await new Promise((r) => setTimeout(r, 3000));
expect(scan.calls, '外部改动必须触发一次全量重扫').toBeGreaterThan(0);
expect(svc.findById(target.id)?.body ?? '', '内存里必须是外部改后的内容').toContain(
'B EXTERNAL EDIT',
);
});
it('F. app 写盘后**立刻**被外部删 ⇒ 删除必须可见(watcher 可能整个吞掉这次删除)', { timeout: 30000 }, async () => {
const target = svc.list().find((n) => n.filename === 'b.md')!;
await svc.write(target.id, 'body written by the APP', 'B-app');
// 外部在 app 写盘后 1ms 内把文件删了。
// 危险点:chokidar 的 awaitWriteFinish 还在等这个文件「大小稳定 50ms」,
// 文件却先没了 ⇒ chokidar 认为「那个待上报的写没了」,**既不发
// 'change' 也不发 'unlink'** ⇒ 外部删除彻底不可见(机器繁忙时必现)。
// 兜底 = 写盘后的**延迟复核**(见 service.scheduleWriteVerify)。
await fs.unlink(join(notesDir, 'b.md'));
await new Promise((r) => setTimeout(r, 3000));
expect(scan.calls, '外部删除必须触发一次全量重扫').toBeGreaterThan(0);
expect(
svc.list().some((n) => n.filename === 'b.md'),
'已被外部删除的笔记不得还留在内存索引里',
).toBe(false);
});
});

View File

@@ -0,0 +1,171 @@
// tests/unit/main/notes-title-fallback-parity.test.ts
//
// 守 r222「无标题笔记的 title 兜底口径」契约。
//
// ── 被守卫的缺陷 ───────────────────────────────────────────────────
// 「笔记没有 fm.title 时显示什么」在代码里曾有**三个口径**:
// ① scan(readNoteHeader,standalone.ts):cleanTitle(fm) ?? stripExt(filename)
// —— 去扩展名的文件名;
// ② read(readNote):cleanTitle(fm) —— 允许 null(编辑器靠 null 显示
// 占位符,且**不能把兜底值写回 fm.title**,这个 null 是语义载体);
// ③ DB 写入(noteResourceOf,service.ts):n.title ?? n.filename
// —— **带 .md 扩展名**!
// ③只在「行来自 readNote/writeNote(title 可为 null)」时触发 ⇒ 无标题
// 笔记每次保存后,resources.title 在 'foo'(scan 写的)与 'foo.md'
// (save 写的)之间**翻转**,FTS SearchHit.title 跟着翻,搜索结果显示
// 带扩展名的标题,与其它条目不一致。
// 另一个同源缺口:cleanTitle 对外部手改的 `title: ""` / 纯空白返回 ''
// —— '' 不是 null,所有 `?? 兜底` 对它失效 ⇒ 列表/DB/搜索出现空标题。
//
// 修法(r222):
// - noteResourceOf 兜底改 stripExt(n.filename),与 scan 同口径;
// - cleanTitle 把空串/纯空白归一为 null(写路径 writeNote 的
// `title.trim() || undefined` 本就写不出空 title,只有外部手改能
// 造出来)。
//
// ── 本测锁四件事 ───────────────────────────────────────────────────
// A. 无标题外部笔记 **save 后** DB title 仍是 stripExt 文件名(修前红:
// HEAD 写成 'foo.md');
// B. fm.title = ''(外部手改)⇒ scan 列表标题走文件名兜底(修前红:
// HEAD 给出空串);
// C. 有 fm.title 的笔记:scan 列表 + save 后 DB title 都不变(回归锚);
// D. 无标题外部笔记 scan 后 DB title = stripExt 文件名(回归锚,scan
// 路径本来就是对的那一边)。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock
// 成内存 Map(与 notes-reload-db-sync.test.ts 同款 harness)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
interface FakeRow {
id: string;
kind: string;
title: string;
relPath: string;
mtime: number;
indexedAt: number;
body: string;
absPath: string;
}
const db = vi.hoisted(() => {
const rows = new Map<string, FakeRow>();
return { rows };
});
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => {
db.rows.set(r.id, { ...r, body, absPath });
}),
deleteResource: vi.fn((id: string) => {
db.rows.delete(id);
}),
listResources: vi.fn((kind?: string) =>
[...db.rows.values()].filter((r) => !kind || r.kind === kind),
),
listResourceRelPaths: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind).map((r) => r.relPath),
),
listStaleIndexIds: vi.fn((kind: string) =>
[...db.rows.values()].filter((r) => r.kind === kind && r.indexedAt === 0).map((r) => r.id),
),
renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => {
db.rows.delete(prevId);
db.rows.set(r.id, { ...r, body, absPath });
}),
}));
// electron 需整体 mock:safe-write 用 shell.trashItem(节点环境没有真回收站)
vi.mock('electron', () => ({
shell: {
trashItem: async (target: string) => {
const { rename } = await import('fs/promises');
const { basename, join: j } = await import('path');
const trashDir = j(target, '..', '..', '.trash');
await fs.mkdir(trashDir, { recursive: true });
await rename(target, j(trashDir, basename(target)));
},
},
}));
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { noteResourceId } from '../../../src/shared/resource-id';
describe('无标题笔记的 title 兜底口径(r222)', () => {
let root: string;
let notesDir: string;
let svc: NotesService;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-notetitle-'));
setRootPath(root);
notesDir = join(root, 'notes');
await fs.mkdir(notesDir, { recursive: true });
db.rows.clear();
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
await fs.rm(root, { recursive: true, force: true });
});
it('A 无标题外部笔记 save 后 DB title 仍是 stripExt 文件名(不带 .md)', async () => {
// 外部丢进来的纯 markdown,没有 frontmatter ⇒ fm.title 缺失
await fs.writeFile(join(notesDir, 'plain.md'), '# 外部笔记\n\n正文\n', 'utf-8');
await svc.load();
const note = svc.list()[0];
expect(note).toBeDefined();
const rowId = noteResourceId(note!.id);
expect(db.rows.get(rowId)?.title).toBe('plain'); // scan 路径(对的那一边)
// 应用内保存一次(不写 title ⇒ preserveExisting 保持无 title)
await svc.write(note!.id, '# 外部笔记\n\n正文,改过了\n');
expect(
db.rows.get(rowId)?.title,
'save 路径的 DB 兜底把扩展名写进 title ⇒ 搜索结果显示 plain.md,与列表的 plain 翻转',
).toBe('plain');
});
it("B fm.title = ''(外部手改)⇒ 列表标题走文件名兜底,不是空串", async () => {
await fs.writeFile(
join(notesDir, 'blank-title.md'),
'---\ntitle: ""\n---\n\n正文\n',
'utf-8',
);
await svc.load();
const note = svc.list()[0];
expect(
note?.title,
"cleanTitle 把 '' 当有效标题返回 ⇒ ?? 兜底失效 ⇒ 列表空标题",
).toBe('blank-title');
// DB 同样不许是空串
expect(db.rows.get(noteResourceId(note!.id))?.title).toBe('blank-title');
});
it('C 有 fm.title 的笔记:列表与 save 后 DB title 都保持 fm.title(回归锚)', async () => {
await fs.writeFile(
join(notesDir, 'titled.md'),
'---\ntitle: 我的标题\n---\n\n正文\n',
'utf-8',
);
await svc.load();
const note = svc.list()[0];
expect(note?.title).toBe('我的标题');
await svc.write(note!.id, '正文,改过了\n');
expect(db.rows.get(noteResourceId(note!.id))?.title).toBe('我的标题');
});
it('D 无标题外部笔记 scan 后 DB title = stripExt 文件名(回归锚)', async () => {
await fs.writeFile(join(notesDir, 'scanned.md'), '正文,连标题行都没有\n', 'utf-8');
await svc.load();
const note = svc.list()[0];
expect(note?.title).toBe('scanned');
expect(db.rows.get(noteResourceId(note!.id))?.title).toBe('scanned');
});
});

View File

@@ -0,0 +1,105 @@
// tests/unit/main/protocol-content-disposition.test.ts
//
// 守卫:rkms:// handler 的 Content-Disposition 必须对**任意文件名字符**安全。
//
// 为什么要有这个守卫(2026-10-20 报障实证):
// 文献「2023 - Huang et al. - ... 1T’ transition metal dichalcogenides.pdf」
// 在应用内打不开(Reader 落「打开失败」错误页),其它 PDF 正常。
// 文件名里的 ’ 是 U+2019(排版右单引号,> U+00FF)。Fetch 规范的 header
// value 必须是 ByteString(每字符 ≤ U+00FF),旧实现把文件名裸拼进
// `inline; filename="${baseName}"` ⇒ Headers.set 当场 throw
// TypeError「Cannot convert argument to a ByteString」⇒ 冒到 handler
// 外层 catch 返 500 ⇒ Reader 探测 fetch 拿 500 ⇒ IO_ERROR 错误页。
// 同库的「2020 - Yu et al. - ... Landauer–Büttiker ...」(– = U+2013)
// 同一根因。任何非拉丁-1 文件名(中文论文.pdf 等)全灭。
// node 实证:Headers().set('Content-Disposition', '... 1T’ ...')
// ⇒ TypeError: character ... has a value of 8217 which is greater than 255。
//
// 修法(RFC 6266 / RFC 5987):
// - filename="..." 兜底段只放 ASCII 可打印字符,越界一律折成 '_'
// - 真实文件名走 filename*=UTF-8''<percent-encoded>,字符集无损
//
// 本测守三件事:
// A. contentDispositionInline 对 U+2019 / 中文 / 引号 / 反斜杠文件名产出
// 合法值:纯 ASCII(ByteString 安全)、可被 Headers.set 接受(真实调用
// Node 全局 Headers,与 handler 运行同款 ByteString 校验)、
// filename* 可无损解回原文件名。
// B. 产出格式符合 RFC 6266:action 是 inline,两段齐全。
// C. source-text 锁:protocol.ts 不再裸拼 `filename="${` 模板(防回退)。
import { readFileSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import { contentDispositionInline } from '@main/utils/content-disposition';
// 与 handler 运行时同款校验:Node 全局 Headers 实现 ByteString 转换,
// 值里有 > U+00FF 字符会 throw(这正是原 bug 的触发器)。
function headersAccept(value: string): boolean {
try {
new Headers().set('Content-Disposition', value);
return true;
} catch {
return false;
}
}
function extFilenameOf(value: string): string | null {
const m = /filename\*=UTF-8''([^;]+)/.exec(value);
return m ? decodeURIComponent(m[1]) : null;
}
describe('contentDispositionInline', () => {
it('U+2019 排版引号文件名:ByteString 安全且无损往返', () => {
const name =
'2023 - Huang et al. - Tight-binding model and quantum transport with disorder for 1T’ transition metal dichalcogenides.pdf';
const v = contentDispositionInline(name);
// ByteString 安全(原 bug 直接 throw)
expect(headersAccept(v)).toBe(true);
// 纯 ASCII
expect([...v].every((c) => c.charCodeAt(0) <= 0x7f)).toBe(true);
// filename* 无损解回
expect(extFilenameOf(v)).toBe(name);
// 兜底段不含原字符
const fallback = /filename="([^"]*)"/.exec(v)?.[1] ?? '';
expect(fallback).not.toContain('’');
});
it('U+2013 / U+00FC 混合文件名(Landauer–Büttiker):同样无损', () => {
const name =
'2020 - Yu et al. - Chebyshev polynomial method to Landauer–Büttiker formula of quantum transport in nanostructures.pdf';
const v = contentDispositionInline(name);
expect(headersAccept(v)).toBe(true);
expect(extFilenameOf(v)).toBe(name);
});
it('中文文件名:全量走 filename*,兜底段不抛', () => {
const name = '拓扑绝缘体综述.pdf';
const v = contentDispositionInline(name);
expect(headersAccept(v)).toBe(true);
expect(extFilenameOf(v)).toBe(name);
});
it('引号 / 反斜杠 / 控制字符:兜底段被清洗,不产生非法 quoted-string', () => {
const name = 'weird"quo\\te\r\nname.pdf';
const v = contentDispositionInline(name);
expect(headersAccept(v)).toBe(true);
// 兜底段里不允许出现未转义引号(会截断 quoted-string)
const fallback = /filename="([^"]*)"/.exec(v)?.[1] ?? '';
expect(fallback).not.toMatch(/["\\\r\n]/);
expect(extFilenameOf(v)).toBe(name);
});
it('普通 ASCII 文件名:两段都在,值原样', () => {
const v = contentDispositionInline('plain paper.pdf');
expect(v.startsWith('inline; ')).toBe(true);
expect(v).toContain('filename="plain paper.pdf"');
expect(extFilenameOf(v)).toBe('plain paper.pdf');
});
it('source-text 锁:protocol.ts 不再裸拼 filename="${ 模板', () => {
const src = readFileSync(join(process.cwd(), 'src/main/protocol.ts'), 'utf-8');
// 旧形态:`inline; filename="${baseName...}"` —— 裸拼 = ByteString throw 回退口
expect(src).not.toContain('filename="${');
expect(src).toContain('contentDispositionInline');
});
});

View File

@@ -0,0 +1,277 @@
// tests/unit/main/rel-path-validation.test.ts
//
// 差分守卫(r186)—— relPath 校验的**四处入口**必须对同一串输入给出
// **同一个判定**,判定来源只有一处(shared/rel-path.ts)。
//
// 背景:仓库里曾有**三份**互不相同的 relPath 校验(shared/rel-path.ts
// 头注有详述):
// A. src/main/ipc/folders.ts requireRelPath / requireParentRelPath
// / requireFolderName
// B. src/main/notes/standalone.ts assertTargetRelPath
// C. src/main/literature/literature-service.ts assertLitTargetRelPath
// 三份规则各说各话,用户能碰到:
// - 磁盘上有一个名字以**空格开头**的夹(Explorer 随手就能建):
// 侧栏**列得出来**(listSubdirs 不做名字过滤)→ 点进去走 A → A 拒收
// → VALIDATION 报错 → 这个夹**永远进不去**;
// - 同一个夹,notes 的 move 走 B → 放行 → 笔记**搬得进去**;
// 文献的 move 走 C → 也放行。
// ⇒ 「应用能往一个它自己不承认存在的夹里搬数据」—— 数据在,界面找不到。
//
// 本测把同一张输入表喂给各入口,要求判定(放行/拒收)**逐条一致**;
// 判定一旦分叉,说明又有人在本入口之外手写了规则。
//
// 判定形态统一为 'ok' | 'reject'(不看错误类型/消息:三处错误类型本就
// 不同 —— ValidationError vs LibraryError —— 那是各文件自己的翻译层)。
//
// 语义分层(shared/rel-path.ts 的设计,本测照此对齐):
// - **path 语义**(指向磁盘上**已存在**的东西):不限段长;根 = 空串,
// 取决于调用点是否 allowRoot。
// → 可比对的三个入口:requireParentRelPath / assertTargetRelPath /
// assertLitTargetRelPath(三者皆 allowRoot=true)。
// - **name 语义**(新建/改名的**单段**名字):段长 ≤ MAX_FOLDER_NAME_CHARS。
// → requireFolderName 与 shared 的 validateFolderName 必须一致。
// - requireRelPath 是**严格 path 语义**(allowRoot=false,拒绝空串),
// 它与上面三个不可直接对齐 —— 单独与 shared 的 validateRelPath(v)
// (同样 allowRoot=false)对齐。
//
// 两条不变量(shared/rel-path.ts 头注):
// I1「能用 name 建的,一定能用 path 进」—— name ⊂ path;
// I2「列得出来的,一定能进得去」—— listSubdirs 用同一份核心规则过滤。
import { describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
vi.mock('electron', () => ({
shell: { trashItem: vi.fn(), showItemInFolder: vi.fn() },
// 本测只碰校验函数,不落盘 —— getPath 不会被真正用到,给个稳定的假路径。
app: { getPath: vi.fn(() => 'C:/tmp/rkms-rel-path-test') },
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
deleteResource: vi.fn(),
listResources: vi.fn(() => []),
upsertResource: vi.fn(),
}));
import {
requireRelPath,
requireFolderName,
requireParentRelPath,
requireNewFolderRelPath,
} from '../../../src/main/ipc/folders';
import { assertTargetRelPath } from '../../../src/main/notes/standalone';
import { assertLitTargetRelPath } from '../../../src/main/literature/literature-service';
import {
validateRelPath,
validateFolderName,
isValidRelPathSegment,
MAX_FOLDER_NAME_CHARS,
} from '../../../src/shared/rel-path';
import { listSubdirs } from '../../../src/main/utils/safe-write';
type Verdict = 'ok' | 'reject';
function verdict(fn: () => unknown): Verdict {
try {
fn();
return 'ok';
} catch {
return 'reject';
}
}
/** 三个「path 语义(allowRoot=true)」入口 —— 必须同进同退。 */
const PATH_ENTRIES: { label: string; check: (v: string) => unknown }[] = [
{ label: 'ipc/folders.requireParentRelPath', check: (v) => requireParentRelPath('ch', v, 'relPath') },
{ label: 'notes/standalone.assertTargetRelPath', check: (v) => assertTargetRelPath(v, 'label') },
{
label: 'literature-service.assertLitTargetRelPath',
check: (v) => assertLitTargetRelPath(v, 'label'),
},
];
/**
* 输入表 —— 覆盖 shared/rel-path.ts 头注列出的每一条规则,以及历史上
* 三份规则**分叉过**的那些串。
*/
const PATH_CASES: { value: string; note: string }[] = [
{ value: 'foo', note: '普通单段' },
{ value: 'foo/bar', note: '两级路径' },
{ value: 'foo/bar/baz', note: '多级路径' },
{ value: ' foo', note: '段首空白 —— 旧 A 拒 / 旧 B、C 放行 ⇒ 统一放行' },
{ value: 'foo ', note: '段尾空白 —— 统一放行' },
{ value: 'a b/c d', note: '路径中间的空格 —— 放行' },
{ value: '资料', note: '中文段' },
{ value: '..', note: '越界段' },
{ value: '../foo', note: '越界段(带子目录)' },
{ value: 'foo/../bar', note: '路径中间的 ..' },
{ value: 'foo..bar', note: '名字里含 ..(不是越界)—— 放行' },
{ value: 'foo/..bar', note: '路径中某段含 .. 但段本身不是 .. —— 放行' },
{ value: '/foo', note: '绝对路径(Unix 形态)' },
{ value: 'C:\\foo', note: '绝对路径(Windows 形态)' },
{ value: 'C:/foo', note: '绝对路径(Windows 混合分隔符)' },
{ value: 'foo//bar', note: '空段(双 /)' },
{ value: 'foo/', note: '结尾多余的 /' },
{ value: '.hidden', note: '隐藏项(点开头)' },
{ value: 'foo/./bar', note: '路径中间的 . 段' },
{ value: 'foo\\bar', note: '反斜杠分段' },
{ value: 'a\u0000b', note: 'NUL 控制字符' },
{ value: 'a\u001fb', note: '其它控制字符' },
{ value: '中文目录', note: '非 ASCII 段' },
{ value: 'a'.repeat(120), note: '超长段(path 语义**不限长** → 放行)' },
];
describe('relPath 校验差分守卫(r186)', () => {
it('三个 path 语义入口对同一串输入判定一致', () => {
for (const { value, note } of PATH_CASES) {
const verdicts = PATH_ENTRIES.map((e) => ({
label: e.label,
v: verdict(() => e.check(value)),
}));
const first = verdicts[0].v;
for (const r of verdicts) {
expect(
r.v,
`入口 ${r.label} 对输入 ${JSON.stringify(value)} 的判定应与 ${verdicts[0].label} 一致`,
).toBe(first);
}
// 判定必须与 shared 的 validateRelPath(allowRoot=true)一致 ——
// 共享模块是唯一真相源,入口只是翻译层。
const sharedVerdict = validateRelPath(value, { allowRoot: true }) === null ? 'ok' : 'reject';
expect(first, `shared 与入口对输入 ${JSON.stringify(value)} 的判定应一致`).toBe(sharedVerdict);
expect(note.length).toBeGreaterThan(0);
}
});
it('根目录 = 空串:allowRoot=true 放行,allowRoot=false 拒收', () => {
for (const e of PATH_ENTRIES) {
expect(verdict(() => e.check(''))).toBe('ok');
}
// requireRelPath 是严格 path 语义(不许把 root 传给 IPC 当路径用)
expect(verdict(() => requireRelPath('ch', '', 'relPath'))).toBe('reject');
expect(validateRelPath('', { allowRoot: false })).not.toBeNull();
expect(validateRelPath('', { allowRoot: true })).toBeNull();
});
it('requireRelPath 与 shared validateRelPath(严格)判定一致', () => {
for (const { value } of PATH_CASES) {
const a = verdict(() => requireRelPath('ch', value, 'relPath'));
const b = validateRelPath(value) === null ? 'ok' : 'reject';
expect(a).toBe(b);
}
});
it('name 语义:requireFolderName 与 shared validateFolderName 判定一致', () => {
const nameCases: string[] = [
'foo',
' foo',
'foo ',
'..',
'.',
'.hidden',
'a/b',
'a\\b',
'a\u0000b',
'中文',
'a..b',
'a'.repeat(MAX_FOLDER_NAME_CHARS),
'a'.repeat(MAX_FOLDER_NAME_CHARS + 1),
];
for (const name of nameCases) {
const a = verdict(() => requireFolderName('ch', name, 'name'));
const b = validateFolderName(name) === null ? 'ok' : 'reject';
expect(`${name.slice(0, 12)}:${a}`).toBe(`${name.slice(0, 12)}:${b}`);
}
});
it('不变量 I1:name 严于 path ⇒ 能建的 name 一定能当 path 用', () => {
const names = ['foo', ' foo', '中文', 'a..b', 'a'.repeat(MAX_FOLDER_NAME_CHARS)];
for (const name of names) {
expect(validateFolderName(name)).toBeNull();
expect(validateRelPath(name, { allowRoot: true })).toBeNull();
}
// 反向不成立(也不该成立):path 放行的超长段,name 语义拒收
expect(validateRelPath('a'.repeat(MAX_FOLDER_NAME_CHARS + 1), { allowRoot: true })).toBeNull();
expect(validateFolderName('a'.repeat(MAX_FOLDER_NAME_CHARS + 1))).not.toBeNull();
});
it('裁决被钉住:历史上分叉的串现在有唯一答案', () => {
// 段首空白:统一放行(旧 A 拒 / 旧 B、C 放行 ⇒ 夹列得出来进不去)
expect(verdict(() => requireParentRelPath('ch', ' 资料', 'p'))).toBe('ok');
expect(verdict(() => assertTargetRelPath(' 资料', 'l'))).toBe('ok');
expect(verdict(() => assertLitTargetRelPath(' 资料', 'l'))).toBe('ok');
expect(verdict(() => requireRelPath('ch', ' 资料', 'p'))).toBe('ok');
// 含 '..' 但不是越界:放行(旧三份都拒 ⇒ 「v1..v2」这类夹列得出来进不去)
expect(verdict(() => requireRelPath('ch', 'v1..v2', 'p'))).toBe('ok');
expect(verdict(() => requireFolderName('ch', 'v1..v2', 'n'))).toBe('ok');
// 真正的越界:仍拒
expect(verdict(() => requireRelPath('ch', '..', 'p'))).toBe('reject');
expect(verdict(() => requireRelPath('ch', '../a', 'p'))).toBe('reject');
expect(verdict(() => requireRelPath('ch', 'a/../b', 'p'))).toBe('reject');
expect(verdict(() => requireParentRelPath('ch', '..', 'p'))).toBe('reject');
});
it('mkdir:叶子段走创建策略(≤100 字符),父段只走路径规则(不限长)', () => {
// 叶子超长 → 拒(app 自己不造超长名字)
expect(verdict(() => requireNewFolderRelPath('ch', 'a'.repeat(MAX_FOLDER_NAME_CHARS + 1), 'p'))).toBe(
'reject',
);
expect(verdict(() => requireNewFolderRelPath('ch', 'a'.repeat(MAX_FOLDER_NAME_CHARS), 'p'))).toBe(
'ok',
);
// 父段超长但已存在于磁盘 → 放行(不能因此拒绝「在里面建子夹」)
expect(
verdict(() => requireNewFolderRelPath('ch', `${'a'.repeat(150)}/b`, 'p')),
).toBe('ok');
});
it('核心段规则是唯一的过滤器:listSubdirs 用同一份判据(不变量 I2)', () => {
// 纯函数层:进不去的名字一律 false
expect(isValidRelPathSegment('正常')).toBe(true);
expect(isValidRelPathSegment(' 资料')).toBe(true);
expect(isValidRelPathSegment('v1..v2')).toBe(true);
expect(isValidRelPathSegment('.hidden')).toBe(false);
expect(isValidRelPathSegment('a\\b')).toBe(false);
expect(isValidRelPathSegment('a\u0000b')).toBe(false);
expect(isValidRelPathSegment('a\u001fb')).toBe(false);
expect(isValidRelPathSegment('..')).toBe(false);
});
});
describe('不变量 I2:列得出来的,一定能进得去(r186)', () => {
it('磁盘上「段首空白」的夹:列得出来,且列出来的 relPath 进得去', async () => {
const root = await fs.mkdtemp(join(tmpdir(), 'rkms-relpath-'));
try {
await fs.mkdir(join(root, 'notes', ' 资料'), { recursive: true });
await fs.mkdir(join(root, 'notes', '正常'), { recursive: true });
await fs.mkdir(join(root, 'notes', '.hidden'), { recursive: true });
const dirs = await listSubdirs(root, 'note', null);
const names = dirs.map((d) => d.name);
expect(names).toContain(' 资料');
expect(names).toContain('正常');
expect(names).not.toContain('.hidden'); // dot 项历来不列
// 关键:列出来的每个 relPath,都必须能被 IPC 的 path 入口接受 ——
// 否则用户点进去只会得到一个 VALIDATION 报错(夹里的笔记在夹视图
// 里看不到 = 数据在、界面找不到)。
for (const d of dirs) {
expect(verdict(() => requireParentRelPath('ch', d.relPath, 'parentRelPath'))).toBe('ok');
}
} finally {
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined);
}
});
it('listSubdirs 的过滤判据来自 shared/rel-path(不另写一份正则)', async () => {
const src = await fs.readFile(
new URL('../../../src/main/utils/safe-write.ts', import.meta.url),
'utf8',
);
// 判据若在 safe-write 里另写一份,两边迟早分叉 —— 分叉的后果就是
// 「列得出来、进不去」的夹(用户可见 bug)。
expect(src).toMatch(/isValidRelPathSegment/);
expect(src).toMatch(/from '\.\.\/\.\.\/shared\/rel-path'/);
});
});

View File

@@ -0,0 +1,281 @@
// tests/unit/main/restore-default-data-path.test.ts
//
// 守卫 r205:「恢复默认数据目录」按钮以前是**死的**。
//
// ── 被守卫的缺陷(用户可见)──────────────────────────────────────────
// 设置页的「恢复默认数据目录」走 IPC SETTINGS_RESTORE_DEFAULT_DATA →
// switchDataPath(defaultDataPath())。而 switchDataPath → changeDataPath
// **要求目标目录为空**(r56 的 HIGH 修复:绝不对用户自有的非空目录 rm -rf):
//
// const existing = await fs.readdir(newPath, …);
// if (existing.length > 0) throw new AppError('VALIDATION',
// `target directory must be empty: ${newPath} (contains N entries)`);
//
// 但默认位置 (<Documents>/ResearchHub/data) 里恰恰躺着**上一次工作区的
// 副本** —— 因为 changeDataPath 是**复制**(旧目录原样保留,r204 头注已
// 记录这一点)。⇒ 用户只要曾经把工作区搬离默认位置,这个按钮就**永久
// 失效**,而且报的是一句用户看不懂的 "target directory must be empty"。
//
// ── 本测锁的契约 ───────────────────────────────────────────────────
// A1/A2 默认目录不存在 / 为空 → 恢复成功(旧行为不变)。
// A3 默认目录**非空**(装着上一次的旧副本)→ 恢复**必须成功**,且:
// - 新默认目录里是**当前**工作区的内容(不是旧副本的);
// - 旧副本**没被删**,只是被改名让开(仍在磁盘上,一个字节都没丢);
// - 旧副本**不在原地**(否则下次恢复又会撞上同一个非空检查)。
// A4 当前工作区**就是**默认目录 → 整个流程 no-op(绝不能把工作区自己
// 让开 —— 那是灾难性数据丢失)。
// A5 当前工作区在默认目录**之内** → 拒绝(同样是「会让开会带走工作区」)。
// A6 恢复失败 → 旧副本仍在 aside 位置,且**错误消息里带出那个路径**
// (用户据此能找回自己的数据)。
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { basename, join } from 'path';
import { tmpdir } from 'os';
const state = vi.hoisted(() => ({
failCopy: false,
writeLocation: vi.fn(async (_p: string) => {}),
listResources: vi.fn(() => [] as unknown[]),
}));
vi.mock('electron', () => ({
app: {
getVersion: () => '9.9.9-test',
getPath: () => 'C:\\fake-user-data',
getName: () => 'rkms-test',
},
shell: {
trashItem: async () => undefined,
},
}));
// location 模块整体 mock:默认路径指向临时目录(真实默认路径在
// <Documents>/ResearchHub/data,测试不可写),writeLocation 不落盘。
//
// ⚠ 临时根必须**每次跑测唯一**(mkdtemp),不能用固定目录名 + 递归删。
// 固定名会让两次并发的跑测(CI 分片 / watch 重跑 / 两个 checkout 同时跑)
// 互删对方正在用的目录树 ⇒ 一片 ENOENT / ENOTEMPTY 假红。
// 实测(2026-10-20):一条命令跑全量、另一条命令单跑本文件 ⇒ 本文件
// 4 例红(ENOENT chmod '…\default\notes' / ENOENT scandir '…\current' /
// ENOTEMPTY rmdir '…\default'),单跑与全量各自都绿。
// 守卫:tests/unit/main/lint/no-fixed-temp-dir.test.ts
const TMP = await fs.mkdtemp(join(tmpdir(), 'rkms-r205-'));
const PATHS = {
current: join(TMP, 'current'),
def: join(TMP, 'default'),
};
vi.mock('../../../src/main/location', () => ({
defaultDataPath: () => PATHS.def,
writeLocation: state.writeLocation,
}));
vi.mock('../../../src/main/db/sqlite', () => ({
closeDB: () => {},
initDB: () => {},
}));
// resources-repo 整体 mock:文献 id 重算(r204)在测试里是空转。
vi.mock('../../../src/main/db/resources-repo', () => ({
listResources: state.listResources,
renameResource: () => {},
}));
vi.mock('../../../src/main/notes/service', () => ({
getNotesService: () => ({
start: async () => {},
stop: async () => {},
}),
}));
vi.mock('../../../src/main/workspace/favorites', () => ({
getFavoritesService: () => ({
start: async () => {},
stop: async () => {},
remapIds: async () => 0,
}),
}));
vi.mock('../../../src/main/literature/literature-service', () => ({
getLiteratureService: () => ({
start: async () => {},
stop: async () => {},
}),
}));
// settings/store:保留**真的** changeDataPath(复制逻辑是被测对象),
//只把与本用例无关的 settings 内存态/DB 初始化 mock 掉。
vi.mock('../../../src/main/settings/store', async (importOriginal) => {
const actual = await importOriginal<
typeof import('../../../src/main/settings/store')
>();
return {
...actual,
changeDataPath: async (newPath: string, currentPath: string) => {
if (state.failCopy) throw new Error('copy failed (test)');
return actual.changeDataPath(newPath, currentPath);
},
initSettings: async () => ({}),
resetSettings: () => ({}),
captureSettingsSnapshot: () => ({}),
};
});
import { getRootPath, setRootPath } from '../../../src/main/utils/safe-write';
import { restoreDefaultDataPath } from '../../../src/main/settings/migration';
async function writeFile(p: string, content: string): Promise<void> {
await fs.mkdir(join(p, '..'), { recursive: true });
await fs.writeFile(p, content, 'utf-8');
}
async function exists(p: string): Promise<boolean> {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
/** 默认目录被「让开」后留下的兄弟目录: <def>.replaced-<ts> */
async function asideDirs(): Promise<string[]> {
const parent = join(PATHS.def, '..');
const prefix = `${basename(PATHS.def)}.replaced-`;
const entries = await fs.readdir(parent);
return entries.filter((e) => e.startsWith(prefix)).map((e) => join(parent, e));
}
async function resetWorkspace(): Promise<void> {
// 清掉上一个用例留下的残骸 —— TMP 名字每次跑测唯一,这里只会清掉本次
// 自己造的东西,不会误伤并发的另一轮跑测。
await fs.rm(TMP, { recursive: true, force: true });
await fs.mkdir(TMP, { recursive: true });
// 当前工作区:一个笔记 + 一份 settings.json(changeDataPath 复制时
// 必须读得到它,读不到会直接失败)
await writeFile(join(PATHS.current, 'notes', 'live.md'), 'live note');
await writeFile(join(PATHS.current, 'settings.json'), '{"rootPath":"x"}');
setRootPath(PATHS.current);
}
describe('恢复默认数据目录(r205)', () => {
beforeEach(async () => {
state.failCopy = false;
state.writeLocation.mockClear();
state.listResources.mockClear();
state.listResources.mockReturnValue([]);
await resetWorkspace();
});
afterEach(() => {
setRootPath(PATHS.current);
});
// 跑完把这次唯一的临时根删掉(不留残骸在系统临时目录里)
afterAll(async () => {
await fs.rm(TMP, { recursive: true, force: true });
});
// === A. 旧行为不变的两条 ===
it('A1 默认目录不存在 → 恢复成功,当前工作区被复制过去', async () => {
await restoreDefaultDataPath();
expect(getRootPath()).toBe(PATHS.def);
expect(await fs.readFile(join(PATHS.def, 'notes', 'live.md'), 'utf-8')).toBe('live note');
expect(state.writeLocation).toHaveBeenCalledWith(PATHS.def);
expect(await asideDirs()).toEqual([]);
});
it('A2 默认目录存在但为空 → 恢复成功', async () => {
await fs.mkdir(PATHS.def, { recursive: true });
await restoreDefaultDataPath();
expect(getRootPath()).toBe(PATHS.def);
expect(await fs.readFile(join(PATHS.def, 'notes', 'live.md'), 'utf-8')).toBe('live note');
});
// === B. 核心:默认目录非空(装着上一次的旧副本) ===
it('A3 默认目录非空 → 恢复成功;新目录是当前工作区;旧副本没丢,只是让开了', async () => {
// 上一次工作区留在默认位置的残骸
await writeFile(join(PATHS.def, 'notes', 'stale.md'), 'stale note');
await writeFile(join(PATHS.def, 'settings.json'), '{"rootPath":"stale"}');
await restoreDefaultDataPath();
// 恢复成功,root 指向默认目录
expect(getRootPath()).toBe(PATHS.def);
// 新默认目录里是**当前**工作区的内容
expect(await fs.readFile(join(PATHS.def, 'notes', 'live.md'), 'utf-8')).toBe('live note');
expect(await exists(join(PATHS.def, 'notes', 'stale.md'))).toBe(false);
// 旧副本**没被删**:让开到兄弟目录,内容还在
const aside = await asideDirs();
expect(aside).toHaveLength(1);
expect(await fs.readFile(join(aside[0], 'notes', 'stale.md'), 'utf-8')).toBe('stale note');
// 旧副本**不在原地**(否则下次恢复又撞上同一个非空检查)
expect(await exists(join(PATHS.def, 'notes', 'stale.md'))).toBe(false);
});
// === C. 两条「绝不能把工作区让开」的防线 ===
it('A4 当前工作区就是默认目录 → no-op(绝不把工作区自己让开)', async () => {
// 工作区**就是**默认目录
await fs.rm(PATHS.current, { recursive: true, force: true });
await writeFile(join(PATHS.def, 'notes', 'live.md'), 'live note');
await writeFile(join(PATHS.def, 'settings.json'), '{"rootPath":"x"}');
setRootPath(PATHS.def);
await restoreDefaultDataPath();
// 原封不动
expect(await fs.readFile(join(PATHS.def, 'notes', 'live.md'), 'utf-8')).toBe('live note');
expect(await asideDirs()).toEqual([]);
expect(state.writeLocation).not.toHaveBeenCalled();
expect(getRootPath()).toBe(PATHS.def);
});
it('A5 当前工作区在默认目录之内 → 拒绝,且默认目录内容一动不动', async () => {
const inner = join(PATHS.def, 'inner');
await writeFile(join(inner, 'notes', 'live.md'), 'live note');
await writeFile(join(PATHS.def, 'notes', 'stale.md'), 'stale note');
setRootPath(inner);
await expect(restoreDefaultDataPath()).rejects.toThrow();
// 默认目录没被让开、没被删
expect(await fs.readFile(join(PATHS.def, 'notes', 'stale.md'), 'utf-8')).toBe('stale note');
expect(await fs.readFile(join(inner, 'notes', 'live.md'), 'utf-8')).toBe('live note');
expect(await asideDirs()).toEqual([]);
});
// === D. 失败路径:不许丢数据,也不许让用户「数据没了」还不知道 ===
it('A6 恢复失败 → 旧副本仍在 aside,错误消息里带出那个路径', async () => {
await writeFile(join(PATHS.def, 'notes', 'stale.md'), 'stale note');
state.failCopy = true;
const err = await restoreDefaultDataPath().then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
const message = (err as Error).message;
const aside = await asideDirs();
expect(aside).toHaveLength(1);
// 旧副本内容还在
expect(await fs.readFile(join(aside[0], 'notes', 'stale.md'), 'utf-8')).toBe('stale note');
// 消息里给出 aside 路径(用户据此能找回自己的数据)
expect(message).toContain(aside[0]);
});
// === E. 不变式:本轮只给「恢复默认」开特例,没有削弱 MIGRATE 的防线 ===
it('A7 用户自选的非空目标目录仍然被拒绝(「目标目录必须为空」没被削弱)', async () => {
const target = join(TMP, 'user-target');
await writeFile(join(target, 'photo.jpg'), 'x');
const { changeDataPath } = await import('../../../src/main/settings/store');
await expect(changeDataPath(target, PATHS.current)).rejects.toThrow(/must be empty/);
// 用户自己目录里的东西一个字节都没动(r56 的 HIGH 不变式)
expect(await fs.readFile(join(target, 'photo.jpg'), 'utf-8')).toBe('x');
});
});

View File

@@ -0,0 +1,187 @@
// tests/unit/main/settings-store-read-failure.test.ts
//
// 守卫:settings.json 的「读失败」与「解析失败」必须**分开处置**。
//
// audit r200 (2026-10-20) 修的 bug(r199 atomic-file 同一 bug 类的上层版):
// src/main/settings/store.ts 的 initSettings 旧实现把
// `readFileCapped(...)` 与 `JSON.parse(...)` 裹在**同一个 try** 里:
// catch → 非 ENOENT / 非 IO_ERROR ⇒ 一律当「文件损坏」处理
// → `fs.rename(settings.json, settings.json.corrupt-<ts>.json)`
// → loaded 保持 {} → currentSettings = DEFAULT_SETTINGS
// ⇒ 除 ENOENT / size-cap 外的**任何读错误**(EACCES 权限 / EPERM /
// EBUSY 被同步盘或杀毒占用 / EIO / EISDIR 路径其实是目录)都会:
// 1. 把用户的 settings.json **改名走**(内容可能完好,只是此刻读不到);
// 2. 让应用带着**默认设置**启动 —— 主题 / 语言 / 布局 / 导入根全丢;
// 3. 零错误提示(用户只看到「设置怎么变回默认了」)。
// 即:一次瞬时读失败 = 用户设置被重置。与本函数自己对 size-cap 的
// 策略注释「让首启崩比『静默接受 defaults 覆盖用户设置』更安全」自相矛盾。
//
// 本文件的 7 例锁住新契约:
// 1. 读失败(EACCES)→ initSettings **抛错**,原文件内容一字不动,
// **不产生 .corrupt-***,不写出新的 settings.json(最关键的一条);
// 2. 读失败(EISDIR:路径其实是目录)→ 抛错,目录原样保留,不被改名走;
// 3. 读失败 → 错误码是 SETTINGS_UNREADABLE(且该 code 在
// IPC_ERROR_CODES union 里 —— 否则 wrap.ts 会 fallback INTERNAL
// **并擦掉 message**,自救指引就传不到用户眼前);
// 4. **parse 失败(真·JSON 损坏)仍然 quarantine**(老契约不能被改坏):
// 坏文件改名保留,内容完整,不抛错;
// 5. ENOENT(首启)→ 走默认值,不产生 .corrupt-*;
// 6. 正常加载 → 用户设置(theme='dark')**原样保留**在盘上;
// 7. 读阶段的瞬时 EBUSY 会被退避重试(而不是被当成「文件损坏」)。
import { mkdtemp, rm, readFile, readdir, stat, writeFile, mkdir } from 'fs/promises';
import { promises as fsPromises } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// electron mock —— store.ts 取 app.getVersion(),safe-write 取 shell,
// location 取 app.getPath('userData')。三个都在模块作用域被 import,
// mock 工厂必须把用到的那几个都摆出来(缺一个就会在 import 期炸)。
vi.mock('electron', () => ({
app: {
getVersion: () => '9.9.9-test',
getPath: () => 'C:\\fake-user-data',
getName: () => 'rkms-test',
},
shell: {
trashItem: async () => undefined,
},
}));
const { initSettings, getSettings } = await import('../../../src/main/settings/store');
const { setRootPath } = await import('../../../src/main/utils/safe-write');
/** 造一个带 errno code 的错误(模拟 EACCES / EBUSY / EISDIR) */
function fsError(code: string, msg: string): NodeJS.ErrnoException {
const e = new Error(msg) as NodeJS.ErrnoException;
e.code = code;
return e;
}
/** dataPath 下的 settings.json 路径 */
function settingsFileOf(dataPath: string): string {
return join(dataPath, 'settings.json');
}
describe('settings store — 读失败 ≠ 文件损坏(r200)', () => {
let tmpRoot: string;
let dataPath: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'settings-store-test-'));
dataPath = join(tmpRoot, 'data');
await mkdir(dataPath, { recursive: true });
// safe-write 的 rootPath 单例:initSettings 末尾的 saveSettings 走
// safeWriteFileAtomic → resolveInsideDataPath → getRootPath()
setRootPath(dataPath);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});
it('读失败(EACCES)→ 抛错 + 原文件内容一字不动 + 不 quarantine + 不写出新 settings.json', async () => {
const filePath = settingsFileOf(dataPath);
const original = JSON.stringify({ theme: 'dark', language: 'en-US' }, null, 2);
await writeFile(filePath, original, 'utf8');
const spy = vi
.spyOn(fsPromises, 'open')
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
await expect(initSettings(dataPath)).rejects.toThrow();
spy.mockRestore();
// 用户的设置**原封不动**留在盘上
expect(await readFile(filePath, 'utf8')).toBe(original);
// 没有被当成「损坏文件」改名走
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
// 没有被「用默认值写一份新的」顶掉
const siblings = await readdir(dataPath);
expect(siblings.filter((n) => n === 'settings.json')).toHaveLength(1);
});
it('读失败(EISDIR:路径其实是目录)→ 抛错 + 目录原样保留 + 不被改名走', async () => {
const filePath = settingsFileOf(dataPath);
await mkdir(filePath); // 目录占位
await expect(initSettings(dataPath)).rejects.toThrow();
// 目录还在原地,类型没变(旧实现会把它 rename 成 .corrupt-*)
expect((await stat(filePath)).isDirectory()).toBe(true);
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
it('读失败 → 错误码是 SETTINGS_UNREADABLE,且该 code 在 IPC_ERROR_CODES union 里', async () => {
const filePath = settingsFileOf(dataPath);
await writeFile(filePath, JSON.stringify({ theme: 'dark' }), 'utf8');
const spy = vi
.spyOn(fsPromises, 'open')
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
const err = await initSettings(dataPath).then(
() => null,
(e: unknown) => e,
);
spy.mockRestore();
expect(err).toBeInstanceOf(Error);
const code = (err as { code?: unknown }).code;
expect(code).toBe('SETTINGS_UNREADABLE');
// union 里的 code 才有意义:wrap.ts 对 union 外的 code 会 fallback
// INTERNAL **并擦掉 message** —— 那条自救指引就传不到用户眼前
const { IPC_ERROR_CODES } = await import('../../../src/shared/types');
expect(IPC_ERROR_CODES as readonly string[]).toContain('SETTINGS_UNREADABLE');
// 消息要能指导用户自救(关掉占用它的程序 / 修权限),而不是「删掉文件」
expect((err as Error).message).toMatch(/left untouched/i);
});
it('parse 失败(真·JSON 损坏)仍然 quarantine(老契约不能被改坏):坏文件改名保留,不抛错', async () => {
const filePath = settingsFileOf(dataPath);
const garbage = '{ this is not json';
await writeFile(filePath, garbage, 'utf8');
// parse 失败**不抛错**(启动继续),而是 quarantine + 走默认值
await expect(initSettings(dataPath)).resolves.toBeDefined();
const siblings = await readdir(dataPath);
const quarantined = siblings.filter((n) => n.startsWith('settings.json.corrupt-'));
expect(quarantined).toHaveLength(1);
// 原件已被改名走,内容完整保留在 .corrupt-* 里
expect(await readFile(join(dataPath, quarantined[0]), 'utf8')).toBe(garbage);
});
it('ENOENT(首启)→ 走默认值,不产生 .corrupt-*', async () => {
await expect(initSettings(dataPath)).resolves.toBeDefined();
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
it('正常加载 → 用户设置(theme)原样保留在盘上', async () => {
const filePath = settingsFileOf(dataPath);
await writeFile(filePath, JSON.stringify({ theme: 'dark' }, null, 2), 'utf8');
await initSettings(dataPath);
expect(getSettings().theme).toBe('dark');
const onDisk = JSON.parse(await readFile(filePath, 'utf8')) as { theme?: string };
expect(onDisk.theme).toBe('dark');
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
it('读阶段的瞬时 EBUSY 被退避重试(而不是被当成「文件损坏」)', async () => {
const filePath = settingsFileOf(dataPath);
await writeFile(filePath, JSON.stringify({ theme: 'dark' }), 'utf8');
const spy = vi
.spyOn(fsPromises, 'open')
.mockImplementationOnce(() => Promise.reject(fsError('EBUSY', 'file is busy')));
await expect(initSettings(dataPath)).resolves.toBeDefined();
spy.mockRestore();
expect(getSettings().theme).toBe('dark');
expect((await readdir(dataPath)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
});

View File

@@ -0,0 +1,168 @@
// tests/unit/main/utils/atomic-file.test.ts
//
// 守卫:AtomicFile 是「用户亲手维护的单文件状态」的通用抽象 —— 本项目里
// 唯一的生产实例就是用户的收藏夹 <root>/workspace/favorites/favorites.md。
// 它是**唯一**的数据保护层(parse 失败 → quarantine;写 → 原子写),
// 之前**没有任何直接测试**(tests/unit/main/utils/ 只有 safe-write)。
//
// audit r199 (2026-10-20) 修的 bug:start() 里 fs.readFile 和 this.parse
// 共用一个 try/catch ⇒ **任何读错误**都被当成「文件损坏」:
// 1. 把用户的 favorites.md rename 成 .corrupt-<ts>(内容可能完好!);
// 2. 内存态回落**空列表**且不抛错 ⇒ 界面上 ★ 全部消失,零错误提示;
// 3. 用户下一次点 ★ → persist 写出一个**新的** favorites.md ⇒
// 原来的 200 条收藏只剩 .corrupt-* 那一份。
// 即:一次瞬时读失败(权限 / 被占用 / 路径其实是目录)= 收藏全丢。
//
// 本文件的 6 例锁住新契约:
// 1. 读失败(EISDIR:路径其实是目录)→ start() 抛错,目录原样保留,
// **不产生 .corrupt-***,后续写入被拒(写锁);
// 2. 读失败(EACCES:权限不足)→ 原文件内容**一字不动**,不 quarantine,
// 写入被拒 —— 这是「用户的 200 条收藏还在盘上」那条最关键的断言;
// 3. **parse 失败仍然 quarantine**(r196 契约不能被 r199 改坏):
// 坏文件被改名保留,内存态回落默认值,之后仍可正常写;
// 4. ENOENT → 写一份 defaultValue 落地(首次运行路径);
// 5. 正常加载 → 内存态 = 解析结果,无 .corrupt-*;
// 6. 读阶段的瞬时 EBUSY 会被退避重试(而不是被当成「损坏」)。
import { mkdtemp, rm, readFile, readdir, stat, writeFile, mkdir } from 'fs/promises';
import { promises as fsPromises } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AtomicFile } from '../../../../src/main/utils/atomic-file';
interface Shape {
entries: string[];
}
const DEFAULT_VALUE: Shape = { entries: [] };
function makeFile(filePath: string): AtomicFile<Shape> {
return new AtomicFile<Shape>({
filePath,
defaultValue: DEFAULT_VALUE,
serialize: (v) => JSON.stringify(v),
parse: (raw) => {
const p = JSON.parse(raw) as { entries?: unknown };
if (!Array.isArray(p.entries)) throw new Error('entries must be an array');
return { entries: p.entries as string[] };
},
logScope: 'atomic-file-test',
});
}
/** 造一个带 errno code 的错误(模拟 EACCES / EBUSY / EISDIR) */
function fsError(code: string, msg: string): NodeJS.ErrnoException {
const e = new Error(msg) as NodeJS.ErrnoException;
e.code = code;
return e;
}
describe('atomic-file', () => {
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'atomic-file-test-'));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});
it('读失败(EISDIR:路径其实是目录)→ 抛错 + 目录原样保留 + 不 quarantine + 写入被拒', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await mkdir(filePath); // 目录占位 → readFile 必然失败
const file = makeFile(filePath);
await expect(file.start()).rejects.toThrow();
// 文件还在原地(没被 rename 走),类型也没变
expect((await stat(filePath)).isDirectory()).toBe(true);
// 没有产生任何 .corrupt-* 兄弟文件
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
// 写锁生效:★ toggle 不能把用户数据覆盖成空态
await expect(file.write({ entries: ['x'] })).rejects.toThrow(/Refusing to overwrite/);
});
it('读失败(EACCES:权限不足)→ 原文件内容一字不动,写入被拒', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const original = JSON.stringify({ entries: ['a', 'b', 'c'] });
await writeFile(filePath, original, 'utf8');
const file = makeFile(filePath);
const spy = vi
.spyOn(fsPromises, 'readFile')
.mockImplementationOnce(() => Promise.reject(fsError('EACCES', 'permission denied')));
await expect(file.start()).rejects.toThrow();
spy.mockRestore();
// 用户的收藏内容**原封不动**留在盘上
expect(await readFile(filePath, 'utf8')).toBe(original);
// 没有被当成「损坏文件」改名走
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
// 写锁生效:后续 toggle 不会把这份完好数据覆盖掉
await expect(file.write({ entries: ['x'] })).rejects.toThrow(/Refusing to overwrite/);
expect(await readFile(filePath, 'utf8')).toBe(original);
});
it('parse 失败仍然 quarantine(r196 契约):坏文件改名保留,内存态回落默认值,之后可正常写', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const garbage = 'this is not json at all';
await writeFile(filePath, garbage, 'utf8');
const file = makeFile(filePath);
// parse 失败**不抛错**(启动继续),而是 quarantine + 回落默认值
await expect(file.start()).resolves.toBeUndefined();
expect(file.get()).toEqual(DEFAULT_VALUE);
const siblings = await readdir(tmpRoot);
const quarantined = siblings.filter((n) => n.startsWith('favorites.md.corrupt-'));
expect(quarantined).toHaveLength(1);
// 原件已被改名走,内容完整保留在 .corrupt-* 里
expect(await readFile(join(tmpRoot, quarantined[0]), 'utf8')).toBe(garbage);
await expect(stat(filePath)).rejects.toThrow();
// quarantine 成功 ⇒ 没有锁写(数据已经安全留存)
await file.write({ entries: ['fresh'] });
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ entries: ['fresh'] });
});
it('ENOENT(首次运行)→ 写一份 defaultValue 落地,内存态 = 默认值', async () => {
const filePath = join(tmpRoot, 'favorites.md');
const file = makeFile(filePath);
await file.start();
expect(file.get()).toEqual(DEFAULT_VALUE);
expect((await stat(filePath)).isFile()).toBe(true);
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(DEFAULT_VALUE);
});
it('正常加载 → 内存态 = 解析结果,无 .corrupt-*', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await writeFile(filePath, JSON.stringify({ entries: ['x', 'y'] }), 'utf8');
const file = makeFile(filePath);
await file.start();
expect(file.get()).toEqual({ entries: ['x', 'y'] });
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
it('读阶段的瞬时 EBUSY 被退避重试(而不是被当成「文件损坏」)', async () => {
const filePath = join(tmpRoot, 'favorites.md');
await writeFile(filePath, JSON.stringify({ entries: ['x'] }), 'utf8');
const file = makeFile(filePath);
const spy = vi
.spyOn(fsPromises, 'readFile')
.mockImplementationOnce(() => Promise.reject(fsError('EBUSY', 'file is busy')));
await expect(file.start()).resolves.toBeUndefined();
spy.mockRestore();
expect(file.get()).toEqual({ entries: ['x'] });
expect((await readdir(tmpRoot)).filter((n) => n.includes('.corrupt-'))).toHaveLength(0);
});
});

View File

@@ -0,0 +1,72 @@
// tests/unit/main/utils/errno-code.test.ts
//
// 守 errnoCode 的收窄契约(2026-10-20,r201):
//
// unknown → string | undefined,判据是**own property** 且值是 string。
//
// 为什么是 helper 而不是 inline `(err as { code?: unknown }).code`:
// inline 让 code 保持 unknown,`code === 'ENOENT'` 在 number / Symbol / null
// 上都严格不等 —— 运行时行为其实**等价**,但契约不显式;helper 把
// "不是 string 的 code 一律落 undefined"这条写成类型,读者不用再推。
//
// 本文件锁三条:
// 1. own string code → 原样返回;
// 2. 非 string code(number / null / undefined / Symbol / 对象)→ undefined;
// 3. **原型链上的 code 不算** —— 契约写的是 own property,`Object.prototype`
// 上挂一个 code 就让所有对象都"有 code"是经典事故。
//
// 同族:errPayloadStr → tests/unit/main/utils/error-payload-str.test.ts
// error.ts 全量 → tests/unit/utils/error.test.ts
import { describe, expect, it } from 'vitest';
import { errnoCode } from '../../../../src/main/utils/error';
describe('errnoCode — own string code 收窄(r201)', () => {
it('own string code → 原样返回', () => {
expect(errnoCode({ code: 'ENOENT' })).toBe('ENOENT');
expect(errnoCode({ code: 'EACCES' })).toBe('EACCES');
expect(errnoCode({ code: '' })).toBe('');
});
it('own code 但不是 string → undefined', () => {
expect(errnoCode({ code: 42 })).toBeUndefined();
expect(errnoCode({ code: null })).toBeUndefined();
expect(errnoCode({ code: undefined })).toBeUndefined();
expect(errnoCode({ code: Symbol('E') })).toBeUndefined();
expect(errnoCode({ code: { nested: true } })).toBeUndefined();
});
it('没有 code 字段 → undefined', () => {
expect(errnoCode({})).toBeUndefined();
expect(errnoCode({ errno: -2 })).toBeUndefined();
});
it('原型链上的 code → undefined(own property 收紧)', () => {
// 契约写的是 own property。`Object.prototype.code` 一旦被挂上,
// 用 `'code' in err` 判据会让**每一个**对象都"有 code"。
const proto = { code: 'INHERITED' } as Record<string, unknown>;
const child = Object.create(proto) as Record<string, unknown>;
expect(errnoCode(child)).toBeUndefined();
expect(errnoCode(Object.create({}) as Record<string, unknown>)).toBeUndefined();
});
it('null-proto 对象上的 own code 仍能被读到(hasOwnProperty.call 兼容)', () => {
const np = Object.create(null) as Record<string, unknown>;
np.code = 'ENOENT';
expect(errnoCode(np)).toBe('ENOENT');
});
it('非对象(字符串 / 数字 / null / undefined / 布尔)→ undefined', () => {
expect(errnoCode('ENOENT')).toBeUndefined();
expect(errnoCode(42)).toBeUndefined();
expect(errnoCode(null)).toBeUndefined();
expect(errnoCode(undefined)).toBeUndefined();
expect(errnoCode(true)).toBeUndefined();
});
it('NodeJS.ErrnoException 形态(真实 fs 错误)→ code 原样', () => {
const e = new Error('ENOENT: no such file') as NodeJS.ErrnoException;
e.code = 'ENOENT';
expect(errnoCode(e)).toBe('ENOENT');
});
});

View File

@@ -0,0 +1,89 @@
// tests/unit/main/utils/error-payload-str.test.ts
//
// 守 errPayloadStr 的单行日志契约(2026-10-20,r201):
//
// unknown → string,永不 undefined、永不 throw。
//
// 它是 errPayload 的"字符串版",直接喂 log.error / IPC 错误消息。关键点:
// 1. Error 有 stack → "message\nstack";无 stack → 只有 message。
// 2. cause 链拼成 "…\nCaused by: …",让根因在日志里**可见**
// (AppError / ValidationError 都用 { cause } 把根因传下去)。
// 3. cause 为 null / undefined → 不拼,避免 "Caused by: null" 噪音。
// 4. cause 环引用 → 截断成 '[Circular cause]',**不**无限递归。
// 5. 永不返回 undefined —— toast / dialog 渲染要稳定。
//
// 同族:errnoCode → tests/unit/main/utils/errno-code.test.ts
// error.ts 全量 → tests/unit/utils/error.test.ts
import { describe, expect, it } from 'vitest';
import { errPayloadStr } from '../../../../src/main/utils/error';
describe('errPayloadStr — 单行日志串(r201)', () => {
it('Error 带 stack → message + "\\n" + stack', () => {
const e = new Error('boom');
const s = errPayloadStr(e);
expect(s).toBe(`boom\n${e.stack}`);
expect(s.startsWith('boom\n')).toBe(true);
});
it('Error 无 stack → 只有 message', () => {
const e = new Error('boom');
Object.defineProperty(e, 'stack', { value: undefined, configurable: true });
expect(errPayloadStr(e)).toBe('boom');
});
it('字符串原样返回', () => {
expect(errPayloadStr('plain')).toBe('plain');
});
it('null / undefined → 字面量,永不 undefined', () => {
expect(errPayloadStr(null)).toBe('null');
expect(errPayloadStr(undefined)).toBe('undefined');
expect(typeof errPayloadStr(new Error('x'))).toBe('string');
});
it('普通对象 → [object Object]', () => {
expect(errPayloadStr({})).toBe('[object Object]');
});
it('null-proto 对象不炸(降级链兜住)', () => {
expect(() => errPayloadStr(Object.create(null))).not.toThrow();
expect(errPayloadStr(Object.create(null))).toBe('[object Object]');
});
it('cause 链被拼成 "Caused by:" 序列', () => {
const a = new Error('A');
const b = new Error('B', { cause: a });
const c = new Error('C', { cause: b });
const s = errPayloadStr(c);
expect(s.startsWith('C\n')).toBe(true);
expect(s).toContain('Caused by: B\n');
expect(s).toContain('Caused by: A\n');
});
it('cause === null → 不拼 "Caused by: null"', () => {
const e = new Error('x', { cause: null });
expect(errPayloadStr(e)).not.toContain('Caused by');
});
it('cause 环引用 → 截断为 [Circular cause],不栈溢出', () => {
const e = new Error('loop') as Error & { cause?: unknown };
e.cause = e;
let s = '';
expect(() => {
s = errPayloadStr(e);
}).not.toThrow();
expect(s).toContain('[Circular cause]');
});
it('互相引用的两节点环 → 同样终止', () => {
const a = new Error('A') as Error & { cause?: unknown };
const b = new Error('B', { cause: a });
a.cause = b;
let s = '';
expect(() => {
s = errPayloadStr(a);
}).not.toThrow();
expect(s).toContain('[Circular cause]');
});
});

View File

@@ -0,0 +1,129 @@
// tests/unit/main/utils/file-check.test.ts
//
// 守 assertFileExists 的**错误分类**契约(2026-10-20,r201):
//
// fs.open('r') 失败时的错误码必须区分:
// ENOENT / 无 code → FILE_MISSING("文件不存在")
// 其它( EACCES / EPERM / EISDIR / EBUSY …)→ IO_ERROR
//
// 为什么这条分类是行为面(不是风格):调用方(notes 的"在文件夹中显示"、文献
// 的 open 路径)据此决定 UI 文案与是否可重试。旧实现一律 FILE_MISSING,
// 权限问题被错报成"文件不存在",用户不停 retry,真实根因被埋没 —— r33 修过
// 一次(r33 只分了 ENOENT / 其它两支),r96 又把"拿不到 code"这一支从
// IO_ERROR 改回 FILE_MISSING(保守:不认识 → 当成不存在)。
//
// 本文件锁住 r96 之后的三支语义 —— 源码注释里明确写着"摘 !code 分支必红":
// 1. ENOENT → FILE_MISSING
// 2. 错误对象**没有** code → FILE_MISSING(保守兜底)
// 3. EACCES 等其它 code → IO_ERROR
// 4. 空路径 → FILE_MISSING
// 5. 打开成功 → 句柄一定被 close(不泄漏 fd)
import { promises as fsPromises } from 'fs';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { assertFileExists } from '../../../../src/main/utils/file-check';
import { createLogger } from '../../../../src/main/utils/logger';
const log = createLogger('file-check-test');
/** 造一个带 errno code 的错误(模拟 EACCES / EPERM / EISDIR / EBUSY) */
function fsError(code: string, msg: string): NodeJS.ErrnoException {
const e = new Error(msg) as NodeJS.ErrnoException;
e.code = code;
return e;
}
/** 取 LibraryError 的 code */
function codeOf(e: unknown): string | undefined {
return (e as { code?: string }).code;
}
describe('assertFileExists — 错误分类(r201)', () => {
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(join(tmpdir(), 'rkms-filecheck-'));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpRoot, { recursive: true, force: true });
});
it('空路径 → FILE_MISSING(不发起 syscall)', async () => {
const spy = vi.spyOn(fsPromises, 'open');
const err = await assertFileExists('', '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect(codeOf(err)).toBe('FILE_MISSING');
expect(spy).not.toHaveBeenCalled();
});
it('文件存在 → 正常 resolve', async () => {
const p = join(tmpRoot, 'there.md');
await writeFile(p, '# hi', 'utf-8');
await expect(assertFileExists(p, '文件不存在', log)).resolves.toBeUndefined();
});
it('文件不存在(ENOENT)→ FILE_MISSING,且消息带上 errno 文本便于排查', async () => {
const p = join(tmpRoot, 'missing.md');
const err = await assertFileExists(p, '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(Error);
expect(codeOf(err)).toBe('FILE_MISSING');
expect((err as Error).message).toContain('文件不存在');
expect((err as Error).message).toMatch(/ENOENT|no such file/i);
});
it('无 code 的错误 → FILE_MISSING(r96 保守兜底:不认识就当不存在)', async () => {
const spy = vi
.spyOn(fsPromises, 'open')
.mockImplementationOnce(() => Promise.reject(new Error('weird failure')) as never);
const err = await assertFileExists(join(tmpRoot, 'x.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('FILE_MISSING');
expect(spy).toHaveBeenCalledTimes(1);
});
it('EACCES → IO_ERROR(不是 FILE_MISSING)', async () => {
vi.spyOn(fsPromises, 'open').mockImplementationOnce(
() => Promise.reject(fsError('EACCES', 'permission denied')) as never,
);
const err = await assertFileExists(join(tmpRoot, 'x.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('IO_ERROR');
});
it('EISDIR(路径其实是目录)→ IO_ERROR', async () => {
vi.spyOn(fsPromises, 'open').mockImplementationOnce(
() => Promise.reject(fsError('EISDIR', 'illegal operation on a directory')) as never,
);
const err = await assertFileExists(join(tmpRoot, 'dir.md'), '文件不存在', log).then(
() => null,
(e: unknown) => e,
);
expect(codeOf(err)).toBe('IO_ERROR');
});
it('打开成功 → 句柄一定被 close(不泄漏 fd)', async () => {
const close = vi.fn(async () => undefined);
const fakeFh = { close } as unknown as Awaited<ReturnType<typeof fsPromises.open>>;
vi.spyOn(fsPromises, 'open').mockImplementationOnce((() =>
Promise.resolve(fakeFh)) as unknown as typeof fsPromises.open);
await expect(
assertFileExists(join(tmpRoot, 'ok.md'), '文件不存在', log),
).resolves.toBeUndefined();
expect(close).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,148 @@
// tests/unit/main/utils/migrate-old-userdata-partial-copy.test.ts
//
// 行为锁(audit-r243,2026-10-21):**跨盘搬迁(copy fallback)失败时,不能留下
// 「半份目标副本」** —— 留了就会在下次启动把 source 端原件删掉,永久丢数据。
//
// ── 缺陷链路(旧实现)────────────────────────────────────────────────
// 1. 首次启动:userData 跨盘(OneDrive 重定向 / appData 挪到 D 盘)
// ⇒ fs.rename 抛 EXDEV ⇒ 走 copy fallback。
// 2. copy 中途失败(ENOSPC / EIO / 断电)⇒ newPath 留下**半份**拷贝
// (文件分支:截断文件;目录分支:部分子树)。旧实现只记一条 warning。
// 3. 下次启动:moveItem 先 lstat(newPath) ⇒ **存在** ⇒ 返回
// {ok:true, skipped} ⇒ 没有 warning ⇒ hasFailure 仍为 false ⇒
// itemsHandled 满 ⇒ 启动流程判定「新端已有对应副本」⇒
// **删掉 source 端原件**。
// ⇒ 用户原始 settings.json / location.json 只剩那份截断副本 —— 永久丢数据。
//
// ── 修法 ────────────────────────────────────────────────────────────
// copy 失败时把目标端残留删掉 ⇒ 下次启动「目标端不存在」⇒ 重新从 source
// 搬 ⇒ source 始终是权威副本。
//
// ── 本测怎么造出 EXDEV + 中途失败 ───────────────────────────────────
// Windows 上没法真造跨盘 rename,故 mock 'fs' 的 promises:
// · rename 一律抛 EXDEV(逼代码走 copy fallback)
// · copyFile / cp 造出「目标端半份残留」后抛 ENOSPC
// 然后跑两轮迁移:第一轮失败、第二轮恢复正常 ⇒ 断言 source 在第一轮后
// 仍在、第二轮才被删(旧实现:第一轮就删了)。
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'fs/promises';
import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const flags = vi.hoisted(() => ({ failCopy: true }));
vi.mock('electron', () => ({
app: { getPath: () => '' },
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
const real = actual.promises;
// audit-r253 (2026-10-21):`typeof import('fs')` 里**没有** default 属性
// (@types/node 的 fs 是 CJS 导出,default 由 esModuleInterop 合成,
// 不体现在模块命名空间的类型上)⇒ 直接写 actual.default 是 TS2339。
// 这里显式把「可能有 default」的形状加回去,运行时语义不变。
const withDefault = actual as typeof actual & { default?: typeof actual };
return {
...actual,
default: withDefault.default ?? actual,
promises: {
...real,
// 跨盘:rename 一律 EXDEV ⇒ 逼代码走 copy fallback
rename: async (oldPath: string, newPath: string) => {
const err = new Error('EXDEV: cross-device link not permitted') as NodeJS.ErrnoException;
err.code = 'EXDEV';
void oldPath;
void newPath;
throw err;
},
// 中途失败:先在目标端造出半份残留,再抛 ENOSPC
copyFile: async (src: string, dest: string) => {
if (flags.failCopy) {
await real.writeFile(dest, 'PARTIAL', 'utf8');
const err = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException;
err.code = 'ENOSPC';
void src;
throw err;
}
return real.copyFile(src, dest);
},
cp: async (src: string, dest: string, opts?: unknown) => {
if (flags.failCopy) {
await real.mkdir(dest, { recursive: true });
await real.writeFile(join(dest, 'partial.bin'), 'PARTIAL', 'utf8');
const err = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException;
err.code = 'ENOSPC';
void src;
void opts;
throw err;
}
return real.cp(src, dest, opts as never);
},
},
};
});
describe('migrateUserDataAppItems:跨盘 copy 失败不留半份残留(audit-r243)', () => {
let oldUserData: string;
let newUserData: string;
beforeEach(async () => {
vi.resetModules();
flags.failCopy = true;
oldUserData = await mkdtemp(join(tmpdir(), 'rkms-oldud-'));
newUserData = await mkdtemp(join(tmpdir(), 'rkms-newud-'));
// 老 userData 里放 3 个应用文件(闸门要求「每个 item 都被处理过」)
await writeFile(join(oldUserData, 'location.json'), '{"root":"C:/data"}', 'utf8');
await writeFile(join(oldUserData, 'settings.json'), '{"theme":"dark"}', 'utf8');
await mkdir(join(oldUserData, 'index'), { recursive: true });
await writeFile(join(oldUserData, 'index', 'a.txt'), 'A', 'utf8');
});
afterEach(async () => {
vi.resetModules();
await rm(oldUserData, { recursive: true, force: true });
await rm(newUserData, { recursive: true, force: true });
});
it('copy 失败后:source 原件仍在 + 目标端无半份残留(下次启动能重试)', async () => {
const { migrateUserDataAppItems } = await import(
'../../../../src/main/utils/migrate-old-userdata'
);
const report = await migrateUserDataAppItems(oldUserData, newUserData);
// ① 搬失败 ⇒ 必须有 warning(闸门据此保留 source)
expect(report.warnings.length, 'copy 失败却没有 warning').toBeGreaterThan(0);
// ② source 原件必须还在
expect(existsSync(join(oldUserData, 'settings.json')), 'source 被删了 ⇒ 丢数据').toBe(true);
expect(existsSync(join(oldUserData, 'location.json')), 'source 被删了 ⇒ 丢数据').toBe(true);
// ③ 目标端**不能**留半份残留(旧实现的致命点)
expect(
existsSync(join(newUserData, 'settings.json')),
'目标端留了半份残留 ⇒ 下次启动会把它当「已搬完」并删掉 source',
).toBe(false);
expect(existsSync(join(newUserData, 'location.json')), '目标端留了半份残留').toBe(false);
});
it('第二轮(copy 恢复正常)⇒ 正常搬走并删 source(证明第一轮没误删、也没卡死)', async () => {
const { migrateUserDataAppItems } = await import(
'../../../../src/main/utils/migrate-old-userdata'
);
await migrateUserDataAppItems(oldUserData, newUserData);
// 第一轮之后 source 必须还在(否则就没得搬了)
expect(existsSync(join(oldUserData, 'settings.json'))).toBe(true);
flags.failCopy = false;
const report2 = await migrateUserDataAppItems(oldUserData, newUserData);
// 第二轮:搬成功 ⇒ 目标端有完整副本
const copied = await readFile(join(newUserData, 'settings.json'), 'utf8');
expect(copied).toBe('{"theme":"dark"}');
// 搬完才删 source(跨盘走的是 copy + rm 分支,source 在 moveItem 里就被删了)
expect(existsSync(join(oldUserData, 'settings.json')), '搬成功后 source 仍留着').toBe(false);
expect(report2.warnings.length, '第二轮不该再有 warning').toBe(0);
// 目录项也搬过去了
expect(existsSync(join(newUserData, 'index', 'a.txt'))).toBe(true);
});
});

View File

@@ -0,0 +1,122 @@
// tests/unit/main/utils/safe-write-trash-symlink.test.ts
//
// 行为锁(audit-r243,2026-10-21):**删文件夹时不得把 symlink 的目标也删掉**。
//
// ── 缺陷 ────────────────────────────────────────────────────────────
// safe-write.ts 的 trashDirContents(递归删目录内容的内部 helper)在遇到
// 子项 symlink 时走的是 `shell.trashItem(child)`。而 shell.trashItem 在
// Windows 上 **follow symlink** —— 同一文件里 safeTrash 的头注(r151)明明白白
// 写着这件事,并因此对 symlink 直接抛 UNSAFE_WRITE。
// trashDirContents 却反着来:旧注释说「lstat 已经挡过 planted 越界」——
// **假的**:上面 lstat 的是**父目录**,子项 symlink 从没被 lstat 过。
//
// 实证后果:用户在 literature/links/ 放一个指向家目录某个 PDF 的 symlink,
// 再「删除文件夹 links/」⇒ **dataPath 外的那个 PDF 被移进回收站**。
// 越界删除(用户只说删文件夹,没说删文件夹里链接指向的东西)。
//
// ── 修法 ────────────────────────────────────────────────────────────
// symlink 子项不再走 shell.trashItem,改用 fs.rm(force+recursive):
// rm 对 symlink 只摘**链接本身**,不 follow(unlink 语义),
// 链接指向的目标留在原处(Windows 目录 junction 实测:目标仍在)。
//
// ── 本测怎么「让假实现露馅」──────────────────────────────────────────
// mock 的 shell.trashItem **模拟 Windows 的真实语义**:对传入路径做
// realpath(跟随 symlink)再把**目标**搬进假回收站。
// ⇒ 旧实现下,外部文件会被搬走(断言挂);新实现下,shell.trashItem 根本
// 不会收到 symlink 路径,外部文件原地不动。
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'fs/promises';
import { existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const trashCalls: string[] = [];
// mock electron:trashItem 模拟 Windows 真实语义 —— follow symlink 后搬走**目标**
vi.mock('electron', () => ({
shell: {
trashItem: vi.fn(async (target: string) => {
trashCalls.push(target);
// 真实 Windows 行为:跟随 symlink,搬走的是**目标**
const { realpath, rename } = await import('fs/promises');
const { join: j, basename: b } = await import('path');
const real = await realpath(target);
const trashDir = j(real, '..', '..', '.fake-trash');
await mkdir(trashDir, { recursive: true });
await rename(real, j(trashDir, b(real)));
return true;
}),
},
}));
describe('safeRecursiveTrash 不 follow symlink(audit-r243)', () => {
let dataRoot: string;
let outsideDir: string;
let outsideFile: string;
beforeEach(async () => {
vi.resetModules();
trashCalls.length = 0;
dataRoot = await mkdtemp(join(tmpdir(), 'rkms-trash-link-'));
outsideDir = await mkdtemp(join(tmpdir(), 'rkms-outside-'));
outsideFile = join(outsideDir, 'tax-return.pdf');
await writeFile(outsideFile, 'PRECIOUS', 'utf8');
});
afterEach(async () => {
vi.resetModules();
await rm(dataRoot, { recursive: true, force: true });
await rm(outsideDir, { recursive: true, force: true });
});
it('目录里的 symlink 只摘链接本身,dataPath 外的目标文件原地不动', async () => {
const { setRootPath, safeRecursiveTrash } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(dataRoot);
const dir = join(dataRoot, 'literature', 'links');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, 'real.pdf'), 'REAL', 'utf8');
const link = join(dir, 'evil.pdf');
try {
await symlink(outsideFile, link, 'file');
} catch (err: unknown) {
// 没开开发者模式 / 无符号链接权限 ⇒ 本机无法构造前提,跳过
const code = (err as { code?: string }).code;
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOSYS') {
return;
}
throw err;
}
await safeRecursiveTrash(join(dataRoot, 'literature', 'links'));
// ① 外部目标文件必须还在(旧实现:被 shell.trashItem follow 搬走)
expect(existsSync(outsideFile), 'dataPath 外的目标文件被删了 ⇒ 越界删除').toBe(true);
// ② symlink 本身被摘掉(链接不再是链接 / 已不存在)
expect(existsSync(link), 'symlink 本身没被摘掉').toBe(false);
// ③ shell.trashItem 不得收到 symlink 路径(旧实现会收到)
expect(
trashCalls.some((p) => p === link),
`shell.trashItem 收到了 symlink 路径:${trashCalls.join(', ')}`,
).toBe(false);
});
it('目录里的普通文件仍走回收站(别把修复做成「什么都不删」)', async () => {
const { setRootPath, safeRecursiveTrash } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(dataRoot);
const dir = join(dataRoot, 'literature', 'plain');
await mkdir(dir, { recursive: true });
const realFile = join(dir, 'a.pdf');
await writeFile(realFile, 'REAL', 'utf8');
await safeRecursiveTrash(join(dataRoot, 'literature', 'plain'));
expect(trashCalls.length, '普通文件没走 trashItem').toBeGreaterThan(0);
expect(existsSync(realFile), '普通文件还在(没被搬走)').toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
// tests/unit/main/utils/safe-write.test.ts
//
// 守卫:safe-write 是主进程所有写操作的唯一入口(rootPath 边界检查)。
// 写的对错决定用户数据安全 —— UNSAFE_WRITE 误抛会让笔记无法保存;
// 反之漏抛会写到 rootPath 外,污染用户家目录。
// 4 例守最关键契约:
// 1. setRootPath + getRootPath round-trip,未初始化抛 INTERNAL。
// 2. safeWriteFileAtomic 写进 rootPath 内的子目录 OK(注意是相对路径,
// 函数内部 resolve(dp, target))。
// 3. 试图写绝对路径到 rootPath 外抛 UnsafeWriteError(code='UNSAFE_WRITE')。
// 4. ENAMETOOLONG-style 超长单段名显式 reject(而非让 fs 抛)。当前
// safeRecursiveTrash 等走 resolveInsideDataPath 链,parent UNSAFE
// 入口撞 ENAMETOOLONG 应被显式 catch 转为 UNSAFE_WRITE;此处简化
// 验证「safeWriteFileAtomic 接受短名成功、文件名超长被拒」的形状。
//
// 历史:audit-r164 N4 (2026-10-15) 把 filesystem ENAMETOOLONG 兜底换成
// main 显式 reject;memory r165 锁定 setI18nLangProvider 槽;本测
// 复跑同一组约束,断不再回退。
import { mkdtemp, rm, stat, mkdir } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
describe('safe-write', () => {
let tmpRoot: string;
// 关键:每次 beforeEach 重置模块 —— safe-write 的 currentDataPath / langProvider
// 都是 module-level 单例,vi.resetModules + 重新 import 让测试彼此隔离
// (memory r165 第五个避坑)。
beforeEach(async () => {
vi.resetModules();
tmpRoot = await mkdtemp(join(tmpdir(), 'sw-test-'));
});
afterEach(async () => {
vi.resetModules();
await rm(tmpRoot, { recursive: true, force: true });
});
it('getRootPath 未初始化抛 INTERNAL', async () => {
const { getRootPath } = await import('../../../../src/main/utils/safe-write');
try {
getRootPath();
expect.fail('应抛错');
} catch (e: unknown) {
expect((e as { code?: string }).code).toBe('INTERNAL');
}
});
it('setRootPath + getRootPath round-trip,然后 in-root 写入成功', async () => {
const { setRootPath, getRootPath, safeWriteFileAtomic } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
expect(getRootPath()).toBe(tmpRoot);
const target = 'notes/hello.md';
await safeWriteFileAtomic(target, '# Hello\n');
const onDisk = await stat(join(tmpRoot, 'notes', 'hello.md'));
expect(onDisk.isFile()).toBe(true);
});
it('写 rootPath 外抛 UnsafeWriteError(code UNSAFE_WRITE)', async () => {
const { setRootPath, safeWriteFileAtomic, UnsafeWriteError } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
// 拿到一个 parent 目录的绝对路径作为 attack
const outsideDir = await mkdtemp(join(tmpdir(), 'sw-out-'));
try {
const attack = join(outsideDir, 'evil.md');
let caught: unknown;
try {
await safeWriteFileAtomic(attack, 'x');
} catch (e: unknown) {
caught = e;
}
expect(caught).toBeInstanceOf(UnsafeWriteError);
expect((caught as { code?: string }).code).toBe('UNSAFE_WRITE');
} finally {
await rm(outsideDir, { recursive: true, force: true });
}
});
it('单段文件名长度超 255 显式 reject(不合 ENAMETOOLONG 透传)', async () => {
const { setRootPath, safeWriteFileAtomic } = await import(
'../../../../src/main/utils/safe-write'
);
setRootPath(tmpRoot);
await mkdir(join(tmpRoot, 'notes'), { recursive: true });
const huge = 'a'.repeat(300) + '.md';
let code: string | undefined;
let rejected = false;
try {
await safeWriteFileAtomic(`notes/${huge}`, 'x');
} catch (e: unknown) {
rejected = true;
code = (e as { code?: string }).code;
}
expect(rejected).toBe(true);
// 不限具体是 UNSAFE_WRITE 还是 VALIDATION,只要不在文件系统层抛 ENAMETOOLONG 即可
expect(code).not.toBe('ENAMETOOLONG');
expect(typeof code).toBe('string');
});
});

View File

@@ -0,0 +1,113 @@
// tests/unit/main/utils/scrub-internal.test.ts
//
// 守 scrubInternal 的**脱敏**契约(2026-10-20,r201):
//
// 把 INTERNAL 错误消息里的用户家目录替换成 <rootPath> 占位符。
//
// 为什么这是隐私面:IPC 错误消息会被结构化日志全量记录(以及任何抓 IPC
// 错误日志的 telemetry 渠道)。fs 抛错原文含 `failed at
// C:\Users\foo\ResearchHub\notes\x.md` ⇒ 家目录一路泄到 renderer / 日志。
//
// 本文件锁住:
// 1. root 全路径被替换成 <rootPath>(后面必须跟分隔符或字符串结尾 ——
// `ResearchHubX\y` 不是 `ResearchHub` 的子目录,不能误伤);
// 2. 大小写不敏感文件系统(win32/darwin)上大小写变体同样脱敏;
// 3. root 带尾分隔符(盘根 `C:\`)时也能正确脱敏(r124 修的静默失效);
// 4. 正则元字符(root 里有 `+` `[` `(` 等)被正确转义;
// 5. 超过 500 字符截断(防单条消息炸 IPC buffer);
// 6. rootPath 未初始化 → 原样返回(不阻断错误上报);
import { describe, expect, it, vi } from 'vitest';
import { isCaseInsensitiveFs } from '../../../../src/main/utils/platform';
const ROOT = 'C:\\Users\\foo\\ResearchHub';
/** 盘根(`C:\`)/POSIX 根(`/`)两条语义都依赖 platform,分别用 skipIf 隔离 */
const isWin = process.platform === 'win32';
/** 加载一份全新的模块实例(safe-write 的 rootPath 是 module 级单例) */
async function load(root?: string): Promise<(msg: string) => string> {
vi.resetModules();
if (root !== undefined) {
const { setRootPath } = await import('../../../../src/main/utils/safe-write');
setRootPath(root);
}
const { scrubInternal } = await import('../../../../src/main/utils/scrub-internal');
return scrubInternal;
}
describe('scrubInternal — 家目录脱敏(r201)', () => {
it('root 全路径 → <rootPath> 占位符', async () => {
const scrub = await load(ROOT);
const out = scrub(`failed at ${ROOT}\\notes\\x.md`);
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
expect(out).not.toContain('Users');
});
it('root 出现在字符串结尾 → 同样替换', async () => {
const scrub = await load(ROOT);
expect(scrub(`path is ${ROOT}`)).toBe('path is <rootPath>');
});
it('仅是前缀相同的兄弟目录(ResearchHubX)→ 不误伤', async () => {
const scrub = await load(ROOT);
const out = scrub(`failed at ${ROOT}X\\y.md`);
expect(out).toContain('ResearchHubX');
expect(out).not.toContain('<rootPath>');
});
it('与 root 无关的路径 → 原样保留', async () => {
const scrub = await load(ROOT);
const msg = 'failed at D:\\other\\place\\y.md';
expect(scrub(msg)).toBe(msg);
});
it.skipIf(!isCaseInsensitiveFs)(
'大小写不敏感 FS(win32/darwin):大小写变体同样脱敏',
async () => {
const scrub = await load(ROOT);
const out = scrub('failed at c:\\users\\FOO\\researchhub\\notes\\x.md');
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
},
);
it.skipIf(isCaseInsensitiveFs || isWin)('大小写敏感 FS(POSIX):大小写变体不脱敏', async () => {
const scrub = await load('/srv/data');
const out = scrub('failed at /SRV/DATA/x.md');
expect(out).toBe('failed at /SRV/DATA/x.md');
});
it.skipIf(!isWin)('root 带尾分隔符(盘根 C:\\)→ 盘内路径仍被脱敏(r124)', async () => {
const scrub = await load('C:\\');
const out = scrub('failed at C:\\Users\\foo\\ResearchHub\\notes\\x.md');
expect(out).toBe('failed at <rootPath>\\Users\\foo\\ResearchHub\\notes\\x.md');
expect(out).not.toContain('C:\\Users');
});
it.skipIf(isWin)('root 长度 < 2(POSIX 根 /)→ 保守跳过,整条消息原样', async () => {
const scrub = await load('/');
const msg = 'failed at /srv/data/x.md';
expect(scrub(msg)).toBe(msg);
});
it('root 含正则元字符 → 被正确转义(不误伤、不漏伤)', async () => {
const weird = 'C:\\a+b[test]\\root';
const scrub = await load(weird);
const out = scrub(`failed at ${weird}\\notes\\x.md`);
expect(out).toBe('failed at <rootPath>\\notes\\x.md');
});
it('超长消息 → 截断到 500 字符 + 省略号(防炸 IPC buffer)', async () => {
const scrub = await load(ROOT);
const long = 'x'.repeat(600);
const out = scrub(long);
expect(out).toHaveLength(501);
expect(out.endsWith('…')).toBe(true);
});
it('rootPath 未初始化 → 原样返回(不阻断错误上报)', async () => {
const scrub = await load();
const msg = 'failed at C:\\Users\\foo\\secret\\x.md';
expect(scrub(msg)).toBe(msg);
});
});

View File

@@ -0,0 +1,96 @@
// tests/unit/main/utils/self-write-echo.test.ts
//
// 守 perf r239「自写回声抑制器」的**契约**(不碰 service,单测纯逻辑):
//
// 1. 打点后、同指纹 ⇒ 命中(自写回声,抑制重扫)。
// 2. 指纹不同 ⇒ 不命中(文件被别人动过 ⇒ 外部改动必须可见)。
// —— 这条是**时间窗口实现的老洞**:窗口内的外部改动会被一起吞掉。
// 3. 记录**不被消费**:同一份指纹要能反复命中(应用连续 autosave 时
// 指纹会被新的写入覆盖,不会互相干扰)。
// 4. TTL 之外 ⇒ 不命中(平台丢事件 / 回声迟到太久 ⇒ 宁可多重扫,
// 不可漏扫)。
// 5. 路径大小写 / 分隔符差异 ⇒ 同一把钥匙(win32 不区分大小写)。
// 6. 过期打点不会永久占位(map 会自清理)。
import { describe, expect, it } from 'vitest';
import {
SelfWriteEchoSuppressor,
sameFingerprint,
} from '../../../../src/main/utils/self-write-echo';
import type { FileFingerprint } from '../../../../src/main/utils/self-write-echo';
import { isCaseInsensitiveFs } from '../../../../src/main/utils/platform';
const fp = (over: Partial<FileFingerprint> = {}): FileFingerprint => ({
mtimeMs: 1000,
size: 200,
ino: 7,
...over,
});
describe('SelfWriteEchoSuppressor(r239)', () => {
it('同指纹 ⇒ 命中自写回声', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 100)).toBe(true);
});
it('指纹不同 ⇒ 不命中(外部改动必须可见)', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
// 外部工具改过 ⇒ mtime 变 / size 变 / inode 变,任一不同即不命中
expect(s.isSelfEcho('C:/lib/a.md', fp({ mtimeMs: 2000 }), 100)).toBe(false);
expect(s.isSelfEcho('C:/lib/a.md', fp({ size: 201 }), 100)).toBe(false);
expect(s.isSelfEcho('C:/lib/a.md', fp({ ino: 8 }), 100)).toBe(false);
});
it('记录不被消费:同一份指纹反复命中', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 100)).toBe(true);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 200)).toBe(true);
});
it('TTL 之外不命中(回声迟到太久 / 平台丢事件)', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 30_001)).toBe(false);
});
it('没打点过的路径不命中', () => {
const s = new SelfWriteEchoSuppressor(30_000);
expect(s.isSelfEcho('C:/lib/never-written.md', fp(), 10)).toBe(false);
});
it('分隔符风格差异视为同一把钥匙', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:\\lib\\a.md', fp(), 0);
expect(s.isSelfEcho('C:/lib/a.md', fp(), 10)).toBe(true);
});
it('大小写不敏感文件系统上大小写差异也算同一把钥匙', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:\\lib\\a.md', fp(), 0);
// 大小写折叠只发生在 case-insensitive FS 上(win32 / macOS APFS);
// POSIX 上大小写是路径语义的一部分 ⇒ 不能折叠。
const same = isCaseInsensitiveFs
? s.isSelfEcho('C:/LIB/A.md', fp(), 10)
: !s.isSelfEcho('C:/LIB/A.md', fp(), 10);
expect(same).toBe(true);
});
it('过期打点被清掉,不会永久占位', () => {
const s = new SelfWriteEchoSuppressor(30_000);
s.mark('C:/lib/a.md', fp(), 0);
expect(s.size).toBe(1);
// 之后的 mark 会顺手 prune 掉过期的
s.mark('C:/lib/b.md', fp(), 50_000);
expect(s.size).toBe(1);
expect(s.isSelfEcho('C:/lib/b.md', fp(), 50_100)).toBe(true);
});
it('sameFingerprint:三因子全等才算同一份内容', () => {
expect(sameFingerprint(fp(), fp())).toBe(true);
expect(sameFingerprint(fp(), fp({ mtimeMs: 1 }))).toBe(false);
expect(sameFingerprint(fp(), fp({ size: 1 }))).toBe(false);
expect(sameFingerprint(fp(), fp({ ino: 1 }))).toBe(false);
});
});

View File

@@ -0,0 +1,151 @@
// tests/unit/main/utils/ssrf.test.ts
//
// 守 SSRF 防护的**范围判定**契约(2026-10-20,r201):
//
// isPrivateIpLiteral(host) : 同步,判字面 IP 是否落在禁止段
// isPrivateUrl(urlString) : 异步,判整个 URL 是否指向禁止地址
//
// 这份文件是主进程唯一的出站地址白名单(当前 caller:window.ts 的
// setWindowOpenHandler 同源防御)。判错的方向是"把公网当内网拦掉"
//(误伤)或"把内网当公网放行"(SSRF)—— 两个方向都要锁。
//
// 覆盖的段位(源码 IPV4_PRIVATE_RANGES / IPV6_PRIVATE_RANGES 的语义):
// IPv4 : 0/8 10/8 127/8 100.64/10 169.254/16 172.16/12 192.168/16
// 224/4(multicast) 240/4(reserved)
// IPv6 : ::/128 ::1/128 fe80::/10 fc00::/7 ff00::/8 2002::/16(6to4)
// 64:ff9b::/96(NAT64)
// IPv4-mapped IPv6(::ffff:a.b.c.d)按**数值**识别(r85),不依赖文本前缀。
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { lookupMock } = vi.hoisted(() => ({ lookupMock: vi.fn() }));
vi.mock('dns', async (importOriginal) => {
const actual = await importOriginal<typeof import('dns')>();
return { ...actual, lookup: lookupMock };
});
import { isPrivateIpLiteral, isPrivateUrl } from '../../../../src/main/utils/ssrf';
/** 让 mock 的 dns.lookup 回调返回给定的地址列表 */
function resolveTo(...addresses: string[]): void {
lookupMock.mockImplementation(
(_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => {
cb(null, addresses.map((address) => ({ address, family: address.includes(':') ? 6 : 4 })));
},
);
}
describe('isPrivateIpLiteral — IPv4 段位(r201)', () => {
it('RFC1918 / loopback / CGNAT / link-local / multicast / reserved 全拦', () => {
for (const ip of [
'0.0.0.0',
'10.0.0.1',
'127.0.0.1',
'127.1.2.3',
'100.64.0.1',
'169.254.1.1',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'224.0.0.1',
'240.0.0.1',
]) {
expect(isPrivateIpLiteral(ip), ip).toBe(true);
}
});
it('公网地址放行', () => {
for (const ip of ['1.1.1.1', '8.8.8.8', '172.32.0.1', '172.15.255.255', '100.128.0.1', '223.255.255.255']) {
expect(isPrivateIpLiteral(ip), ip).toBe(false);
}
});
it('非 IP 字面量(hostname)→ false,交由调用方进一步解析', () => {
expect(isPrivateIpLiteral('example.com')).toBe(false);
expect(isPrivateIpLiteral('localhost')).toBe(false);
});
});
describe('isPrivateIpLiteral — IPv6 段位(r201)', () => {
it('loopback / unspecified / link-local / ULA / multicast / 6to4 / NAT64 全拦', () => {
for (const ip of [
'::',
'::1',
'fe80::1',
'fc00::1',
'fd12:3456::1',
'ff02::1',
'2002::1',
'64:ff9b::1',
]) {
expect(isPrivateIpLiteral(ip), ip).toBe(true);
}
});
it('公网 IPv6 放行', () => {
expect(isPrivateIpLiteral('2606:4700:4700::1')).toBe(false);
expect(isPrivateIpLiteral('2001:db8::1')).toBe(false);
});
it('IPv4-mapped IPv6 按数值识别(r85):三种写法都拦', () => {
expect(isPrivateIpLiteral('::ffff:127.0.0.1')).toBe(true);
expect(isPrivateIpLiteral('::ffff:7f00:1')).toBe(true);
expect(isPrivateIpLiteral('0:0:0:0:0:ffff:7f00:1')).toBe(true);
});
it('IPv4-mapped 公网地址放行', () => {
expect(isPrivateIpLiteral('::ffff:8.8.8.8')).toBe(false);
});
});
describe('isPrivateUrl — URL 层(r201)', () => {
beforeEach(() => {
lookupMock.mockReset();
resolveTo('93.184.216.34');
});
it('非 http(s) 协议 → 拒', async () => {
await expect(isPrivateUrl('file:///C:/Windows/System32')).resolves.toBe(true);
await expect(isPrivateUrl('ftp://example.com/x')).resolves.toBe(true);
});
it('非法 URL → 拒(不抛)', async () => {
await expect(isPrivateUrl('not a url')).resolves.toBe(true);
});
it('host 是私有 IP 字面量 → 拒(不走 DNS)', async () => {
await expect(isPrivateUrl('http://127.0.0.1:8080/x')).resolves.toBe(true);
await expect(isPrivateUrl('http://[::1]/x')).resolves.toBe(true);
expect(lookupMock).not.toHaveBeenCalled();
});
it('host 是公网 IP 字面量 → 放行', async () => {
await expect(isPrivateUrl('https://8.8.8.8/x')).resolves.toBe(false);
});
it('hostname 解析到内网 IP → 拒(防"字面合法、解析到内网")', async () => {
resolveTo('93.184.216.34', '10.0.0.5');
await expect(isPrivateUrl('https://sneaky.example.com/x')).resolves.toBe(true);
});
it('hostname 全部解析到公网 → 放行', async () => {
resolveTo('93.184.216.34');
await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(false);
});
it('DNS 解析失败 → 保守拒(不抛)', async () => {
lookupMock.mockImplementation(
(_host: string, _opts: unknown, cb: (err: unknown, addrs: unknown[]) => void) => {
cb(new Error('ENOTFOUND'), []);
},
);
await expect(isPrivateUrl('https://example.com/x')).resolves.toBe(true);
});
it('IDN 域名:URL 解析器已转 punycode(纯 ASCII)→ 走 DNS 判定', async () => {
// 源码注释说「拒绝非 ASCII hostname」,但 URL 的 hostname 一定是
// punycode 形式(ASCII)—— 该分支对 URL 入参实际不可达。此处锁住
// **真实行为**(punycode 放行),避免有人以为 IDN 会被拦而误改。
await expect(isPrivateUrl('https://例え.jp/x')).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,115 @@
// tests/unit/main/watcher-close-quiet.test.ts
//
// 守 r216「关掉 chokidar watcher 后,晚到的 awaitWriteFinish 轮询错误不得
// 变成主进程 uncaught exception」契约。
//
// 根因(chokidar 4.0.3 源码实证):
// - awaitWriteFinish 的轮询是 setTimeout 链,不在 close() 清理范围内;
// - close() 第一行 removeAllListeners();
// - 轮询晚到时文件已被删/搬走 ⇒ stat 报错;Windows 对 pending-delete
// 路径报 EPERM(不是 ENOENT)⇒ chokidar 只容忍 ENOENT,其余走
// awfEmit(err) ⇒ emit('error') ⇒ 无监听者 ⇒ EventEmitter 同步 throw。
// 本仓库基线实测:全量跑测时 folder-scoped-file-ops.test.ts 的 teardown
// 删临时目录,晚到轮询炸出 2 例 uncaught exception(EPERM stat)。
//
// 修法 = closeWatcherQuietly:close 之后在已弃实例上补挂 noop 'error' 监听,
// 给迟到事件一个去处。本测试从两个方向锁:
// 1. 确定性单元锁:close 后 emit('error') 不再 throw(无监听时必 throw);
// 2. 真时序回归:轮询窗口内 close + 递归删目录 ⇒ 修前此处以 uncaught
// exception 形式炸(vitest 记 Errors),修后安然。
import { afterEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import chokidar, { type FSWatcher } from 'chokidar';
import { closeWatcherQuietly } from '../../../src/main/utils/watcher-release';
describe('closeWatcherQuietly(r216)—— 晚到的 watcher 错误不炸主进程', () => {
const dirs: string[] = [];
const watchers: FSWatcher[] = [];
afterEach(async () => {
// 兜底清理:就算断言中途失败也不把临时目录/句柄留给下一个用例
await Promise.all(watchers.splice(0).map((w) => closeWatcherQuietly(w).catch(() => {})));
await Promise.all(
dirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true }).catch(() => {})),
);
});
it('close 后实例上迟到 error 事件不再 throw(EventEmitter 对无监听者的 error 是同步 throw)', async () => {
const dir = await fs.mkdtemp(join(tmpdir(), 'rkms-wcloseq-'));
dirs.push(dir);
const w = chokidar.watch(dir, { ignoreInitial: true });
watchers.push(w);
await closeWatcherQuietly(w);
// 修前:close() 已 removeAllListeners ⇒ 这一行同步 throw;
// 修后:noop 监听兜底 ⇒ 安然。这正是 chokidar awfEmit(err) 晚到时的
// 真实调用形态(w.emit('error', err))。
expect(() => w.emit('error', new Error('EPERM: late awaitWriteFinish stat'))).not.toThrow();
});
it('close 本身的失败仍然透传给调用方(静默 close 错误 = 句柄泄漏无人知)', async () => {
const fake = {
close: async (): Promise<void> => {
throw new Error('boom');
},
on: (): unknown => fake,
} as unknown as FSWatcher;
await expect(closeWatcherQuietly(fake)).rejects.toThrow('boom');
});
it('close 失败时 noop 监听照样补上(finally 语义)', async () => {
let errorListenerCount = 0;
const fake = {
close: async (): Promise<void> => {
throw new Error('boom');
},
on: (event: string): unknown => {
if (event === 'error') errorListenerCount += 1;
return fake;
},
} as unknown as FSWatcher;
await expect(closeWatcherQuietly(fake)).rejects.toThrow('boom');
expect(errorListenerCount).toBe(1);
});
it('真时序回归:awaitWriteFinish 轮询窗口内 close + 递归删目录,无 uncaught', async () => {
const dir = await fs.mkdtemp(join(tmpdir(), 'rkms-wcloseq-'));
dirs.push(dir);
const w = chokidar.watch(dir, {
ignoreInitial: true,
// 与 notes/service.ts 同量级的窗口(50/25),拉长 stabilityThreshold
// 保证 close 时轮询链一定还活着
awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 25 },
});
watchers.push(w);
await new Promise<void>((resolve) => w.on('ready', () => resolve()));
await fs.writeFile(join(dir, 'a.md'), 'hello');
// 等 chokidar 检测到这次 add 并进入 awaitWriteFinish 轮询窗口。
// 用户态 'add' 事件要等稳定(400ms)后才发,不能等它;给 readdirp
// 检测 + 首个轮询周期足够余量即可。
await new Promise((r) => setTimeout(r, 150));
await closeWatcherQuietly(w);
// 目录递归删除 ⇒ 仍在轮询的 stat 撞 Windows pending-delete ⇒ EPERM
// (chokidar 只容忍 ENOENT)⇒ 修前:emit('error') 无监听 ⇒ uncaught;
// 撞上时本用例的「红」形式就是 vitest 报告 Uncaught Exception(基线
// 实测 2 例)。诚实说明:EPERM 只在 stat 与 rm 并发时出现(rm 先删
// 文件,之后的 stat 是 ENOENT 被 chokidar 静默)⇒ 竞态部分是冒烟;
// 确定性部分是下面的硬断言。
await fs.rm(dir, { recursive: true, force: true });
dirs.splice(dirs.indexOf(dir), 1);
// 放过足够多轮询周期,让晚到错误有机会炸出来
await new Promise((r) => setTimeout(r, 500));
// 确定性硬断言(防空过):真 FSWatcher 在 close 摘光监听之后,
// noop error 监听必须已补挂 —— 这是晚到错误不炸的唯一依靠
expect(w.listenerCount('error')).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,175 @@
// tests/unit/main/watcher-restart-budget.test.ts
//
// 守 r197「watcher 连续失败上限」的真<E79A84><E79C9F><EFBFBD>语义。
//
// 旧实现:`watcherRestartAttempts` 只在 `start()` / `stop()` 里清零 ——
// 于是「连续失败上限」实际是**终身**预算:
// - 用户在一个长会话里遭遇 6 次**孤立**的 watcher 错误(网络盘抖一下 /
// OneDrive 同步冲突 / Defender 扫一下 / 目录被临时改名),每次都成功
// 重启、每次都正常工作 —— 计数却一路累加;
// - 第 6 次错误时计数越过上限 ⇒ 永久放弃 watcher(只在日志里,UI 无提示)
// ⇒ 之后**所有**外部改动都不再被感知 —— 用户看到的是「我在资源管理器
// 里改了文件,应用里的列表一直不动」。
//
// 上限本身要留着(永久坏路径下 2s 一轮的 log 风暴是真实问题),所以修法不是
// 删上限,而是让「连续」名副其实:重启成功并**健康存活一段时间**后,计数
// 清零;而「起来就又报错」不算健康(清零 ⇒ 无限重试 ⇒ 上限形同虚设)。
//
// 本测是行为级守卫:mock chokidar(不依赖真实文件系统事件),用 fake timers
// 精确推进 2s 退避与 30s 健康期。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
vi.mock('electron', () => ({
shell: { trashItem: async () => undefined },
}));
interface FakeWatcher {
on(ev: string, fn: (p?: unknown) => void): FakeWatcher;
removeAllListeners(): void;
close(): Promise<void>;
__emit(ev: string, p?: unknown): void;
}
const chok = vi.hoisted(() => ({ watchers: [] as FakeWatcher[] }));
vi.mock('chokidar', () => {
const make = (): FakeWatcher => {
const handlers = new Map<string, Array<(p?: unknown) => void>>();
const w: FakeWatcher = {
on(ev, fn) {
const arr = handlers.get(ev) ?? [];
arr.push(fn);
handlers.set(ev, arr);
return w;
},
removeAllListeners() {
handlers.clear();
},
close: async () => undefined,
__emit(ev, p) {
for (const fn of [...(handlers.get(ev) ?? [])]) fn(p);
},
};
chok.watchers.push(w);
return w;
};
return { default: { watch: make }, watch: make };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
const RESTART_DELAY_MS = 2000;
const HEALTHY_MS = 30_000;
/** 让当前 watcher 报一次错,并推进 2s 让重启发生。
* healthyGapMs > 0 时再推进健康期(让计数有机会清零)。 */
async function failOnce(healthyGapMs = 0): Promise<void> {
const w = chok.watchers[chok.watchers.length - 1];
w.__emit('error', new Error('watcher boom'));
await vi.advanceTimersByTimeAsync(RESTART_DELAY_MS);
if (healthyGapMs > 0) {
await vi.advanceTimersByTimeAsync(healthyGapMs);
}
}
describe('watcher 连续失败上限(r197)', () => {
let root: string;
beforeEach(async () => {
chok.watchers.length = 0;
vi.useFakeTimers();
root = await fs.mkdtemp(join(tmpdir(), 'rkms-watchbudget-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
});
afterEach(async () => {
vi.useRealTimers();
await fs.rm(root, { recursive: true, force: true });
});
it('notes:连续 6 次错误(每次都活不过健康期)→ 第 6 次放弃 watcher', async () => {
const svc = new NotesService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 5; i++) await failOnce(0);
expect(gaveUp).toBe(0); // 前 5 次还在重试预算内
await failOnce(0);
expect(gaveUp).toBe(1); // 第 6 次越限 → 放弃
await svc.stop();
});
it('notes:6 次孤立错误(每次都健康存活 30s)→ 不放弃 watcher', async () => {
const svc = new NotesService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
// 每次错误都隔着「重启 + 30s 健康期」—— 计数器应当每次都归零
for (let i = 0; i < 6; i++) await failOnce(HEALTHY_MS);
expect(gaveUp).toBe(0);
await svc.stop();
});
it('literature:6 次孤立错误(每次都健康存活 30s)→ 不放弃 watcher', async () => {
const svc = new LiteratureService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 6; i++) await failOnce(HEALTHY_MS);
expect(gaveUp).toBe(0);
await svc.stop();
});
it('literature:连续 6 次错误 → 第 6 次放弃(上限语义不变)', async () => {
const svc = new LiteratureService();
let gaveUp = 0;
svc.on('watcher-failed', () => {
gaveUp += 1;
});
await svc.start({ skipInitialLoad: true });
for (let i = 0; i < 5; i++) await failOnce(0);
expect(gaveUp).toBe(0);
await failOnce(0);
expect(gaveUp).toBe(1);
await svc.stop();
});
});

View File

@@ -0,0 +1,60 @@
// tests/unit/main/window-defaults.test.ts
//
// 守「窗口初始尺寸」契约(audit r176,2026-10-19):
//
// 1) 默认宽度 1280 → 1440(用户报「软件的默认界面宽度可以再稍微宽一些」)。
// 锁死 1440 是为了防回退 —— 这个值用户看不见代码,改回去不会有任何
// 报错,只能靠测试记住。
// 2) 尺寸关系的合法性:min ≤ default。min > default 的组合会让「缩到最小」
// 操作被 Chromium 静默顶回,用户体感是 bug 而不是设计。
// 3) MIN_WINDOW_WIDTH 必须 ≤ 1366 —— 1366×768 是还相当常见的小屏;
// 若 min 超过屏宽,这类用户根本没法把窗口收到自己想要的窄度。
// 4) src/main/window.ts 必须真的引用这些常量(而不是自己又写了一份
// 字面量)—— 否则「常量改了但窗口没用上」这种漂移会静默发生。
import { readFileSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
import {
DEFAULT_WINDOW_WIDTH,
DEFAULT_WINDOW_HEIGHT,
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
} from '../../../src/main/window-defaults';
const ROOT = process.cwd();
function read(rel: string): string {
return readFileSync(join(ROOT, rel), 'utf8');
}
describe('窗口初始尺寸(window-defaults)', () => {
it('默认宽度是 1440(比上一版 1280 宽)', () => {
expect(DEFAULT_WINDOW_WIDTH).toBe(1440);
});
it('默认高度维持 800 —— 本轮只放宽宽度', () => {
expect(DEFAULT_WINDOW_HEIGHT).toBe(800);
});
it('min 尺寸关系合法:min ≤ default', () => {
expect(MIN_WINDOW_WIDTH).toBeLessThanOrEqual(DEFAULT_WINDOW_WIDTH);
expect(MIN_WINDOW_HEIGHT).toBeLessThanOrEqual(DEFAULT_WINDOW_HEIGHT);
expect(MIN_WINDOW_WIDTH).toBeGreaterThan(0);
expect(MIN_WINDOW_HEIGHT).toBeGreaterThan(0);
});
it('最小宽度不超过 1366 —— 常见小屏用户也要能收窄窗口', () => {
expect(MIN_WINDOW_WIDTH).toBeLessThanOrEqual(1366);
});
it('窗口创建处真的用的是这些常量(没有另写一份字面量)', () => {
const src = read('src/main/window.ts');
expect(src).toContain('width: DEFAULT_WINDOW_WIDTH');
expect(src).toContain('height: DEFAULT_WINDOW_HEIGHT');
expect(src).toContain('minWidth: MIN_WINDOW_WIDTH');
expect(src).toContain('minHeight: MIN_WINDOW_HEIGHT');
// 旧的字面量不该残留在窗口创建处
expect(src).not.toMatch(/width:\s*1280\b/);
expect(src).not.toMatch(/width:\s*1440\b/);
});
});

View File

@@ -0,0 +1,113 @@
// tests/unit/renderer/components/codemirror-history-semantics.test.ts
//
// 守「笔记撤销 / 重做」所依赖的 **CodeMirror 6 真实语义**(用真库,不用假实现)。
//
// 为什么要有这层:markdown-editor-history.test.tsx 里 CodeMirror 是被 mock 掉的,
// 它只能证明「我们的代码传了正确的参数」;证明不了「这些参数在真 CodeMirror 上
// 真的产生我们想要的结果」。若哪天 CodeMirror 改了 history 的语义(例如
// addToHistory:false 不再清栈 / setState 不再重置 history),mock 版测试照样全绿,
// 而真机上会悄悄退化成「跨笔记撤销污染」。这层就是那个沉默退化的警报器。
//
// 锁的三条真实语义(本轮实现的前提):
// 1. history 是挂在 EditorState 上的 StateField ⇒ view.setState(新 state)
// ⇒ 撤销栈**归零**(这是「切笔记清空撤销栈」唯一可靠的实现方式);
// 2. 整段替换 + Transaction.addToHistory.of(false) ⇒ 外部灌入的正文不进栈;
// 3. 关键场景:笔记 A 里敲了字 → 切到笔记 B(setState)→ 在 B 里按撤销
// ⇒ **撤不了任何东西、也不能把 A 的字撤回来**(跨笔记污染 = 数据事故:
// 撤回来的 A 内容会被 autosave 写进 B)。
//
// 变异思路(哪几处改动会红):
// - 把「切文档」从 setState 改成普通 dispatch ⇒ 例 3 红(B 里 undo 会撤掉 A 的字);
// - 去掉 addToHistory.of(false) ⇒ 例 2 红;
// - 断言写反(把「栈归零」写成「栈保留」)⇒ 例 1、例 2 红。
import { describe, expect, it } from 'vitest';
import { EditorState, Transaction } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { history, undoDepth, redoDepth, undo, redo } from '@codemirror/commands';
/** 造一个挂载在 jsdom 里的真 CodeMirror 视图。 */
function mount(doc: string): EditorView {
const host = document.createElement('div');
document.body.appendChild(host);
return new EditorView({
state: EditorState.create({ doc, extensions: [history()] }),
parent: host,
});
}
/** 模拟一次用户击键(带 userEvent,CodeMirror 才会把它记进撤销栈)。 */
function type(view: EditorView, text: string): void {
view.dispatch({ changes: { from: view.state.doc.length, insert: text }, userEvent: 'input.type' });
}
describe('CodeMirror 6 真实语义:撤销栈', () => {
it('1. setState(新 state)⇒ 撤销栈归零 + 正文/DOM 换成新文档', () => {
const view = mount('第一篇');
type(view, '改过了');
expect(undoDepth(view.state), '敲字后有 1 层可撤').toBe(1);
view.setState(EditorState.create({ doc: '第二篇', extensions: [history()] }));
expect(view.state.doc.toString(), '正文换成新文档').toBe('第二篇');
expect(undoDepth(view.state), '撤销栈必须归零').toBe(0);
expect(redoDepth(view.state), '重做栈也必须归零').toBe(0);
expect(view.dom.textContent, 'DOM 显示的也是新正文').toContain('第二篇');
expect(view.contentDOM, 'contentDOM 仍在(焦点/光标依赖它)').toBeTruthy();
view.destroy();
});
it('2. 整段替换 + addToHistory:false ⇒ 外部灌入的正文不进撤销栈', () => {
const view = mount('abc');
type(view, 'X');
expect(undoDepth(view.state)).toBe(1);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'ZZZ' },
annotations: Transaction.addToHistory.of(false),
});
expect(view.state.doc.toString()).toBe('ZZZ');
expect(undoDepth(view.state), '外部替换后旧事件被 mapping 吃掉 ⇒ 栈空').toBe(0);
expect(undo(view), '没有可撤的东西时 undo 命令返回 false').toBe(false);
view.destroy();
});
it('3. 跨笔记:在 A 里敲的字,切到 B 之后按撤销撤不回来(数据污染防线)', () => {
const view = mount('A 的正文');
type(view, '!!!');
expect(view.state.doc.toString()).toBe('A 的正文!!!');
expect(undoDepth(view.state)).toBe(1);
// 切到笔记 B:重建 state(本轮实现里由 docToken 变化触发)
view.setState(EditorState.create({ doc: 'B 的正文', extensions: [history()] }));
expect(view.state.doc.toString()).toBe('B 的正文');
// 在 B 里按撤销 ⇒ 什么也撤不了,B 的正文一个字都不能少
expect(undo(view), 'B 里没有可撤的编辑 ⇒ undo 返回 false').toBe(false);
expect(view.state.doc.toString(), 'B 的正文未被 A 的编辑污染').toBe('B 的正文');
// B 里的新编辑可以正常撤销(撤销功能本身没被 setState 弄坏)
type(view, '?');
expect(undoDepth(view.state)).toBe(1);
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('B 的正文');
view.destroy();
});
it('4. undo / redo 命令真实生效(undo 改回正文、redo 再改回来)', () => {
const view = mount('abc');
type(view, 'X');
expect(undo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('abc');
expect(redoDepth(view.state), '撤销后有 1 层可重做').toBe(1);
expect(redo(view)).toBe(true);
expect(view.state.doc.toString()).toBe('abcX');
view.destroy();
});
});

View File

@@ -0,0 +1,144 @@
// tests/unit/renderer/components/folder-row-interactions.test.tsx
//
// 守 r174「文件夹行是死的」契约 —— FolderRowOption 此前是 Phase 1 stub:
// 声明了 onContextMenu / onContextMenuAt / onHover 三个 prop 却一个都没用,
// 并且 draggable={false} 让浏览器根本不发起原生 drag。后果:
// - 右键文件夹 → 菜单永远不弹(NotesList / LiteratureList 里那套
// folderMenuItems:剪切 / 粘贴 / 移到根目录 / 删除 全都够不着);
// - hover 文件夹 → activeId 不跟手(SR 的 aria-activedescendant 落在
// 别的行上);
// - 拖文件夹 → r173 接好的 folders-move-dir 链路永远收不到载荷。
//
// 本测是**行为级** guard:不锁实现细节,只锁「用户做的动作真的到达了
// 父级回调」。父级接线(坐标 vs 事件)另由 NotesList / LiteratureList 侧
// 负责。
//
// r176(树状改造)追加:a11y 语义升格 —— 侧栏从 listbox/option 变成
// tree/treeitem,aria-level / aria-expanded 必须在行上,否则读屏器
// 报「树里没有层级」;缩进也必须真的按 depth 走(层级感是树的核心
// 视觉线索)。另 `onOpen` 改名 `onActivate`(语义从「钻入」变成
// 「设为落点 + 切换展开」)。
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
import { FolderRowOption } from '../../../../src/renderer/src/components/common/FolderChrome';
import type { DiskFolderNode } from '../../../../src/shared/types';
// i18n:组件内 FolderNameInput / aria 走 useTranslation —— 测试里只关心
// 交互,不关心文案,直接 mock 成 key 原样返回。
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
const folder: DiskFolderNode = { relPath: 'a/b', name: 'b', parentRelPath: 'a' };
function renderRow(overrides: Partial<React.ComponentProps<typeof FolderRowOption>> = {}) {
const props = {
folder,
domId: 'note-row-folder-a/b',
isActive: false,
isCut: false,
isDropHot: false,
isRenaming: false,
depth: 1,
expanded: false,
hasChildren: true,
isTarget: false,
posInSet: 1,
setSize: 3,
onActivate: vi.fn(),
onContextMenu: vi.fn(),
onContextMenuAt: vi.fn(),
onHover: vi.fn(),
onDragStart: vi.fn(),
onDragOver: vi.fn(),
onDragLeave: vi.fn(),
onDrop: vi.fn(),
onCommitRename: vi.fn().mockResolvedValue(true),
onCancelRename: vi.fn(),
...overrides,
};
const utils = render(<FolderRowOption {...props} />);
const row = utils.container.querySelector('[data-folder-relpath]') as HTMLElement;
return { ...utils, row, props };
}
describe('FolderRowOption 交互接线(r174)', () => {
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('右键文件夹 → 父级 onContextMenu 收到 (folder, event)', () => {
const { row, props } = renderRow();
fireEvent.contextMenu(row, { clientX: 12, clientY: 34 });
expect(props.onContextMenu).toHaveBeenCalledTimes(1);
const [f, ev] = (props.onContextMenu as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(f).toBe(folder);
expect((ev as MouseEvent).clientX).toBe(12);
});
it('hover 文件夹 → 父级 onHover 收到 folder', () => {
const { row, props } = renderRow();
fireEvent.mouseEnter(row);
expect(props.onHover).toHaveBeenCalledWith(folder);
});
it('文件夹行 draggable=true,dragstart 真的到达父级', () => {
const { row, props } = renderRow();
expect(row.getAttribute('draggable')).toBe('true');
fireEvent.dragStart(row);
expect(props.onDragStart).toHaveBeenCalledTimes(1);
expect((props.onDragStart as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toBe(folder);
});
it('renaming 态 draggable=false(别拖 input)', () => {
const { row } = renderRow({ isRenaming: true });
expect(row.getAttribute('draggable')).toBe('false');
});
it('Shift+F10 → 父级 onContextMenuAt 收到坐标', () => {
const { row, props } = renderRow();
fireEvent.keyDown(row, { key: 'F10', shiftKey: true });
expect(props.onContextMenuAt).toHaveBeenCalledTimes(1);
const [f, x, y] = (props.onContextMenuAt as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(f).toBe(folder);
expect(typeof x).toBe('number');
expect(typeof y).toBe('number');
});
it('Enter → 父级 onActivate 收到 folder', () => {
const { row, props } = renderRow();
fireEvent.keyDown(row, { key: 'Enter' });
expect(props.onActivate).toHaveBeenCalledWith(folder);
});
// === r176:树语义 ===
it('行是 treeitem(不是 option)—— aria-level / aria-expanded 只在 treeitem 上有效', () => {
const { row } = renderRow({ depth: 2, expanded: true, hasChildren: true });
expect(row.getAttribute('role')).toBe('treeitem');
expect(row.getAttribute('aria-level')).toBe('3'); // depth + 1
expect(row.getAttribute('aria-expanded')).toBe('true');
});
it('叶子文件夹(无子节点)不带 aria-expanded —— 免得读屏 announce 一个不存在的展开态', () => {
const { row } = renderRow({ hasChildren: false, expanded: false });
expect(row.hasAttribute('aria-expanded')).toBe(false);
});
it('缩进按 depth 递增(层级感是树的核心视觉线索)', () => {
const { row: d0 } = renderRow({ depth: 0 });
const { row: d2 } = renderRow({ depth: 2 });
expect(d0.style.paddingLeft).toBe('12px');
expect(d2.style.paddingLeft).toBe('36px'); // 12 + 2*12
});
it('落点文件夹(isTarget)带 list-row-tree-target 类(与主选中态区分的视觉)', () => {
const { row } = renderRow({ isTarget: true });
expect(row.className).toContain('list-row-tree-target');
});
});

View File

@@ -0,0 +1,89 @@
// tests/unit/renderer/components/import-no-auto-open.test.ts
//
// 守卫(r177):「导入文件后不应该马上自动打开」。
//
// 旧行为(notes 侧 audit-r22 引入的 macOS Finder 风格、literature 侧同款):
// 导入成功 → select(最后一条导入的资源) + setActive('notes'/'library')
// + requestFocusNote(光标丢进 body)
// → 右侧面板被强制切到刚导入的文件,用户正在读的笔记/PDF 被顶掉,读一半
// 的内容被切走,还得再点回去。
//
// 期望契约:导入只做「落盘(必要时搬进当前文件夹)+ toast(+ 列表滚到新行)」,
// **不改变选中态、不切面板、不抢焦点**。要打开,用户自己点那一行。
//
// 本测是**源码契约 guard**:两个列表组件的 importFiles 回调体里不允许出现
// select( / setActive( / requestFocusNote(
// 为什么用源码扫描而不是渲染组件测:
// NotesList / LiteratureList 都依赖 react-virtuoso,jsdom 下容器高度为 0,
// 行根本不渲染 —— 渲染测测不到「点了导入之后有没有自动打开」这个行为,
// 只能退到源码契约。仓库里 layering / i18n-parity 同款做法。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = process.cwd();
/**
* 从 `const importFiles = useCallback(` 起,做花括号配平,截出回调体文本。
* 先剥掉注释(注释里可能有花括号),字符串字面量里的花括号在本仓库这两个
* 回调里不存在(importFiles 的文案 key 都不带 '{}')。
*/
function extractCallbackBody(fileRel: string, marker: string): string {
const src = readFileSync(join(ROOT, fileRel), 'utf8');
const start = src.indexOf(marker);
expect(start, `${fileRel} 里找不到 ${marker}`).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
expect(open, `${fileRel} 里 ${marker} 之后没有 '{'`).toBeGreaterThan(-1);
const stripped = src
.slice(open)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^[ \t]*\/\/.*$/gm, '');
let depth = 0;
for (let i = 0; i < stripped.length; i++) {
const ch = stripped[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return stripped.slice(0, i);
}
}
throw new Error(`${fileRel}: ${marker} 的回调体花括号没配平,提取失败`);
}
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
describe('导入后不自动打开(r177)', () => {
it('notes 侧 importFiles 不 select / 不切面板 / 不抢焦点', () => {
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
expect(body).not.toMatch(/\bselect\(/);
expect(body).not.toMatch(/\bsetActive\(/);
expect(body).not.toMatch(/\brequestFocusNote\(/);
});
it('literature 侧 importFiles 不 select / 不切面板', () => {
const body = extractCallbackBody(LIT, 'const importFiles = useCallback(');
expect(body).not.toMatch(/\bselect\(/);
expect(body).not.toMatch(/\bsetActive\(/);
expect(body).not.toMatch(/\brequestFocusNote\(/);
});
it('notes 侧导入仍然落盘 + 仍搬进当前文件夹 + 仍出成功 toast', () => {
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
expect(body).toMatch(/\bimportFromFile\(/);
expect(body).toMatch(/\bmoveToFolder\(/);
expect(body).toMatch(/toast\.importSuccess/);
});
it('literature 侧导入仍然落盘 + 仍搬进当前文件夹 + 仍出成功 toast', () => {
const body = extractCallbackBody(LIT, 'const importFiles = useCallback(');
expect(body).toMatch(/\bimportFromFile\(/);
expect(body).toMatch(/\bapi\.litMove\(/);
expect(body).toMatch(/literature\.importSuccess/);
});
it('notes 侧仍把新行滚进视野(落点可见),但只是滚动不是选中', () => {
const body = extractCallbackBody(NOTES, 'const importFiles = useCallback(');
expect(body).toMatch(/scrollToIndex/);
});
});

View File

@@ -0,0 +1,116 @@
// tests/unit/renderer/components/list-panel-reset-width.test.tsx
//
// 守 r240「恢复默认宽度」按钮的行为契约。
//
// 背景:左侧列表面板(WorkbenchListPanel)的宽度可拖拽,默认值在 r132/r175/r238
// 三轮里来回调过(320→240→280)。用户拖过分隔条之后没有显式途径回退到默认
// —— 唯一的手势是「双击分隔条」(VerticalResizeHandle 的 onDoubleClick),
// 那是隐藏手势,用户不会知道它存在。r240 加了一个显式按钮。
//
// 本测锁(行为级,不测实现细节):
// A. 按钮真实存在、可点、且**不在 tablist 内部** —— tablist 的 a11y 结构
// 只能含 tab;按钮塞进去会让 NVDA 念出一堆无关项(与 Tabs 的 roving
// tabindex 约定也冲突:Tab 键到不了它)。
// B. 点一下 ⇒ 面板宽度回到默认(不是回落到「上次拖拽前的值」)。
// C. 复位**会落盘**(localStorage 写成默认值)⇒ 重启后仍生效,不是一次性
// 的视觉回弹(hook 的 [width] effect 负责写盘)。
// D. 已经在默认值时点按钮 ⇒ 不抛错、宽度不变(幂等)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
// i18n:只关心交互与键名,不在乎文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// workspace store:ListPanel 只用 active / setActive
vi.mock('../../../../src/renderer/src/store/workspace', () => {
const state = { active: 'library', setActive: vi.fn() };
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useWorkspaceStore.getState = () => state;
return { useWorkspaceStore };
});
// 懒加载的列表组件:本测不关心列表内容,换掉避免 react-virtuoso / IPC 噪声
vi.mock('../../../../src/renderer/src/components/literature/LiteratureList', () => ({
LiteratureList: () => <div data-testid="lit-list" />,
}));
vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({
NotesList: () => <div data-testid="notes-list" />,
}));
import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel';
import {
LIST_PANEL_DEFAULT,
LIST_PANEL_WIDTH_KEY,
} from '../../../../src/renderer/src/components/layout/list-panel-width';
const RESET_LABEL = 'workbench.listPanelResetWidth';
function renderPanel() {
const { container } = render(<WorkbenchListPanel />);
const section = container.querySelector('section') as HTMLElement;
const button = container.querySelector(`button[aria-label="${RESET_LABEL}"]`) as HTMLElement;
return { container, section, button };
}
describe('左侧列表面板「恢复默认宽度」按钮(r240)', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('按钮存在,且在 tablist **之外**(不污染 tablist 的 a11y 结构)', () => {
const { container, button } = renderPanel();
expect(button).toBeTruthy();
expect(container.querySelector('[role="tablist"]')).toBeTruthy();
// 按钮不能是 tablist 的后代
expect(container.querySelector(`[role="tablist"] button[aria-label="${RESET_LABEL}"]`)).toBeNull();
// 也不能被塞进 tabpanel(那是列表内容的容器)
expect(container.querySelector(`[role="tabpanel"] button[aria-label="${RESET_LABEL}"]`)).toBeNull();
});
it('点按钮 ⇒ 宽度回到默认值(从用户拖过的宽度回退)', () => {
// 模拟用户拖到 400(偏好已落盘)
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
const { section, button } = renderPanel();
expect(section.style.width).toBe('400px');
fireEvent.click(button);
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
});
it('复位会落盘 ⇒ 重启后仍是默认值(不是一次性视觉回弹)', () => {
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
const { button } = renderPanel();
fireEvent.click(button);
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe(String(LIST_PANEL_DEFAULT));
});
it('已经在默认值时点按钮 ⇒ 幂等(宽度不变,不抛错)', () => {
const { section, button } = renderPanel();
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
fireEvent.click(button);
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe(String(LIST_PANEL_DEFAULT));
});
it('复位目标始终是**当前**默认值常量(不是硬编码的旧数字)', () => {
// 语义锁:按钮复位到 list-panel-width.ts 里的默认值 ——
// 以后默认值再变(r240 之后可能还有 r24x),按钮自动跟随,不会退回旧数字。
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
const { section, button } = renderPanel();
fireEvent.click(button);
expect(section.style.width).toBe(`${LIST_PANEL_DEFAULT}px`);
expect(LIST_PANEL_DEFAULT).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,143 @@
// tests/unit/renderer/components/list-panel-width-policy.test.ts
//
// 守左侧列表面板(文献/笔记 Tab)的宽度策略(r175 起,r241 改默认值):
//
// 1) 数值本身:min=200、default=300、max=720。
// (r241:用户第二次说「左侧栏默认宽度可以稍微宽一些」⇒ default 280→300;
// min / max 不动 —— 拖宽天花板保留,最小宽度不动)
// 2) **storageKey 升版** —— 每次改默认值都必须换 key。原因:hook 的
// [width] effect 会把当前宽度(含首次挂载时的默认值)落盘到 localStorage
// ⇒ 「默认值」第一次运行后就被固化。只改常量不改 key ⇒ 存量用户永远
// 看不到新默认(r175 / r238 / r241 各踩过一次)。
// 3) **定向迁移** —— 升版的同时把旧 key 里「用户真拖过」的宽度搬到新 key;
// 等于旧默认(280)的那个值视为自动落盘,不迁移 ⇒ 新默认生效。
// 这比 r175 的「纯升版,手动偏好一起重置」更友好。
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { renderHook, act, cleanup } from '@testing-library/react';
import {
LIST_PANEL_MIN,
LIST_PANEL_MAX,
LIST_PANEL_DEFAULT,
LIST_PANEL_WIDTH_KEY,
LEGACY_LIST_PANEL_WIDTH_KEY,
migrateLegacyListPanelWidth,
} from '../../../../src/renderer/src/components/layout/list-panel-width';
import { useResizablePanel } from '../../../../src/renderer/src/hooks/useResizablePanel';
function mountPanel(storageKey: string = LIST_PANEL_WIDTH_KEY) {
return renderHook(() =>
useResizablePanel({
storageKey,
defaultWidth: LIST_PANEL_DEFAULT,
min: LIST_PANEL_MIN,
max: LIST_PANEL_MAX,
side: 'left',
}),
);
}
describe('左侧列表面板宽度策略(r241)', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('min / default / max 关系合法', () => {
expect(LIST_PANEL_MIN).toBeGreaterThan(0);
expect(LIST_PANEL_DEFAULT).toBeGreaterThanOrEqual(LIST_PANEL_MIN);
expect(LIST_PANEL_MAX).toBeGreaterThan(LIST_PANEL_DEFAULT);
});
it('默认宽度 = 300(r241:280→300),min 200 / max 720 不动', () => {
expect(LIST_PANEL_MIN).toBe(200);
expect(LIST_PANEL_DEFAULT).toBe(300);
expect(LIST_PANEL_MAX).toBe(720);
});
it('storageKey 已升版 —— v3 旧 key 不再被直接读取', () => {
expect(LIST_PANEL_WIDTH_KEY).not.toBe(LEGACY_LIST_PANEL_WIDTH_KEY);
expect(LIST_PANEL_WIDTH_KEY).toBe('workbench.listPanel.width.v4');
expect(LEGACY_LIST_PANEL_WIDTH_KEY).toBe('workbench.listPanel.width.v3');
});
it('无偏好时用默认宽度', () => {
const { result } = mountPanel();
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
});
it('新 key 里越界的旧值被丢弃 → 回落默认', () => {
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '1500');
const { result } = mountPanel();
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
});
it('新 key 里的合法偏好被保留(手动拖过的宽度不丢)', () => {
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '400');
const { result } = mountPanel();
expect(result.current.width).toBe(400);
});
it('setWidth 夹紧到 [min, max] —— 最小宽度真的生效', () => {
const { result } = mountPanel();
act(() => result.current.setWidth(10));
expect(result.current.width).toBe(LIST_PANEL_MIN);
act(() => result.current.setWidth(99999));
expect(result.current.width).toBe(LIST_PANEL_MAX);
});
});
describe('v3 → v4 定向迁移(r241)', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('v3 里「用户真拖过」的宽度被搬到 v4', () => {
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '400');
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe('400');
});
it('v3 里等于旧默认 280 的值不迁移 ⇒ 新默认 300 生效', () => {
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '280');
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
const { result } = mountPanel();
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
});
it('v4 已有值时不覆盖(幂等 —— 不推翻新 key 下已有的偏好)', () => {
localStorage.setItem(LIST_PANEL_WIDTH_KEY, '360');
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '400');
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBe('360');
});
it('v3 里越界的脏值不迁移', () => {
localStorage.setItem(LEGACY_LIST_PANEL_WIDTH_KEY, '1500');
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
});
it('v3 缺失(全新安装)⇒ 不迁移,直接用新默认', () => {
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
const { result } = mountPanel();
expect(result.current.width).toBe(LIST_PANEL_DEFAULT);
});
it('v2 及更早的 key 不再参与迁移(只认上一版 v3)', () => {
// v2 是 r238 之前的 key —— r241 的迁移源是 v3。
// 若实现误把 v2 也当来源,r238 时代「手动拖过 400」的老用户会在
// 两次升版后被重新当成有效偏好(本该在 r238 就处理完)。
localStorage.setItem('workbench.listPanel.width.v2', '400');
migrateLegacyListPanelWidth();
expect(localStorage.getItem(LIST_PANEL_WIDTH_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,271 @@
// tests/unit/renderer/components/markdown-editor-history.test.tsx
//
// 守「笔记编辑的撤销 / 重做」在**编辑器层**的三条契约:
// 1. 能力上报:doc 变了之后,编辑器把「现在能不能撤 / 能不能重做」告诉上层,
// 且**只在翻转时**上报(每次击键都上报 ⇒ 父级每次击键都重渲 = perf 回归);
// 2. 外部灌入的正文**不进撤销栈**(用户按撤销撤的是「自己敲的字」,
// 不是「外部替换进来的整篇」)⇒ dispatch 必须带 addToHistory:false;
// 3. 换文档(docToken 变化)⇒ 重建 state ⇒ 撤销栈归零 —— 否则在新笔记里
// 按「撤销」会撤掉**上一篇**笔记的字(还会被 autosave 写进新笔记,
// 跨笔记污染正文)。
//
// 第 3 条是本轮最隐蔽的一条:CodeMirror 的 history 是挂在 EditorState 上的
// StateField,切笔记只是把正文整段替换掉,旧笔记的编辑事件仍留在栈里。
// 「整段替换 + addToHistory:false」顺带清栈的路子依赖「替换范围覆盖全部
// 历史事件」—— 正文为空时替换是空操作 ⇒ 清不掉 ⇒ 必须走 setState 重建。
//
// 变异思路(哪几处改动会红):
// - 去掉 reportHistory 调用 / 不比较翻转 ⇒ 例 1 红;
// - 去掉 annotations(Transaction.addToHistory.of(false)) ⇒ 例 2 红;
// - setState 路径改成普通 dispatch(或不重建) ⇒ 例 3 红。
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
import { useState } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// ── 假 CodeMirror ─────────────────────────────────────────────────────────
// 够用即可:实现本组件用到的那几个 API,并把 updateListener 的回调暴露出来,
// 让测试能手动「敲一次键」。撤销栈用两个计数器模拟:
// undoDepth/redoDepth —— 当前栈深(> 0 即「能撤 / 能重做」);
// EditorState.create —— 造新 state ⇒ 栈归零(真实 CodeMirror 的 history
// 是 StateField,新 state = 字段初始值 = 空栈)。
const cm = vi.hoisted(() => {
const api = {
doc: '',
undoDepth: 0,
redoDepth: 0,
dispatches: [] as Array<{ changes?: { insert?: string }; annotations?: unknown }>,
listeners: [] as Array<(u: unknown) => void>,
setStates: [] as Array<{ doc: string }>,
focusCalls: 0,
undoCalls: 0,
redoCalls: 0,
view: null as null | {
state: unknown;
dispatch: (tr: unknown) => void;
setState: (s: unknown) => void;
focus: () => void;
},
makeDoc() {
return {
get length() {
return api.doc.length;
},
toString() {
return api.doc;
},
};
},
reset() {
api.doc = '';
api.undoDepth = 0;
api.redoDepth = 0;
api.dispatches = [];
api.listeners = [];
api.setStates = [];
api.focusCalls = 0;
api.undoCalls = 0;
api.redoCalls = 0;
api.view = null;
},
};
return api;
});
vi.mock('@codemirror/state', () => ({
EditorState: {
create({ doc, extensions }: { doc: string; extensions: unknown[] }) {
cm.doc = doc;
cm.listeners = [];
// 新 state ⇒ 撤销栈归零(history 是 StateField,新 state = 初始值)
cm.undoDepth = 0;
cm.redoDepth = 0;
for (const ext of extensions) {
const e = ext as { __kind?: string; __fn?: (u: unknown) => void };
if (e && e.__kind === 'updateListener' && typeof e.__fn === 'function') {
cm.listeners.push(e.__fn);
}
}
return { doc: cm.makeDoc(), extensions };
},
},
Transaction: {
addToHistory: { of: (v: boolean) => ({ __kind: 'addToHistory', value: v }) },
},
}));
vi.mock('@codemirror/view', () => {
const facet = (kind: string) => ({
of: (v: unknown) => ({ __kind: kind, __fn: v }),
});
class EditorView {
state: unknown;
parent: unknown;
constructor(opts: { state: unknown; parent: unknown }) {
this.state = opts.state;
this.parent = opts.parent;
cm.view = this as never;
}
dispatch(tr: { changes?: { insert?: string }; annotations?: unknown }) {
cm.dispatches.push(tr);
if (tr && tr.changes) {
cm.doc = tr.changes.insert ?? cm.doc;
this.state = { doc: cm.makeDoc() };
for (const l of cm.listeners) l({ docChanged: true, state: this.state });
}
}
setState(state: { doc: { toString: () => string } }) {
cm.setStates.push({ doc: state.doc.toString() });
cm.doc = state.doc.toString();
this.state = state;
}
focus() {
cm.focusCalls += 1;
}
destroy() {
/* noop */
}
static theme() {
return { __kind: 'theme' };
}
static lineWrapping = { __kind: 'lineWrapping' };
static contentAttributes = facet('contentAttributes');
static updateListener = facet('updateListener');
}
return {
EditorView,
keymap: { of: () => ({ __kind: 'keymap' }) },
drawSelection: () => ({ __kind: 'drawSelection' }),
dropCursor: () => ({ __kind: 'dropCursor' }),
rectangularSelection: () => ({ __kind: 'rectangularSelection' }),
};
});
vi.mock('@codemirror/commands', () => ({
defaultKeymap: [],
historyKeymap: [],
indentWithTab: { __kind: 'indentWithTab' },
history: () => ({ __kind: 'history' }),
undoDepth: () => cm.undoDepth,
redoDepth: () => cm.redoDepth,
undo: () => {
cm.undoCalls += 1;
return true;
},
redo: () => {
cm.redoCalls += 1;
return true;
},
}));
vi.mock('@codemirror/lang-markdown', () => ({
markdown: () => ({ __kind: 'markdown' }),
markdownLanguage: { __kind: 'markdownLanguage' },
}));
import { MarkdownEditor, type EditorHistoryFlags } from '../../../../src/renderer/src/components/editor/MarkdownEditor';
/** 模拟一次击键:doc 变了 ⇒ CodeMirror 触发 updateListener(并进撤销栈)。 */
function type(text: string): void {
cm.doc = text;
cm.undoDepth = 1;
for (const l of cm.listeners) l({ docChanged: true, state: cm.view?.state });
}
describe('MarkdownEditor:撤销 / 重做(编辑器层)', () => {
beforeEach(() => {
cm.reset();
});
it('1. 能力上报:doc 变了之后上报 canUndo,只在**翻转**时上报', async () => {
const seen: EditorHistoryFlags[] = [];
function Harness() {
const [v, setV] = useState('hello');
return (
<MarkdownEditor
value={v}
onChange={setV}
onHistoryChange={(f) => seen.push(f)}
/>
);
}
render(<Harness />);
await waitFor(() => expect(cm.view).not.toBeNull());
// 还没敲过字 ⇒ 栈是空的 ⇒ 上报一次 canUndo=false
expect(seen.length).toBeGreaterThan(0);
expect(seen[seen.length - 1]).toEqual({ canUndo: false, canRedo: false });
// 敲一次字 ⇒ 栈深 1 ⇒ 上报 canUndo=true
act(() => {
type('hello!');
});
expect(seen[seen.length - 1]).toEqual({ canUndo: true, canRedo: false });
// 再敲几次 ⇒ 能力位没翻转 ⇒ **不得**再上报(否则父级每次击键都重渲)
const countAfterFlip = seen.length;
act(() => {
type('hello!!');
type('hello!!!');
});
expect(seen.length, '能力位没翻转就不该再上报').toBe(countAfterFlip);
});
it('2. 外部灌入的正文不进撤销栈(dispatch 带 addToHistory:false)', async () => {
function Controlled({ value }: { value: string }) {
return <MarkdownEditor value={value} onChange={() => {}} />;
}
const { rerender } = render(<Controlled value="hello" />);
await waitFor(() => expect(cm.view).not.toBeNull());
rerender(<Controlled value="外部写进来的正文" />);
await waitFor(() => expect(cm.dispatches.length).toBeGreaterThan(0));
const tr = cm.dispatches[0] as { annotations?: { __kind?: string; value?: boolean } };
expect(tr.annotations?.__kind, '外部同步必须带 addToHistory 标注').toBe('addToHistory');
expect(tr.annotations?.value).toBe(false);
});
it('3. 换文档(docToken 变化)⇒ 重建 state ⇒ 撤销栈归零 + 灌入新正文', async () => {
function Controlled({ value, token }: { value: string; token: number }) {
return <MarkdownEditor value={value} onChange={() => {}} docToken={token} />;
}
const { rerender } = render(<Controlled value="第一篇" token={1} />);
await waitFor(() => expect(cm.view).not.toBeNull());
// 用户在第一篇里敲了字 ⇒ 栈深 1
act(() => {
type('第一篇改过了');
});
expect(cm.undoDepth).toBe(1);
// 切到第二篇(token 变)⇒ 重建 state ⇒ 栈归零
rerender(<Controlled value="第二篇" token={2} />);
await waitFor(() => expect(cm.setStates.length).toBe(1));
expect(cm.setStates[0].doc).toBe('第二篇');
expect(cm.undoDepth, '换文档后撤销栈必须归零').toBe(0);
});
it('4. 命令式句柄:undo / redo 走 CodeMirror 的命令,并把焦点还给编辑器', async () => {
const apiRef: { current: import('../../../../src/renderer/src/components/editor/MarkdownEditor').MarkdownEditorApi | null } =
{ current: null };
render(<MarkdownEditor value="hello" onChange={() => {}} apiRef={apiRef} />);
await waitFor(() => expect(cm.view).not.toBeNull());
expect(apiRef.current, '父级必须拿得到命令式句柄').toBeTruthy();
act(() => {
apiRef.current?.undo();
});
expect(cm.undoCalls).toBe(1);
// 点按钮会把焦点挪到按钮上 ⇒ 撤销后必须把焦点还给编辑器,光标才看得见
expect(cm.focusCalls).toBeGreaterThan(0);
act(() => {
apiRef.current?.redo();
});
expect(cm.redoCalls).toBe(1);
});
});

View File

@@ -0,0 +1,204 @@
// tests/unit/renderer/components/markdown-editor-sync-cost.test.tsx
//
// 守 r247「每次击键造两份全文字符串」的修复契约 ——
// MarkdownEditor 的「value → view 同步」这一步不得再把整篇正文
// (doc.toString())重新构一遍。
//
// 背景(用户诉求「使用流畅,不要有卡顿」):
// 旧实现在**每次击键**做两次全文字符串构建:
// ① updateListener:`onChangeRef.current(u.state.doc.toString())`
// —— 必需(要把新正文交给上层),O(全文长);
// ② effect [value]:`view.state.doc.toString() !== value`
// —— 又造一份同样的全文字符串,只为了回答「doc 是不是已经等于
// value?」;而答案几乎恒为「相等」(value 就是 ① 刚发上去的那份)
// ⇒ 整次 O(n) 构建 + 比较纯属浪费。
// 10 万字笔记 = 每次击键多造一个 10 万字符的字符串,连续输入时
// GC 压力明显(整串不可增量复用)。
//
// 修法:lastEmittedRef 记「本编辑器最后一次自己发出去的正文」;
// value === lastEmitted ⇒ doc 里已经是它 ⇒ O(1) 短路,连 toString 都不调。
//
// 本测锁两个可观测量:
// A. 击键 N 次 ⇒ doc.toString() 调用次数 = 1(mount 时的初始比对)+ N(每次
// 击键 listener 那一次)—— **effect 那一步必须是 0 次**;
// B. 外部改 value(切笔记 / 外部写 body)⇒ 仍必须走完整比较并 dispatch
// (短路不能把外部改动吃掉)。
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, act, waitFor } from '@testing-library/react';
import { useState } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// ── 假 CodeMirror ─────────────────────────────────────────────────────────
// 够用即可:只实现本组件用到的那几个 API,并把 updateListener 的回调
// 暴露出来,让测试能手动「敲一次键」。
const cm = vi.hoisted(() => {
const api = {
doc: '',
toStringCalls: 0,
dispatches: [] as Array<unknown>,
listeners: [] as Array<(u: unknown) => void>,
view: null as null | { state: unknown; dispatch: (tr: unknown) => void },
// 撤销栈深度(假 CodeMirror 专用)—— 供 undoDepth/redoDepth 回答
undoDepth: 0,
redoDepth: 0,
makeDoc() {
return {
get length() {
return api.doc.length;
},
toString() {
api.toStringCalls += 1;
return api.doc;
},
};
},
reset() {
api.doc = '';
api.toStringCalls = 0;
api.dispatches = [];
api.listeners = [];
api.view = null;
api.undoDepth = 0;
api.redoDepth = 0;
},
};
return api;
});
vi.mock('@codemirror/state', () => ({
EditorState: {
create({ doc, extensions }: { doc: string; extensions: unknown[] }) {
cm.doc = doc;
cm.listeners = [];
for (const ext of extensions) {
const e = ext as { __kind?: string; __fn?: (u: unknown) => void };
if (e && e.__kind === 'updateListener' && typeof e.__fn === 'function') {
cm.listeners.push(e.__fn);
}
}
return { doc: cm.makeDoc(), extensions };
},
},
// 撤销栈相关:外部灌入的正文必须带 addToHistory:false(不进撤销栈)
Transaction: {
addToHistory: { of: (v: boolean) => ({ __kind: 'addToHistory', value: v }) },
},
}));
vi.mock('@codemirror/view', () => {
const facet = (kind: string) => ({
of: (v: unknown) => ({ __kind: kind, __fn: v }),
});
class EditorView {
state: unknown;
parent: unknown;
constructor(opts: { state: unknown; parent: unknown }) {
this.state = opts.state;
this.parent = opts.parent;
cm.view = this as never;
}
dispatch(tr: { changes?: { insert?: string } }) {
cm.dispatches.push(tr);
if (tr && tr.changes) {
cm.doc = tr.changes.insert ?? cm.doc;
this.state = { doc: cm.makeDoc() };
for (const l of cm.listeners) l({ docChanged: true, state: this.state });
}
}
destroy() {
/* noop */
}
static theme() {
return { __kind: 'theme' };
}
static lineWrapping = { __kind: 'lineWrapping' };
static contentAttributes = facet('contentAttributes');
static updateListener = facet('updateListener');
}
return {
EditorView,
keymap: { of: () => ({ __kind: 'keymap' }) },
drawSelection: () => ({ __kind: 'drawSelection' }),
dropCursor: () => ({ __kind: 'dropCursor' }),
rectangularSelection: () => ({ __kind: 'rectangularSelection' }),
};
});
vi.mock('@codemirror/commands', () => ({
defaultKeymap: [],
historyKeymap: [],
indentWithTab: { __kind: 'indentWithTab' },
history: () => ({ __kind: 'history' }),
// 撤销栈深度 —— 编辑器每次状态变化都会问一次(按钮 enabled 态的来源)
undoDepth: () => cm.undoDepth,
redoDepth: () => cm.redoDepth,
undo: () => true,
redo: () => true,
}));
vi.mock('@codemirror/lang-markdown', () => ({
markdown: () => ({ __kind: 'markdown' }),
markdownLanguage: { __kind: 'markdownLanguage' },
}));
import { MarkdownEditor } from '../../../../src/renderer/src/components/editor/MarkdownEditor';
/** 模拟一次击键:doc 变了 ⇒ CodeMirror 触发 updateListener。 */
function type(text: string): void {
cm.doc = text;
for (const l of cm.listeners) l({ docChanged: true, state: cm.view?.state });
}
function Harness({ initial }: { initial: string }) {
const [v, setV] = useState(initial);
return (
<>
<MarkdownEditor value={v} onChange={setV} />
<output data-testid="out">{v}</output>
</>
);
}
/** 受控 harness:同一个编辑器实例上换 value(模拟外部改 body / 换笔记)。 */
function Controlled({ value }: { value: string }) {
return <MarkdownEditor value={value} onChange={() => {}} />;
}
describe('perf r247:MarkdownEditor 击键同步成本', () => {
beforeEach(() => {
cm.reset();
});
it('连击 N 次 ⇒ doc.toString() 只在「mount 比对 + 每次击键的 listener」被调,effect 那一步为 0', async () => {
const { getByTestId } = render(<Harness initial="hello" />);
await waitFor(() => expect(cm.view).not.toBeNull());
// mount:创建 view 后比对一次(initialDoc === value ⇒ 不 dispatch)
expect(cm.toStringCalls, 'mount 只应比对一次').toBe(1);
for (const text of ['hello!', 'hello!!', 'hello!!!']) {
act(() => {
type(text);
});
expect(getByTestId('out').textContent, '击键内容必须真的到了上层').toBe(text);
}
// 3 次击键 ⇒ listener 3 次 + mount 1 次 = 4
// 旧实现 = 4 + 3(effect 每次再构一份全文)= 7
expect(cm.toStringCalls, '同步 effect 不得再构全文').toBe(4);
});
it('外部改 value ⇒ 仍走完整比较并 dispatch(短路不能吃掉外部改动)', async () => {
const { rerender } = render(<Controlled value="hello" />);
await waitFor(() => expect(cm.view).not.toBeNull());
rerender(<Controlled value="外部写进来的正文" />);
await waitFor(() => expect(cm.dispatches.length).toBeGreaterThan(0));
const tr = cm.dispatches[0] as { changes: { insert: string } };
expect(tr.changes.insert, '外部正文必须被 dispatch 进编辑器').toBe('外部写进来的正文');
expect(cm.doc).toBe('外部写进来的正文');
});
});

View File

@@ -0,0 +1,232 @@
// tests/unit/renderer/components/panel-resize-drag.test.tsx
//
// 守「拖拽分隔条改面板宽度」这一交互在**拖拽过程中**的行为契约。
//
// 背景(r199 修复的三个用户可见缺陷):
// 1. 左侧列表面板:宽度写在 outer <section> 上,而 hook 的 panelRef 绑在
// inner div → 拖拽期间 hook 直写的是 inner div 的 width,outer section
// 的 width 仍是 React state ⇒ **面板在拖拽过程中根本不变**,内容溢出
// 压到分隔条上,分隔条也不跟鼠标走 ⇒ 「鼠标点击拉动」操作失效。
// 2. 文献 PDF 区域:iframe 直接 100% 跟随容器宽度 ⇒ 拖拽每一帧都在重排
// Chromium PDF 渲染(实测每帧 +16ms,60fps 掉到 ~30fps)⇒ 缩小时很卡。
// 3. 拖拽期间父级若重渲,React 会用旧 state 覆写 style.width ⇒ 面板跳回。
//
// 本测锁(行为级):
// A. 拖拽过程中,承载视觉宽度的元素宽度必须**跟着鼠标实时变**;
// B. 拖拽过程中,PDF iframe 的尺寸必须**冻结**(不让 PDF 每帧重排),
// mouseup 之后才允许跟上新尺寸;
// C. 拖拽结束后,宽度落定且与拖拽结果一致。
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup, act } from '@testing-library/react';
// jsdom 没有 PointerEvent(见 memory jsdom-test-gaps:按需 per-case 垫片,
// 不污染全局 setup)。手柄改用 pointer 事件后,必须补上这个类 —— 否则
// fireEvent.pointerDown 退化成普通 Event,clientX / button / pointerId
// 全部丢失,测出来的行为不是浏览器里的行为。
class FakePointerEvent extends MouseEvent {
pointerId: number;
pointerType: string;
isPrimary: boolean;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
this.pointerType = init.pointerType ?? 'mouse';
this.isPrimary = init.isPrimary ?? true;
}
}
beforeAll(() => {
(window as unknown as { PointerEvent: unknown }).PointerEvent = FakePointerEvent;
});
// i18n:只关心交互,不关心文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// workspace store:ListPanel 只用 active / setActive
vi.mock('../../../../src/renderer/src/store/workspace', () => {
const state = { active: 'library', setActive: vi.fn() };
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useWorkspaceStore.getState = () => state;
return { useWorkspaceStore };
});
// 懒加载的列表组件:本测不关心列表内容,换掉避免 react-virtuoso / IPC 噪声
vi.mock('../../../../src/renderer/src/components/literature/LiteratureList', () => ({
LiteratureList: () => <div data-testid="lit-list" />,
}));
vi.mock('../../../../src/renderer/src/components/notes/NotesList', () => ({
NotesList: () => <div data-testid="notes-list" />,
}));
// notes store:WorkbenchContentPanel 只读 selectedId(笔记列是否打开)
vi.mock('../../../../src/renderer/src/store/notes', () => {
const state = { selectedId: 'n1', select: vi.fn() };
const useNotesStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useNotesStore.getState = () => state;
return { useNotesStore };
});
// literature store:WorkbenchContentPanel 读 items + selectedId 派生 ReaderFile
vi.mock('../../../../src/renderer/src/store/literature', () => {
const state = {
items: [{ id: 'l1', absPath: 'C:/papers/a.pdf' }],
selectedId: 'l1',
select: vi.fn(),
};
const useLiteratureStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useLiteratureStore.getState = () => state;
return { useLiteratureStore };
});
// 懒加载的阅读器 / 笔记视图:本测只关心分隔条与列宽,不关心内容
vi.mock('../../../../src/renderer/src/components/reader/Reader', () => ({
Reader: () => <div data-testid="reader" />,
}));
vi.mock('../../../../src/renderer/src/components/notes/NotesView', () => ({
NotesView: () => <div data-testid="notes-view" />,
}));
import { WorkbenchListPanel } from '../../../../src/renderer/src/components/layout/WorkbenchListPanel';
import { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
import {
LIST_PANEL_MIN,
LIST_PANEL_DEFAULT as DEFAULT_W,
} from '../../../../src/renderer/src/components/layout/list-panel-width';
import { isResizeDragActive } from '../../../../src/renderer/src/lib/resize-drag-registry';
function drag(separator: HTMLElement, fromX: number, toX: number) {
// r234:整条手势必须发 **pointer 系**事件 —— 真实 Chromium 在
// pointerdown 被 preventDefault(useResizablePanel.onResizeStart 就是这么
// 做的)后,整个手势**不再发任何兼容 mouse 事件**(PointerEvents-v3;
// 真机 Electron 30 / Chromium 124 + CDP 实证:页面只见 pointerdown /
// pointermove / pointerup,mousemove / mouseup 一个都没有)。
// 旧版这里发 pointerdown + mousemove + mouseup 的混合序列 —— 真实浏览器
// 从不这么发,jsdom 里却畅通 ⇒ hook 监听 mouse 系时测试假绿、真机卡死。
fireEvent.pointerDown(separator, { clientX: fromX, clientY: 100, button: 0, pointerId: 1 });
act(() => {
fireEvent.pointerMove(window, { clientX: toX, clientY: 100, pointerId: 1 });
});
return () => {
act(() => {
fireEvent.pointerUp(window, { clientX: toX, clientY: 100, pointerId: 1 });
});
};
}
describe('面板分隔条拖拽', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
});
it('拖拽过程中承载宽度的 section 宽度实时跟随鼠标(不只在 mouseup 后才变)', () => {
const { container } = render(<WorkbenchListPanel />);
const section = container.querySelector('section') as HTMLElement;
const separator = container.querySelector('[role="separator"]') as HTMLElement;
expect(section).toBeTruthy();
expect(separator).toBeTruthy();
// 初始宽度来自 state
expect(section.style.width).toBe(`${DEFAULT_W}px`);
const end = drag(separator, 1000, 1100); // 向右拖 100px ⇒ 加宽 100px
// 拖拽中:宽度必须已经变了(实时跟手),而不是停在初始值
expect(section.style.width).toBe(`${DEFAULT_W + 100}px`);
end();
// mouseup 后仍然一致
expect(section.style.width).toBe(`${DEFAULT_W + 100}px`);
});
it('拖拽收窄时被最小宽度夹住(不会缩到 0)', () => {
const { container } = render(<WorkbenchListPanel />);
const section = container.querySelector('section') as HTMLElement;
const separator = container.querySelector('[role="separator"]') as HTMLElement;
const end = drag(separator, 1000, 200); // 向左拖 800px ⇒ 远超最小宽度
expect(section.style.width).toBe(`${LIST_PANEL_MIN}px`);
end();
expect(section.style.width).toBe(`${LIST_PANEL_MIN}px`);
});
it('拖拽期间全局登记「正在拖拽」—— PDF 阅读器据此冻结尺寸', () => {
const { container } = render(<WorkbenchListPanel />);
const separator = container.querySelector('[role="separator"]') as HTMLElement;
expect(isResizeDragActive()).toBe(false);
const end = drag(separator, 1000, 1100);
expect(isResizeDragActive()).toBe(true);
end();
expect(isResizeDragActive()).toBe(false);
});
});
// r241:默认宽度 280→300,storageKey v3→v4 —— 面板层面验证:
// (1) v3 里「用户真拖过」的宽度被迁移并生效(不被新默认覆盖);
// (2) v3 里等于旧默认 280 的值 = 自动落盘 ⇒ 走新默认 300。
// (r238 那轮是 v2→v3 / 240→280;迁移源随每次升版往前挪一版。)
describe('左侧面板宽度默认值升级(r241)', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('v3 里手动拖过的宽度(400)被迁移过来并生效', () => {
localStorage.setItem('workbench.listPanel.width.v3', '400');
const { container } = render(<WorkbenchListPanel />);
const section = container.querySelector('section') as HTMLElement;
expect(section.style.width).toBe('400px');
});
it('v3 里是旧默认 280 ⇒ 不迁移,回落到新默认 300', () => {
localStorage.setItem('workbench.listPanel.width.v3', '280');
const { container } = render(<WorkbenchListPanel />);
const section = container.querySelector('section') as HTMLElement;
expect(section.style.width).toBe('300px');
});
});
describe('主区文献列(双列)分隔条拖拽', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('文献列(PDF 区域)宽度在拖拽过程中实时跟随鼠标', () => {
const { container } = render(<WorkbenchContentPanel />);
const separator = container.querySelector('[role="separator"]') as HTMLElement;
expect(separator).toBeTruthy();
// 分隔条左边那个 div 就是文献列(承载 width 的元素)
const readerCol = separator.previousElementSibling as HTMLElement;
expect(readerCol.style.width).toBe('640px');
const end = drag(separator, 1000, 900); // 向左拖 100px ⇒ 文献列收窄 100px
expect(readerCol.style.width).toBe('540px');
end();
expect(readerCol.style.width).toBe('540px');
});
});

View File

@@ -0,0 +1,207 @@
// tests/unit/renderer/components/pdf-reader-resize-freeze.test.tsx
//
// 守 r199「拖面板宽度时 PDF 区域很卡」的修复契约 —— PDF iframe 的**尺寸**
// 在面板拖拽期间必须冻结,拖拽结束才跟上。
//
// 为什么这是行为契约(而不是实现细节):
// Chromium 的 PDF 渲染器在 iframe 尺寸变化时必须重排 + 重新光栅化可见页面。
// 实测(Chromium,本地 http 多页 PDF,每帧改一次宽度):
// 不动 → 16.3ms/帧
// 改普通 HTML iframe 尺寸 → 16.4ms/帧
// 改 PDF iframe 尺寸 → 32.8ms/帧 ← 60fps 掉到 ~30fps
// 改容器尺寸但冻结 iframe 尺寸 → 16.4ms/帧
// 即「iframe 尺寸每帧变」就是那 ~16ms/帧的来源。本测锁住「尺寸冻结」
// 这条契约 —— 将来谁把 iframe 改回 w-full 跟随容器,本测立刻转红。
//
// 附带锁:拖拽结束后尺寸必须**跟上**最终值(冻结不能变成「永远不更新」)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { PDFReader } from '../../../../src/renderer/src/components/reader/PDFReader';
import {
beginResizeDrag,
endResizeDrag,
resetResizeDragRegistry,
} from '../../../../src/renderer/src/lib/resize-drag-registry';
// ── jsdom 垫片 ────────────────────────────────────────────────────────────
// jsdom 没有 ResizeObserver,也没有真实布局(getBoundingClientRect 全 0)。
// 这里自造一个可手动触发的 ResizeObserver + 一个可变矩形。
type Observed = { target: Element; contentRect: DOMRectReadOnly };
const observers: Array<{ cb: (entries: Observed[]) => void; targets: Set<Element> }> = [];
class FakeResizeObserver {
private rec: { cb: (entries: Observed[]) => void; targets: Set<Element> };
constructor(cb: (entries: Observed[]) => void) {
this.rec = { cb, targets: new Set() };
observers.push(this.rec);
}
observe(el: Element) {
this.rec.targets.add(el);
}
unobserve(el: Element) {
this.rec.targets.delete(el);
}
disconnect() {
this.rec.targets.clear();
}
}
function emitResize(): void {
for (const rec of observers) {
rec.cb(
Array.from(rec.targets).map((target) => ({
target,
contentRect: { width: 0, height: 0 } as DOMRectReadOnly,
})),
);
}
}
let rect = { width: 640, height: 800 };
function iframeOf(container: HTMLElement): HTMLIFrameElement {
return container.querySelector('iframe') as HTMLIFrameElement;
}
describe('PDF 阅读器:面板拖拽期间冻结 iframe 尺寸(r199)', () => {
beforeEach(() => {
observers.length = 0;
rect = { width: 640, height: 800 };
resetResizeDragRegistry();
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
() =>
({
width: rect.width,
height: rect.height,
top: 0,
left: 0,
right: rect.width,
bottom: rect.height,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect,
);
});
afterEach(() => {
cleanup();
resetResizeDragRegistry();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('首帧就把 iframe 尺寸对齐容器(不留在 100% 跟随态)', () => {
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
const iframe = iframeOf(container);
expect(iframe.style.width).toBe('640px');
expect(iframe.style.height).toBe('800px');
});
it('拖拽期间容器变窄 → iframe 尺寸冻结不动(不让 PDF 每帧重排)', () => {
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
const iframe = iframeOf(container);
expect(iframe.style.width).toBe('640px');
act(() => {
beginResizeDrag();
});
// 拖拽中:容器变窄(面板收窄),iframe 尺寸必须**保持** 640
rect = { width: 400, height: 800 };
act(() => {
emitResize();
});
expect(iframe.style.width).toBe('640px');
// 再收窄一次,依然冻结
rect = { width: 320, height: 800 };
act(() => {
emitResize();
});
expect(iframe.style.width).toBe('640px');
});
it('拖拽结束 → iframe 一次性跟上最终尺寸(冻结不是「永远不更新」)', () => {
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
const iframe = iframeOf(container);
act(() => {
beginResizeDrag();
});
rect = { width: 400, height: 800 };
act(() => {
emitResize();
});
expect(iframe.style.width).toBe('640px');
act(() => {
endResizeDrag();
});
expect(iframe.style.width).toBe('400px');
expect(iframe.style.height).toBe('800px');
});
it('非拖拽场景(窗口缩放):尺寸落定后更新', () => {
vi.useFakeTimers();
try {
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
const iframe = iframeOf(container);
expect(iframe.style.width).toBe('640px');
rect = { width: 500, height: 800 };
act(() => {
emitResize();
vi.advanceTimersByTime(200);
});
expect(iframe.style.width).toBe('500px');
} finally {
vi.useRealTimers();
}
});
// audit 2026-10-21 (r252):「落定定时器」与「拖拽开始」的竞态。
// 旧实现只在 ResizeObserver 回调那一刻查拖拽状态 —— 排好的 150ms 定时器
// 到点时**不再查** ⇒ 若这 150ms 里用户开始了拖拽,定时器会在拖拽**中途**
// 改 iframe 尺寸 ⇒ Chromium 重排 + 重新光栅化 PDF(实测每帧 +16ms)
// ⇒ 拖拽第一帧掉帧。
it('拖拽开始时已挂着待落定定时器 ⇒ 定时器到点也不得在拖拽中改尺寸', () => {
vi.useFakeTimers();
try {
const { container } = render(<PDFReader absPath="C:/x/a.pdf" filename="a.pdf" />);
const iframe = iframeOf(container);
expect(iframe.style.width).toBe('640px');
// ① 非拖拽场景的一次 resize ⇒ 排一个 150ms 的落定定时器
rect = { width: 500, height: 800 };
act(() => {
emitResize();
});
expect(iframe.style.width, '定时器还没到点,尺寸不变').toBe('640px');
// ② 定时器到点之前,用户开始拖分隔条
act(() => {
beginResizeDrag();
});
// ③ 定时器到点 ⇒ **不得**在拖拽中途改 iframe 尺寸
act(() => {
vi.advanceTimersByTime(200);
});
expect(iframe.style.width, '拖拽中不得改尺寸(PDF 每帧重排 = 掉帧)').toBe('640px');
// ④ 拖拽结束 → 照旧一次性跟上最终尺寸(跳过定时器不会丢更新)
act(() => {
endResizeDrag();
});
expect(iframe.style.width).toBe('500px');
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,97 @@
// tests/unit/renderer/components/reader-decode-memo.test.tsx
//
// 守 r247「Reader 每次重渲都重解一遍整个文件」的修复契约 ——
// 文本类文件的 UTF-8 解码必须**只在内容真的变了**时做一次。
//
// 背景(用户诉求「使用流畅,不要有卡顿」):
// 旧实现在**每次 render** 都执行 `TEXT_DECODER.decode(state.data)` ——
// 这是 O(字节数) 的 UTF-8 解码 + 一份新字符串分配。state.data 本身是
// 稳定引用,内容没变却在每次重渲时重解一遍:
// 1MB 的 txt/md = 每次 render 造一个 1M 字符的字符串。
// Reader 会在窗口 resize / 重试 nonce / 主题切换 / 父级任何 state 变化
// 时重渲 ⇒「拖着窗口边缘抖一下」就能把主线程塞满解码。
//
// 修法:useMemo([ext, state.data])—— ext / data 都没变 ⇒ 不重解。
//
// 本测锁:① 首屏解码一次;② 父级用**新对象**重渲(内容没变)不再解码;
// ③ 换文件(内容真变了)必须再解一次(短路不能把新文件吃掉)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
const readFileForReader = vi.fn();
vi.mock('../../../../src/renderer/src/lib/api', () => ({
api: { readFileForReader: (...args: unknown[]) => readFileForReader(...args) },
unwrap: (r: unknown) => r,
IpcError: class IpcError extends Error {},
asIpcError: () => null,
hasApi: () => true,
}));
import { Reader } from '../../../../src/renderer/src/components/reader/Reader';
const FILE = {
id: 'f1',
extension: '.txt',
absPath: '/tmp/rkms-perf/a.txt',
filename: 'a.txt',
};
function bytesOf(text: string): Uint8Array {
// jsdom 陷阱(实测):`new TextEncoder().encode()` 返回的是**另一个 realm** 的
// Uint8Array —— `b instanceof Uint8Array === false` ⇒ Reader 的
// `rawData instanceof Uint8Array` 守卫会走「文件格式损坏」分支。
// 这里用本 realm 的构造器直接造字节(手写 UTF-8 编码,不走 TextEncoder)。
const enc = encodeURIComponent(text).replace(
/%([0-9A-F]{2})/g,
(_m, h: string) => String.fromCharCode(parseInt(h, 16)),
);
const out = new Uint8Array(enc.length);
for (let i = 0; i < enc.length; i += 1) out[i] = enc.charCodeAt(i);
return out;
}
describe('perf r247:Reader 文本解码必须 memo 化', () => {
beforeEach(() => {
readFileForReader.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('首屏解码一次;父级用新对象重渲(内容未变)不再解码', async () => {
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
readFileForReader.mockResolvedValue({ ok: true, data: { data: bytesOf('hello 世界') } });
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
// 正文渲染出来 ⇒ 解码确实发生了
await waitFor(() => expect(container.textContent).toContain('hello 世界'));
expect(decodeSpy, '首屏只应解码一次').toHaveBeenCalledTimes(1);
// 父级重渲,但 file 是**新对象**(内容一模一样)—— 模拟 resize / 主题切换等
const before = decodeSpy.mock.calls.length;
rerender(<Reader file={{ ...FILE }} />);
expect(decodeSpy.mock.calls.length, '内容没变就不该再解一遍').toBe(before);
expect(decodeSpy).toHaveBeenCalledTimes(1);
});
it('换文件(内容真变了)⇒ 必须再解一次', async () => {
const decodeSpy = vi.spyOn(TextDecoder.prototype, 'decode');
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第一份') } });
const { container, rerender } = render(<Reader file={{ ...FILE }} />);
await waitFor(() => expect(container.textContent).toContain('第一份'));
expect(decodeSpy).toHaveBeenCalledTimes(1);
readFileForReader.mockResolvedValueOnce({ ok: true, data: { data: bytesOf('第二份') } });
rerender(
<Reader file={{ ...FILE, id: 'f2', absPath: '/tmp/rkms-perf/b.txt', filename: 'b.txt' }} />,
);
await waitFor(() => expect(container.textContent).toContain('第二份'));
expect(decodeSpy, '新文件必须重新解码').toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,139 @@
// tests/unit/renderer/components/reader-language-freshness.test.tsx
//
// 守 audit 2026-10-21 (r252):Reader 的文案必须**跟着界面语言**走。
//
// 两处「烤死语言」的坑:
// ① toast:「在系统应用打开」失败时的 toast 走 useCallback([file?.absPath])
// —— react-i18next 的 t 是 `i18n.getFixedT(lng)`,身份跟着语言走;
// deps 里没有 t ⇒ 用户在设置里换语言后回调**不重建** ⇒ 闭包里的 t
// 停在旧语言 ⇒ toast 用旧语言渲染(实测 getFixedT('zh-CN') 与
// getFixedT('en-US') 是两个不同函数,身份不等)。
// ② 错误描述:`t('reader.errorGeneric')` / `t('reader.errorFileFormatCorrupt')`
// 在**出错那一刻**翻译好、烤进 state ⇒ 换语言后描述还是旧语言,
// 而标题(渲染期翻译)是新语言 ⇒ **中英混排**。
//
// 本测用一份**可控语言**的 i18n mock(模拟 react-i18next 的真实语义:
// 语言不变 ⇒ t 身份稳定;语言变 ⇒ t 换新身份),锁:
// ① 换语言后 toast 用新语言;
// ② 换语言后错误描述用新语言(且标题/描述同语言,不混排)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
// ── 可控语言的 i18n mock(行为对齐 react-i18next)────────────────────────────
// t 的身份:每个语言一个固定函数 ⇒ 语言不变时稳定(不会导致无谓重建),
// 换语言 ⇒ 换新身份(依赖 t 的 useCallback 会重建)。
const i18nMock = vi.hoisted(() => {
const state = { lang: 'zh-CN' };
const dict: Record<string, Record<string, string>> = {
'reader.errorGeneric': { 'zh-CN': '无法显示该文件', 'en-US': 'Cannot display this file' },
'reader.errorFileFormatCorrupt': {
'zh-CN': '文件内容格式异常',
'en-US': 'File content format is invalid',
},
'reader.errorOpenExternal': {
'zh-CN': '在系统应用打开',
'en-US': 'Open in system app',
},
'reader.errorRetry': { 'zh-CN': '重试', 'en-US': 'Retry' },
'toast.openFailed': { 'zh-CN': '打开失败: {msg}', 'en-US': 'Open failed: {msg}' },
};
const cache = new Map<string, (k: string, opts?: { msg?: string }) => string>();
function makeT(lang: string) {
let fn = cache.get(lang);
if (!fn) {
fn = (k: string, opts?: { msg?: string }) => {
const s = dict[k]?.[lang] ?? k;
return opts?.msg !== undefined ? s.replace('{msg}', opts.msg) : s;
};
cache.set(lang, fn);
}
return fn;
}
return { state, makeT };
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: i18nMock.makeT(i18nMock.state.lang) }),
}));
const readFileForReader = vi.fn();
const openPath = vi.fn();
vi.mock('../../../../src/renderer/src/lib/api', () => ({
api: {
readFileForReader: (...args: unknown[]) => readFileForReader(...args),
openPath: (...args: unknown[]) => openPath(...args),
},
unwrap: (r: unknown) => r,
IpcError: class IpcError extends Error {},
asIpcError: () => null,
hasApi: () => true,
}));
import { Reader } from '../../../../src/renderer/src/components/reader/Reader';
import { useToast } from '../../../../src/renderer/src/lib/toast';
const FILE = {
id: 'f1',
extension: '.txt',
absPath: '/tmp/rkms-lang/a.txt',
filename: 'a.txt',
};
describe('Reader 文案必须跟着界面语言走(r252)', () => {
beforeEach(() => {
i18nMock.state.lang = 'zh-CN';
readFileForReader.mockReset();
openPath.mockReset();
useToast.setState({ toasts: [] });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('换语言后,「在系统应用打开」失败的 toast 用新语言', async () => {
readFileForReader.mockResolvedValue({ ok: false, error: { code: 'IO_ERROR', message: 'boom' } });
openPath.mockRejectedValue(new Error('boom'));
const { rerender } = render(<Reader file={{ ...FILE }} />);
await waitFor(() => expect(screen.getByText('在系统应用打开')).toBeTruthy());
// 中文:点一次 → toast 是中文
await act(async () => {
fireEvent.click(screen.getByText('在系统应用打开'));
});
expect(useToast.getState().toasts.at(-1)?.message).toBe('打开失败: boom');
// 换语言(真实场景:设置里改「界面语言」⇒ i18n 触发整棵树重渲)
i18nMock.state.lang = 'en-US';
rerender(<Reader file={{ ...FILE }} />);
await waitFor(() => expect(screen.getByText('Open in system app')).toBeTruthy());
await act(async () => {
fireEvent.click(screen.getByText('Open in system app'));
});
expect(
useToast.getState().toasts.at(-1)?.message,
'换语言后 toast 必须用新语言(旧实现:回调不重建 ⇒ 停在旧语言)',
).toBe('Open failed: boom');
});
it('换语言后,错误描述用新语言(标题与描述同语言,不混排)', async () => {
// 走「文件内容格式异常」那条路径:返回的 data 不是 Uint8Array
readFileForReader.mockResolvedValue({ ok: true, data: { data: 'not-bytes' } });
const { rerender } = render(<Reader file={{ ...FILE }} />);
await waitFor(() => expect(screen.getByText('文件内容格式异常')).toBeTruthy());
expect(screen.getByText('无法显示该文件'), '标题此刻是中文').toBeTruthy();
i18nMock.state.lang = 'en-US';
rerender(<Reader file={{ ...FILE }} />);
await waitFor(() => expect(screen.getByText('File content format is invalid')).toBeTruthy());
expect(
screen.queryByText('文件内容格式异常'),
'旧语言的描述不得残留(旧实现:翻译结果被烤进 state)',
).toBeNull();
expect(screen.getByText('Cannot display this file'), '标题也跟着换').toBeTruthy();
});
});

View File

@@ -0,0 +1,229 @@
// tests/unit/renderer/components/search/SearchPanel.test.tsx
//
// 守 audit-r33「搜索面板键盘交互」契约
// (src/renderer/src/components/search/SearchPanel.tsx 头注指向本文件,
// 原先标 🔒(守卫缺失);r33 修订后的单一 handler 版本)。
//
// r32 的 split-callback 设计(<input> 上的 handler 把焦点挪到
// <div role="listbox">,再由 listbox 上的另一份 handler 接管)看似遵循
// a11y 模式,实际违反 WAI-ARIA combobox 规范,并造成**用户可见**故障:
//
// 用户打完 "react" 按 ↓(焦点被挪到 listbox)→ 再打 "hooks"
// ⇒ 字符进不了 input(div 不接受文字输入)⇒ 关键词停在 "react",
// 用户以为自己搜的是 "reacthooks",实际搜的是 "react"。
//
// 本测锁的契约(全部用户可见):
// 1. 焦点**全程**留在 input:ArrowDown / ArrowUp / Enter 之后
// document.activeElement 仍是 input;
// 2. 虚拟选中项由 input 的 aria-activedescendant 跟踪,
// 对应行 aria-selected="true";
// 3. ArrowDown / ArrowUp 到边界不越界(不悬空、不绕回);
// 4. Enter 打开**当前 active** 的命中项:
// 笔记 → 选中笔记 + 切到 notes 面板 + 请求聚焦该笔记;
// 文献 → 选中该文献(id 走 litResourceId)+ 切到 library 面板;
// 两种情况都关闭搜索面板;
// 5. IME 组字过程中按 Enter / 方向键**不**当成"打开命中项"
// (否则误导航 + 误关面板,关键词还停在 stale 状态);
// 6. combobox 语义:input role=combobox + aria-controls 指向真实存在的
// listbox + aria-expanded 随有无结果变化。
//
// 退化后的用户可见后果:
// - 1 退化 ⇒ 打完词按一下 ↓ 就再也打不出字了(搜索面板不可用);
// - 4 退化 ⇒ 回车打开的是第一行而不是用户选中的那一行(跳错文件);
// - 5 退化 ⇒ 中文输入法选词(回车上屏)会直接跳走并关掉面板。
//
// 变异思路(哪几处改动会红):
// - 在 input 的 keydown 里加 listboxRef.current?.focus() ⇒ 例 1 红;
// - 把 aria-activedescendant 从 input 挪走 / 删掉 ⇒ 例 2、3 红;
// - Enter 用 flatHits[0] 而不是 flatHits[activeIdx] ⇒ 例 4 红;
// - 删掉 e.nativeEvent.isComposing 守卫 ⇒ 例 5 红;
// - listbox 去掉 id / input 去掉 aria-controls ⇒ 例 6 红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, cleanup, waitFor } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('i18next', () => ({
t: (k: string, opts?: { count?: number }) =>
opts && typeof opts.count === 'number' ? `${k}:${opts.count}` : k,
}));
const notesSelect = vi.fn();
const literatureSelect = vi.fn();
const setActive = vi.fn();
const requestFocusNote = vi.fn();
vi.mock('../../../../../src/renderer/src/store/notes', () => ({
useNotesStore: { getState: () => ({ select: notesSelect }) },
}));
vi.mock('../../../../../src/renderer/src/store/literature', () => ({
useLiteratureStore: { getState: () => ({ select: literatureSelect }) },
}));
vi.mock('../../../../../src/renderer/src/store/workspace', () => ({
useWorkspaceStore: {
getState: () => ({ setActive, requestFocusNote }),
},
}));
import { SearchPanel } from '../../../../../src/renderer/src/components/search/SearchPanel';
import type { SearchHit } from '../../../../../src/shared/types';
const NOTE_HIT: SearchHit = {
kind: 'note',
id: 'note-1',
relPath: 'a.md',
title: '深度学习综述',
snippet: '片段',
score: 1,
};
const FILE_HIT: SearchHit = {
kind: 'file',
id: 'file-1',
relPath: 'b.pdf',
title: '某论文',
snippet: '片段',
score: 1,
};
const NOTE2_HIT: SearchHit = {
kind: 'note',
id: 'note-2',
relPath: 'c.md',
title: '另一篇笔记',
snippet: '片段',
score: 1,
};
function optionEls(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
}
/** 输入查询词 → 300ms debounce → IPC → 结果渲染完。 */
async function openWith(hits: SearchHit[], onClose = vi.fn()) {
(window as unknown as { api: unknown }).api = {
search: vi.fn().mockResolvedValue({ ok: true, data: hits }),
};
render(<SearchPanel open onClose={onClose} />);
const input = screen.getByLabelText('search.inputAria') as HTMLInputElement;
fireEvent.change(input, { target: { value: '关键词' } });
await waitFor(() => expect(optionEls().length).toBeGreaterThan(0), { timeout: 3000 });
return { input, onClose };
}
describe('SearchPanel:键盘交互(audit-r33 单一 handler 契约)', () => {
beforeEach(() => {
notesSelect.mockClear();
literatureSelect.mockClear();
setActive.mockClear();
requestFocusNote.mockClear();
});
afterEach(() => {
cleanup();
});
it('1. 焦点全程留在 input(方向键 / 回车之后都不挪走)', async () => {
const { input } = await openWith([NOTE_HIT, FILE_HIT]);
input.focus();
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(document.activeElement, 'ArrowDown 后焦点必须还在 input').toBe(input);
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(document.activeElement, 'ArrowUp 后焦点必须还在 input').toBe(input);
fireEvent.keyDown(input, { key: 'Enter' });
expect(document.activeElement, 'Enter 后焦点必须还在 input').toBe(input);
});
it('2. 虚拟选中项由 aria-activedescendant 跟踪,对应行 aria-selected=true', async () => {
const { input } = await openWith([NOTE_HIT, FILE_HIT]);
expect(input.getAttribute('role')).toBe('combobox');
// 初始 activeIdx = 0 ⇒ 第一行
expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1');
expect(optionEls()[0]!.getAttribute('aria-selected')).toBe('true');
expect(optionEls()[1]!.getAttribute('aria-selected')).toBe('false');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-file-1');
expect(optionEls()[1]!.getAttribute('aria-selected')).toBe('true');
expect(optionEls()[0]!.getAttribute('aria-selected')).toBe('false');
});
it('3. 方向键到边界不越界(不悬空、不绕回)', async () => {
const { input } = await openWith([NOTE_HIT, FILE_HIT]);
// 已在第一行,再按 ArrowUp ⇒ 仍是第一行
fireEvent.keyDown(input, { key: 'ArrowUp' });
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1');
// 已在最后一行,再按 ArrowDown ⇒ 仍是最后一行
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-file-1');
expect(document.getElementById('search-hit-file-1')).not.toBeNull();
});
it('4a. Enter 打开当前 active 的**笔记**命中:选中 + 切面板 + 请求聚焦 + 关面板', async () => {
// 刻意让 active 落在**第二条**(不是第一条)—— 抓「回车总是打开第一行」的回归
const { input, onClose } = await openWith([NOTE_HIT, NOTE2_HIT, FILE_HIT]);
fireEvent.keyDown(input, { key: 'ArrowDown' }); // active = 第二行(另一篇笔记)
fireEvent.keyDown(input, { key: 'Enter' });
expect(notesSelect).toHaveBeenCalledWith(NOTE2_HIT.id);
expect(notesSelect).not.toHaveBeenCalledWith(NOTE_HIT.id);
expect(requestFocusNote).toHaveBeenCalledWith(NOTE2_HIT.id);
expect(setActive).toHaveBeenCalledWith('notes');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('4b. Enter 打开当前 active 的**文献**命中:按 litResourceId 选中 + 切到 library', async () => {
const { input, onClose } = await openWith([NOTE_HIT, NOTE2_HIT, FILE_HIT]);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowDown' }); // active = 第三行(file)
fireEvent.keyDown(input, { key: 'Enter' });
expect(literatureSelect).toHaveBeenCalledWith('lit:file-1');
expect(setActive).toHaveBeenCalledWith('library');
expect(notesSelect).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
it('5. IME 组字中按 Enter / 方向键不当作打开命中项(不跳走、不关面板)', async () => {
const { input, onClose } = await openWith([NOTE_HIT, FILE_HIT]);
// isComposing = true:用户正在用输入法上屏选词
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
expect(notesSelect).not.toHaveBeenCalled();
expect(literatureSelect).not.toHaveBeenCalled();
expect(onClose, 'IME 组字时回车不得关面板').not.toHaveBeenCalled();
// 方向键同理放行:active 不动
fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true });
expect(input.getAttribute('aria-activedescendant')).toBe('search-hit-note-1');
});
it('6. combobox 语义:aria-controls 指向真实存在的 listbox,aria-expanded 随结果变化', async () => {
const { input } = await openWith([NOTE_HIT, FILE_HIT]);
expect(input.getAttribute('aria-controls')).toBe('search-listbox');
const listbox = document.getElementById('search-listbox');
expect(listbox).not.toBeNull();
expect(listbox!.getAttribute('role')).toBe('listbox');
// 有结果 ⇒ 展开
expect(input.getAttribute('aria-expanded')).toBe('true');
// listbox 不抢焦点(不参与 tab 序列)
expect(listbox!.hasAttribute('tabindex')).toBe(false);
});
it('7. Escape 关闭搜索面板', async () => {
const { onClose } = await openWith([NOTE_HIT]);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,174 @@
// tests/unit/renderer/components/search/search-panel-slice-consistency.test.tsx
//
// 守 audit-r77「渲染行集合 == 键盘可达集合」契约
// (src/renderer/src/components/search/SearchPanel.tsx 头注指向本文件,
// 原先标 🔒(守卫缺失))。
//
// 旧实现的 bug:section 渲染用的是**未 slice** 的 noteHits / fileHits,
// 而键盘导航用的 flatHits 是 **slice(0, 50)** 之后的集合 —— 两者只在
// 「主进程恰好也返回 50 条」时才相等。一旦主进程 limit 调大:
//
// * 屏幕上多出一批行(section 头还按未截断的 count 虚报),
// * 但 ArrowDown 永远走不到它们(flatHits 只有 50 条)⇒
// 「鼠标能点、键盘到不了」的行,SR 用户(只能靠键盘)直接看不到。
//
// 本测锁的用户可见结果:
// 1. 渲染出来的命中行数 == 面板上限(50),不多不少;
// 2. section 头的 count 与**该 section 实际渲染的行数**一致(不虚报);
// 3. 键盘从第一行一路走到最后,停下的那一行就是**屏幕上最后一行**
// (渲染集 == 可达集);
// 4. 命中数不足上限时,渲染行数 == 命中数(不被误截断)。
//
// 退化后的用户可见后果:
// - 1/3 退化 ⇒ 底部几行鼠标能点、键盘永远到不了;SR 用户无法定位这些结果;
// - 2 退化 ⇒ section 头写着「文献 30」,实际只列了 20 条,用户以为自己看全了。
//
// 变异思路(哪几处改动会红):
// - section 改回用未 slice 的 noteHits / fileHits ⇒ 例 1、2、3 红;
// - MAX_VISIBLE_RESULTS 与渲染集解耦(比如渲染不限量)⇒ 例 1 红;
// - section count 用未截断的数量 ⇒ 例 2 红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, cleanup, waitFor } from '@testing-library/react';
// i18n:面板走 useTranslation;section 头走 i18next.t 的 count 插值 ——
// 把 count 一起渲染出来,才能断言「count 与实际行数一致」。
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('i18next', () => ({
t: (k: string, opts?: { count?: number }) =>
opts && typeof opts.count === 'number' ? `${k}:${opts.count}` : k,
}));
const notesSelect = vi.fn();
const literatureSelect = vi.fn();
const setActive = vi.fn();
const requestFocusNote = vi.fn();
vi.mock('../../../../../src/renderer/src/store/notes', () => ({
useNotesStore: { getState: () => ({ select: notesSelect }) },
}));
vi.mock('../../../../../src/renderer/src/store/literature', () => ({
useLiteratureStore: { getState: () => ({ select: literatureSelect }) },
}));
vi.mock('../../../../../src/renderer/src/store/workspace', () => ({
useWorkspaceStore: {
getState: () => ({ setActive, requestFocusNote }),
},
}));
import { SearchPanel } from '../../../../../src/renderer/src/components/search/SearchPanel';
import type { SearchHit } from '../../../../../src/shared/types';
const MAX_VISIBLE = 50;
function noteHits(n: number): SearchHit[] {
return Array.from({ length: n }, (_, i) => ({
kind: 'note' as const,
id: `note-${i}`,
relPath: `note-${i}.md`,
title: `笔记 ${i}`,
snippet: `片段 ${i}`,
score: 1,
}));
}
function fileHits(n: number): SearchHit[] {
return Array.from({ length: n }, (_, i) => ({
kind: 'file' as const,
id: `file-${i}`,
relPath: `file-${i}.pdf`,
title: `文献 ${i}`,
snippet: `片段 ${i}`,
score: 1,
}));
}
/** 所有渲染出来的命中行(Overlay 走 Portal,行在 document.body 里)。 */
function optionEls(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="option"]'));
}
/** 输入查询词 → 面板 300ms debounce 后才发 IPC → 等结果渲染完。 */
async function search(text: string, hits: SearchHit[]) {
(window as unknown as { api: unknown }).api = {
search: vi.fn().mockResolvedValue({ ok: true, data: hits }),
};
render(<SearchPanel open onClose={() => {}} />);
const input = screen.getByLabelText('search.inputAria') as HTMLInputElement;
fireEvent.change(input, { target: { value: text } });
await waitFor(() => expect(optionEls().length).toBeGreaterThan(0), { timeout: 3000 });
return input;
}
describe('SearchPanel:渲染集 == 键盘可达集(audit-r77)', () => {
beforeEach(() => {
notesSelect.mockClear();
literatureSelect.mockClear();
setActive.mockClear();
requestFocusNote.mockClear();
});
afterEach(() => {
cleanup();
});
it('1. 命中数超过上限时,渲染行数被截到上限(不多渲染)', async () => {
await search('关键词', [...noteHits(30), ...fileHits(30)]);
expect(optionEls()).toHaveLength(MAX_VISIBLE);
});
it('2. section 头的 count 与该 section 实际渲染的行数一致(不虚报)', async () => {
await search('关键词', [...noteHits(30), ...fileHits(30)]);
const noteRows = optionEls().filter((el) => el.id.startsWith('search-hit-note-'));
const fileRows = optionEls().filter((el) => el.id.startsWith('search-hit-file-'));
// 截断后:notes 30 条全留,files 只留前 20 条
expect(noteRows).toHaveLength(30);
expect(fileRows).toHaveLength(20);
const body = document.body.textContent ?? '';
expect(body).toContain('search.sectionNotes:30');
expect(body).toContain('search.sectionLibrary:20');
});
it('3. 键盘走到底,停下的就是屏幕上最后一行(渲染集 == 可达集)', async () => {
const input = await search('关键词', [...noteHits(30), ...fileHits(30)]);
// 一路按到底(远超行数,验证不会越界/绕回)
for (let i = 0; i < MAX_VISIBLE + 20; i++) {
fireEvent.keyDown(input, { key: 'ArrowDown' });
}
const rows = optionEls();
const last = rows[rows.length - 1]!;
// 当前 active 行 = 最后一行
const active = rows.filter((el) => el.getAttribute('aria-selected') === 'true');
expect(active).toHaveLength(1);
expect(active[0]!.id).toBe(last.id);
// 且 input 的 aria-activedescendant 指向这一行
expect(input.getAttribute('aria-activedescendant')).toBe(last.id);
expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).not.toBeNull();
});
it('3b. 每一次 ArrowDown 之后,active 指向的行都真实存在(不悬空)', async () => {
const input = await search('关键词', [...noteHits(30), ...fileHits(30)]);
for (let i = 0; i < MAX_VISIBLE + 5; i++) {
fireEvent.keyDown(input, { key: 'ArrowDown' });
const id = input.getAttribute('aria-activedescendant');
expect(id, 'aria-activedescendant 不能为空').toBeTruthy();
expect(document.getElementById(id!), `第 ${i} 次 ArrowDown 后指向的行必须存在`).not.toBeNull();
}
});
it('4. 命中数不足上限时,渲染行数 == 命中数(不被误截断)', async () => {
await search('关键词', [...noteHits(3), ...fileHits(2)]);
expect(optionEls()).toHaveLength(5);
const body = document.body.textContent ?? '';
expect(body).toContain('search.sectionNotes:3');
expect(body).toContain('search.sectionLibrary:2');
});
});

View File

@@ -0,0 +1,143 @@
// tests/unit/renderer/components/search/search-snippet-highlight.test.tsx
//
// 守 r194「搜索结果预览把 <mark> 标签当普通文字显示」契约。
//
// 现象:主进程 FTS5 的 snippet() 产出的是「命中片段包在 <mark> / </mark>
// 里」的字符串;旧实现直接在结果行渲染 `{hit.snippet}` —— React 把它当
// 纯文本 ⇒ 用户看到的预览是
//
// 本文介绍<mark>学习</mark>模型的基...
//
// 即标签的字面量,而不是高亮。命中预览是搜索面板里最显眼的输出,这个
// 缺陷等于「高亮功能从未生效」。
//
// 本测锁三件事:
// 1. 用户可见结果:命中片段渲染成真正的 <mark> 元素,文本里不再出现
// "<mark>" 字面量;
// 2. 安全:片段正文来自用户写的笔记,渲染路径**不得**把它当 HTML 解析
// (dangerouslySetInnerHTML)—— 笔记里的 "<script>" 只能是文本;
// 3. 边界:缺标记 / 只有半个标记 / 空串都不崩、不丢字。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, waitFor, fireEvent } from '@testing-library/react';
import { SearchSnippet, splitSearchSnippet } from '../../../../../src/renderer/src/components/search/SearchSnippet';
// i18n:面板走 useTranslation / i18next.t —— 只关心结构,不关心文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('i18next', () => ({
t: (k: string) => k,
}));
// store:面板只在「点中命中项」时读 store.getState(),渲染期不读 ——
// 这里给最小桩,避免把整个 notes/literature store 的 IPC 副作用拖进测试
vi.mock('../../../../../src/renderer/src/store/notes', () => ({
useNotesStore: { getState: () => ({ select: vi.fn() }) },
}));
vi.mock('../../../../../src/renderer/src/store/literature', () => ({
useLiteratureStore: { getState: () => ({ select: vi.fn() }) },
}));
vi.mock('../../../../../src/renderer/src/store/workspace', () => ({
useWorkspaceStore: { getState: () => ({ setActive: vi.fn(), requestFocusNote: vi.fn() }) },
}));
const MARKED = '本文介绍<mark>学习</mark>模型的基本原理';
describe('splitSearchSnippet(r194)', () => {
it('无标记 → 单个非命中段', () => {
expect(splitSearchSnippet('纯文本')).toEqual([{ text: '纯文本', hit: false }]);
});
it('有标记 → 命中段与上下文段分开', () => {
expect(splitSearchSnippet(MARKED)).toEqual([
{ text: '本文介绍', hit: false },
{ text: '学习', hit: true },
{ text: '模型的基本原理', hit: false },
]);
});
it('空串 → 空数组', () => {
expect(splitSearchSnippet('')).toEqual([]);
});
it('只有开始标记 → 剩余整段算命中,不丢字', () => {
expect(splitSearchSnippet('前缀<mark>尾巴')).toEqual([
{ text: '前缀', hit: false },
{ text: '尾巴', hit: true },
]);
});
it('只有结束标记 → 当普通文本(不吞字)', () => {
expect(splitSearchSnippet('尾巴</mark>')).toEqual([{ text: '尾巴</mark>', hit: false }]);
});
});
describe('SearchSnippet 渲染(r194)', () => {
afterEach(() => {
cleanup();
});
it('命中片段渲染成真正的 <mark> 元素,标签不再字面量出现', () => {
const { container } = render(<SearchSnippet snippet={MARKED} />);
const marks = container.querySelectorAll('mark');
expect(marks).toHaveLength(1);
expect(marks[0]!.textContent).toBe('学习');
// 可见文本 = 原文去掉标记
expect(container.textContent).toBe('本文介绍学习模型的基本原理');
// 字面量不得出现在可见文本里
expect(container.textContent).not.toContain('<mark>');
expect(container.textContent).not.toContain('</mark>');
});
it('笔记正文里的 <script> 只作为文本显示,不被解析成元素', () => {
const nasty = '正文<script>alert(1)</script>结尾<mark>命中</mark>';
const { container } = render(<SearchSnippet snippet={nasty} />);
// 没有任何 script 元素落进 DOM
expect(container.querySelector('script')).toBeNull();
// 内容以文本形式完整保留
expect(container.textContent).toContain('<script>alert(1)</script>');
expect(container.querySelectorAll('mark')).toHaveLength(1);
});
it('空片段 → 不渲染任何内容且不崩', () => {
const { container } = render(<SearchSnippet snippet="" />);
expect(container.textContent).toBe('');
});
});
// === 面板级:用户实际看到的东西 ===
describe('SearchPanel 命中行(r194)', () => {
const hit = {
kind: 'note' as const,
id: 'note-1',
relPath: 'a.md',
title: '深度学习综述',
snippet: MARKED,
score: 1,
};
beforeEach(() => {
(window as unknown as { api: unknown }).api = {
search: vi.fn().mockResolvedValue({ ok: true, data: [hit] }),
};
});
afterEach(() => {
cleanup();
});
it('命中行渲染出 <mark> 元素,预览里没有标签字面量', async () => {
const { SearchPanel } = await import(
'../../../../../src/renderer/src/components/search/SearchPanel'
);
render(<SearchPanel open onClose={() => {}} />);
// 输入查询词(面板 300ms debounce 后才发 IPC)
// Overlay 走 Portal 渲染到 document.body —— input 不在 container 里
const input = document.querySelector('input')!;
fireEvent.change(input, { target: { value: '学习' } });
await waitFor(
() => {
expect(document.querySelectorAll('mark').length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
expect(document.querySelector('mark')!.textContent).toBe('学习');
// 整个面板的可读文本里都不该出现标签字面量
expect(document.body.textContent).not.toContain('<mark>');
expect(document.body.textContent).not.toContain('</mark>');
});
});

View File

@@ -0,0 +1,99 @@
// tests/unit/renderer/components/sidebar-op-parity.test.ts
//
// 守用户诉求「左侧的文献和笔记列表 … 相关的文件操作也是对应的」——
// 两个侧栏的**文件行右键菜单**必须提供同一组操作。
//
// 旧状态(实测):笔记行有 rename / cut / copy / paste / showInFolder / trash,
// 文献行只有 cut / copy / paste / showInFolder / delete —— **没有重命名**。
// 用户视角:笔记能右键改名,文献不能;想在文献上改名只能去资源管理器,
// 改完 app 里还是旧名(两侧行为不对应)。
//
// r187 给文献域补上 rename(literature:rename-file 通道 + 服务层 + 弹窗),
// 本测把「两侧操作集相同」这条契约锁住 —— 以后任一侧删/加操作都会红。
//
// 为什么用源码扫描而不是渲染测:
// NotesList / LiteratureList 都依赖 react-virtuoso,jsdom 下容器高度为 0,
// 行根本不渲染(同 import-no-auto-open.test.ts 的理由),右键菜单更是
// 交互后才出现 ⇒ 退到源码契约。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = process.cwd();
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
/**
* 抽出 `const menuItems = useMemo<ContextMenuItem[]>(() => { ... })` 的
* 回调体文本(花括号配平;先剥注释避免注释里的花括号干扰配平)。
*/
function extractMenuItemsBody(fileRel: string): string {
const src = readFileSync(join(ROOT, fileRel), 'utf8');
const marker = 'const menuItems = useMemo<ContextMenuItem[]>(() => {';
const start = src.indexOf(marker);
expect(start, `${fileRel} 里找不到 ${marker}`).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
const stripped = src
.slice(open)
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^[ \t]*\/\/.*$/gm, '');
let depth = 0;
for (let i = 0; i < stripped.length; i += 1) {
const ch = stripped[i];
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) return stripped.slice(0, i);
}
}
throw new Error(`${fileRel}: menuItems 回调体花括号没配平`);
}
/** 收集回调体里的菜单项 id(只取 `id: 'xxx'` 形态的字面量)。 */
function menuItemIds(body: string): string[] {
const out: string[] = [];
const re = /\bid:\s*'([a-zA-Z0-9_-]+)'/g;
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
out.push(m[1]!);
}
return out;
}
/** 两侧的语义对齐表:notes 的 'trash' 与 literature 的 'delete' 是同一件事
* (都把文件移进系统回收站),归一到 'delete' 再比。 */
const ALIAS: Record<string, string> = { trash: 'delete' };
function normalizedSet(fileRel: string): Set<string> {
const ids = menuItemIds(extractMenuItemsBody(fileRel));
return new Set(ids.filter((id) => id !== 'sep1' && id !== 'sep2').map((id) => ALIAS[id] ?? id));
}
describe('两侧栏文件行操作集对应(r187)', () => {
it('文献行菜单与笔记行菜单提供同一组操作', () => {
const notes = normalizedSet(NOTES);
const lit = normalizedSet(LIT);
// 双方都必须有的操作
for (const op of ['rename', 'cut', 'copy', 'paste', 'showInFolder', 'delete']) {
expect(notes.has(op), `notes 侧缺「${op}」`).toBe(true);
expect(lit.has(op), `literature 侧缺「${op}」`).toBe(true);
}
// 且不许出现「只有一侧有」的操作(两侧集合完全相等)
const onlyNotes = [...notes].filter((op) => !lit.has(op));
const onlyLit = [...lit].filter((op) => !notes.has(op));
expect({ onlyNotes, onlyLit }).toEqual({ onlyNotes: [], onlyLit: [] });
});
it('文献侧重命名真的接到了 store.renameFile(不是空壳菜单项)', () => {
const src = readFileSync(join(ROOT, LIT), 'utf8');
// store 取用
expect(src).toMatch(/useLiteratureStore\(\(s\) => s\.renameFile\)/);
// 菜单项回调真的调 openRename
const body = extractMenuItemsBody(LIT);
expect(body).toMatch(/id:\s*'rename'/);
expect(body).toMatch(/openRename\(/);
// openRename 真的调 store 的 renameFile
expect(src).toMatch(/await renameFile\(/);
});
});

View File

@@ -0,0 +1,183 @@
// tests/unit/renderer/components/standalone-editor-id-drift.test.tsx
//
// 守 r192「保存时 id 漂移」在**编辑器**这一侧的行为契约。
//
// 场景(用户可复现):
// 1. 用户打开一篇笔记,改了标题(标记 dirty);
// 2. 自动保存触发 —— 主进程把 frontmatter id 重钉为路径哈希,返回**新 id**;
// 3. 用户**在保存往返期间**又敲了几个字(新内容还没落盘);
// 4. renderer 拿到新 id,编辑器收到的 note prop 的 id 变了。
//
// 旧行为(两处都丢字):
// - 编辑器把「id 变了」当成**切到另一篇笔记**:用**已死的旧 id** 去 flush
// (主进程已删掉旧 byId 键)⇒ 必撞 NOT_FOUND ⇒ 正文只进 failedDrafts
// (旧 id 键)⇒ 再也读不回来;
// - flush 之后 setBody(note.body) / setTitle(note.title) 把用户在保存之后
// 新敲的字**直接擦掉**(note.body 是保存那一刻的快照,不含第 3 步的字)。
//
// 本测锁(行为级,只锁用户可见的行为,不锁实现):
// 1. 同一个 relPath、id 变了 ⇒ **不得**再用旧 id 发起保存;
// 2. 本地正文 / 标题**不被**回滚(用户保存后敲的字还在);
// 3. 反面对照:换成**另一篇**笔记(relPath 不同)⇒ 仍然照旧 flush 旧笔记
// (不能把「切笔记」这条路一起关掉)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, act, cleanup } from '@testing-library/react';
// i18n:只关心交互,不关心文案 —— key 原样返回
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// workspace store:编辑器只用到 pendingSaveActiveNote.version(⌘S 通道)
// + consumeSaveActiveNoteRequest();本测不走 ⌘S,后者恒 false。
vi.mock('../../../../src/renderer/src/store/workspace', () => {
const state = {
pendingSaveActiveNote: { version: 0 },
consumeSaveActiveNoteRequest: () => false,
};
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useWorkspaceStore.getState = () => state;
return { useWorkspaceStore };
});
// notes store:编辑器只在两处读它 ——
// 1) 切笔记 flush 前查「旧笔记还在不在列表里」(mustContain 旧 id,否则
// 走 skip 分支,测不到死 id flush);
// 2) ⌘S 通道读 selectedId(本测不走)。
vi.mock('../../../../src/renderer/src/store/notes', () => {
const state = {
notes: [{ id: 'oldid', relPath: 'a.md' }],
selectedId: 'oldid',
};
return {
useNotesStore: {
getState: () => state,
},
};
});
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
// 行为等价(受控 value + onChange(string))。
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
MarkdownEditor: (props: { value: string; onChange: (v: string) => void }) =>
(
<textarea
data-testid="body-editor"
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
),
}));
import { StandaloneNoteEditorCore } from '../../../../src/renderer/src/components/notes/StandaloneNoteEditorCore';
import type { StandaloneNote } from '../../../../src/shared/types';
const OLD_ID = 'oldid';
const NEW_ID = 'newid';
function note(id: string, relPath: string, body: string, title = 'T1'): StandaloneNote {
return {
id,
relPath,
filename: relPath.split('/').pop() ?? relPath,
title,
tags: null,
mtime: 1,
body,
};
}
describe('StandaloneNoteEditorCore:id 漂移 ≠ 切笔记(r192)', () => {
beforeEach(() => {
cleanup();
// 假定时器:autosave 防抖(默认 1.5s)不能在测试里自己 fire ——
// 本测只关心「手动点保存」这一条路径。
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it('同一文件换了 id:不得用旧 id 发起保存,且本地标题不被回滚', async () => {
let resolveSave: (() => void) | undefined;
const onSave = vi.fn(
(_id: string, _body: string, _title: string) =>
new Promise<void>((res) => {
resolveSave = res;
}),
);
const { rerender } = render(
<StandaloneNoteEditorCore note={note(OLD_ID, 'a.md', 'B1')} onSave={onSave} titleEditable />,
);
const titleInput = screen.getByLabelText('common.noteTitleAria') as HTMLInputElement;
const bodyInput = screen.getByTestId('body-editor') as HTMLTextAreaElement;
// 1. 改标题 + 改正文 → dirty
fireEvent.change(titleInput, { target: { value: 'T2' } });
fireEvent.change(bodyInput, { target: { value: 'B2' } });
expect(titleInput.value).toBe('T2');
// 2. 手动保存(autosave 被假定时器压住)→ onSave 以**旧 id** 发起
fireEvent.click(screen.getByRole('button', { name: /common\.save/ }));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave.mock.calls[0]![0]).toBe(OLD_ID);
expect(onSave.mock.calls[0]![1]).toBe('B2');
// 3. 保存往返期间用户又敲了字(这些字**还没落盘**)
fireEvent.change(titleInput, { target: { value: 'T3' } });
fireEvent.change(bodyInput, { target: { value: 'B3' } });
// 4. 保存落地 —— 主进程把 fm.id 重钉后返回新 id
await act(async () => {
resolveSave?.();
});
// 5. renderer 拿到新 id(store 已重映射)⇒ 编辑器收到的 note 换了 id,
// 但**同一个文件**(relPath 不变),body/title 是保存那一刻的快照。
rerender(
<StandaloneNoteEditorCore
note={note(NEW_ID, 'a.md', 'B2', 'T2')}
onSave={onSave}
titleEditable
/>,
);
// 断言 1:不得拿旧 id(已死)再发起一次保存
expect(onSave).toHaveBeenCalledTimes(1);
expect(
onSave.mock.calls.some((call) => call[0] === OLD_ID && call[1] !== 'B2'),
'漂移后不得用旧 id 发起 flush',
).toBe(false);
// 断言 2:用户保存后敲的字不能被回滚
expect((screen.getByLabelText('common.noteTitleAria') as HTMLInputElement).value).toBe('T3');
expect((screen.getByTestId('body-editor') as HTMLTextAreaElement).value).toBe('B3');
});
it('反面对照:真的换到另一篇笔记(relPath 不同)⇒ 仍然照旧把旧笔记 flush 掉', async () => {
const onSave = vi.fn((_id: string, _body: string, _title: string) => Promise.resolve());
const { rerender } = render(
<StandaloneNoteEditorCore note={note(OLD_ID, 'a.md', 'B1')} onSave={onSave} titleEditable />,
);
const titleInput = screen.getByLabelText('common.noteTitleAria') as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: 'T2' } });
// 切到**另一篇**笔记 —— 旧笔记必须被 flush(否则这段字就丢了)
rerender(
<StandaloneNoteEditorCore note={note('other', 'b.md', 'X1')} onSave={onSave} titleEditable />,
);
const flushCalls = onSave.mock.calls.filter((call) => call[0] === OLD_ID);
expect(flushCalls.length, '切到另一篇笔记时,旧笔记必须照旧 flush').toBe(1);
expect(flushCalls[0]![2]).toBe('T2');
});
});

View File

@@ -0,0 +1,296 @@
// tests/unit/renderer/components/standalone-editor-note-moved.test.tsx
//
// 守 audit 2026-10-21(P0 数据丢失):「笔记被搬走 / 改名」时,编辑器里**未落盘**
// 的内容必须落到**新 id** 上,而不是主进程已经删掉的旧 id。
//
// 旧行为(两条路都丢字,且**永久**丢):
// 1. 主进程的笔记 id = relPath 的哈希(standalone.ts repin 规则)⇒ 搬移/改名
// 后同一个文件换了 id,旧 id 在 byId 里被 delete ⇒ 成了**死键**;
// 2. 编辑器判「id 变了 = 换了另一篇笔记」用的是 relPath(r192 的漂移判据)
// ⇒ 搬移/改名**必然**改 relPath ⇒ 必然被判成「用户切了笔记」;
// 3. 于是按**旧 id** flush ⇒ 主进程 NOT_FOUND ⇒ 内容只进 failedDrafts
// (**旧 id 键**)⇒ 而笔记现在的新 id 取不到那份草稿 ⇒ 这段字永久消失。
//
// 本测锁(行为级,只锁用户可见的行为):
// M1. 搬移:flush 必须打到**新 id**;本地正文不得被搬移前的快照回滚。
// M2. 改名(full 为 null ⇒ 编辑器整条链卸载):flush 同样打到**新 id**。
// M3. 对照:真的切到**另一篇**笔记(没有重映射)⇒ 仍然照旧 flush 旧 id。
// M4. store 层:搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」,
// 而且编辑器被卸载 ⇒ 未落盘内容只能走卸载 flush)。
// M5. store 层:搬移后的保存不得带 expectedMtime —— 主进程 repin 重写了文件,
// 磁盘 mtime 必然变,带搬移前的旧 mtime 必报**假 CONFLICT**(「文件被外部
// 修改」,其实是我们自己搬的)。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, screen, act, cleanup } from '@testing-library/react';
// i18n:只关心交互,不关心文案 —— key 原样返回
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// workspace store:编辑器只用到 pendingSaveActiveNote.version(⌘S 通道)
// + consumeSaveActiveNoteRequest();本测不走 ⌘S,后者恒 false。
// 注意:编辑器是**当 hook 用**的(useWorkspaceStore(selector))⇒ mock 必须是
// 可调用的,不能只给 getState。
vi.mock('../../../../src/renderer/src/store/workspace', () => {
const state = {
pendingSaveActiveNote: { version: 0 },
consumeSaveActiveNoteRequest: () => false,
};
const useWorkspaceStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useWorkspaceStore.getState = () => state;
return { useWorkspaceStore };
});
// toast:store 的 CONFLICT 分支会弹 toast,本测不让它真的弹。
vi.mock('../../../../src/renderer/src/lib/toast', () => ({
useToast: { getState: () => ({ show: vi.fn() }) },
}));
// MarkdownEditor 是 CodeMirror(jsdom 里不可测)⇒ 换成普通 textarea,
// 行为等价(受控 value + onChange(string))。
vi.mock('../../../../src/renderer/src/components/editor/MarkdownEditor', () => ({
MarkdownEditor: (props: { value: string; onChange: (v: string) => void }) => (
<textarea
data-testid="body-editor"
value={props.value}
onChange={(e) => props.onChange(e.target.value)}
/>
),
}));
import { useNotesStore } from '../../../../src/renderer/src/store/notes';
import { resetNoteIdRemap } from '../../../../src/renderer/src/lib/note-id-remap';
import { StandaloneNoteEditorCore } from '../../../../src/renderer/src/components/notes/StandaloneNoteEditorCore';
import type { StandaloneNote } from '../../../../src/shared/types';
const OLD_ID = 'old-note';
const NEW_ID = 'new-note';
interface MockApi {
writeNote: ReturnType<typeof vi.fn>;
readNote: ReturnType<typeof vi.fn>;
listNotes: ReturnType<typeof vi.fn>;
renameNoteFile: ReturnType<typeof vi.fn>;
onNotesChanged: ReturnType<typeof vi.fn>;
}
function note(id: string, relPath: string, body: string, title = 'T1', mtime = 111): StandaloneNote {
return {
id,
relPath,
filename: relPath.split('/').pop() ?? relPath,
title,
tags: null,
mtime,
body,
};
}
let api: MockApi;
function setupApi(): void {
api = {
writeNote: vi.fn((id: string, body: string, title: string) =>
Promise.resolve({ ok: true, data: note(id, 'a.md', body, title, 222) }),
),
readNote: vi.fn(),
listNotes: vi.fn().mockResolvedValue({ ok: true, data: [] }),
renameNoteFile: vi.fn(),
onNotesChanged: vi.fn().mockReturnValue(() => {}),
};
(window as unknown as { api: MockApi }).api = api;
}
function seedStore(n: StandaloneNote): void {
useNotesStore.setState({
notes: [
{
id: n.id,
relPath: n.relPath,
filename: n.filename,
title: n.title,
tags: n.tags,
mtime: n.mtime,
},
],
selectedId: n.id,
selectedIds: new Set<string>([n.id]),
anchorId: n.id,
activeNoteFull: n,
loadingFull: false,
clipboard: null,
lastError: null,
displayOrderById: {},
});
}
describe('笔记被搬走/改名:未落盘内容必须落到新 id(audit 2026-10-21 P0)', () => {
beforeEach(() => {
cleanup();
// 登记表是模块级的 ⇒ 每个用例重来,别让上一个用例的映射串进来。
resetNoteIdRemap();
setupApi();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
vi.clearAllMocks();
});
it('M1. 搬移:未落盘内容必须按新 id 落盘,且本地正文不被搬移前的快照回滚', () => {
const target = note(OLD_ID, 'a.md', 'B0');
seedStore(target);
const onSave = (id: string, body: string, title: string): Promise<void> =>
useNotesStore.getState().save(id, body, title).then(() => undefined);
const { rerender } = render(
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
);
// 用户在搬移前敲了字(还没落盘)
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
// 搬移:主进程 fs.rename + repin ⇒ id 变了;store 收到 idRemap
act(() => {
useNotesStore.getState().applyMoveRemap(
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
);
});
// 父组件按新 store 状态重渲(真实路径:selectedId / activeNoteFull 都换了)
const st = useNotesStore.getState();
expect(st.activeNoteFull?.id, 'store 必须把 activeNoteFull 换到新 id').toBe(NEW_ID);
rerender(
<StandaloneNoteEditorCore note={st.activeNoteFull!} onSave={onSave} titleEditable />,
);
// 断言 1:flush 必须打到**新 id**(旧 id 已是主进程死键)
const calls = api.writeNote.mock.calls;
expect(calls.length, '搬移后必须补落盘一次').toBeGreaterThan(0);
expect(
calls.every((c) => c[0] !== OLD_ID),
'绝不能按旧 id 写 —— 那是主进程的死键(必撞 NOT_FOUND ⇒ 永久丢字)',
).toBe(true);
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
// 断言 2:本地正文不得被「搬移前」的 note.body 回滚(否则用户刚敲的字消失)
expect((screen.getByTestId('body-editor') as HTMLTextAreaElement).value).toBe('B1');
});
it('M2. 改名(full 清空 ⇒ 编辑器卸载):未落盘内容同样必须按新 id 落盘', async () => {
const target = note(OLD_ID, 'a.md', 'B0');
seedStore(target);
const renamed = note(NEW_ID, 'renamed.md', 'B0');
api.renameNoteFile.mockResolvedValue({ ok: true, data: renamed });
api.listNotes.mockResolvedValue({
ok: true,
data: [
{
id: NEW_ID,
relPath: 'renamed.md',
filename: 'renamed.md',
title: renamed.title,
tags: null,
mtime: 222,
},
],
});
const onSave = (id: string, body: string, title: string): Promise<void> =>
useNotesStore.getState().save(id, body, title).then(() => undefined);
const { rerender } = render(
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
);
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
// 改名:主进程 repin frontmatter id ⇒ 旧 id 成死键
await act(async () => {
await useNotesStore.getState().renameFile(OLD_ID, 'renamed.md');
});
// 真实路径:activeNoteFull 被清空 ⇒ NotesView 渲染 Skeleton ⇒ 编辑器卸载
rerender(<div />);
const calls = api.writeNote.mock.calls;
expect(calls.length, '改名后必须补落盘一次').toBeGreaterThan(0);
expect(calls[0]![0], '必须按新 id 写').toBe(NEW_ID);
expect(calls[0]![1], '必须带上用户刚敲的正文').toBe('B1');
});
it('M3. 对照:真的切到另一篇笔记(无重映射)⇒ 仍然照旧 flush 旧 id', () => {
const target = note(OLD_ID, 'a.md', 'B0');
seedStore(target);
const onSave = (id: string, body: string, title: string): Promise<void> =>
useNotesStore.getState().save(id, body, title).then(() => undefined);
const { rerender } = render(
<StandaloneNoteEditorCore note={target} onSave={onSave} titleEditable />,
);
fireEvent.change(screen.getByTestId('body-editor'), { target: { value: 'B1' } });
// 切到**另一篇**笔记(没有重映射登记)
rerender(
<StandaloneNoteEditorCore note={note('other', 'c.md', 'X0')} onSave={onSave} titleEditable />,
);
const calls = api.writeNote.mock.calls;
expect(calls.length, '切笔记时旧笔记必须被 flush').toBeGreaterThan(0);
expect(calls[0]![0], '切笔记 ⇒ 照旧写回旧 id(旧笔记还活着)').toBe(OLD_ID);
expect(calls[0]![1]).toBe('B1');
});
it('M4. 搬移后 notes[] 里不得留着死 id(否则右侧渲染「笔记已不可用」)', () => {
const target = note(OLD_ID, 'a.md', 'B0');
seedStore(target);
act(() => {
useNotesStore.getState().applyMoveRemap(
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
);
});
const st = useNotesStore.getState();
expect(
st.notes.some((n) => n.id === OLD_ID),
'列表里不得留着死 id(点它必然 NOT_FOUND)',
).toBe(false);
const moved = st.notes.find((n) => n.id === NEW_ID);
expect(moved, '列表里必须有新 id 那一行').toBeTruthy();
expect(moved?.relPath, '新行的 relPath 必须跟着搬').toBe('sub/a.md');
});
it('M5. 搬移后的保存不得带 expectedMtime(否则必报假 CONFLICT)', async () => {
const target = note(OLD_ID, 'a.md', 'B0', 'T1', 111);
seedStore(target);
act(() => {
useNotesStore.getState().applyMoveRemap(
new Map([[OLD_ID, { id: NEW_ID, relPath: 'sub/a.md' }]]),
);
});
await act(async () => {
await useNotesStore.getState().save(NEW_ID, 'B1', 'T1');
});
const call = api.writeNote.mock.calls.at(-1)!;
expect(call[0]).toBe(NEW_ID);
expect(
call[3],
'搬移后磁盘 mtime 必然变(repin 重写了文件)⇒ 不得再拿旧 mtime 比对',
).toBeUndefined();
});
});

View File

@@ -0,0 +1,169 @@
// tests/unit/renderer/components/system/error-boundary-fallback.test.tsx
//
// 守 ErrorBoundary 的三契约(src/renderer/src/components/system/ErrorBoundary.tsx
// 头注指向本文件,audit-r112 登记,本轮补齐 —— 原先标 🔒(守卫缺失)):
//
// 1. **吞抛**(onError 自身失败不能再次触发 boundary)
// 上报回调通常是网络/遥测调用,它在 boundary 之外抛错 ⇒ React 会
// 把它当成新的渲染错误,boundary 已经在处理错误态 ⇒ 结果是
// 「错误处理又炸了」,用户看到的是崩溃而不是可重试的兜底界面。
// 2. **reset 重挂载**
// 兜底界面上的「重试」必须真的把子树重新挂载(而不是只把错误态
// 清掉后继续渲染同一棵已炸的树)。
// 3. **fallback 优先**
// 调用方给了自定义 fallback ⇒ 用它,不用内置 ErrorFallback。
//
// 附带守:无错时正常透传 children(不劫持正常渲染路径)。
//
// 变异思路:去掉 try/catch ⇒ 例 1 红;reset 不 setState ⇒ 例 2 红;
// 忽略 props.fallback ⇒ 例 3 红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
vi.mock('../../../../../src/renderer/src/lib/toast', () => ({
useToast: () => () => undefined,
}));
import { ErrorBoundary } from '../../../../../src/renderer/src/components/system/ErrorBoundary';
/** 必炸的子组件 —— 用来验证 boundary 真的接住了。 */
function Boom({ onRender }: { onRender?: () => void }): React.ReactElement {
onRender?.();
throw new Error('boom');
}
describe('ErrorBoundary 契约(audit-r112 补齐)', () => {
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// React 在捕获到渲染错误时必然打 console.error —— 这里静音,
// 只留断言本身说话。
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleError.mockRestore();
cleanup();
});
it('无错时正常渲染 children(不劫持正常路径)', () => {
render(
<ErrorBoundary name="Ok">
<div data-testid="child">hello</div>
</ErrorBoundary>,
);
expect(screen.getByTestId('child').textContent).toBe('hello');
});
it('子组件抛错 ⇒ 渲染内置 ErrorFallback(role=alert),不是白屏', () => {
render(
<ErrorBoundary name="Reader">
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole('alert')).toBeTruthy();
expect(screen.queryByTestId('child')).toBeNull();
});
it('onError 被调用,并带上 error 与 componentStack', () => {
const onError = vi.fn();
render(
<ErrorBoundary name="Reader" onError={onError}>
<Boom />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledTimes(1);
const [err, info] = onError.mock.calls[0];
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe('boom');
expect(info).toHaveProperty('componentStack');
});
it('onError 自身抛错 ⇒ 被吞掉,兜底界面照常显示(错误处理不二次爆炸)', () => {
const onError = vi.fn(() => {
throw new Error('reporter down');
});
expect(() =>
render(
<ErrorBoundary name="Reader" onError={onError}>
<Boom />
</ErrorBoundary>,
),
).not.toThrow();
// 兜底界面仍在(没被 reporter 的异常顶掉)
expect(screen.getByRole('alert')).toBeTruthy();
});
it('「重试」⇒ reset ⇒ 子树重新挂载(第二次不炸就恢复渲染)', () => {
let shouldThrow = true;
const renders: number[] = [];
function Flaky() {
renders.push(renders.length);
if (shouldThrow) throw new Error('boom');
return <div data-testid="recovered">ok</div>;
}
render(
<ErrorBoundary name="Reader">
<Flaky />
</ErrorBoundary>,
);
expect(screen.getByRole('alert')).toBeTruthy();
expect(screen.queryByTestId('recovered')).toBeNull();
shouldThrow = false;
fireEvent.click(screen.getByRole('button', { name: /重试|retry/i }));
expect(screen.getByTestId('recovered').textContent).toBe('ok');
// 兜底界面已被换掉
expect(screen.queryByRole('alert')).toBeNull();
});
it('自定义 fallback 优先于内置 ErrorFallback', () => {
render(
<ErrorBoundary
name="Reader"
fallback={({ error }) => <div data-testid="custom">{error.message}</div>}
>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByTestId('custom').textContent).toBe('boom');
// 内置的 role=alert 不应出现
expect(screen.queryByRole('alert')).toBeNull();
});
it('自定义 fallback 能拿到 reset(ErrorFallbackProps 契约)', () => {
let shouldThrow = true;
function Flaky() {
if (shouldThrow) throw new Error('boom');
return <div data-testid="recovered">ok</div>;
}
render(
<ErrorBoundary
name="Reader"
fallback={({ reset }) => (
<button data-testid="custom-retry" onClick={reset}>
retry
</button>
)}
>
<Flaky />
</ErrorBoundary>,
);
shouldThrow = false;
fireEvent.click(screen.getByTestId('custom-retry'));
expect(screen.getByTestId('recovered').textContent).toBe('ok');
});
it('子组件挂载被记录过一次(证明 boundary 是渲染期接住,不是事后补画)', () => {
const onRender = vi.fn();
render(
<ErrorBoundary name="Reader">
<Boom onRender={onRender} />
</ErrorBoundary>,
);
expect(onRender).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,175 @@
// tests/unit/renderer/components/system/error-fallback-i18n-echo.test.tsx
//
// 守 ErrorFallback 的 **i18n 回声兜底 + 插值 + 剪贴板** 三案契约
// (src/renderer/src/components/system/ErrorFallback.tsx 头注指向本文件,
// audit-r112 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// 为什么这条重要:错误界面是**兜底界面** —— 它出现的时刻往往正是应用
// 已经不正常的时刻。若此时 useTranslation 还没就绪,t() 会回声 key 本身,
// 用户看到的不是「出错了」而是 `errorBoundary.title` / `errorBoundary.regionLabel`
// 这种裸 key,比白屏还让人困惑(而且这恰好是「出错时还想给用户一个清楚
// 的解释」这件事被自己搞砸的典型)。
//
// 三案各自钉住:
// 回声(interp):t() 返回 key 形态 ⇒ 走 zh-CN 直接 import 的 FALLBACK 兜底,
// 屏幕上**不能**出现裸 key;
// 插值:兜底串里的 {name} 必须被替换成真实区域名(且缺失变量保留字面量);
// 剪贴板:复制成功/失败各弹一条**已本地化**的 toast(不是 key)。
//
// 变异思路:把 t() 改成永远回声 / 永远就绪 / 把 FALLBACK 换成 en / 去掉
// interpolate 替换 —— 本文件逐条会红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// i18n 状态开关 —— 'echo' 模拟 i18n 未就绪(t 回声 key);
// 'ready' 模拟 i18n 已就绪(t 返回带前缀的真实值)。
const i18nState = { mode: 'echo' as 'echo' | 'ready' };
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => (i18nState.mode === 'echo' ? key : `T:${key}`),
}),
}));
// toast —— 只记录调用,不引入 zustand store。
const toastCalls: { type: string; message: string }[] = [];
vi.mock('../../../../../src/renderer/src/lib/toast', () => ({
useToast: () => (opts: { type: string; message: string }) => {
toastCalls.push(opts);
},
}));
import { ErrorFallback } from '../../../../../src/renderer/src/components/system/ErrorFallback';
const ZH = {
title: '出错了',
description: '应用遇到了一个意外错误,可以重试或复制堆栈上报。',
errorInfoLabel: '错误信息',
noMessage: '(无错误信息)',
collapseStack: '收起堆栈',
expandStack: '展开堆栈',
retry: '重试',
copyStack: '复制堆栈',
regionLabel: '区域: {name}',
topLevelError: '顶层错误',
};
function makeError(message = 'boom', stack = 'Error: boom\n at x.ts:1:1') {
const e = new Error(message);
e.stack = stack;
return e;
}
describe('ErrorFallback 的 i18n 回声兜底(audit-r112 补齐)', () => {
beforeEach(() => {
i18nState.mode = 'echo';
toastCalls.length = 0;
});
afterEach(() => {
cleanup();
});
it('i18n 未就绪(t 回声 key)⇒ 渲染 zh-CN 兜底文案,屏幕上不出现裸 key', () => {
render(<ErrorFallback error={makeError()} reset={() => {}} name="LibraryReader" />);
const text = document.body.textContent ?? '';
expect(text).toContain(ZH.title);
expect(text).toContain(ZH.description);
expect(text).toContain(ZH.retry);
// 裸 key 绝不能出现在用户可见文本里
expect(text).not.toContain('errorBoundary.');
expect(text).not.toContain('toast.');
});
it('区域名走 regionLabel 插值:显示真实 name,不留 {name} 占位符', () => {
render(<ErrorFallback error={makeError()} reset={() => {}} name="LibraryReader" />);
const text = document.body.textContent ?? '';
expect(text).toContain('LibraryReader');
expect(text).not.toContain('{name}');
expect(text).toContain('区域:');
});
it('没有 name ⇒ 走 topLevelError(不是 regionLabel 的空插值)', () => {
render(<ErrorFallback error={makeError()} reset={() => {}} />);
const text = document.body.textContent ?? '';
expect(text).toContain(ZH.topLevelError);
expect(text).not.toContain('{name}');
});
it('i18n 已就绪 ⇒ 用 i18n 的值,不回退到 zh-CN 兜底', () => {
i18nState.mode = 'ready';
render(<ErrorFallback error={makeError()} reset={() => {}} name="X" />);
const text = document.body.textContent ?? '';
expect(text).toContain('T:errorBoundary.title');
expect(text).not.toContain(ZH.title);
});
it('error.message 为空 ⇒ 显示 noMessage 兜底(不显示空白)', () => {
render(<ErrorFallback error={makeError('')} reset={() => {}} />);
expect(document.body.textContent ?? '').toContain(ZH.noMessage);
});
it('堆栈默认折叠(aria-expanded=false);点击后展开 <pre> 并可再收起', () => {
render(<ErrorFallback error={makeError()} reset={() => {}} />);
const toggle = screen.getByRole('button', { name: new RegExp(ZH.expandStack) });
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(document.querySelector('pre')).toBeNull();
fireEvent.click(toggle);
const collapse = screen.getByRole('button', { name: new RegExp(ZH.collapseStack) });
expect(collapse.getAttribute('aria-expanded')).toBe('true');
expect(document.querySelector('pre')?.textContent).toContain('at x.ts:1:1');
fireEvent.click(collapse);
expect(document.querySelector('pre')).toBeNull();
});
it('无 stack ⇒ 不渲染折叠按钮(不给用户一个点不开的开关)', () => {
const e = new Error('boom');
e.stack = '';
render(<ErrorFallback error={e} reset={() => {}} />);
expect(screen.queryByRole('button', { name: new RegExp(ZH.expandStack) })).toBeNull();
});
it('复制堆栈成功 ⇒ 弹 success toast,消息是**已本地化**的文案(不是 key)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
render(<ErrorFallback error={makeError()} reset={() => {}} name="R" />);
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.copyStack) }));
await vi.waitFor(() => expect(toastCalls).toHaveLength(1));
expect(toastCalls[0].type).toBe('success');
expect(toastCalls[0].message).toBe('堆栈已复制到剪贴板');
expect(toastCalls[0].message).not.toContain('toast.');
// 复制内容含区域名 + 错误消息
expect(writeText).toHaveBeenCalledTimes(1);
expect(String(writeText.mock.calls[0][0])).toContain('R');
expect(String(writeText.mock.calls[0][0])).toContain('boom');
});
it('复制堆栈失败 ⇒ 弹 error toast(已本地化文案),不吞异常', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
render(<ErrorFallback error={makeError()} reset={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.copyStack) }));
await vi.waitFor(() => expect(toastCalls).toHaveLength(1));
expect(toastCalls[0].type).toBe('error');
expect(toastCalls[0].message).toBe('复制失败,请手动选择');
});
it('「重试」按钮调用 reset(ErrorBoundary 用它重挂载子树)', () => {
const reset = vi.fn();
render(<ErrorFallback error={makeError()} reset={reset} />);
fireEvent.click(screen.getByRole('button', { name: new RegExp(ZH.retry) }));
expect(reset).toHaveBeenCalledTimes(1);
});
it('错误区是 role="alert"(SR 立即播报,不等用户去逛)', () => {
render(<ErrorFallback error={makeError()} reset={() => {}} />);
expect(screen.getByRole('alert')).toBeTruthy();
});
});

View File

@@ -0,0 +1,134 @@
// tests/unit/renderer/components/theme-picker.test.tsx
//
// 守 r247「设置页主题选择器改成色卡网格」的行为契约。
//
// 背景:预设从 4 档扩到 15 档后,原来那排「文字芯片 + 一个小图标」完全
// 撑不住了 —— 15 个芯片换行铺满,用户看不出每个配色长什么样,只能一个个
// 点下去试。改成网格色卡后,每张卡片带一块**真实配色**的缩略预览。
//
// 本测锁的行为面:
// A. radiogroup 语义完整(容器 role / 一张卡片 role=radio / 选中态唯一 /
// roving tabindex)—— 键盘用户要能只用方向键切主题。
// B. 点卡片 → 回调该预设 id(设置页据此落盘)。
// C. 每张卡片的缩略图挂在**正确的 token 作用域**上:
// 具体预设挂 data-theme-preset="<id>",「默认」挂 data-theme-preview
// (跟随当前浅色 / 深色设置)—— 预览色必须等于真正切过去后的配色。
// D. 「默认」预设的预览跟随 settings.theme(light → light 预览)。
// E. 选中「强制明暗」的预设时给出提示(否则上面的「浅色 / 深色」开关
// 看起来像失灵了)。
// F. ThemePicker.tsx 里**不含任何硬编码颜色** —— 配色只有 globals.css
// 一份,预览不可能与真实主题漂移。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { readFileSync } from 'fs';
import { join } from 'path';
// i18n:只关心键名与插值结构,不在乎文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string, opts?: Record<string, string>) => (opts?.polarity ? `${k}[${opts.polarity}]` : k),
}),
}));
// settings store:ThemePicker 只读 settings.theme(决定「默认」预设的预览极性)
vi.mock('../../../../src/renderer/src/store/settings', () => {
const state = { settings: { theme: 'dark' } as { theme: string } };
const useSettingsStore = ((selector?: (s: unknown) => unknown) =>
selector ? selector(state) : state) as unknown as {
(selector?: (s: unknown) => unknown): unknown;
getState: () => unknown;
};
useSettingsStore.getState = () => state;
return { useSettingsStore };
});
import { ThemePicker, PRESET_OPTIONS } from '../../../../src/renderer/src/components/settings/ThemePicker';
import { THEME_PRESETS } from '../../../../src/shared/types';
const ROOT = process.cwd();
function renderPicker(value: string = 'default') {
const onChange = vi.fn();
const utils = render(
<ThemePicker
value={value as never}
onChange={onChange}
labelledBy="preset-label"
describedBy="preset-desc"
/>,
);
return { ...utils, onChange };
}
describe('主题选择器色卡网格(r247)', () => {
beforeEach(() => {
cleanup();
});
afterEach(() => {
cleanup();
});
it('PRESET_OPTIONS 覆盖全部预设且顺序与 THEME_PRESETS 一致', () => {
expect(PRESET_OPTIONS.map((p) => p.id)).toEqual([...THEME_PRESETS]);
});
it('容器是 radiogroup,每档一张 role=radio 卡片', () => {
const { container } = renderPicker();
const group = container.querySelector('[role="radiogroup"]');
expect(group).not.toBeNull();
expect(group?.getAttribute('aria-labelledby')).toBe('preset-label');
expect(group?.getAttribute('aria-describedby')).toBe('preset-desc');
const cards = container.querySelectorAll('[role="radio"]');
expect(cards.length).toBe(THEME_PRESETS.length);
});
it('选中态唯一:只有当前预设 aria-checked=true 且 tabIndex=0', () => {
const { container } = renderPicker('nord');
const cards = Array.from(container.querySelectorAll('[role="radio"]'));
const checked = cards.filter((c) => c.getAttribute('aria-checked') === 'true');
expect(checked.length).toBe(1);
expect(checked[0].getAttribute('tabindex')).toBe('0');
for (const c of cards.filter((x) => x !== checked[0])) {
expect(c.getAttribute('tabindex')).toBe('-1');
}
});
it('点卡片回调该预设 id', () => {
const { container, onChange } = renderPicker('default');
const cards = Array.from(container.querySelectorAll('[role="radio"]'));
fireEvent.click(cards[3]);
expect(onChange).toHaveBeenCalledWith(THEME_PRESETS[3]);
});
it('具体预设的缩略图挂在 data-theme-preset 上(预览 = 真实配色)', () => {
const { container } = renderPicker('default');
for (const preset of THEME_PRESETS) {
if (preset === 'default') continue;
const scoped = container.querySelector(`[data-theme-preset="${preset}"]`);
expect(scoped, `${preset} 的缩略图没有挂 data-theme-preset`).not.toBeNull();
}
});
it('「默认」预设的缩略图跟随浅色 / 深色设置(而不是固定一档)', () => {
const { container } = renderPicker('default');
const scoped = container.querySelector('[data-theme-preview]');
expect(scoped).not.toBeNull();
expect(scoped?.getAttribute('data-theme-preview')).toBe('dark');
});
it('选中强制明暗的预设时提示「主题」开关对它无效', () => {
const { container } = renderPicker('nord');
expect(container.textContent).toContain('settings.appearanceThemePresetFixed');
const { container: c2 } = renderPicker('default');
expect(c2.textContent).not.toContain('settings.appearanceThemePresetFixed');
});
it('ThemePicker.tsx 里不含硬编码颜色(配色只有 globals.css 一份)', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/components/settings/ThemePicker.tsx'),
'utf8',
);
const hexes = src.match(/#[0-9a-fA-F]{3,8}\b/g) ?? [];
expect(hexes, `色卡组件里不该有硬编码颜色: ${hexes.join(', ')}`).toEqual([]);
});
});

View File

@@ -0,0 +1,156 @@
// tests/unit/renderer/components/ui/button-contract.test.tsx
//
// 守 Button 原语的**行为契约**(src/renderer/src/components/ui/Button.tsx 头注
// 指向本文件,audit-r118 登记 6 例 / 7 变异精确;本轮补齐,原先标 🔒(守卫缺失))。
//
// Button 是全站按钮唯一入口(确认框 / 工具栏 / 错误页 / 设置页),它身上钉着
// 几件**别处不会重复**的语义:
// 1. 默认 type="button" —— HTML <button> 的默认 type 是 "submit",在
// <form> 里点一下会误提交。Button 必须显式兜住。
// 2. loading ⇒ 自动 disabled + aria-busy + 插入 spinner + **摘掉 onClick**
// (r143:防异步动作期间重复点击;spinner 在前、文字不变避免宽度跳变)。
// 3. data-* 透传(r18)—— ConfirmDialog 的 autoFocusSelector 需要用
// querySelector 命中真按钮,故必须透传,不能被 Button 的 prop 白名单吃掉。
// 4. size lg = h-11(r150 a11y:WCAG 2.5.5 AAA 触摸目标 ≥ 44×44)。
//
// 变异思路:每条断言都对着**一个具体会被改坏的点**,不是快照式对比。
import { describe, expect, it, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { Button } from '../../../../../src/renderer/src/components/ui/Button';
function renderButton(ui: React.ReactElement) {
const { container, unmount } = render(ui);
const btn = container.querySelector('button');
if (!btn) throw new Error('Button 未渲染出 <button>');
return { btn, container, unmount };
}
describe('Button 契约(audit-r118 补齐)', () => {
it('默认 type="button"(HTML 默认是 submit,在 <form> 里会误提交)', () => {
const { btn, unmount } = renderButton(<Button>ok</Button>);
expect(btn.getAttribute('type')).toBe('button');
unmount();
});
it('loading ⇒ disabled + aria-busy + spinner + 摘掉 onClick', () => {
const onClick = vi.fn();
const { btn, container, unmount } = renderButton(
<Button loading onClick={onClick}>
保存
</Button>,
);
expect(btn.disabled, 'loading 期间必须禁用').toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
// 文字不变 + spinner 在前(宽度不跳变)
expect(btn.textContent).toContain('保存');
const svg = container.querySelector('svg');
expect(svg, 'loading 时应有 Loader2 图标').not.toBeNull();
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(svg?.getAttribute('class')).toContain('animate-spin');
// 视觉:不可点 cursor + 轻微透明
expect(btn.className).toContain('cursor-not-allowed');
// disabled 按钮的 click 由浏览器/React 双重挡住 ⇒ onClick 不得触发
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
unmount();
});
it('loading 覆盖 disabled 的语义(两者都传也只 disabled 一次,不报错)', () => {
const { btn, unmount } = renderButton(
<Button loading disabled>
x
</Button>,
);
expect(btn.disabled).toBe(true);
expect(btn.getAttribute('aria-busy')).toBe('true');
unmount();
});
it('非 loading 时 disabled ⇒ 点击不触发 onClick;非 disabled ⇒ 触发一次', () => {
const onClick = vi.fn();
const { btn, unmount } = renderButton(
<Button disabled onClick={onClick}>
x
</Button>,
);
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
unmount();
const onClick2 = vi.fn();
const { btn: btn2, unmount: unmount2 } = renderButton(
<Button onClick={onClick2}>x</Button>,
);
fireEvent.click(btn2);
expect(onClick2).toHaveBeenCalledTimes(1);
unmount2();
});
it('data-* 透传(r18:ConfirmDialog 的 autoFocusSelector 要靠它命中真按钮)', () => {
const { btn, unmount } = renderButton(
<Button data-testid="confirm-ok" data-confirm-role="primary">
确认
</Button>,
);
expect(btn.getAttribute('data-testid')).toBe('confirm-ok');
expect(btn.getAttribute('data-confirm-role')).toBe('primary');
unmount();
});
it('非 data-* 的多余 prop 不落到 DOM(rest 白名单外一律丢弃)', () => {
const { btn, unmount } = renderButton(
<Button {...({ nonsense: 'x' } as Record<string, unknown>)}>y</Button>,
);
expect(btn.getAttribute('nonsense')).toBeNull();
unmount();
});
it('size 档位类名映射(xs/sm/md/lg,lg = h-11 的 44px 触摸目标)', () => {
const cases: [ 'xs' | 'sm' | 'md' | 'lg', string ][] = [
['xs', 'h-6'],
['sm', 'h-[26px]'],
['md', 'h-[30px]'],
['lg', 'h-11'],
];
for (const [size, expectCls] of cases) {
const { btn, unmount } = renderButton(<Button size={size}>z</Button>);
expect(btn.className, `size=${size} 应带 ${expectCls}`).toContain(expectCls);
unmount();
}
});
it('variant 档位类名映射(primary/secondary/ghost/danger)+ 默认 secondary', () => {
const variants = ['primary', 'secondary', 'ghost', 'danger'] as const;
for (const v of variants) {
const { btn, unmount } = renderButton(<Button variant={v}>z</Button>);
expect(btn.className, `variant=${v} 应带 btn-${v}`).toContain(`btn-${v}`);
unmount();
}
const { btn, unmount } = renderButton(<Button>z</Button>);
expect(btn.className, '默认 variant 必须是 secondary').toContain('btn-secondary');
unmount();
});
it('aria-label / title 透传(缺省时属性不存在,不留空串)', () => {
const { btn, unmount } = renderButton(
<Button aria-label="关闭面板" title="tooltip">
z
</Button>,
);
expect(btn.getAttribute('aria-label')).toBe('关闭面板');
expect(btn.getAttribute('title')).toBe('tooltip');
unmount();
const { btn: bare, unmount: unmount2 } = renderButton(<Button>z</Button>);
expect(bare.getAttribute('aria-label')).toBeNull();
expect(bare.getAttribute('title')).toBeNull();
unmount2();
});
it('className 由调用方追加(不覆盖内置档位类)', () => {
const { btn, unmount } = renderButton(<Button className="my-cls">z</Button>);
expect(btn.className).toContain('my-cls');
expect(btn.className).toContain('btn');
unmount();
});
});

View File

@@ -0,0 +1,69 @@
// tests/unit/renderer/components/ui/input-aria-contract.test.tsx
//
// 守 audit r252:Input 的 aria-describedby 必须**同时**指向调用方给的说明
// 与组件自己挂的错误文案节点。
//
// 旧实现的坑:属性写在 `{...rest}` **之前** ——
// `aria-describedby={hasError ? errId : rest['aria-describedby']}`
// 而展开在后面 ⇒ rest 里若也带 aria-describedby,展开会把它整个盖掉
// ⇒ 错误文案 <p role="alert"> 还在 DOM 里,却不再被 input 引用
// ⇒ 读屏用户念 input 时**听不到错误原因**(视觉正常、a11y 断链)。
// 今天是「潜在」缺陷(现有 6 个调用点没有同时传 error + aria-describedby),
// 但它是**契约级**的雷:下一个照着文档写的调用方就会踩到。
//
// 本测锁:
// 1. 同时传 error + aria-describedby ⇒ 两个 id 都在(不被覆盖);
// 2. 只传 error ⇒ 指到错误节点;
// 3. 只传 aria-describedby ⇒ 原样保留;
// 4. 都不传 ⇒ 不产出该属性。
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { Input } from '../../../../../src/renderer/src/components/ui/Input';
describe('Input 的 aria 契约(r252)', () => {
it('同时传 error + aria-describedby ⇒ 两个 id 都被引用(错误节点不丢)', () => {
render(
<Input
value="x"
onChange={() => undefined}
error="文件名已存在"
aria-describedby="hint-1"
/>,
);
const input = screen.getByRole('textbox');
const described = input.getAttribute('aria-describedby') ?? '';
const ids = described.split(/\s+/).filter(Boolean);
// 调用方给的说明
expect(ids, '调用方给的 aria-describedby 不得被覆盖').toContain('hint-1');
// 组件挂的错误节点:DOM 里那个 role=alert 的 <p> 的 id 必须在列表里
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('文件名已存在');
expect(ids, '错误文案节点必须被 input 引用(否则读屏听不到错误原因)').toContain(
alert.getAttribute('id') ?? '',
);
expect(alert.getAttribute('id')).toBeTruthy();
});
it('只传 error ⇒ 指到错误节点', () => {
render(<Input value="x" onChange={() => undefined} error="必填" />);
const input = screen.getByRole('textbox');
const alert = screen.getByRole('alert');
expect(input.getAttribute('aria-describedby')).toBe(alert.getAttribute('id'));
expect(input.getAttribute('aria-invalid')).toBe('true');
});
it('只传 aria-describedby ⇒ 原样保留', () => {
render(<Input value="x" onChange={() => undefined} aria-describedby="hint-2" />);
expect(screen.getByRole('textbox').getAttribute('aria-describedby')).toBe('hint-2');
});
it('都不传 ⇒ 不产出该属性', () => {
render(<Input value="x" onChange={() => undefined} />);
expect(screen.getByRole('textbox').hasAttribute('aria-describedby')).toBe(false);
});
});

View File

@@ -0,0 +1,195 @@
// tests/unit/renderer/components/ui/modal-contract.test.tsx
//
// 守 Modal 原语的**对话框语义契约**(src/renderer/src/components/ui/Modal.tsx
// 头注指向本文件,audit-r117 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// Modal 是全站唯一「role=dialog」的模态壳(确认框 / 重命名 / 文献详情都用它),
// 它身上钉着几件**语义载体**属性,错了 SR 用户直接失能:
// 1. role="dialog" + aria-modal="true" —— SR 才知道这是对话框并把背景
// 判为惰性;
// 2. aria-labelledby 指向标题 h2 —— SR 念的是「对话框标题」而不是
// 整段正文;
// 3. description ⇒ aria-describedby + 一个含说明的节点 —— **最关键**:
// 「此操作不可撤销」「.md 扩展名会自动追加」这类后果说明,SR 用户
// 光听标题听不到,只写在视觉上等于没写;
// 4. dismissible=false ⇒ **不渲染**右上角 ✕ —— 「点不动的假按钮」
// 比「没有按钮」更像 bug(audit 2026-10-04 MED);
// 5. autoFocusSelector / initialFocusRef 必须**真的转发**给 Overlay
// (r124:此前 Modal 壳根本没接收这两个 prop,Overlay 头注的承诺悬空,
// 导致 NotesView rename 弹窗的 autoFocus 被 Overlay 兜底 focus 偷走)。
//
// 变异思路:去掉 aria-describedby 接线 / 去掉 dismissible 分支 / 不转发
// initialFocusRef / description 传 ReactNode 时包错标签 —— 逐条会红。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { useRef } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
import { Modal } from '../../../../../src/renderer/src/components/ui/Modal';
async function flushFocus() {
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
}
afterEach(() => {
cleanup();
});
function renderModal(ui: React.ReactElement) {
const { container, unmount } = render(ui);
return { container, unmount };
}
describe('Modal 对话框语义契约(audit-r117 补齐)', () => {
it('open=false ⇒ 什么都不渲染', () => {
renderModal(
<Modal open={false} onClose={() => {}} title="T">
body
</Modal>,
);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it('role=dialog + aria-modal=true + aria-labelledby 指向标题 h2', () => {
renderModal(
<Modal open onClose={() => {}} title="重命名笔记">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-modal')).toBe('true');
const labelledBy = dialog.getAttribute('aria-labelledby');
expect(labelledBy, 'aria-labelledby 不得为空').toBeTruthy();
const title = document.getElementById(labelledBy!);
expect(title?.textContent).toBe('重命名笔记');
expect(title?.tagName).toBe('H2');
});
it('description 传 string ⇒ 自动包 <p> 并接上 aria-describedby', () => {
renderModal(
<Modal open onClose={() => {}} title="T" description="此操作不可撤销">
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
const descId = dialog.getAttribute('aria-describedby');
expect(descId, '有 description 时必须挂 aria-describedby').toBeTruthy();
const desc = document.getElementById(descId!);
expect(desc?.textContent).toBe('此操作不可撤销');
expect(desc?.tagName, 'string 形态必须包成 <p>').toBe('P');
});
it('description 传 ReactNode ⇒ 包 <div> 渲染(不强制 <p>)', () => {
renderModal(
<Modal
open
onClose={() => {}}
title="T"
description={<strong data-testid="rich">重点</strong>}
>
body
</Modal>,
);
const dialog = screen.getByRole('dialog');
const descId = dialog.getAttribute('aria-describedby');
const desc = document.getElementById(descId!);
expect(desc?.tagName).toBe('DIV');
expect(desc?.querySelector('[data-testid="rich"]')).not.toBeNull();
});
it('不传 description ⇒ 不挂 aria-describedby(别让 SR 念空串)', () => {
renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBeNull();
});
it('dismissible 默认 true ⇒ 渲染右上角关闭按钮,点它调 onClose', () => {
const onClose = vi.fn();
renderModal(
<Modal open onClose={onClose} title="T">
body
</Modal>,
);
const btn = screen.getByRole('button', { name: 'common.close' });
fireEvent.click(btn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('dismissible=false ⇒ 不渲染关闭按钮(不装一个点了没反应的假控件)', () => {
renderModal(
<Modal open onClose={() => {}} title="T" dismissible={false}>
body
</Modal>,
);
expect(screen.queryByRole('button', { name: 'common.close' })).toBeNull();
});
it('footer 传了才渲染底部条', () => {
renderModal(
<Modal open onClose={() => {}} title="T" footer={<button>确定</button>}>
body
</Modal>,
);
expect(screen.getByRole('button', { name: '确定' })).toBeTruthy();
cleanup();
renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.queryByRole('button', { name: '确定' })).toBeNull();
});
it('initialFocusRef 被转发给 Overlay(焦点落在指定元素,不被面板兜底偷走)', async () => {
function Case() {
const ref = useRef<HTMLInputElement>(null);
return (
<Modal open onClose={() => {}} title="T" initialFocusRef={ref}>
<input ref={ref} data-testid="target" />
</Modal>
);
}
render(<Case />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('target'));
});
it('autoFocusSelector 被转发给 Overlay', async () => {
render(
<Modal open onClose={() => {}} title="T" autoFocusSelector="[data-auto]">
<button data-auto data-testid="auto">
auto
</button>
</Modal>,
);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('auto'));
});
it('width prop 落到面板 className(默认 max-w-xl)', () => {
const { unmount } = renderModal(
<Modal open onClose={() => {}} title="T">
body
</Modal>,
);
expect(screen.getByRole('dialog').className).toContain('max-w-xl');
unmount();
const { unmount: u2 } = renderModal(
<Modal open onClose={() => {}} title="T" width="max-w-3xl">
body
</Modal>,
);
expect(screen.getByRole('dialog').className).toContain('max-w-3xl');
u2();
});
});

View File

@@ -0,0 +1,279 @@
// tests/unit/renderer/components/ui/overlay-effects.test.tsx
//
// 守 Overlay 的**六项副作用契约**(src/renderer/src/components/ui/Overlay.tsx
// 头注指向本文件,audit-r115 登记「六项承诺逐条 11 例 + 9 变异验红」,
// 本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// Overlay 是 Modal / ConfirmDialog / Drawer 的**唯一**副作用容器 ——
// 「打开就挡住整个视口」这件事的六个副作用全在这里,别处不重复实现。
// 任何一条退化,后果都是**用户可见**的:
//
// 1. Portal 到 body —— 否则 transform/opacity 上下文污染,
// 面板的 fixed 定位相对错误的祖先算;
// 2. body 滚动锁 —— 背景能滚 ⇒ 弹窗期间页面在动,用户失去方位;
// 3. ESC 关闭(仅栈顶) —— 多层弹层时一次 ESC 只应关最上面那层;
// 4. 进入移焦 / 关闭还焦—— 键盘用户 Tab 进弹窗后焦点仍在背景,
// 关闭后焦点丢失(落 body)⇒ 下一步操作失向;
// 5. Tab 焦点陷阱 —— Tab 跑出弹窗 = 焦点跑到被遮住的内容上;
// 6. 遮罩点击关闭 —— 且**只**在点到遮罩本身时关(点到面板内不关)。
//
// 变异思路:去掉 portal / 去掉滚动锁 / 去掉 isTop 判定 / 去掉焦点归还 /
// 去掉 trapFocus 分支 / 去掉 e.target===e.currentTarget 判定 —— 逐条会红。
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { useRef } from 'react';
import {
Overlay,
pushOverlayLayer,
removeOverlayLayer,
isOverlayLayerTop,
} from '../../../../../src/renderer/src/components/ui/Overlay';
/** Overlay 内部用 window.setTimeout(0) 做「等面板渲染完再 focus」。 */
async function flushFocus() {
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
}
afterEach(() => {
cleanup();
document.body.style.overflow = '';
document.body.style.paddingRight = '';
});
describe('Overlay 六项副作用契约(audit-r115 补齐)', () => {
it('1. Portal 到 body(面板不在 React 容器里)', () => {
const { container } = render(
<Overlay open onClose={() => {}}>
<div data-testid="panel">panel</div>
</Overlay>,
);
expect(container.querySelector('[data-testid="panel"]')).toBeNull();
expect(document.body.querySelector('[data-testid="panel"]')).not.toBeNull();
});
it('2. 打开时锁 body 滚动 + 补偿滚动条宽度;关闭后原样还原', () => {
const { rerender } = render(
<Overlay open onClose={() => {}}>
<div>p</div>
</Overlay>,
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Overlay open={false} onClose={() => {}}>
<div>p</div>
</Overlay>,
);
expect(document.body.style.overflow).toBe('');
expect(document.body.style.paddingRight).toBe('');
});
it('3. ESC 关闭;closeOnEscape=false 时不响应', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose}>
<div>p</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
const onClose2 = vi.fn();
render(
<Overlay open onClose={onClose2} closeOnEscape={false}>
<div>q</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose2).not.toHaveBeenCalled();
});
it('3b. 多层弹层:ESC 只关栈顶那层(不是一次全关)', () => {
const closeOuter = vi.fn();
const closeInner = vi.fn();
render(
<Overlay open onClose={closeOuter}>
<div>
<Overlay open onClose={closeInner}>
<div>inner</div>
</Overlay>
</div>
</Overlay>,
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(closeInner).toHaveBeenCalledTimes(1);
expect(closeOuter).not.toHaveBeenCalled();
});
it('4. 打开后焦点移入面板;关闭后焦点归还给打开前的元素', async () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
expect(document.activeElement).toBe(trigger);
const { rerender } = render(
<Overlay open onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
await flushFocus();
const panel = document.querySelector('[data-testid="panel"]')!;
// 面板容器(Overlay 自己的 panel div)拿到焦点
expect(document.activeElement).not.toBe(trigger);
expect(panel.parentElement?.contains(document.activeElement)).toBe(true);
rerender(
<Overlay open={false} onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
await flushFocus();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('4b. initialFocusRef 优先于 autoFocusSelector,两者都优先于面板兜底', async () => {
function Case({ mode }: { mode: 'ref' | 'selector' | 'none' }) {
const ref = useRef<HTMLButtonElement>(null);
return (
<Overlay
open
onClose={() => {}}
initialFocusRef={mode === 'ref' ? ref : undefined}
autoFocusSelector={mode === 'selector' ? '[data-auto]' : undefined}
>
<button ref={ref} data-testid="by-ref">
ref
</button>
<button data-auto data-testid="by-selector">
selector
</button>
</Overlay>
);
}
const { unmount } = render(<Case mode="ref" />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('by-ref'));
unmount();
const { unmount: unmount2 } = render(<Case mode="selector" />);
await flushFocus();
expect(document.activeElement).toBe(screen.getByTestId('by-selector'));
unmount2();
});
it('5. Tab 焦点陷阱:焦点在面板末尾时 Tab 回到面板首个可聚焦元素', async () => {
render(
<Overlay open onClose={() => {}}>
<button data-testid="first">1</button>
<button data-testid="last">2</button>
</Overlay>,
);
await flushFocus();
const last = screen.getByTestId('last');
last.focus();
expect(document.activeElement).toBe(last);
const ev = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
document.dispatchEvent(ev);
expect(ev.defaultPrevented, '焦点在末尾时 Tab 必须被拦下').toBe(true);
expect(document.activeElement).toBe(screen.getByTestId('first'));
});
it('5b. Shift+Tab 在面板首个元素时绕到末尾', async () => {
render(
<Overlay open onClose={() => {}}>
<button data-testid="first">1</button>
<button data-testid="last">2</button>
</Overlay>,
);
await flushFocus();
screen.getByTestId('first').focus();
const ev = new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(screen.getByTestId('last'));
});
it('5c. trapFocus=false ⇒ 不拦 Tab', async () => {
render(
<Overlay open onClose={() => {}} trapFocus={false}>
<button data-testid="first">1</button>
</Overlay>,
);
await flushFocus();
const ev = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
document.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
});
it('6. 点遮罩关闭;点面板内部不关', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose}>
<div data-testid="panel">p</div>
</Overlay>,
);
const scrim = document.querySelector('[data-testid="panel"]')!.parentElement!.parentElement!;
fireEvent.click(scrim);
expect(onClose).toHaveBeenCalledTimes(1);
onClose.mockClear();
fireEvent.click(screen.getByTestId('panel'));
expect(onClose).not.toHaveBeenCalled();
});
it('6b. closeOnBackdrop=false ⇒ 点遮罩不关', () => {
const onClose = vi.fn();
render(
<Overlay open onClose={onClose} closeOnBackdrop={false}>
<div data-testid="panel">p</div>
</Overlay>,
);
const scrim = document.querySelector('[data-testid="panel"]')!.parentElement!.parentElement!;
fireEvent.click(scrim);
expect(onClose).not.toHaveBeenCalled();
});
it('open=false ⇒ 什么都不渲染(且不锁滚动)', () => {
render(
<Overlay open={false} onClose={() => {}}>
<div data-testid="panel">p</div>
</Overlay>,
);
expect(document.querySelector('[data-testid="panel"]')).toBeNull();
expect(document.body.style.overflow).toBe('');
});
});
describe('Overlay 栈 API(r80:ContextMenu 接入同一栈)', () => {
beforeEach(() => {
// 每个用例从空栈开始 —— 栈是模块级状态,跨用例会串。
// push/remove 走公开 API 清理。
for (let i = 0; i < 10; i++) removeOverlayLayer(Symbol('probe'));
});
it('push → 自己是栈顶;再 push 一个则前者不是栈顶', () => {
const a = Symbol('a');
const b = Symbol('b');
pushOverlayLayer(a);
expect(isOverlayLayerTop(a)).toBe(true);
pushOverlayLayer(b);
expect(isOverlayLayerTop(b)).toBe(true);
expect(isOverlayLayerTop(a)).toBe(false);
removeOverlayLayer(b);
expect(isOverlayLayerTop(a)).toBe(true);
removeOverlayLayer(a);
});
it('remove 一个不在栈里的 id 不抛错(幂等)', () => {
const a = Symbol('a');
expect(() => removeOverlayLayer(a)).not.toThrow();
});
});

View File

@@ -0,0 +1,168 @@
// tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
//
// 守 Tabs 的 **WAI-ARIA tab pattern 键盘契约**(src/renderer/src/components/ui/Tabs.tsx
// 头注指向本文件,audit-r111 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
//
// 这条契约全站只有 Tabs 一处实现,漏了不会有第二个实现提醒你:
// 1. ← / → 在 tab 间移动(带环绕),Home / End 跳首尾;
// 2. **roving tabindex** —— 只有 selected 的 tab tabIndex=0,其余 -1,
// 整个 tablist 在 Tab 序列里只占一个停靠点;
// 3. 选中态随键盘移动,且**焦点跟着走**(WAI-ARIA 要求焦点与 selection
// 同步,否则 SR 读到的选中项与视觉焦点不一致);
// 4. active 指向一个不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为);
// 5. 单 tab 列表:←/→/Home/End 算回原位 ⇒ **不** preventDefault
// (否则会吞掉 textarea 里方向键移动光标等默认行为,audit 2026-09-07)。
//
// 变异思路:每条断言钉一个具体会被改坏的点(环绕 / roving / 焦点跟随 /
// no-op 分支 / 单 tab 不拦默认)。
import { describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { Tabs } from '../../../../../src/renderer/src/components/ui/Tabs';
const TABS = [
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
{ id: 'c', label: 'C' },
];
/** rAF 在 jsdom 里是 ~16ms 的定时器,键盘切换后要把那一帧跑完。 */
async function flushFrame() {
await act(async () => {
await new Promise((r) => setTimeout(r, 40));
});
}
function renderTabs(active = 'b', tabs = TABS) {
const onChange = vi.fn();
const { container, unmount } = render(
<Tabs tabs={tabs} active={active} onChange={onChange} ariaLabel="模式" />,
);
const list = container.querySelector('[role="tablist"]');
if (!list) throw new Error('Tabs 未渲染 tablist');
return { list, onChange, container, unmount };
}
/**
* 受控外壳 —— Tabs 是受控组件(active 由 caller 持有),键盘导航的
* **环绕**行为只有在 active 真的跟着变时才能观察到。静态 active 恒为
* 'b' 时,每次 → 都只能算出同一个 next,环绕是看不见的。
*/
function renderControlled(initial = 'b', tabs = TABS) {
const onChange = vi.fn();
function Harness() {
const [active, setActive] = useState(initial);
return (
<Tabs
tabs={tabs}
active={active}
onChange={(id) => {
onChange(id);
setActive(id);
}}
ariaLabel="模式"
/>
);
}
const { container, unmount } = render(<Harness />);
const list = container.querySelector('[role="tablist"]');
if (!list) throw new Error('Tabs 未渲染 tablist');
return { list, onChange, container, unmount };
}
function tabButtons(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
}
describe('Tabs 键盘导航契约(audit-r111 补齐)', () => {
it('→ 前进一档(到末尾后环绕回首),← 后退一档(到首位后环绕到末)', () => {
const { list, onChange, unmount } = renderControlled('b');
fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).toHaveBeenLastCalledWith('c');
fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).toHaveBeenLastCalledWith('a'); // 环绕
fireEvent.keyDown(list, { key: 'ArrowLeft' });
expect(onChange).toHaveBeenLastCalledWith('c'); // 反向环绕
expect(onChange).toHaveBeenCalledTimes(3);
unmount();
});
it('Home / End 跳到首 / 尾', () => {
const { list, onChange, unmount } = renderTabs('b');
fireEvent.keyDown(list, { key: 'End' });
expect(onChange).toHaveBeenLastCalledWith('c');
fireEvent.keyDown(list, { key: 'Home' });
expect(onChange).toHaveBeenLastCalledWith('a');
unmount();
});
it('roving tabindex:只有 selected 的 tab 可 Tab 进入,其余 -1', () => {
const { container, unmount } = renderTabs('b');
const btns = tabButtons(container);
expect(btns.map((b) => b.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']);
unmount();
});
it('键盘切换后焦点跟到新选中的 tab(WAI-ARIA:焦点与 selection 同步)', async () => {
const { list, container, unmount } = renderTabs('b');
fireEvent.keyDown(list, { key: 'ArrowRight' });
await flushFrame();
const btns = tabButtons(container);
// 组件是受控的(active 不变则仍渲染旧选中态),但**焦点**已经移到 c
expect(document.activeElement).toBe(btns[2]);
unmount();
});
it('active 指向不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为)', () => {
const { list, onChange, unmount } = renderTabs('missing');
const ev = fireEvent.keyDown(list, { key: 'ArrowRight' });
expect(onChange).not.toHaveBeenCalled();
expect(ev).toBe(true); // preventDefault 未被调用 ⇒ 返回 true
unmount();
});
it('单 tab 列表:←/→/Home/End 不 preventDefault、不触发 onChange', () => {
const one = [{ id: 'only', label: 'O' }];
const { list, onChange, unmount } = renderTabs('only', one);
for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) {
const ev = fireEvent.keyDown(list, { key });
expect(ev, `${key} 不应被 preventDefault 拦下`).toBe(true);
}
expect(onChange).not.toHaveBeenCalled();
unmount();
});
it('非导航键(Enter / 空格 / 普通字符)完全不管', () => {
const { list, onChange, unmount } = renderTabs('b');
for (const key of ['Enter', ' ', 'x', 'Tab']) {
fireEvent.keyDown(list, { key });
}
expect(onChange).not.toHaveBeenCalled();
unmount();
});
it('ARIA 接线:tablist/tab 角色、aria-selected、id 与 aria-controls 配对', () => {
const { container, unmount } = renderTabs('b');
const list = container.querySelector('[role="tablist"]');
expect(list?.getAttribute('aria-label')).toBe('模式');
const btns = tabButtons(container);
expect(btns.map((b) => b.id)).toEqual(['tab-a', 'tab-b', 'tab-c']);
expect(btns.map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']);
expect(btns.map((b) => b.getAttribute('aria-controls'))).toEqual([
'panel-a',
'panel-b',
'panel-c',
]);
unmount();
});
it('鼠标点击某个 tab ⇒ onChange(该 id)', () => {
const { container, onChange, unmount } = renderTabs('a');
const btns = tabButtons(container);
fireEvent.click(btns[2]);
expect(onChange).toHaveBeenCalledWith('c');
unmount();
});
});

Some files were not shown because too many files have changed in this diff Show More