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