266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
// tests/unit/renderer/hooks/use-sidebar-menus.test.ts
|
|
//
|
|
// 守 r232「侧栏右键菜单两段同构块收编」契约:
|
|
//
|
|
// 收编前,NotesList / LiteratureList 各自持有一份**逐字相同**的
|
|
// ① 文件夹行菜单(folderMenuItems)与 ② 空白区菜单(containerMenuItems)。
|
|
// 两域的文件夹操作面(设为落点 / 新建子夹 / 重命名 / 剪切 / 粘贴内容 /
|
|
// 粘贴文件夹 / 移到根 / 删除)是**同一个用户心智**,却有两份实现 ——
|
|
// 孪生分叉一旦发生,后果是「某一域的某个文件夹操作消失或指向错目标」
|
|
// (r228 即孪生分叉长成真 bug 的实例)。
|
|
//
|
|
// 收编后两域共用 hooks/use-sidebar-menus.ts,差异只剩注入点:
|
|
// 文件夹菜单零注入;空白区菜单注入「首项」+「粘贴项 label key」。
|
|
//
|
|
// ── 锁两类东西(与 r224-r227 同模式)──────────────────────────────
|
|
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
|
|
// 文件夹菜单:menu 为空 ⇒ 空;粘贴(内容)看剪贴板;粘贴(文件夹)看
|
|
// 文件夹剪贴板且排除自粘;移到根看 parentRelPath;删除恒在末尾;
|
|
// 空白区菜单:首项可注入;搜索态隐藏新建文件夹/粘贴文件夹;全选
|
|
// 只在有可见条目时出现并把可见 id 全量交给 selectMany;
|
|
// 2. 结构契约:两个组件必须走 useFolderMenuItems / useContainerMenuItems,
|
|
// 且不得再内联菜单项字面量 —— 孪生块复活即红(修前红)。
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { renderHook } from '@testing-library/react';
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
// i18n —— t 回显 key,便于断言「用的是哪个 key」。
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => key,
|
|
}),
|
|
}));
|
|
|
|
import {
|
|
useContainerMenuItems,
|
|
useFolderMenuItems,
|
|
} from '../../../../src/renderer/src/hooks/use-sidebar-menus';
|
|
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
|
|
|
|
interface FolderOpts {
|
|
folderMenu?: unknown;
|
|
folderClipboard?: string | null;
|
|
targetRelPath?: string | null;
|
|
}
|
|
|
|
function makeFolders(over: FolderOpts = {}) {
|
|
const calls: string[] = [];
|
|
const folders = {
|
|
folderMenu:
|
|
over.folderMenu === undefined
|
|
? { folder: { relPath: 'a/b', name: 'b', parentRelPath: 'a' }, x: 1, y: 2 }
|
|
: over.folderMenu,
|
|
folderClipboard: over.folderClipboard ?? null,
|
|
targetRelPath: over.targetRelPath ?? null,
|
|
startCreateSubfolder: vi.fn((f: { relPath: string }) => calls.push(`newSub:${f.relPath}`)),
|
|
startRename: vi.fn((f: { relPath: string }) => calls.push(`rename:${f.relPath}`)),
|
|
cutFolder: vi.fn((p: string) => calls.push(`cut:${p}`)),
|
|
startCreate: vi.fn(() => calls.push('newFolder')),
|
|
pasteFolderInto: vi.fn(async (p: string) => {
|
|
calls.push(`pasteFolder:${p}`);
|
|
return { ok: true };
|
|
}),
|
|
moveFolderNow: vi.fn(async (p: string, t: string | null) => {
|
|
calls.push(`moveToRoot:${p}->${t}`);
|
|
return { ok: true };
|
|
}),
|
|
requestDelete: vi.fn((f: { relPath: string }) => calls.push(`delete:${f.relPath}`)),
|
|
} as unknown as DiskFolderSidebar;
|
|
return { folders, calls };
|
|
}
|
|
|
|
function renderFolderMenu(over: FolderOpts = {}, clipboard: unknown = null) {
|
|
const { folders, calls } = makeFolders(over);
|
|
const pasteTo = vi.fn(async () => {
|
|
calls.push('pasteTo');
|
|
});
|
|
const runFolderOp = vi.fn((op: () => Promise<unknown>) => {
|
|
void op();
|
|
});
|
|
const { result } = renderHook(() => useFolderMenuItems({ folders, clipboard, pasteTo, runFolderOp }));
|
|
return { items: result.current, calls, pasteTo, runFolderOp };
|
|
}
|
|
|
|
function renderContainerMenu(
|
|
over: {
|
|
folders?: FolderOpts;
|
|
clipboard?: unknown;
|
|
searchActive?: boolean;
|
|
visibleItems?: ReadonlyArray<{ id: string }>;
|
|
primaryItem?: { id: string; labelKey: string; onSelect: () => void };
|
|
pasteLabelKey?: string;
|
|
} = {},
|
|
) {
|
|
const { folders, calls } = makeFolders(over.folders ?? {});
|
|
const pasteToTarget = vi.fn(() => calls.push('pasteToTarget'));
|
|
const runFolderOp = vi.fn((op: () => Promise<unknown>) => {
|
|
void op();
|
|
});
|
|
const selectMany = vi.fn((ids: string[]) => calls.push(`selectMany:${ids.join(',')}`));
|
|
const primaryItem = over.primaryItem ?? {
|
|
id: 'newNote',
|
|
labelKey: 'notes.listEmptyAction',
|
|
onSelect: () => calls.push('primary'),
|
|
};
|
|
const { result } = renderHook(() =>
|
|
useContainerMenuItems({
|
|
folders,
|
|
clipboard: over.clipboard ?? null,
|
|
pasteToTarget,
|
|
runFolderOp,
|
|
searchActive: over.searchActive ?? false,
|
|
visibleItems: over.visibleItems ?? [],
|
|
primaryItem,
|
|
pasteLabelKey: over.pasteLabelKey ?? 'notes.contextPaste',
|
|
selectMany,
|
|
}),
|
|
);
|
|
return { items: result.current, calls, selectMany, pasteToTarget, primaryItem };
|
|
}
|
|
|
|
const ids = (items: ReadonlyArray<{ id: string }>) => items.map((i) => i.id);
|
|
|
|
describe('useFolderMenuItems(r232 收编块的行为契约)', () => {
|
|
it('没有打开的文件夹菜单 ⇒ 空数组', () => {
|
|
const { items } = renderFolderMenu({ folderMenu: null });
|
|
expect(items).toEqual([]);
|
|
});
|
|
|
|
it('默认项集合:新建子夹 / 重命名 / 剪切 + 末尾的删除(无「设为当前文件夹」—— 点行即设落点)', () => {
|
|
const { items } = renderFolderMenu();
|
|
expect(ids(items)).toEqual(['newSub', 'rename', 'cut', 'moveToRoot', 'sep1', 'delete']);
|
|
// 删除恒在最后一项
|
|
expect(ids(items).at(-1)).toBe('delete');
|
|
});
|
|
|
|
it('「粘贴(内容)」只在内容剪贴板非空时出现;点击打到 pasteTo(目标 = 该文件夹)', () => {
|
|
const empty = renderFolderMenu();
|
|
expect(ids(empty.items)).not.toContain('pasteItems');
|
|
|
|
const withClipboard = renderFolderMenu({}, { some: 'payload' });
|
|
expect(ids(withClipboard.items)).toContain('pasteItems');
|
|
withClipboard.items.find((i) => i.id === 'pasteItems')?.onSelect?.();
|
|
expect(withClipboard.pasteTo).toHaveBeenCalledWith('a/b');
|
|
});
|
|
|
|
it('「粘贴(文件夹)」在文件夹剪贴板非空且不是自己时出现(防自粘)', () => {
|
|
const self = renderFolderMenu({ folderClipboard: 'a/b' });
|
|
expect(ids(self.items)).not.toContain('pasteFolder');
|
|
|
|
const other = renderFolderMenu({ folderClipboard: 'a/c' });
|
|
expect(ids(other.items)).toContain('pasteFolder');
|
|
other.items.find((i) => i.id === 'pasteFolder')?.onSelect?.();
|
|
expect(other.calls).toContain('pasteFolder:a/b');
|
|
});
|
|
|
|
it('「移到根」只在非根目录出现;目标 = 根(null)', () => {
|
|
const root = renderFolderMenu({
|
|
folderMenu: { folder: { relPath: 'b', name: 'b', parentRelPath: null }, x: 0, y: 0 },
|
|
});
|
|
expect(ids(root.items)).not.toContain('moveToRoot');
|
|
|
|
const nested = renderFolderMenu();
|
|
expect(ids(nested.items)).toContain('moveToRoot');
|
|
nested.items.find((i) => i.id === 'moveToRoot')?.onSelect?.();
|
|
expect(nested.calls).toContain('moveToRoot:a/b->null');
|
|
});
|
|
|
|
it('动作接线:新建子夹 / 剪切 / 删除各打到 folders 的对应方法', () => {
|
|
const { items, calls } = renderFolderMenu();
|
|
items.find((i) => i.id === 'newSub')?.onSelect?.();
|
|
items.find((i) => i.id === 'cut')?.onSelect?.();
|
|
items.find((i) => i.id === 'delete')?.onSelect?.();
|
|
expect(calls).toEqual(['newSub:a/b', 'cut:a/b', 'delete:a/b']);
|
|
});
|
|
});
|
|
|
|
describe('useContainerMenuItems(r232 收编块的行为契约)', () => {
|
|
it('首项 = 注入的主操作(id 与 label key 都由调用方给)', () => {
|
|
const { items } = renderContainerMenu({
|
|
primaryItem: { id: 'import', labelKey: 'literature.listEmptyAction', onSelect: () => {} },
|
|
});
|
|
expect(items[0]?.id).toBe('import');
|
|
expect(items[0]?.label).toBe('literature.listEmptyAction');
|
|
});
|
|
|
|
it('粘贴项 label 走注入的 pasteLabelKey(两域 locale 树不同)', () => {
|
|
const notes = renderContainerMenu({ clipboard: { x: 1 }, pasteLabelKey: 'notes.contextPaste' });
|
|
expect(notes.items.find((i) => i.id === 'paste')?.label).toBe('notes.contextPaste');
|
|
|
|
const lit = renderContainerMenu({
|
|
clipboard: { x: 1 },
|
|
pasteLabelKey: 'literature.contextPaste',
|
|
primaryItem: { id: 'import', labelKey: 'literature.listEmptyAction', onSelect: () => {} },
|
|
});
|
|
expect(lit.items.find((i) => i.id === 'paste')?.label).toBe('literature.contextPaste');
|
|
});
|
|
|
|
it('搜索态:隐藏「新建文件夹」与「粘贴(文件夹)」(落点在搜索视图不可见)', () => {
|
|
const normal = renderContainerMenu({
|
|
folders: { folderClipboard: 'x' },
|
|
clipboard: { x: 1 },
|
|
});
|
|
expect(ids(normal.items)).toContain('newFolder');
|
|
expect(ids(normal.items)).toContain('pasteFolder');
|
|
|
|
const searching = renderContainerMenu({
|
|
folders: { folderClipboard: 'x' },
|
|
clipboard: { x: 1 },
|
|
searchActive: true,
|
|
});
|
|
expect(ids(searching.items)).not.toContain('newFolder');
|
|
expect(ids(searching.items)).not.toContain('pasteFolder');
|
|
// 剪贴板粘贴与主操作仍保留(都影响可见条目,有反馈)
|
|
expect(ids(searching.items)).toContain('paste');
|
|
});
|
|
|
|
it('「全选」只在有可见条目时出现,并把可见 id 全量交给 selectMany', () => {
|
|
const empty = renderContainerMenu();
|
|
expect(ids(empty.items)).not.toContain('selectAll');
|
|
|
|
const some = renderContainerMenu({ visibleItems: [{ id: 'n1' }, { id: 'n2' }] });
|
|
expect(ids(some.items)).toContain('selectAll');
|
|
some.items.find((i) => i.id === 'selectAll')?.onSelect?.();
|
|
expect(some.selectMany).toHaveBeenCalledWith(['n1', 'n2']);
|
|
});
|
|
|
|
it('分隔符:粘贴块前 sep1、全选前 sep2', () => {
|
|
const { items } = renderContainerMenu({
|
|
clipboard: { x: 1 },
|
|
visibleItems: [{ id: 'n1' }],
|
|
});
|
|
const list = ids(items);
|
|
// audit-r254:旧写法 `expect(list.indexOf('sep1')).toBeLessThan(list.indexOf('paste'))`
|
|
// 在 **sep1 缺席**时是**恒真**的 —— indexOf 缺席返回 -1,而 -1 < 任意
|
|
// 合法下标 ⇒ 分隔符被删掉,守卫照样绿(假绿)。
|
|
// 这里改成:先断言两者**都在**,再断言先后。
|
|
const sep1 = list.indexOf('sep1');
|
|
const paste = list.indexOf('paste');
|
|
const sep2 = list.indexOf('sep2');
|
|
const selectAll = list.indexOf('selectAll');
|
|
expect(sep1, 'sep1 分隔符必须存在').toBeGreaterThanOrEqual(0);
|
|
expect(paste, 'paste 项必须存在').toBeGreaterThanOrEqual(0);
|
|
expect(sep2, 'sep2 分隔符必须存在').toBeGreaterThanOrEqual(0);
|
|
expect(selectAll, 'selectAll 项必须存在').toBeGreaterThanOrEqual(0);
|
|
expect(sep1, 'sep1 必须在 paste 之前').toBeLessThan(paste);
|
|
expect(sep2, 'sep2 必须在 selectAll 之前').toBeLessThan(selectAll);
|
|
});
|
|
});
|
|
|
|
describe('r232 结构契约:孪生块不得复活', () => {
|
|
const ROOT = process.cwd();
|
|
for (const rel of [
|
|
'src/renderer/src/components/notes/NotesList.tsx',
|
|
'src/renderer/src/components/literature/LiteratureList.tsx',
|
|
]) {
|
|
it(`${rel} 走 useFolderMenuItems / useContainerMenuItems`, () => {
|
|
const src = readFileSync(join(ROOT, rel), 'utf-8');
|
|
expect(src, '文件夹行菜单必须走 hooks/use-sidebar-menus').toContain('useFolderMenuItems');
|
|
expect(src, '空白区菜单必须走 hooks/use-sidebar-menus').toContain('useContainerMenuItems');
|
|
expect(src, '孪生块复活:组件里又内联了文件夹菜单项').not.toContain("id: 'newSub'");
|
|
expect(src, '孪生块复活:组件里又内联了空白区菜单项').not.toContain("id: 'selectAll'");
|
|
});
|
|
}
|
|
});
|