Files
Notes/tests/unit/file-list-drag.test.js
2026-10-18 04:39:37 +08:00

227 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/file-list.js —— 拖拽投放(drag → dragover → drop)事件委托单测
//
// 覆盖:
// - 普通拖拽文件到文件夹:onDropIntoFolder(entry, folderEntry, { copy:false })
// - 按住 Ctrl/Cmd 拖拽到文件夹:onDropIntoFolder(entry, folderEntry, { copy:true })
// - 拖到非文件夹(文件)不放:不触发
// - 文件夹拖到自己的子孙目录:视为非法目标,不触发
//
// jsdom 不原生支持 DragEvent 的 dataTransfer,这里手动给事件挂一个 dataTransfer 桩。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as FileListMod from '../../src/file-list.js';
const { FileList } = FileListMod;
function makeDataTransfer() {
return {
data: {},
effectAllowed: '',
dropEffect: '',
setData(type, val) { this.data[type] = String(val); },
getData(type) { return this.data[type] || ''; },
};
}
function fire(el, type, { ctrlKey = false, metaKey = false } = {}) {
const ev = new Event(type, { bubbles: true, cancelable: true });
ev.dataTransfer = makeDataTransfer();
// ctrlKey / metaKey 在 jsdom 的 Event 上只读,用 defineProperty 注入
Object.defineProperty(ev, 'ctrlKey', { value: ctrlKey });
Object.defineProperty(ev, 'metaKey', { value: metaKey });
el.dispatchEvent(ev);
return ev;
}
describe('FileList 拖拽投放', () => {
let element;
let list;
let onDrop;
beforeEach(() => {
document.body.innerHTML = `<ul id="files"></ul>`;
element = document.getElementById('files');
onDrop = vi.fn();
list = new FileList({ element, onSelect: vi.fn(), onDropIntoFolder: onDrop });
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
]);
});
function liFor(path) {
// querySelector 里路径含 / 与中文,用属性选择器 + 转义;jsdom 无 CSS.escape,
// 直接用 [data-path="..."](路径不含引号,安全)并兜底到 find。
return element.querySelector(`.file-item[data-path="${path}"]`)
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
}
it('普通拖拽文件到文件夹 → 触发 move(copy:false)', () => {
const fileLi = liFor('/d/note.md');
const folderLi = liFor('/d/sub');
fire(fileLi, 'dragstart');
fire(folderLi, 'dragover');
expect(folderLi.classList.contains('is-drop-target')).toBe(true);
fire(folderLi, 'drop');
expect(onDrop).toHaveBeenCalledTimes(1);
const [dragged, target, opts] = onDrop.mock.calls[0];
expect(dragged.path).toBe('/d/note.md');
expect(target.path).toBe('/d/sub');
expect(target.entryType).toBe('folder');
expect(opts).toEqual({ copy: false });
});
it('按住 Ctrl 拖拽文件到文件夹 → 触发 copy(copy:true)', () => {
const fileLi = liFor('/d/note.md');
const folderLi = liFor('/d/sub');
fire(fileLi, 'dragstart');
fire(folderLi, 'dragover', { ctrlKey: true });
fire(folderLi, 'drop', { ctrlKey: true });
expect(onDrop).toHaveBeenCalledTimes(1);
const [dragged, target, opts] = onDrop.mock.calls[0];
expect(dragged.path).toBe('/d/note.md');
expect(target.path).toBe('/d/sub');
expect(opts).toEqual({ copy: true });
});
it('拖到非文件夹(文件)不放 → 不触发', () => {
const fileLi = liFor('/d/note.md');
fire(fileLi, 'dragstart');
fire(fileLi, 'dragover'); // 拖到自己身上
fire(fileLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
expect(fileLi.classList.contains('is-drop-target')).toBe(false);
});
it('文件夹拖到自身 → 非法目标,不触发', () => {
const folderLi = liFor('/d/sub');
fire(folderLi, 'dragstart');
fire(folderLi, 'dragover');
fire(folderLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
});
it('文件夹拖到自己的子孙目录 → 非法目标,不触发', () => {
list.setFiles([
{ name: 'A', path: '/d/A', entryType: 'folder' },
{ name: 'B', path: '/d/A/B', entryType: 'folder' },
]);
const aLi = liFor('/d/A');
const bLi = liFor('/d/A/B');
fire(aLi, 'dragstart');
fire(bLi, 'dragover');
fire(bLi, 'drop');
expect(onDrop).not.toHaveBeenCalled();
});
it('dragend 清空拖拽源状态', () => {
const fileLi = liFor('/d/note.md');
fire(fileLi, 'dragstart');
expect(fileLi.classList.contains('is-dragging')).toBe(true);
fire(fileLi, 'dragend');
expect(fileLi.classList.contains('is-dragging')).toBe(false);
expect(list._dragEntry).toBeNull();
});
it('dragstart 设 effectAllowed=all(拖行中补按 Ctrl 仍可 copy 落地)', () => {
// Chromium 会把 dragover 的 dropEffect 夹进 effectAllowed:dragstart 若按
// 当时 Ctrl 态锁成 'move',途中补按 Ctrl 要 copy 时 'copy' ⊄ 'move',
// drop 被静默吞掉(Chromium 实测)。意图实时判定由 dragover/drop 的
// ctrlKey 分支负责。
const ev = fire(liFor('/d/note.md'), 'dragstart');
expect(ev.dataTransfer.effectAllowed).toBe('all');
});
it('_isInvalidDropTarget:源在目标目录内 → 拒绝(原地移动会变成 name (2) 静默改名)', () => {
// 方向回归守卫:此前条件写反成「target 在 src 之下」,该守卫从未真正生效。
expect(list._isInvalidDropTarget(
{ path: '/d/sub/x.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(true);
// 兄弟目录的正常拖放不受影响
expect(list._isInvalidDropTarget(
{ path: '/d/note.md', entryType: 'file' },
{ path: '/d/sub', entryType: 'folder' },
)).toBe(false);
});
it('setClipboardCut 高亮跨 setFiles 重渲染保留,传 null 清除', () => {
list.setClipboardCut('/d/note.md');
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
// fs-watcher 推新快照触发重渲染:命令式加的 class 会被 _fillItem 的
// wantClass 重赋值抹掉 —— 高亮必须由渲染期从 clipboardCutPath 补回。
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
{ name: 'other.md', path: '/d/other.md', entryType: 'file' },
]);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
expect(liFor('/d/sub').classList.contains('is-clipboard-cut')).toBe(false);
list.setClipboardCut(null);
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(false);
});
});
describe('FileList 空白区域右键菜单', () => {
let element;
let list;
let onItem;
let onEmpty;
beforeEach(() => {
document.body.innerHTML = `<ul id="files"></ul>`;
element = document.getElementById('files');
onItem = vi.fn();
onEmpty = vi.fn();
list = new FileList({
element,
onSelect: vi.fn(),
onContextMenu: onItem,
onContextMenuEmpty: onEmpty,
});
list.setFiles([
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
]);
});
function rightClick(target, { x = 4, y = 6 } = {}) {
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y });
target.dispatchEvent(ev);
}
function liFor(path) {
return element.querySelector(`.file-item[data-path="${path}"]`)
|| Array.from(element.querySelectorAll('.file-item')).find((li) => li.dataset.path === path);
}
it('右键空白区域(ul 本身)→ 触发 onContextMenuEmpty,不触发 onContextMenu', () => {
rightClick(element);
expect(onEmpty).toHaveBeenCalledTimes(1);
expect(onEmpty.mock.calls[0][0]).toBe(4);
expect(onEmpty.mock.calls[0][1]).toBe(6);
expect(onItem).not.toHaveBeenCalled();
});
it('右键具体条目 → 触发 onContextMenu,不触发 onContextMenuEmpty', () => {
rightClick(liFor('/d/note.md'));
expect(onItem).toHaveBeenCalledTimes(1);
expect(onEmpty).not.toHaveBeenCalled();
});
it('未提供 onContextMenuEmpty 时,空白区域右键不报错也不弹菜单', () => {
const ul = document.createElement('ul');
document.body.appendChild(ul);
const bare = new FileList({ element: ul, onSelect: vi.fn(), onContextMenu: vi.fn() });
bare.setFiles([{ name: 'a.md', path: '/d/a.md', entryType: 'file' }]);
expect(() => rightClick(ul)).not.toThrow();
});
});