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,477 @@
// tests/unit/renderer/hooks/disk-folder-dnd.test.ts
//
// 守卫:useDiskFolderSidebar 的 startItemsDrag + handleTargetDrop 端到端
// 跑通。dragstart 写 RKMS_MOVE_MIME(payload.ids + kind),drop 端读
// payload、调 moveItems、走 api.notesMove(id, targetRelPath)。这是
// 用户反馈「文件拖入文件夹不工作」的最高风险路径,必须在 jsdom 里
// 完整实跑(r166 之前这些 handler 是 stub 形态,丢 mime / 读 mime 错位
// 是隐性回归点)。
//
// 与 notes-cut-paste.test.ts 互补 —— 后者守 store 路径(store paste
// 自动走 api.notesMove),本测守 drag-into-folder 路径(走 disk hook
// 的 handleTargetDrop),两条 pipeline 都要自洽。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useDiskFolderSidebar,
ROOT_DROP_ID,
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
import { readMovePayload } from '../../../../src/renderer/src/lib/folder-dnd';
import type { Api } from '../../../../src/shared/api-types';
// Api 类型 90+ 方法,这里直接 `as unknown as Api` 假装接口形状;被测 hook 只
// 用 notesListDirs / litListDirs / notesMove / notesMoveDir。
function makeApi(): Api & { notesMove: ReturnType<typeof vi.fn>; notesListDirs: ReturnType<typeof vi.fn>; litListDirs: ReturnType<typeof vi.fn>; notesMoveDir: ReturnType<typeof vi.fn> } {
return {
notesMove: vi.fn(),
notesMoveDir: vi.fn(),
notesListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
} as unknown as Api & {
notesMove: ReturnType<typeof vi.fn>;
notesListDirs: ReturnType<typeof vi.fn>;
litListDirs: ReturnType<typeof vi.fn>;
notesMoveDir: ReturnType<typeof vi.fn>;
};
}
function makeDataTransfer(): DataTransfer {
const types: string[] = [];
const store = new Map<string, string>();
return {
types,
store,
setData(mime: string, val: string) {
if (!store.has(mime)) types.push(mime);
store.set(mime, val);
},
getData(mime: string) {
return store.get(mime) ?? '';
},
effectAllowed: 'move' as 'move' | 'copy' | 'none' | 'link',
dropEffect: 'move' as 'move' | 'copy' | 'none' | 'link',
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
clearData() {
store.clear();
types.length = 0;
},
} as unknown as DataTransfer;
}
describe('useDiskFolderSidebar drag-into-folder 链路', () => {
let api: ReturnType<typeof makeApi>;
beforeEach(() => {
api = makeApi();
});
it('startItemsDrag 写 RKMS_MOVE_MIME 含 ids + kind', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1', 'n2'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
const raw = dt.getData('application/x-rkms-move');
const payload = JSON.parse(raw);
expect(payload.type).toBe('items');
expect(payload.kind).toBe('note');
expect(payload.ids).toEqual(['n1', 'n2']);
});
it('handleTargetDrop 读 MIME + 调 api.notesMove 用 targetRelPath', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
// drag 段:用 startItemsDrag 写
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1', 'n2'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'n1', relPath: 'target/n1.md' } });
// drop 段:落地到 'archive' 文件夹
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
});
expect(api.notesMove).toHaveBeenCalledTimes(2);
expect(api.notesMove).toHaveBeenNthCalledWith(1, 'n1', 'archive');
expect(api.notesMove).toHaveBeenNthCalledWith(2, 'n2', 'archive');
});
it('handleTargetDrop 落到 ROOT_DROP_ID 等价 null / 空串(根语义)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'n1', relPath: 'n1.md' } });
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, ROOT_DROP_ID);
});
// 落根:moveItems 把 null 转 '' 走 IPC,与 store paste(null) 同语义
expect(api.notesMove).toHaveBeenCalledWith('n1', '');
});
it('handleTargetDrop 跨 kind 直接忽略(notes MIME 投到 literature 树上)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'literature', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
// 手工写一份 note kind 的 payload,模拟「跨 kind 越界」
dt.setData(
'application/x-rkms-move',
JSON.stringify({ type: 'items', kind: 'note', ids: ['n1'], relPaths: [] }),
);
});
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
});
expect(api.notesMove).not.toHaveBeenCalled();
});
it('handleTargetDrop 部分失败累积 → 首个 throw(errors 含 ok=false 项)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1', 'n2'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockImplementation((id: string) => {
if (id === 'n2') {
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
}
return Promise.resolve({ ok: true, data: { id, relPath: 'archive/' + id + '.md' } });
});
let caught: unknown = null;
await act(async () => {
try {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
} catch (err: unknown) {
caught = err;
}
});
expect(caught).not.toBeNull();
expect((caught as Error).message).toMatch(/moveItems failed 1\/2/);
expect((caught as { errors: Array<{ id: string; code: string }> }).errors).toEqual([
{ id: 'n2', code: 'IO_ERROR', message: 'disk fail' },
]);
// 两条 IPC 都发了(累计),不是首条 throw 后停
expect(api.notesMove).toHaveBeenCalledTimes(2);
});
it('r168:handleTargetDrop 全成功后调 onItemsMoved,带 (oldId → {id, relPath})', async () => {
const onItemsMoved = vi.fn();
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1', 'n2'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockImplementation((id: string) => {
return Promise.resolve({ ok: true, data: { id: 'new-' + id, relPath: 'archive/' + id + '.md' } });
});
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
});
expect(onItemsMoved).toHaveBeenCalledTimes(1);
const remap = onItemsMoved.mock.calls[0]![0] as Map<
string,
{ id: string; relPath?: string }
>;
expect(remap.size).toBe(2);
expect(remap.get('n1')?.id).toBe('new-n1');
expect(remap.get('n1')?.relPath).toBe('archive/n1.md');
expect(remap.get('n2')?.id).toBe('new-n2');
});
it('r168:handleTargetDrop 部分失败 → 不调 onItemsMoved(避免半截 state)', async () => {
const onItemsMoved = vi.fn();
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1', 'n2'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockImplementation((id: string) => {
if (id === 'n2') {
return Promise.resolve({ ok: false, code: 'IO_ERROR', message: 'disk fail' });
}
return Promise.resolve({ ok: true, data: { id, relPath: 'archive/' + id + '.md' } });
});
await act(async () => {
try {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
} catch {
// swallow
}
});
expect(onItemsMoved).not.toHaveBeenCalled();
});
it('r168:onItemsMoved 不传 = 不抛(兼容既有调用)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startItemsDrag(['n1'], {
dataTransfer: dt,
} as unknown as Parameters<typeof result.current.startItemsDrag>[1]);
});
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'new-n1', relPath: 'a/n1.md' } });
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'a');
});
// 不抛 + IPC 一次
expect(api.notesMove).toHaveBeenCalledTimes(1);
});
it('r168:moveItems 直接调用也返回 idRemap(caller 拿到后可手动回调 store)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
api.notesMove.mockResolvedValue({ ok: true, data: { id: 'new-n1', relPath: 'a/n1.md' } });
let remap: Map<string, { id: string; relPath?: string }> = new Map();
await act(async () => {
remap = await result.current.moveItems('a', ['n1']);
});
expect(remap.size).toBe(1);
expect(remap.get('n1')?.id).toBe('new-n1');
expect(remap.get('n1')?.relPath).toBe('a/n1.md');
});
// === r173:文件夹(子树)搬移 —— 拖 / 切 / 粘三条入口 ===
//
// 旧实现的两个死点:
// 1) startFolderDrag 写 ids: [] → readMovePayload 拒收(ids 必须非空
// 且每项非空串)→ 载荷在读端被丢掉,drop 端永远收不到;
// 2) pasteFolderInto / moveFolderNow 是 throw「not yet supported」的占位。
it('r173:startFolderDrag 写的载荷能被 readMovePayload 接受(ids 非空且 = relPath)', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startFolderDrag(
{ relPath: 'sub', name: 'sub', parentRelPath: null },
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
);
});
const payload = readMovePayload(dt);
expect(payload).not.toBeNull();
expect(payload!.type).toBe('folder');
expect(payload!.relPaths).toEqual(['sub']);
expect(payload!.ids).toEqual(['sub']);
});
it('r173:文件夹拖到目标目录 → 调 api.notesMoveDir(oldRelPath, targetRelPath)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startFolderDrag(
{ relPath: 'sub', name: 'sub', parentRelPath: null },
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
);
});
api.notesMoveDir.mockResolvedValue({
ok: true,
data: { relPath: 'archive/sub', idRemap: [] },
});
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
});
expect(api.notesMoveDir).toHaveBeenCalledTimes(1);
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', 'archive');
});
it('r173:文件夹拖到根(ROOT_DROP_ID)→ 目标父目录 = null', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startFolderDrag(
{ relPath: 'sub', name: 'sub', parentRelPath: null },
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
);
});
api.notesMoveDir.mockResolvedValue({
ok: true,
data: { relPath: 'sub', idRemap: [] },
});
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, ROOT_DROP_ID);
});
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', null);
});
it('r173:文件夹搬移成功 → onItemsMoved 收到 idRemap(选中态跟到新 id)', async () => {
const onItemsMoved = vi.fn();
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api, onItemsMoved }),
);
const dt = makeDataTransfer();
act(() => {
result.current.startFolderDrag(
{ relPath: 'sub', name: 'sub', parentRelPath: null },
{ dataTransfer: dt } as unknown as Parameters<typeof result.current.startFolderDrag>[1],
);
});
api.notesMoveDir.mockResolvedValue({
ok: true,
data: {
relPath: 'archive/sub',
idRemap: [{ oldId: 'n1', newId: 'n2', relPath: 'archive/sub/n1.md' }],
},
});
await act(async () => {
await result.current.handleTargetDrop({
dataTransfer: dt,
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.DragEvent, 'archive');
});
expect(onItemsMoved).toHaveBeenCalledTimes(1);
const remap = onItemsMoved.mock.calls[0]![0] as Map<string, { id: string; relPath?: string }>;
expect(remap.get('n1')?.id).toBe('n2');
expect(remap.get('n1')?.relPath).toBe('archive/sub/n1.md');
});
it('r173:剪切文件夹 → 粘贴到目标目录 → 走 moveDir 且剪贴板清空', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.cutFolder('sub');
});
expect(result.current.folderClipboard).toBe('sub');
api.notesMoveDir.mockResolvedValue({
ok: true,
data: { relPath: 'archive/sub', idRemap: [] },
});
await act(async () => {
await result.current.pasteFolderInto('archive');
});
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', 'archive');
// 粘贴成功 → 剪贴板清空(再切一次是重新剪切)
expect(result.current.folderClipboard).toBeNull();
});
it('r173:粘贴失败 → 抛错且剪贴板保留(可重试)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.cutFolder('sub');
});
api.notesMoveDir.mockResolvedValue({
ok: false,
code: 'IO_ERROR',
message: 'disk fail',
});
let caught: unknown = null;
await act(async () => {
try {
await result.current.pasteFolderInto('archive');
} catch (err: unknown) {
caught = err;
}
});
expect(caught).not.toBeNull();
expect((caught as Error).message).toMatch(/disk fail/);
expect(result.current.folderClipboard).toBe('sub');
});
it('r173:moveFolderNow 显式搬移(「移到根目录」入口)→ 目标父目录 null', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
api.notesMoveDir.mockResolvedValue({
ok: true,
data: { relPath: 'sub', idRemap: [] },
});
let newRel = '';
await act(async () => {
newRel = await result.current.moveFolderNow('sub', null);
});
expect(api.notesMoveDir).toHaveBeenCalledWith('sub', null);
expect(newRel).toBe('sub');
});
});

View File

@@ -0,0 +1,164 @@
// tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
//
// 守卫(r175 → r176 树形态):useDiskFolderSidebar 的两处时序/状态防御
//
// 1. 懒加载的**按目录序号守卫** —— 此前裸 await 后无条件 setSubdirs:
// 快速连点文件夹 A → B(间隔小于一次 IPC roundtrip)时,A 的响应若后到,
// 会把 A 的子项写进 state —— 面包屑显示 B,列表里却是 A 的子目录。
// 守卫:每次请求领 seq,响应回来时 seq 不是最新就丢弃。
//
// r176 树形态下守卫改成**按目录**计数:同一个目录并发两次拉取
// (展开 → 立刻再展开)时,先发的响应后到会覆盖新快照。不同目录
// 各算各的 —— 「A 目录的响应」不会污染「B 目录的缓存」。
//
// 2. 搜索态下 startCreate / startCreateSubfolder **必须 no-op** ——
// 搜索态的 inline input 行被 rows 组装侧 gate 掉(!searchActive 才
// 挂 input 行),旧实现「进搜索态点 ✚ 按钮」会置 editing='create'
// 但没有 input 行渲染:用户看到什么都不发生,且 editing 卡住,清空
// 搜索后 input 行突然冒出来。键盘入口早已 gate,这里把守卫收到
// hook 内部,让工具栏 / 右键 / 快捷键三条入口行为一致。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import {
useDiskFolderSidebar,
resetFolderTreeRegistry,
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
import type { Api } from '../../../../src/shared/api-types';
function makeApi(): Api {
return {
notesListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
} as unknown as Api;
}
function dir(relPath: string) {
return { relPath, name: relPath.split('/').pop() ?? relPath, parentRelPath: null };
}
describe('useDiskFolderSidebar 懒加载序号守卫 + 搜索态 gate', () => {
let api: ReturnType<typeof makeApi>;
beforeEach(() => {
api = makeApi();
// registry 是模块级(跨 tab 切换要保住展开态)—— 每个用例重来一份。
resetFolderTreeRegistry('note');
});
it('同一目录并发两次拉取:慢的旧响应后到时不能覆盖新快照', async () => {
// 第一次拉 A(慢),第二次拉 A(快)—— 模拟「展开 → 立刻再展开」
let resolveSlow: (v: unknown) => void = () => {};
const slow = new Promise((res) => {
resolveSlow = res;
});
let call = 0;
(api.notesListDirs as ReturnType<typeof vi.fn>).mockImplementation(
(kind: string, rel: string | null) => {
if (rel !== 'A') return Promise.resolve({ ok: true, data: [] });
call += 1;
if (call === 1) return slow;
return Promise.resolve({ ok: true, data: [dir('A/fresh')] });
},
);
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
// 先展开 A(慢),再立刻展开 A(快)
await act(async () => {
result.current.expandFolder('A');
});
await act(async () => {
result.current.expandFolder('A');
});
await waitFor(() => {
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/fresh']);
});
// 旧响应此刻才到 —— 应当被序号守卫丢弃
await act(async () => {
resolveSlow({ ok: true, data: [dir('A/stale')] });
await slow;
});
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/fresh']);
});
it('不同目录各算各的 —— B 目录的响应不会被 A 目录的请求挤掉', async () => {
let resolveA: (v: unknown) => void = () => {};
const aPromise = new Promise((res) => {
resolveA = res;
});
(api.notesListDirs as ReturnType<typeof vi.fn>).mockImplementation(
(kind: string, rel: string | null) => {
if (rel === 'A') return aPromise;
if (rel === 'B') return Promise.resolve({ ok: true, data: [dir('B/child')] });
return Promise.resolve({ ok: true, data: [] });
},
);
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await act(async () => {
result.current.expandFolder('A');
});
await act(async () => {
result.current.expandFolder('B');
});
await waitFor(() => {
expect(result.current.childrenByDir.get('B')?.map((d) => d.relPath)).toEqual(['B/child']);
});
await act(async () => {
resolveA({ ok: true, data: [dir('A/stale')] });
await aPromise;
});
// A 的缓存拿到自己的数据,B 不受影响
expect(result.current.childrenByDir.get('A')?.map((d) => d.relPath)).toEqual(['A/stale']);
expect(result.current.childrenByDir.get('B')?.map((d) => d.relPath)).toEqual(['B/child']);
});
it('搜索态 startCreate 是 no-op(不置 editing 态)', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: true, api }),
);
act(() => {
result.current.startCreate();
});
expect(result.current.editing).toBeNull();
});
it('非搜索态 startCreate 正常进入编辑态', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.startCreate();
});
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: null, name: '' });
});
// === r176:落点语义 ===
it('activateFolder = 设为落点 + 展开(一次手势两件事)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.activateFolder('A');
});
expect(result.current.targetRelPath).toBe('A');
expect(result.current.expanded.has('A')).toBe(true);
// 再点一次 = 折叠(落点保持)
act(() => {
result.current.activateFolder('A');
});
expect(result.current.targetRelPath).toBe('A');
expect(result.current.expanded.has('A')).toBe(false);
});
});

View File

@@ -0,0 +1,169 @@
// tests/unit/renderer/hooks/disk-folder-tree.test.ts
//
// 守 r176「左侧栏改树状」的**端到端接线**契约 —— 纯函数(建树)在
// folder-tree.test.ts 里锁,本测锁「hook 状态 → 建树输入」这一段:
// 挂载 → 拉根目录子项
// 点文件夹 → 展开 + 设为落点 + 懒加载该目录子项
// 新建文件夹 → 落在**当前落点**下(不是「钻入到的目录」)
// 落点处于折叠态 → 新建前先展开(否则 input 行落在看不见的地方)
//
// 这段是「用户点了文件夹 → 树真的长出子树 → 新建落在点上的文件夹」
// 这条主链路的唯一自动化防线 —— 组件层(NotesList / LiteratureList)
// 依赖 react-virtuoso,jsdom 容器高度 0 ⇒ 行不渲染,渲染测覆盖不到。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import {
useDiskFolderSidebar,
resetFolderTreeRegistry,
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
import { buildTreeRows } from '../../../../src/renderer/src/lib/folder-tree';
import type { Api } from '../../../../src/shared/api-types';
function node(relPath: string, parentRelPath: string | null) {
return { relPath, name: relPath.slice(relPath.lastIndexOf('/') + 1), parentRelPath };
}
// 磁盘形状:
// root
// ├── a
// │ └── deep
// └── b
const TREE: Record<string, ReturnType<typeof node>[]> = {
'': [node('a', null), node('b', null)],
a: [node('a/deep', 'a')],
'a/deep': [],
b: [],
};
function makeApi(): Api {
return {
notesListDirs: vi.fn().mockImplementation((kind: string, rel: string | null) => {
const key = rel ?? '';
return Promise.resolve({ ok: true, data: TREE[key] ?? [] });
}),
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
} as unknown as Api;
}
describe('useDiskFolderSidebar —— 树状接线(r176)', () => {
let api: ReturnType<typeof makeApi>;
beforeEach(() => {
api = makeApi();
resetFolderTreeRegistry('note');
});
it('挂载时拉一次根目录子项(根 = null)', async () => {
renderHook(() => useDiskFolderSidebar({ kind: 'note', searchActive: false, api }));
await waitFor(() => {
expect((api.notesListDirs as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([
'note',
null,
]);
});
});
it('点文件夹(a)→ 展开 + 设为落点 + 懒加载该目录子项', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a', 'b']);
});
await act(async () => {
result.current.activateFolder('a');
});
expect(result.current.targetRelPath).toBe('a');
expect(result.current.expanded.has('a')).toBe(true);
await waitFor(() => {
expect(result.current.childrenByDir.get('a')?.map((f) => f.relPath)).toEqual(['a/deep']);
});
expect((api.notesListDirs as ReturnType<typeof vi.fn>).mock.calls).toContainEqual([
'note',
'a',
]);
});
it('hook 状态直接喂建树函数 → 展开的目录长出子树', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')).toBeDefined();
});
const build = () =>
buildTreeRows<{ id: string; relPath: string }>({
childrenByDir: result.current.childrenByDir,
itemsByDir: new Map(),
expanded: result.current.expanded,
targetRelPath: result.current.targetRelPath,
searchActive: false,
});
expect(build().map((r) => r.id)).toEqual(['folder:a', 'folder:b']);
await act(async () => {
result.current.activateFolder('a');
});
await waitFor(() => {
expect(result.current.childrenByDir.get('a')).toBeDefined();
});
expect(build().map((r) => r.id)).toEqual(['folder:a', 'folder:a/deep', 'folder:b']);
});
it('新建文件夹落在**当前落点**下(不是根)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await act(async () => {
result.current.activateFolder('a');
});
act(() => {
result.current.startCreate();
});
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: 'a', name: '' });
});
it('没有落点时新建落在根', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.startCreate();
});
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: null, name: '' });
});
it('落点处于折叠态时,新建前先把它展开(input 行才看得见)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
// 先让落点处于折叠态:activate 一次展开、再一次折叠(落点保持)
act(() => {
result.current.activateFolder('a');
});
act(() => {
result.current.activateFolder('a');
});
expect(result.current.targetRelPath).toBe('a');
expect(result.current.expanded.has('a')).toBe(false);
act(() => {
result.current.startCreate();
});
expect(result.current.expanded.has('a')).toBe(true);
});
it('子目录新建:父目录就是被点的那个文件夹', () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
act(() => {
result.current.startCreateSubfolder(node('a', null));
});
expect(result.current.editing).toEqual({ mode: 'create', parentRelPath: 'a', name: '' });
});
});

View File

@@ -0,0 +1,177 @@
// tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
//
// 守 r184「磁盘目录结构被**外部**改动后,左侧栏树必须跟着变」契约。
//
// 背景(用户诉求:「左侧的文献和笔记列表应该和对应的文件夹完全一致」):
// 树的数据源 childrenByDir 是**懒加载 + 缓存**的 —— 每个目录一次
// listDirs IPC,结果进缓存,之后只靠应用自己的操作(新建 / 改名 /
// 删除 / 搬移)去 applyStructuralChange + loadChildren 修正。
//
// ⇒ 磁盘上**目录结构**被外部改动时(用户在资源管理器里建了夹 / 删了夹 /
// 改了名 —— 这在本项目里是常规操作:用户的笔记就放在
// <rootPath>/notes/ 下),缓存从来没被碰过:
// - 外部新建的夹**永远不出现**(缓存里没有,且 expandFolder 只在
// 「没缓存过」时才拉 → 折叠再展开也刷不出来);
// - 外部删掉的夹**永远还在**(点进去是空的);
// - 外部改名的夹显示**旧名**,里面文件的 relPath 变了 ⇒ 文件行从
// 当前目录视图里「消失」—— 用户视角就是「文件凭空不见了」。
//
// 主进程侧同样有洞:notes / literature 的 chokidar 只监听
// add / change / unlink,**不监听 addDir / unlinkDir** ⇒ 光建一个空
// 夹(没有文件)时主进程连 'changed' 都不发,renderer 根本不知道。
//
// 本测锁(渲染层):
// 1. 外部新建文件夹 → 树的根子项跟着多出该夹;
// 2. 外部删除文件夹 → 树的根子项跟着少掉该夹(且缓存里的孤儿键清掉);
// 3. 外部事件不该引发**无谓重渲**(内容没变就不换引用)。
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import {
useDiskFolderSidebar,
resetFolderTreeRegistry,
} from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
import type { Api } from '../../../../src/shared/api-types';
function node(relPath: string, parentRelPath: string | null) {
return { relPath, name: relPath.slice(relPath.lastIndexOf('/') + 1), parentRelPath };
}
describe('useDiskFolderSidebar —— 外部目录改动后树跟着变(r184)', () => {
let tree: Record<string, ReturnType<typeof node>[]>;
let changedHandler: (() => void) | null;
let api: Api;
let listDirsMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
tree = { '': [node('a', null)], a: [] };
changedHandler = null;
listDirsMock = vi.fn((kind: string, rel: string | null) =>
Promise.resolve({ ok: true, data: tree[rel ?? ''] ?? [] }),
);
api = {
notesListDirs: listDirsMock,
litListDirs: vi.fn().mockResolvedValue({ ok: true, data: [] }),
onNotesChanged: vi.fn((h: () => void) => {
changedHandler = h;
return () => {
changedHandler = null;
};
}),
onLiteratureChanged: vi.fn(() => () => {}),
} as unknown as Api;
resetFolderTreeRegistry('note');
});
it('外部新建文件夹 → 树里出现该夹', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a']);
});
// 用户在资源管理器里建了 notes/newdir/
tree = { '': [node('a', null), node('newdir', null)], a: [] };
act(() => {
changedHandler?.();
});
await waitFor(() => {
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual([
'a',
'newdir',
]);
});
});
it('外部删除文件夹 → 树里少掉该夹,缓存里的孤儿键也清掉', async () => {
tree = { '': [node('a', null), node('b', null)], a: [], b: [] };
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['a', 'b']);
});
// 展开 a → 缓存里出现 'a' 这个 key
await act(async () => {
result.current.activateFolder('a');
});
await waitFor(() => {
expect(result.current.childrenByDir.get('a')).toBeDefined();
});
// 用户在资源管理器里删了 notes/a/
tree = { '': [node('b', null)], b: [] };
act(() => {
changedHandler?.();
});
await waitFor(() => {
expect(result.current.childrenByDir.get('')?.map((f) => f.relPath)).toEqual(['b']);
});
// 孤儿缓存键(a)不该留着 —— 否则展开态残留 + 再次展开时拉回幽灵子树
expect(result.current.childrenByDir.has('a')).toBe(false);
});
it('外部删掉「落点」目录 → 落点回根(否则新建/粘贴会把刚删的夹凭空建回来)', async () => {
tree = { '': [node('a', null)], a: [] };
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')).toBeDefined();
});
await act(async () => {
result.current.activateFolder('a');
});
expect(result.current.targetRelPath).toBe('a');
tree = { '': [] };
act(() => {
changedHandler?.();
});
await waitFor(() => {
expect(result.current.targetRelPath).toBeNull();
});
});
it('外部删掉已展开的目录 → 展开态里不再有它', async () => {
tree = { '': [node('a', null)], a: [] };
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await act(async () => {
result.current.activateFolder('a');
});
expect(result.current.expanded.has('a')).toBe(true);
tree = { '': [] };
act(() => {
changedHandler?.();
});
await waitFor(() => {
expect(result.current.expanded.has('a')).toBe(false);
});
});
it('内容没变时不换引用(不引发无谓重渲)', async () => {
const { result } = renderHook(() =>
useDiskFolderSidebar({ kind: 'note', searchActive: false, api }),
);
await waitFor(() => {
expect(result.current.childrenByDir.get('')).toBeDefined();
});
const before = result.current.childrenByDir.get('');
act(() => {
changedHandler?.();
});
await waitFor(() => {
expect(listDirsMock.mock.calls.length).toBeGreaterThan(1);
});
expect(result.current.childrenByDir.get('')).toBe(before);
});
});

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');
});
}
});

View File

@@ -0,0 +1,304 @@
// tests/unit/renderer/hooks/use-listbox-keys.test.ts
//
// 守 r225「handleListboxKeyDown 同构块收编」契约(收编 ②):
// NotesList(153 行)/ LiteratureList(136 行)的容器级键盘处理收进
// hooks/use-listbox-keys.ts。差异收敛为注入点(onPasteItems /
// onDeleteSelection / 可选 onRenameItem);lit 的 Delete 分支
// `!== 'input'` 与 notes 的 `=== 'item'` 在封闭三元并
// {'folder','item','input'} 下等价,统一为后者。
//
// ── 锁两类东西(与 r224 同模式)────────────────────────────────────
// 1. 行为契约:输入焦点 gate / mod+c/x/v/a/n / F2(folder + 可选 item)/
// Delete(folder + item)/ Enter/Space 容器 commit 与防二次触发 gate /
// ←/→ 树导航 —— 这些是收编前两块代码的既有行为,对 HEAD 也必须绿;
// 2. 结构契约:两组件源码不得再出现 findFirstChildRow /
// `handleListboxKeyDown = useCallback`,孪生块复活即红(修前红)。
import { describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { readFileSync } from 'fs';
import { join } from 'path';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useListboxKeys, type UseListboxKeysOptions } from '../../../../src/renderer/src/hooks/use-listbox-keys';
import type { TreeRow } from '../../../../src/renderer/src/lib/folder-tree';
import type { DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
interface Row {
id: string;
relPath: string;
}
const N1: Row = { id: 'n1', relPath: 'docs/n1.md' };
const N2: Row = { id: 'n2', relPath: 'n2.md' };
function folderRow(relPath: string, expanded: boolean): TreeRow<Row> {
return {
rowType: 'folder',
id: 'folder:' + relPath,
folder: { relPath, name: relPath },
depth: 0,
expanded,
hasChildren: true,
isTarget: false,
parentRelPath: '',
posInSet: 1,
setSize: 1,
} as unknown as TreeRow<Row>;
}
function itemRow(item: Row, parentRelPath: string): TreeRow<Row> {
return {
rowType: 'item',
id: item.id,
item,
depth: parentRelPath === '' ? 0 : 1,
parentRelPath,
posInSet: 1,
setSize: 1,
} as unknown as TreeRow<Row>;
}
// 0: folder docs(expanded) / 1: item n1 ∈ docs / 2: item n2 ∈ 根 / 3: folder empty(折叠)
const ROWS: TreeRow<Row>[] = [
folderRow('docs', true),
itemRow(N1, 'docs'),
itemRow(N2, ''),
folderRow('empty', false),
];
function makeFolders() {
return {
cutFolder: vi.fn(),
folderClipboard: null as string | null,
pasteFolderInto: vi.fn().mockResolvedValue(undefined),
targetRelPath: '',
startCreate: vi.fn(),
startRename: vi.fn(),
requestDelete: vi.fn().mockResolvedValue(undefined),
activateFolder: vi.fn(),
expandFolder: vi.fn(),
collapseFolder: vi.fn(),
} as unknown as DiskFolderSidebar;
}
function makeKeyEvent(
key: string,
opts: { mod?: boolean; shift?: boolean; targetIsInput?: boolean; containerGate?: boolean } = {},
) {
const container = { tagName: 'DIV', isContentEditable: false };
const target = opts.targetIsInput
? { tagName: 'INPUT', isContentEditable: false }
: container;
return {
key,
ctrlKey: opts.mod ?? false,
metaKey: false,
altKey: false,
shiftKey: opts.shift ?? false,
target,
currentTarget: opts.containerGate === false ? { tagName: 'DIV' } : container,
preventDefault: vi.fn(),
} as unknown as ReactKeyboardEvent;
}
function setup(overrides: Partial<UseListboxKeysOptions<Row>> = {}) {
const calls = {
copySelection: vi.fn(),
cutSelection: vi.fn(),
onPasteItems: vi.fn(),
runFolderOp: vi.fn((op: () => Promise<unknown>) => void op()),
selectMany: vi.fn(),
onDeleteSelection: vi.fn(),
handleSelect: vi.fn(),
handleActiveById: vi.fn(),
onRenameItem: vi.fn(),
};
const folders = makeFolders();
const opts: UseListboxKeysOptions<Row> = {
rows: ROWS,
activeId: null,
visibleItems: [N1, N2],
clipboard: null,
searchActive: false,
folders,
...calls,
...overrides,
};
const hook = renderHook((p: UseListboxKeysOptions<Row>) => useListboxKeys<Row>(p), {
initialProps: opts,
});
return { ...hook, calls, folders };
}
describe('useListboxKeys(r225 收编块的行为契约)', () => {
it('输入焦点 gate:目标是 INPUT/TEXTAREA/contentEditable ⇒ 全键让行', () => {
const { result, calls } = setup();
act(() => result.current(makeKeyEvent('c', { mod: true, targetIsInput: true })));
expect(calls.copySelection).not.toHaveBeenCalled();
});
it('mod+c 复制选区;mod+x 在文件夹行剪切文件夹、否则剪切选区', () => {
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('c', { mod: true })));
expect(calls.copySelection).toHaveBeenCalledTimes(1);
act(() => result.current(makeKeyEvent('x', { mod: true })));
expect(folders.cutFolder).toHaveBeenCalledWith('docs');
expect(calls.cutSelection).not.toHaveBeenCalled();
// active 是 item 行 ⇒ 按选区剪切
rerender({ ...setupDefaults(calls, folders), activeId: 'n2' });
act(() => result.current(makeKeyEvent('x', { mod: true })));
expect(calls.cutSelection).toHaveBeenCalledTimes(1);
});
it('mod+v:条目剪贴板优先;空则文件夹剪贴板兜底;搜索态不兜底(r131/r134)', () => {
const { result, calls, folders, rerender } = setup({ clipboard: { ids: ['n2'] } });
act(() => result.current(makeKeyEvent('v', { mod: true })));
expect(calls.onPasteItems).toHaveBeenCalledTimes(1);
expect(folders.pasteFolderInto).not.toHaveBeenCalled();
// 条目剪贴板空 + 文件夹剪贴板非空 ⇒ runFolderOp 兜底
(folders as { folderClipboard: string | null }).folderClipboard = 'docs';
rerender({ ...setupDefaults(calls, folders), clipboard: null });
act(() => result.current(makeKeyEvent('v', { mod: true })));
expect(calls.runFolderOp).toHaveBeenCalled();
expect(folders.pasteFolderInto).toHaveBeenCalledWith('');
// 搜索态 ⇒ 不兜底
calls.runFolderOp.mockClear();
(folders.pasteFolderInto as ReturnType<typeof vi.fn>).mockClear();
rerender({ ...setupDefaults(calls, folders), clipboard: null, searchActive: true });
act(() => result.current(makeKeyEvent('v', { mod: true })));
expect(calls.runFolderOp).not.toHaveBeenCalled();
});
it('mod+a 全选当前可见项;mod+Shift+N 搜索态不接收(r134)', () => {
const { result, calls, folders, rerender } = setup();
act(() => result.current(makeKeyEvent('a', { mod: true, shift: true })));
expect(calls.selectMany).toHaveBeenCalledWith(['n1', 'n2']);
act(() => result.current(makeKeyEvent('n', { mod: true, shift: true })));
expect(folders.startCreate).toHaveBeenCalledTimes(1);
rerender({ ...setupDefaults(calls, folders), searchActive: true });
act(() => result.current(makeKeyEvent('n', { mod: true, shift: true })));
expect(folders.startCreate).toHaveBeenCalledTimes(1); // 没有第二次
});
it('F2:文件夹行重命名;item 行仅在传了 onRenameItem 时触发(文献域不传)', () => {
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('F2')));
expect(folders.startRename).toHaveBeenCalledTimes(1);
expect(calls.onRenameItem).not.toHaveBeenCalled();
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
act(() => result.current(makeKeyEvent('F2')));
expect(calls.onRenameItem).toHaveBeenCalledWith('n1');
// 不传 onRenameItem ⇒ F2 在 item 行什么都不做(文献域原行为)
const bare = setup({ activeId: 'n1', onRenameItem: undefined });
const e = makeKeyEvent('F2');
act(() => bare.result.current(e));
expect(bare.calls.onRenameItem).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('Delete:文件夹行 requestDelete;item 行 onDeleteSelection', () => {
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('Delete')));
expect(folders.requestDelete).toHaveBeenCalledTimes(1);
expect(calls.onDeleteSelection).not.toHaveBeenCalled();
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
act(() => result.current(makeKeyEvent('Delete')));
expect(calls.onDeleteSelection).toHaveBeenCalledTimes(1);
});
it('Enter/Space 容器级 commit:folder=activate,item=select;行持焦不二次触发(r76)', () => {
const { result, calls, folders, rerender } = setup({ activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('Enter')));
expect(folders.activateFolder).toHaveBeenCalledWith('docs');
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
act(() => result.current(makeKeyEvent(' ')));
expect(calls.handleSelect).toHaveBeenCalledWith(N1);
// gate:target !== currentTarget(行持焦)⇒ 容器不接管
calls.handleSelect.mockClear();
act(() => result.current(makeKeyEvent('Enter', { containerGate: false })));
expect(calls.handleSelect).not.toHaveBeenCalled();
});
it('←/→ 树导航:→ 折叠=展开 / 展开=跳首个子行;← 展开=折叠 / 否则跳父行(r176)', () => {
const { result, calls, folders, rerender } = setup({ activeId: 'folder:empty' });
// → 折叠文件夹 ⇒ 展开
act(() => result.current(makeKeyEvent('ArrowRight')));
expect(folders.expandFolder).toHaveBeenCalledWith('empty');
// → 展开文件夹 ⇒ 跳到第一个子行
rerender({ ...setupDefaults(calls, folders), activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('ArrowRight')));
expect(calls.handleActiveById).toHaveBeenCalledWith('n1');
// → 文件行 ⇒ 无意义
calls.handleActiveById.mockClear();
rerender({ ...setupDefaults(calls, folders), activeId: 'n2' });
act(() => result.current(makeKeyEvent('ArrowRight')));
expect(calls.handleActiveById).not.toHaveBeenCalled();
// ← 展开文件夹 ⇒ 折叠
rerender({ ...setupDefaults(calls, folders), activeId: 'folder:docs' });
act(() => result.current(makeKeyEvent('ArrowLeft')));
expect(folders.collapseFolder).toHaveBeenCalledWith('docs');
// ← 子项 ⇒ 跳父文件夹行
rerender({ ...setupDefaults(calls, folders), activeId: 'n1' });
act(() => result.current(makeKeyEvent('ArrowLeft')));
expect(calls.handleActiveById).toHaveBeenCalledWith('folder:docs');
});
});
// rerender 需要整棵 opts;把 setup 的内部默认抽出来复用
function setupDefaults(
calls: ReturnType<typeof makeCalls>,
folders: DiskFolderSidebar,
): UseListboxKeysOptions<Row> {
return {
rows: ROWS,
activeId: null,
visibleItems: [N1, N2],
clipboard: null,
searchActive: false,
folders,
...calls,
};
}
function makeCalls() {
return {
copySelection: vi.fn(),
cutSelection: vi.fn(),
onPasteItems: vi.fn(),
runFolderOp: vi.fn((op: () => Promise<unknown>) => void op()),
selectMany: vi.fn(),
onDeleteSelection: vi.fn(),
handleSelect: vi.fn(),
handleActiveById: vi.fn(),
onRenameItem: vi.fn(),
};
}
describe('r225 结构契约:孪生块不得复活', () => {
const ROOT = process.cwd();
for (const rel of [
'src/renderer/src/components/notes/NotesList.tsx',
'src/renderer/src/components/literature/LiteratureList.tsx',
]) {
it(`${rel} 走 useListboxKeys,不再自带键盘处理块`, () => {
const src = readFileSync(join(ROOT, rel), 'utf-8');
expect(src, 'listbox 键盘处理必须走 hooks/use-listbox-keys').toContain('useListboxKeys');
expect(src, '孪生块复活:组件里又出现树导航 helper').not.toContain('findFirstChildRow');
expect(src, '孪生块复活:组件里又出现 useCallback 版键盘块').not.toContain(
'handleListboxKeyDown = useCallback',
);
});
}
});

View File

@@ -0,0 +1,378 @@
// tests/unit/renderer/hooks/use-reorder-dnd.test.ts
//
// 守 r224「reorder 拖拽四件套收编」契约:
// NotesList / LiteratureList 里逐字同构的 reorder 块(dragOver/dragLeave/
// drop/dragEnd + 落点指示线 state)收编进 hooks/use-reorder-dnd.ts。
//
// ── 锁两类东西 ─────────────────────────────────────────────────────
// 1. 行为契约:
// dragOver 给 before/after 指示线 + dropEffect='move';
// dragLeave 只在真正离开 row 时清(relatedTarget 判);
// drop 守卫 = 无 payload / 异 kind / 拖回自身 no-op;跨目录落行
// (2026-10-21 起,用户诉求「移出文件夹任意位置都行」)委托
// onCrossFolderMove(未注入则维持 r176 静默拒绝);同目录才按
// 「filter 掉 moving + before 插位 splice」算新顺序调 reorderByIds;
// dragEnd 清指示线;
// 2. 结构契约(防孪生块复活):两个组件源码里不得再出现
// hasReorderPayload / setReorderIndicator —— 谁把四件套抄回组件里,
// 这条立刻红。
import { describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { readFileSync } from 'fs';
import { join } from 'path';
import type { DragEvent as ReactDragEvent } from 'react';
import { useReorderDnd } from '../../../../src/renderer/src/hooks/use-reorder-dnd';
import { writeReorderPayload } from '../../../../src/renderer/src/lib/folder-dnd';
interface Row {
id: string;
relPath: string;
}
const ROWS: Row[] = [
{ id: 'a', relPath: 'a.md' },
{ id: 'b', relPath: 'b.md' },
{ id: 'c', relPath: 'c.md' },
{ id: 'd', relPath: 'sub/d.md' },
];
const ORDER = { a: 0, b: 1, c: 2, d: 3 };
function makeDataTransfer() {
const types: string[] = [];
const store = new Map<string, string>();
return {
types,
setData(mime: string, val: string) {
if (!store.has(mime)) types.push(mime);
store.set(mime, val);
},
getData(mime: string) {
return store.get(mime) ?? '';
},
effectAllowed: 'move',
dropEffect: 'move',
} as unknown as DataTransfer;
}
function makeDragEvent(dt: DataTransfer, clientY = 10, rect?: Partial<DOMRect>) {
const el = {
getBoundingClientRect: () =>
({ top: 0, height: 20, ...rect }) as DOMRect,
contains: () => false,
};
return {
dataTransfer: dt,
clientY,
currentTarget: el,
relatedTarget: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as ReactDragEvent;
}
function setup(kind: 'note' | 'literature' = 'note') {
const reorderByIds = vi.fn();
const hook = renderHook(() =>
useReorderDnd<Row>({
kind,
getItems: () => ROWS,
displayOrderById: ORDER,
reorderByIds,
}),
);
return { ...hook, reorderByIds };
}
describe('useReorderDnd(r224 收编块的行为契约)', () => {
it('dragOver 按鼠标 y 与行中线给 before/after 指示线 + dropEffect=move', () => {
const { result } = setup();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // 15 > 中线 10
});
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'after' });
expect(dt.dropEffect).toBe('move');
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 3)); // 3 < 中线 10
});
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'before' });
});
it('无 reorder payload 的 dragOver 不接管(不 preventDefault)', () => {
const { result } = setup();
const e = makeDragEvent(makeDataTransfer());
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], e);
});
expect(result.current.reorderIndicator).toBeNull();
expect(e.preventDefault).not.toHaveBeenCalled();
});
it('drop 同目录 after 插入:filter 掉 moving 再 splice,reorderByIds 拿全新顺序', () => {
const { result, reorderByIds } = setup();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
// 拖 a 到 b 的下半部分(after)→ [b, a, c]
act(() => {
result.current.handleItemReorderDrop(ROWS[1], makeDragEvent(dt, 15));
});
expect(reorderByIds).toHaveBeenCalledTimes(1);
expect(reorderByIds).toHaveBeenCalledWith(['b', 'a', 'c', 'd']);
expect(result.current.reorderIndicator).toBeNull();
});
it('drop 同目录 before 插入:拖 a 到 b 的上半部分 → [a, b, c]', () => {
const { result, reorderByIds } = setup();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'c', targetId: 'c', before: true, kind: 'note' });
// 拖 c 到 a 的上半部分(before)→ [c, a, b, d]
act(() => {
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
});
expect(reorderByIds).toHaveBeenCalledWith(['c', 'a', 'b', 'd']);
});
it('drop 四道守卫:拖回自身 / 跨目录 / 异 kind / 无 payload 全部 no-op', () => {
const { result, reorderByIds } = setup();
// ① 拖回自身
let dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
});
// ② 跨目录(d 在 sub/,a 在根)→ 未注入 onCrossFolderMove 时维持 r176 拒绝
dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'd', targetId: 'd', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
});
// ③ 异 kind(literature payload 落进 note 列表)
dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'b', targetId: 'b', before: true, kind: 'literature' });
act(() => {
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(dt, 3));
});
// ④ 无 payload
act(() => {
result.current.handleItemReorderDrop(ROWS[0], makeDragEvent(makeDataTransfer(), 3));
});
expect(reorderByIds).not.toHaveBeenCalled();
});
it('dragLeave 真正离开才清指示线;dragEnd 无条件清', () => {
const { result } = setup();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
});
expect(result.current.reorderIndicator).not.toBeNull();
// relatedTarget 在 row 内(contains=true)→ 不清
const innerLeave = makeDragEvent(dt, 15);
(innerLeave.currentTarget as unknown as { contains: () => boolean }).contains = () => true;
(innerLeave as { relatedTarget: unknown }).relatedTarget = {};
act(() => {
result.current.handleItemReorderDragLeave(ROWS[1], innerLeave);
});
expect(result.current.reorderIndicator).not.toBeNull();
// 真正离开 → 清
act(() => {
result.current.handleItemReorderDragLeave(ROWS[1], makeDragEvent(dt, 15));
});
expect(result.current.reorderIndicator).toBeNull();
// 再拖出指示线后 dragEnd → 清
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
});
act(() => {
result.current.handleItemReorderDragEnd(makeDragEvent(dt));
});
expect(result.current.reorderIndicator).toBeNull();
});
});
describe('跨目录落行 = 委托 onCrossFolderMove(2026-10-21 「移出文件夹任意位置」)', () => {
function setupWithMove() {
const reorderByIds = vi.fn();
const onCrossFolderMove = vi.fn();
const hook = renderHook(() =>
useReorderDnd<Row>({
kind: 'note',
getItems: () => ROWS,
displayOrderById: ORDER,
reorderByIds,
onCrossFolderMove,
}),
);
return { ...hook, reorderByIds, onCrossFolderMove };
}
it('子目录条目 d 落到根行 a ⇒ 委托,targetDir=\'\',不走 reorder', () => {
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'd', targetId: 'd', before: true, kind: 'note' });
const e = makeDragEvent(dt, 3);
act(() => {
result.current.handleItemReorderDrop(ROWS[0], e);
});
expect(onCrossFolderMove).toHaveBeenCalledTimes(1);
expect(onCrossFolderMove).toHaveBeenCalledWith('', e);
expect(reorderByIds, '跨目录不得走 reorderByIds(视觉错位,r176)').not.toHaveBeenCalled();
// 已 preventDefault + stopPropagation:容器空白区 ROOT 落点不得二次处理
expect(e.preventDefault).toHaveBeenCalled();
expect(e.stopPropagation).toHaveBeenCalled();
// 指示线照常清(与 reorder 路径一致)
expect(result.current.reorderIndicator).toBeNull();
});
it('根条目 a 落到 sub/ 目录的行 d ⇒ 委托,targetDir=\'sub\'', () => {
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDrop(ROWS[3], makeDragEvent(dt, 3));
});
expect(onCrossFolderMove).toHaveBeenCalledTimes(1);
expect(onCrossFolderMove.mock.calls[0]![0]).toBe('sub');
expect(reorderByIds).not.toHaveBeenCalled();
});
it('同目录落行 ⇒ 不委托(走原 reorder 路径)', () => {
const { result, reorderByIds, onCrossFolderMove } = setupWithMove();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDrop(ROWS[1], makeDragEvent(dt, 15));
});
expect(onCrossFolderMove).not.toHaveBeenCalled();
expect(reorderByIds).toHaveBeenCalledWith(['b', 'a', 'c', 'd']);
});
});
describe('perf r247:拖拽期间的渲染预算(指示线同值不重渲)', () => {
/**
* 背景(用户诉求「使用流畅,不要有卡顿」):
* dragover 是**高频**事件(鼠标一动就发,实测 ~60Hz)。旧实现每次都
* `setReorderIndicator({ id, position })` 造一个**新对象** ⇒ React 的
* 「新 state = 新引用 ⇒ 必须重渲」判定永远成立 ⇒ 每秒 60 次重渲。
* 而重渲的是**整个列表组件**:NotesList / LiteratureList 的 itemContent
* useCallback 把 reorderIndicator 列入 deps ⇒ 指示线一变 itemContent
* 身份就变 ⇒ react-virtuoso 重新渲染**全部可见行**。
* ⇒ 「按住一行拖动」= 整列可见行 60Hz 重渲,大库上必卡。
*
* 修法:落点语义(id + position)没变 ⇒ **连 setState 都不调**。
* 注:曾试过「updater 原样返回 prev ⇒ React 同值 bail-out」,**不成立** ——
* 实测 React 18.3.1(updater 返回同一引用)照样重渲一次,官方措辞是
* 「React may still need to render that specific component again before
* bailing out」。所以只能靠「不发 setState」这一条路。
* 本组用例锁「重渲次数」与「指示线对象身份」两个可观测量。
*/
function setupCounting() {
let renders = 0;
const hook = renderHook(() => {
renders += 1;
return useReorderDnd<Row>({
kind: 'note',
getItems: () => ROWS,
displayOrderById: ORDER,
reorderByIds: vi.fn(),
});
});
return { ...hook, renders: () => renders };
}
it('同一行同一侧连发 30 次 dragOver ⇒ 零额外重渲,指示线对象身份不变', () => {
const { result, renders } = setupCounting();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // 下半部分 ⇒ after
});
const first = result.current.reorderIndicator;
expect(first).toEqual({ id: 'b', position: 'after' });
const rendersAfterFirst = renders();
// 模拟 60Hz 拖拽:指针在同一行内小幅抖动(不跨中线)
for (let i = 0; i < 30; i += 1) {
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15));
});
}
expect(renders(), '同值 dragOver 不得触发任何重渲').toBe(rendersAfterFirst);
expect(result.current.reorderIndicator, '指示线对象身份必须保持不变').toBe(first);
});
it('指示线**真的**变了(跨中线 / 换行)⇒ 仍然照常更新并重渲', () => {
const { result, renders } = setupCounting();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 15)); // after
});
const after = result.current.reorderIndicator;
const r0 = renders();
// 跨中线 ⇒ before(语义变了 ⇒ 必须更新)
act(() => {
result.current.handleItemReorderDragOver(ROWS[1], makeDragEvent(dt, 3));
});
expect(result.current.reorderIndicator).toEqual({ id: 'b', position: 'before' });
expect(result.current.reorderIndicator).not.toBe(after);
expect(renders(), '语义变化必须重渲(否则指示线不更新)').toBeGreaterThan(r0);
// 换到另一行 ⇒ id 变了 ⇒ 必须更新
const before = result.current.reorderIndicator;
act(() => {
result.current.handleItemReorderDragOver(ROWS[2], makeDragEvent(dt, 15));
});
expect(result.current.reorderIndicator).toEqual({ id: 'c', position: 'after' });
expect(result.current.reorderIndicator).not.toBe(before);
});
it('一次跨 3 行的拖拽 ⇒ 重渲次数跟「落点变化次数」走(远小于事件数)', () => {
const { result, renders } = setupCounting();
const dt = makeDataTransfer();
writeReorderPayload(dt, { id: 'a', targetId: 'a', before: true, kind: 'note' });
const r0 = renders();
// 现实手势:指针在 3 行之间移动,**每行内 20 次小幅抖动**(不跨中线)。
// ⇒ 落点只在「换行」时变(3 次),事件却发了 60 次。
// 旧实现 = 60 次重渲;新实现 ≤ 4 次。
for (let rowIdx = 0; rowIdx < 3; rowIdx += 1) {
const row = ROWS[1 + rowIdx]!;
for (let k = 0; k < 20; k += 1) {
act(() => {
result.current.handleItemReorderDragOver(row, makeDragEvent(dt, 15 + (k % 3)));
});
}
}
expect(renders() - r0, '重渲次数必须跟「落点变化次数」走,而不是事件数').toBeLessThanOrEqual(4);
});
});
describe('r224 结构契约:孪生块不得复活', () => {
const ROOT = process.cwd();
for (const rel of [
'src/renderer/src/components/notes/NotesList.tsx',
'src/renderer/src/components/literature/LiteratureList.tsx',
]) {
it(`${rel} 走 useReorderDnd,不再自带四件套`, () => {
const src = readFileSync(join(ROOT, rel), 'utf-8');
expect(src, 'reorder 四件套必须走 hooks/use-reorder-dnd').toContain('useReorderDnd');
expect(src, '孪生块复活:组件里又出现 payload 判读').not.toContain('hasReorderPayload');
expect(src, '孪生块复活:组件里又出现指示线 state').not.toContain('setReorderIndicator');
// 2026-10-21:跨目录落行委托必须接线(漏接 = 静默退回 r176 拒绝,
// 「移出文件夹只能拖底部空白」复发)。根行映射 ROOT_DROP_ID 同守。
expect(src, '跨目录落行委托未接线 onCrossFolderMove').toContain('onCrossFolderMove');
expect(src, '根行落点必须映射 ROOT_DROP_ID').toContain('ROOT_DROP_ID');
});
}
});

View File

@@ -0,0 +1,168 @@
// tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
//
// 守 useResizablePanel 的「调整落盘」契约 —— **松开鼠标后调整必须完成**
// (面板显示的宽度 == 用户偏好 == localStorage 里存的值)。
//
// 背景(2026-10-20 真机实测,Electron + CDP 拖左栏分隔条):
// 拖到 720px 后面板显示 720px,但 localStorage 里
// 'workbench.listPanel.width.v2' 仍是 **440** —— 用户的调整没存下来。
// 重启应用 → 面板又回到 440 ⇒ 「拖了半天,松手后调整没生效」。
//
// 根因(r142 引入的「夹紧值不落盘」守卫自带的一处泄漏):
// 窗口 resize → clampOnResize 算出夹紧后的目标宽度 → 若目标宽度与当前
// 宽度**相同**(最常见:用户没拖过,偏好就落在区间内),setState 同值 ⇒
// React 不重渲 ⇒ [width] effect 不跑 ⇒ 那句「这一次不要落盘」的标记
// (skipPersistRef)**留在原地没人消费**。
// 标记是一次性的「跳过下一次落盘」,不是「跳过这个具体值」⇒
// 下一次真正的用户调整(拖拽松手)落盘时被它吞掉 —— 宽度变了、界面变了、
// 偏好没变 ⇒ 重启后回退。
//
// 本文件锁三条:
// 1. 拖拽松手后 localStorage 必须等于新宽度(泄漏场景 ⇒ 红)。
// 2. 窗口夹紧产生的显示宽度**不污染**用户偏好(r142 的原契约 ⇒ 必须绿)。
// 3. 泄漏场景之后连续两次拖拽,两次都要落盘(标记不得再吞)。
//
// 本文件是 .ts(指针指定)⇒ 用 React.createElement 而不是 JSX。
import { describe, expect, it, beforeAll, beforeEach, afterEach, vi } from 'vitest';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { createElement } from 'react';
import { useResizablePanel } from '../../../../src/renderer/src/hooks/useResizablePanel';
// jsdom 没有 PointerEvent 构造器(见 memory jsdom-test-gaps):
// 垫一个继承 MouseEvent 的替身,让 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;
});
const KEY = 'test.panel.width';
// 视口宽度可写(jsdom 默认 1024,夹紧上限 = 60% = 614)。
function setViewportWidth(w: number): void {
Object.defineProperty(window, 'innerWidth', { value: w, configurable: true, writable: true });
}
interface HarnessProps {
min: number;
max: number;
defaultWidth: number;
}
/** 最小宿主:section 承载视觉宽度(panelRef 必须绑在这层),按钮当手柄。 */
function Harness({ min, max, defaultWidth }: HarnessProps): ReturnType<typeof createElement> {
const api = useResizablePanel({
storageKey: KEY,
defaultWidth,
min,
max,
side: 'left',
});
return createElement(
'section',
{ ref: api.panelRef, style: { width: `${api.width}px` }, 'data-testid': 'panel' },
createElement(
'button',
{ type: 'button', onPointerDown: api.onResizeStart, 'data-testid': 'handle' },
'drag',
),
);
}
function setup(opts?: Partial<HarnessProps>): {
panel: HTMLElement;
handle: HTMLElement;
unmount: () => void;
} {
const utils = render(
createElement(Harness, { min: 200, max: 720, defaultWidth: 240, ...opts }),
);
return {
panel: utils.getByTestId('panel') as HTMLElement,
handle: utils.getByTestId('handle') as HTMLElement,
unmount: utils.unmount,
};
}
/** 模拟一次真实拖拽:按下 → 移动 delta 像素 → 松开。 */
function drag(handle: HTMLElement, delta: number): void {
// r234:必须全发 pointer 系 —— 真机 Chromium 在 pointerdown preventDefault
// 后整个手势不发兼容 mouse 事件(见 panel-resize-drag.test.tsx 头注);
// 混合序列(pointerdown + mouseup)在 jsdom 里成立、在真机不成立 ⇒ 假绿。
fireEvent.pointerDown(handle, { clientX: 0, button: 0, pointerId: 1 });
fireEvent.pointerMove(window, { clientX: delta, pointerId: 1 });
fireEvent.pointerUp(window, { clientX: delta, pointerId: 1 });
}
beforeEach(() => {
localStorage.clear();
setViewportWidth(1024);
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('useResizablePanel 落盘契约(松开鼠标 ⇒ 调整完成)', () => {
it('窗口夹紧(目标宽度 == 当前宽度)之后再拖拽 ⇒ 新宽度必须落盘', () => {
// 偏好 400 落在 [min, max] 内 ⇒ 夹紧后的目标宽度 == 当前宽度 ⇒
// setState 同值 ⇒ [width] effect 不跑 ⇒ 跳过标记泄漏(本用例的靶心)。
localStorage.setItem(KEY, '400');
const { panel, handle } = setup();
expect(panel.style.width).toBe('400px');
// 窗口缩放 ⇒ 触发 clampOnResize(夹紧值 = 400,同值 ⇒ 不重渲)。
setViewportWidth(1024);
fireEvent(window, new Event('resize'));
expect(panel.style.width).toBe('400px');
// 用户拖到 500 并松手。
drag(handle, 100);
expect(panel.style.width).toBe('500px');
// ⇒ 偏好必须落盘(修前:被泄漏的标记吞掉 ⇒ 这里红)。
expect(localStorage.getItem(KEY)).toBe('500');
});
it('窗口夹紧(显示宽度确实变了)不得污染用户偏好', () => {
// r142 原契约:夹紧后的显示宽度只影响显示,偏好保持原值。
localStorage.setItem(KEY, '600');
const { panel } = setup();
expect(panel.style.width).toBe('600px');
// 视口缩到 700 ⇒ 上限 60% = 420 ⇒ 显示宽度被夹到 420。
setViewportWidth(700);
fireEvent(window, new Event('resize'));
expect(panel.style.width).toBe('420px');
// 偏好仍是 600(没有被夹紧值污染)。
expect(localStorage.getItem(KEY)).toBe('600');
});
it('泄漏场景之后连续两次拖拽,两次都要落盘', () => {
localStorage.setItem(KEY, '400');
const { panel, handle } = setup();
fireEvent(window, new Event('resize')); // 制造泄漏
drag(handle, 100);
expect(panel.style.width).toBe('500px');
expect(localStorage.getItem(KEY)).toBe('500');
drag(handle, -50);
expect(panel.style.width).toBe('450px');
expect(localStorage.getItem(KEY)).toBe('450');
});
});

View File

@@ -0,0 +1,388 @@
// tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
//
// 守 r230「拖动分隔条调宽度时卡顿」的修复契约 —— 拖拽期间被拖动面板的
// **内容宽度必须冻结**,拖拽结束才跟上。
//
// 为什么是行为契约(而不是实现细节):
// 拖拽路径本来就是「perf 路径」(useResizablePanel 拖拽期间直写 DOM,
// 绕过 React 重渲)⇒ 卡顿不在 JS,在**布局**:面板宽度每帧变 ⇒ 该侧
// 所有内容每帧重新折行 / 重排表格。
// Electron 真机 + CDP 帧间隔采样(双列 + 笔记列开「渲染显示」,189KB 文档):
// 不冻结(修前)→ 拖文献列分隔条:每帧布局 42ms,帧间隔中位 **50ms**,
// 60 帧里 39 帧超 50ms(肉眼可见的拖不动);
// 拖左侧栏分隔条:每帧布局 45ms,中位 50.1ms。
// 冻结(修后) → 每帧布局 ~1ms,帧间隔中位 **16.7ms**(满 60fps)。
// 拖左栏时主区宽度同样每帧变 ⇒ 冻结必须**全局**(订阅共享登记簿),
// 不能只跟某一个面板的拖拽绑定 —— 这条也是本测的结构契约之一。
//
// 本轮是**新增能力**(不是收编),故:
// - 行为例:对 HEAD 全红(hook 文件不存在)⇒ 修后必须绿;
// - 结构例:锁「组件必须挂 hook」「hook 必须走全局登记簿」,
// 独立于 hook 自身实现,任何人把 hook 调用删掉 / 退化成 per-panel 即红。
import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest';
import { renderHook, render, fireEvent, act, cleanup } from '@testing-library/react';
import { readFileSync } from 'fs';
import { join } from 'path';
// i18n:只关心交互,不关心文案
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// 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 };
});
// workspace store:主区空态身份
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 };
});
// 懒加载的阅读器 / 笔记视图:本测只关心分隔条、列宽与冻结,不关心内容
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 { WorkbenchContentPanel } from '../../../../src/renderer/src/components/layout/WorkbenchContentPanel';
import { useResizeContentFreeze } from '../../../../src/renderer/src/hooks/use-resize-content-freeze';
import {
beginResizeDrag,
endResizeDrag,
isResizeDragActive,
resetResizeDragRegistry,
} from '../../../../src/renderer/src/lib/resize-drag-registry';
// jsdom 不做真实布局:getBoundingClientRect 恒为 0 ⇒ 冻结逻辑会因
// 「宽度为 0 不钉」而全部跳过。这里用一个可变矩形替身。
let rectWidth = 640;
function makeEl(): HTMLDivElement {
const el = document.createElement('div');
document.body.appendChild(el);
return el;
}
describe('r230 行为契约:拖拽期间内容宽度冻结', () => {
beforeEach(() => {
resetResizeDragRegistry();
rectWidth = 640;
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
() =>
({
width: rectWidth,
height: 800,
top: 0,
left: 0,
right: rectWidth,
bottom: 800,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect,
);
});
afterEach(() => {
cleanup();
resetResizeDragRegistry();
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('非拖拽期不碰内容根(不预置任何 inline 宽度)', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
expect(el.style.width).toBe('');
expect(el.style.overflowX).toBe('');
});
it('拖拽开始 → 内容根宽度被钉成拖拽开始那一刻的像素值', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
expect(el.style.width).toBe('640px');
});
it('拖拽中容器变窄 → 内容根宽度不动(不重排)', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
rectWidth = 400;
expect(el.style.width).toBe('640px');
rectWidth = 320;
expect(el.style.width).toBe('640px');
});
it('拖拽中横向溢出被裁掉(不出横向滚动条吃掉纵向空间)', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
expect(el.style.overflowX).toBe('hidden');
});
it('拖拽结束 → 内容根宽度一次性跟上(冻结不是「永远不更新」)', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
rectWidth = 400;
act(() => {
endResizeDrag();
});
expect(el.style.width).toBe('');
expect(el.style.overflowX).toBe('');
});
it('拖拽前就有的 inline 样式在拖拽结束后原样还原(不是清空)', () => {
const el = makeEl();
el.style.width = '50%';
el.style.overflowX = 'auto';
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
expect(el.style.width).toBe('640px');
act(() => {
endResizeDrag();
});
expect(el.style.width).toBe('50%');
expect(el.style.overflowX).toBe('auto');
});
it('宽度为 0(未布局/隐藏)时不钉 —— 钉 0 会让内容彻底消失', () => {
const el = makeEl();
rectWidth = 0;
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
expect(el.style.width).toBe('');
});
it('嵌套拖拽:计数未归零时保持冻结,归零才还原', () => {
const el = makeEl();
const ref = { current: el };
renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
beginResizeDrag();
});
expect(el.style.width).toBe('640px');
act(() => {
endResizeDrag();
});
// 还有一个拖拽在途 ⇒ 仍冻结
expect(el.style.width).toBe('640px');
act(() => {
endResizeDrag();
});
expect(el.style.width).toBe('');
});
it('拖拽中组件卸载 → 还原(不留冻结态 inline 样式)', () => {
const el = makeEl();
const ref = { current: el };
const { unmount } = renderHook(() => useResizeContentFreeze(ref));
act(() => {
beginResizeDrag();
});
expect(el.style.width).toBe('640px');
unmount();
expect(el.style.width).toBe('');
expect(el.style.overflowX).toBe('');
});
});
// 端到端:真实组件 + 真实事件序列(pointerdown → mousemove → mouseup)。
//
// 这一组锁的是用户说的那句「鼠标点击放开之后应该完成调整」——
// 拖拽期间:两列内容宽度被钉死(不重排);
// 松手之后:① 面板宽度提交(DOM + localStorage)② 内容解冻、宽度清空
// ⇒ 内容重新跟着面板走 = 调整真正完成,不是「永远冻结」。
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;
}
}
describe('r230 端到端契约:拖 → 松手 → 完成调整', () => {
beforeAll(() => {
(window as unknown as { PointerEvent: unknown }).PointerEvent = FakePointerEvent;
});
beforeEach(() => {
localStorage.clear();
resetResizeDragRegistry();
// jsdom 无布局:getBoundingClientRect 恒 0 ⇒ 冻结逻辑会因「宽度 0 不钉」
// 全部跳过。给一个固定宽度,让冻结路径真正跑起来。
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
() =>
({
width: 640,
height: 800,
top: 0,
left: 0,
right: 640,
bottom: 800,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect,
);
});
afterEach(() => {
cleanup();
localStorage.clear();
resetResizeDragRegistry();
vi.restoreAllMocks();
});
it('拖拽期间两列内容宽度被钉死;松手后面板宽度提交 + 内容解冻跟上最终宽度', () => {
const { container } = render(<WorkbenchContentPanel />);
const separator = container.querySelector('[role="separator"]') as HTMLElement;
expect(separator).toBeTruthy();
const readerCol = separator.previousElementSibling as HTMLElement;
// ① 拖拽开始
fireEvent.pointerDown(separator, { clientX: 1000, clientY: 100, button: 0, pointerId: 1 });
expect(isResizeDragActive()).toBe(true);
// ② 拖动 100px(向左 = 文献列收窄)
// r234:pointer 系 —— 真机 Chromium 在 pointerdown preventDefault 后
// 不发兼容 mouse 事件(见 panel-resize-drag.test.tsx 头注)。
act(() => {
fireEvent.pointerMove(window, { clientX: 900, clientY: 100, pointerId: 1 });
});
// 面板宽度实时跟手(这一条是 r199 已有的契约,此处作为对照)
expect(readerCol.style.width).toBe('540px');
// 内容宽度被钉死在拖拽开始时的 640px ⇒ 不重排
const frozen = [...container.querySelectorAll('*')].filter(
(el) => (el as HTMLElement).style.overflowX === 'hidden',
);
expect(frozen.length, '两列内容根各被冻结一次').toBe(2);
for (const el of frozen) {
expect((el as HTMLElement).style.width).toBe('640px');
}
// ③ 松手 —— 调整必须在此完成
act(() => {
fireEvent.pointerUp(window, { clientX: 900, clientY: 100, pointerId: 1 });
});
expect(isResizeDragActive()).toBe(false);
// ③-a 面板宽度提交(DOM + localStorage)
expect(readerCol.style.width).toBe('540px');
expect(localStorage.getItem('workbench.readerColumn.width')).toBe('540');
// ③-b 内容解冻:inline 宽度清空 ⇒ 内容重新跟着面板走
for (const el of frozen) {
expect((el as HTMLElement).style.width).toBe('');
expect((el as HTMLElement).style.overflowX).toBe('');
}
const stillFrozen = [...container.querySelectorAll('*')].filter(
(el) => (el as HTMLElement).style.overflowX === 'hidden',
);
expect(stillFrozen.length, '松手后不得残留冻结态').toBe(0);
});
});
describe('r230 结构契约:冻结必须全局 + 组件必须挂上', () => { const ROOT = process.cwd();
it('hook 走全局登记簿(任意分隔条在拖都冻结),不是 per-panel', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/hooks/use-resize-content-freeze.ts'),
'utf-8',
);
expect(src, '冻结必须订阅全局登记簿').toContain('subscribeResizeDrag');
expect(src, '冻结必须读全局拖拽态').toContain('isResizeDragActive');
});
it('主区两列的内容根都挂了 useResizeContentFreeze', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
'utf-8',
);
const calls = src.match(/useResizeContentFreeze\(contentRef\);/g) ?? [];
expect(calls.length, '笔记列 + 文献列各挂一次冻结').toBe(2);
expect(src, '两列内容根都要有 ref').toContain('ref={contentRef}');
});
it('主区不得自己再写一遍冻结(单一真相源在 hook 里)', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
'utf-8',
);
expect(src, '冻结逻辑不得在组件里内联实现').not.toContain('style.width =');
expect(src, '冻结逻辑不得在组件里内联实现').not.toContain('style.overflowX =');
});
it('文献列面板自带 overflow-hidden —— 钉住的内容不得溢出到笔记列', () => {
const src = readFileSync(
join(ROOT, 'src/renderer/src/components/layout/WorkbenchContentPanel.tsx'),
'utf-8',
);
const panelIdx = src.indexOf('ref={readerCol.panelRef}');
expect(panelIdx, '文献列面板必须存在').toBeGreaterThan(-1);
const panelSnippet = src.slice(panelIdx, panelIdx + 400);
expect(panelSnippet, '文献列面板必须裁切溢出的内容').toContain('overflow-hidden');
});
});

View File

@@ -0,0 +1,173 @@
// tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
//
// 守 r226「落点区 DnD 全家桶收编」契约(收编 ③):
// NotesList / LiteratureList 里逐字相同的 ① OS 文件拖入导入链
// (dragActive + dragCounterRef + dragOver/Enter/Leave/Drop + inert
// effect)② 内部 drag 容器落点三件套(ROOT_DROP_ID)③ hidden file
// picker 入口,收进 hooks/use-sidebar-drop-zone.ts;唯一分叉 importFiles
// 由 caller 注入。
//
// ── 锁两类东西(与 r224/r225 同模式)────────────────────────────────
// 1. 行为契约(收编前既有行为,对 HEAD 也必须绿):
// enter 仅 'Files' 类型计数 / 计数法 0↔1 切换 overlay / leave 不负数;
// dragOver 内部=move 否则 copy;drop 内部=取消(不导入)否则计数清零
// + importFiles;容器三件套 gated on isInternalDrag(非内部不
// preventDefault,OS 文件直通 import 链);inert 随 dragActive 增减;
// picker change 后清空 value(同文件再选仍触发);
// 2. 结构契约:组件源码不得再出现 dragCounterRef / setAttribute('inert'
// —— 孪生块复活即红(修前红)。
import { describe, expect, it, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { readFileSync } from 'fs';
import { join } from 'path';
import type { ChangeEvent, DragEvent as ReactDragEvent } from 'react';
import { useSidebarDropZone } from '../../../../src/renderer/src/hooks/use-sidebar-drop-zone';
import { ROOT_DROP_ID, type DiskFolderSidebar } from '../../../../src/renderer/src/hooks/useDiskFolderSidebar';
function makeFolders(internal: boolean) {
return {
isInternalDrag: vi.fn(() => internal),
handleTargetDragOver: vi.fn(),
handleTargetDragLeave: vi.fn(),
handleTargetDrop: vi.fn().mockResolvedValue(undefined),
} as unknown as DiskFolderSidebar;
}
function makeDragEvent(opts: { files?: boolean } = {}) {
const dt = {
types: opts.files ? ['Files'] : ['text/plain'],
files: [{ name: 'x.md' }] as unknown as FileList,
dropEffect: 'none',
effectAllowed: 'all',
} as unknown as DataTransfer;
return {
dataTransfer: dt,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as ReactDragEvent;
}
function setup(internal = false) {
const folders = makeFolders(internal);
const runFolderOp = vi.fn((op: () => Promise<unknown>) => void op());
const importFiles = vi.fn().mockResolvedValue(undefined);
const container = document.createElement('div');
const listContainerRef = { current: container as HTMLElement };
const hook = renderHook(() =>
useSidebarDropZone({ folders, runFolderOp, listContainerRef, importFiles }),
);
return { ...hook, folders, runFolderOp, importFiles, container };
}
describe('useSidebarDropZone(r226 收编块的行为契约)', () => {
it('dragEnter 仅 Files 类型计数;计数 0↔1 切 overlay;leave 不为负', () => {
const { result } = setup();
// 非文件 drag(文本选择 / 内部 reorder)⇒ 不计数不弹层
act(() => result.current.handleDragEnter(makeDragEvent({ files: false })));
expect(result.current.dragActive).toBe(false);
// 两次 enter(子元素冒泡)⇒ 仍只弹一次;一次 leave 不关
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
expect(result.current.dragActive).toBe(true);
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
expect(result.current.dragActive).toBe(true);
// 第二次 leave 归零 ⇒ 关;再一次 leave 不为负(仍关、不抛)
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
expect(result.current.dragActive).toBe(false);
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
expect(result.current.dragActive).toBe(false);
});
it('dragOver:内部 drag=move(容器 drop 会忽略),OS 文件=copy', () => {
const internal = setup(true);
const e1 = makeDragEvent();
act(() => internal.result.current.handleDragOver(e1));
expect(e1.dataTransfer.dropEffect).toBe('move');
const external = setup(false);
const e2 = makeDragEvent();
act(() => external.result.current.handleDragOver(e2));
expect(e2.dataTransfer.dropEffect).toBe('copy');
});
it('drop:内部 drag 落空白 = 取消(不导入);OS 文件 ⇒ 计数清零 + importFiles', async () => {
const internal = setup(true);
await act(async () => {
await internal.result.current.handleDrop(makeDragEvent({ files: true }));
});
expect(internal.importFiles).not.toHaveBeenCalled();
const external = setup(false);
act(() => external.result.current.handleDragEnter(makeDragEvent({ files: true })));
expect(external.result.current.dragActive).toBe(true);
await act(async () => {
await external.result.current.handleDrop(makeDragEvent({ files: true }));
});
expect(external.result.current.dragActive).toBe(false);
expect(external.importFiles).toHaveBeenCalledTimes(1);
});
it('容器三件套 gated on isInternalDrag:内部 ⇒ ROOT_DROP_ID 落点;非内部直通不拦截', () => {
const internal = setup(true);
const e1 = makeDragEvent();
act(() => {
internal.result.current.handleContainerDragOver(e1);
internal.result.current.handleContainerDragLeave(e1);
internal.result.current.handleContainerDrop(e1);
});
expect(internal.folders.handleTargetDragOver).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
expect(internal.folders.handleTargetDragLeave).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
expect(internal.folders.handleTargetDrop).toHaveBeenCalledWith(e1, ROOT_DROP_ID);
expect(e1.preventDefault).toHaveBeenCalled();
// 非内部 ⇒ 全部放行(不 preventDefault —— import overlay 链靠事件未被拦截才生效)
const external = setup(false);
const e2 = makeDragEvent({ files: true });
act(() => {
external.result.current.handleContainerDragOver(e2);
external.result.current.handleContainerDrop(e2);
});
expect(external.folders.handleTargetDragOver).not.toHaveBeenCalled();
expect(external.folders.handleTargetDrop).not.toHaveBeenCalled();
expect(e2.preventDefault).not.toHaveBeenCalled();
});
it('inert 随 dragActive 增/删(a11y H4:dragenter 期间底层不可 Tab)', () => {
const { result, container } = setup();
expect(container.hasAttribute('inert')).toBe(false);
act(() => result.current.handleDragEnter(makeDragEvent({ files: true })));
expect(container.getAttribute('inert')).toBe('');
act(() => result.current.handleDragLeave(makeDragEvent({ files: true })));
expect(container.hasAttribute('inert')).toBe(false);
});
it('file picker:change ⇒ importFiles + 清空 value(同文件再选仍触发)', () => {
const { result, importFiles } = setup();
const input = document.createElement('input');
const files = [{ name: 'a.md' }] as unknown as FileList;
Object.defineProperty(input, 'files', { value: files, configurable: true });
act(() => {
result.current.onFilePickerChange({
target: input,
} as unknown as ChangeEvent<HTMLInputElement>);
});
expect(importFiles).toHaveBeenCalledWith(files);
expect(input.value).toBe('');
});
});
describe('r226 结构契约:孪生块不得复活', () => {
const ROOT = process.cwd();
for (const rel of [
'src/renderer/src/components/notes/NotesList.tsx',
'src/renderer/src/components/literature/LiteratureList.tsx',
]) {
it(`${rel} 走 useSidebarDropZone,不再自带落点区 DnD 机器`, () => {
const src = readFileSync(join(ROOT, rel), 'utf-8');
expect(src, '落点区 DnD 必须走 hooks/use-sidebar-drop-zone').toContain('useSidebarDropZone');
expect(src, '孪生块复活:组件里又出现 dragCounterRef').not.toContain('dragCounterRef');
expect(src, '孪生块复活:组件里又出现 inert 同步').not.toContain("setAttribute('inert'");
});
}
});

View File

@@ -0,0 +1,265 @@
// 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'");
});
}
});