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,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\(/);
});
});