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,144 @@
// tests/unit/renderer/hooks/use-folder-ops.test.ts
//
// 守 r227「文件夹操作 + 粘贴反馈包装」收编契约(收编 ④):
// NotesList / LiteratureList 里逐字相同的 runFolderOp(r173)/
// commitFolderEdit(r176)/ pasteTo(0007) 三段收进 hooks/use-folder-ops.ts;
// 唯一分叉 = log 消息前缀(logTag)与 paste 失败 toast 的 i18n key
// (pasteFailedKey)—— 两域 locale 树不同。
//
// ── 锁两类东西(与 r224-r226 同模式)────────────────────────────────
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
// op 失败 ⇒ log + 错误 toast,不吞异常;成功 ⇒ 静默;
// commitFolderEdit 成功 true / 失败 false(输入框回可编辑态,不卡 busy);
// pasteTo 失败用注入的 key 弹 toast;pasteToTarget 落点 = 当前落点文件夹;
// 2. 结构契约:组件源码不得再出现 'folder op failed' / 'folder edit failed'
// —— 孪生块复活即红(修前红)。
import { describe, expect, it, vi, beforeEach } 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, opts?: { msg?: string }) => (opts?.msg ? `${key}|${opts.msg}` : 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);
},
}));
// logger —— 记录 error 消息(验证 logTag 注入)。
const logErrors: { msg: string; err: unknown }[] = [];
vi.mock('../../../../src/renderer/src/lib/logger-client', () => ({
createLogger: () => ({
error: (msg: string, err: unknown) => logErrors.push({ msg, err }),
warn: () => {},
info: () => {},
debug: () => {},
}),
}));
import { useFolderOps } from '../../../../src/renderer/src/hooks/use-folder-ops';
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
function setup(over: { pasteFailedKey?: string; logTag?: string } = {}) {
const commitEdit = vi.fn().mockResolvedValue(undefined);
const folders = {
commitEdit,
targetRelPath: 'sub/dir',
} as unknown as DiskFolderSidebar;
const pasteClipboard = vi.fn().mockResolvedValue(undefined);
const hook = renderHook(() =>
useFolderOps({
logTag: over.logTag ?? 'NotesList',
pasteFailedKey: over.pasteFailedKey ?? 'toast.pasteNoteFailed',
folders,
pasteClipboard,
}),
);
return { ...hook, commitEdit, pasteClipboard };
}
beforeEach(() => {
toastCalls.length = 0;
logErrors.length = 0;
});
describe('useFolderOps(r227 收编块的行为契约)', () => {
it('runFolderOp:成功静默;失败 ⇒ log + 错误 toast(不吞异常)', async () => {
const { result } = setup();
result.current.runFolderOp(() => Promise.resolve('ok'));
await Promise.resolve();
expect(toastCalls).toHaveLength(0);
expect(logErrors).toHaveLength(0);
const boom = new Error('boom');
result.current.runFolderOp(() => Promise.reject(boom));
await new Promise((r) => setTimeout(r, 0));
expect(logErrors[0].msg).toBe('NotesList folder op failed');
expect(logErrors[0].err).toBe(boom);
expect(toastCalls[0].type).toBe('error');
expect(toastCalls[0].message).toContain('toast.folderMoveFailed');
});
it('runFolderOp:log 前缀随 logTag 注入(两域各用自己的组件名)', async () => {
const { result } = setup({ logTag: 'LiteratureList' });
result.current.runFolderOp(() => Promise.reject(new Error('x')));
await new Promise((r) => setTimeout(r, 0));
expect(logErrors[0].msg).toBe('LiteratureList folder op failed');
});
it('commitFolderEdit:成功 ⇒ true 且无 toast;失败 ⇒ false + 错误 toast', async () => {
const { result, commitEdit } = setup();
await expect(result.current.commitFolderEdit('newName')).resolves.toBe(true);
expect(commitEdit).toHaveBeenCalledWith('newName');
expect(toastCalls).toHaveLength(0);
commitEdit.mockRejectedValueOnce(new Error('bad'));
await expect(result.current.commitFolderEdit('x')).resolves.toBe(false);
expect(toastCalls[0].type).toBe('error');
expect(toastCalls[0].message).toContain('toast.folderMoveFailed');
expect(logErrors[0].msg).toBe('NotesList folder edit failed');
});
it('pasteTo:失败用注入的 i18n key 弹 toast;成功静默', async () => {
const { result, pasteClipboard } = setup({ pasteFailedKey: 'literature.pasteFailedToast' });
pasteClipboard.mockRejectedValueOnce(new Error('nope'));
await result.current.pasteTo('target');
expect(toastCalls[0].message).toContain('literature.pasteFailedToast');
expect(logErrors[0].msg).toBe('NotesList paste failed');
toastCalls.length = 0;
logErrors.length = 0;
await result.current.pasteTo('target');
expect(toastCalls).toHaveLength(0);
expect(logErrors).toHaveLength(0);
});
it('pasteToTarget:落点 = 当前落点文件夹(folders.targetRelPath)', () => {
const { result, pasteClipboard } = setup();
result.current.pasteToTarget();
expect(pasteClipboard).toHaveBeenCalledWith('sub/dir');
});
});
describe('r227 结构契约:孪生块不得复活', () => {
const ROOT = process.cwd();
for (const rel of [
'src/renderer/src/components/notes/NotesList.tsx',
'src/renderer/src/components/literature/LiteratureList.tsx',
]) {
it(`${rel} 走 useFolderOps,不再自带 op 反馈包装`, () => {
const src = readFileSync(join(ROOT, rel), 'utf-8');
expect(src, '文件夹操作/粘贴包装必须走 hooks/use-folder-ops').toContain('useFolderOps');
expect(src, '孪生块复活:组件里又出现 runFolderOp 的实现').not.toContain('folder op failed');
expect(src, '孪生块复活:组件里又出现 commitFolderEdit 的实现').not.toContain('folder edit failed');
});
}
});