update
This commit is contained in:
477
tests/unit/renderer/hooks/disk-folder-dnd.test.ts
Normal file
477
tests/unit/renderer/hooks/disk-folder-dnd.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
164
tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
Normal file
164
tests/unit/renderer/hooks/disk-folder-reload-guard.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
169
tests/unit/renderer/hooks/disk-folder-tree.test.ts
Normal file
169
tests/unit/renderer/hooks/disk-folder-tree.test.ts
Normal 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: '' });
|
||||
});
|
||||
});
|
||||
177
tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
Normal file
177
tests/unit/renderer/hooks/folder-tree-external-refresh.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
144
tests/unit/renderer/hooks/use-folder-ops.test.ts
Normal file
144
tests/unit/renderer/hooks/use-folder-ops.test.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
});
|
||||
304
tests/unit/renderer/hooks/use-listbox-keys.test.ts
Normal file
304
tests/unit/renderer/hooks/use-listbox-keys.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
378
tests/unit/renderer/hooks/use-reorder-dnd.test.ts
Normal file
378
tests/unit/renderer/hooks/use-reorder-dnd.test.ts
Normal 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');
|
||||
});
|
||||
}
|
||||
});
|
||||
168
tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
Normal file
168
tests/unit/renderer/hooks/use-resizable-panel-persist.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
388
tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
Normal file
388
tests/unit/renderer/hooks/use-resize-content-freeze.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
173
tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
Normal file
173
tests/unit/renderer/hooks/use-sidebar-drop-zone.test.ts
Normal 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'");
|
||||
});
|
||||
}
|
||||
});
|
||||
265
tests/unit/renderer/hooks/use-sidebar-menus.test.ts
Normal file
265
tests/unit/renderer/hooks/use-sidebar-menus.test.ts
Normal 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'");
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user