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