// 集成测试:模拟完整的「右键文件 → 弹菜单 → 点击重命名」流程。
// 验证 file-list 的 contextmenu 委托、showFileListContextMenu 的菜单构造、
// 以及 ContextMenu 的 onSelect 回调链路。
/* @vitest-environment jsdom */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileList } from '../../src/file-list.js';
import { ContextMenu } from '../../src/context-menu.js';
import { createFileOps } from '../../src/file-ops.js';
describe('右键文件 → 弹菜单流程', () => {
let list;
let onContextMenu;
let menu;
let container;
beforeEach(() => {
document.body.innerHTML = `
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
onContextMenu = vi.fn();
list = new FileList({
element: document.getElementById('files'),
onSelect: vi.fn(),
onContextMenu,
});
list.setFiles([
{ name: 'a.md', path: '/d/a.md' },
{ name: 'b.md', path: '/d/b.md' },
]);
});
it('右键 li → onContextMenu 拿到对应 file', () => {
const li = document.querySelectorAll('.file-item')[1];
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
clientX: 100, clientY: 200,
});
li.dispatchEvent(evt);
expect(onContextMenu).toHaveBeenCalledTimes(1);
expect(onContextMenu.mock.calls[0][0].name).toBe('b.md');
expect(onContextMenu.mock.calls[0][1]).toBe(100);
expect(onContextMenu.mock.calls[0][2]).toBe(200);
});
it('右键 li → preventDefault 已被调(否则浏览器原生菜单会闪)', () => {
const li = document.querySelector('.file-item');
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
li.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
});
it('右键 ul 上的非 li 区域 → 不触发 onContextMenu,不 preventDefault', () => {
const evt = new MouseEvent('contextmenu', {
bubbles: true, cancelable: true,
});
document.getElementById('files').dispatchEvent(evt);
expect(onContextMenu).not.toHaveBeenCalled();
expect(evt.defaultPrevented).toBe(false);
});
});
describe('showFileListContextMenu 集成', () => {
let menu;
let container;
let lastOnSelect;
beforeEach(() => {
document.body.innerHTML = `
`;
container = document.getElementById('context-menu-test-container');
menu = new ContextMenu({ container });
lastOnSelect = null;
// 拦截 onSelect 以便测试断言「点击菜单项最终走到哪个回调」
const origShow = menu.show.bind(menu);
menu.show = (opts) => {
lastOnSelect = opts.onSelect;
return origShow(opts);
};
});
// 复刻 src/file-ops.js:260-277 的菜单构造逻辑(不 import file-ops,
// 因为它依赖 state / controls / api / callbacks 太重)
function fakeShowFileListContextMenu(entry, x, y, contextMenu, callbacks) {
const isFolder = entry && entry.entryType === 'folder';
const items = [];
if (!isFolder) items.push({ label: '重命名', value: 'rename' });
items.push({ label: '在文件夹中显示', value: 'reveal' });
items.push({ separator: true, value: '' });
items.push({ label: '删除', value: 'delete' });
contextMenu.show({
x, y, items,
onSelect: (value) => {
if (value === 'rename') callbacks.onRename(entry);
else if (value === 'reveal') callbacks.onReveal(entry);
else if (value === 'delete') callbacks.onDelete(entry);
},
});
}
it('菜单包含 rename / reveal / delete + separator', () => {
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'重命名', '在文件夹中显示', '删除',
]);
});
it('folder 条目 → 菜单不包含 rename', () => {
fakeShowFileListContextMenu(
{ name: 'subdir', path: '/d/subdir', entryType: 'folder' }, 10, 10, menu,
{ onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() }
);
const buttons = container.querySelectorAll('.context-menu-item');
expect(Array.from(buttons).map((b) => b.textContent)).toEqual([
'在文件夹中显示', '删除',
]);
});
it('点击「重命名」 → onRename 回调被调,且菜单关闭', () => {
const onRename = vi.fn();
const onReveal = vi.fn();
const onDelete = vi.fn();
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu,
{ onRename, onReveal, onDelete }
);
expect(menu.isVisible()).toBe(true);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[0].click();
expect(onRename).toHaveBeenCalledTimes(1);
expect(onRename.mock.calls[0][0].name).toBe('a.md');
expect(onReveal).not.toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
expect(menu.isVisible()).toBe(false);
});
it('点击「在文件夹中显示」 → onReveal 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[1].click(); // 第二个按钮是「在文件夹中显示」
expect(cb.onReveal).toHaveBeenCalledTimes(1);
expect(cb.onRename).not.toHaveBeenCalled();
});
it('点击「删除」 → onDelete 被调', () => {
const cb = { onRename: vi.fn(), onReveal: vi.fn(), onDelete: vi.fn() };
fakeShowFileListContextMenu(
{ name: 'a.md', path: '/d/a.md' }, 10, 10, menu, cb
);
const buttons = container.querySelectorAll('.context-menu-item');
buttons[2].click(); // 第三个按钮是「删除」
expect(cb.onDelete).toHaveBeenCalledTimes(1);
});
});
// ==========================================================================
// revealFile 分支:文件走 showItemInFolder,文件夹走 openDir
// ==========================================================================
//
// 背景:旧实现把文件和文件夹都丢给 api.showItemInFolder,但主进程会
// stat.isFile() 校验,目录被拒(NOT_A_FILE),用户在 folder 条目上点
// 「在文件夹中显示」反而弹「不是一个文件」错误 toast。修复后文件夹走
// api.openDir(shell.openPath 直接打开目录本身)。
//
// 这里直接 import createFileOps,用 vi.fn() 桩 api.openDir /
// api.showItemInFolder / window.api.friendlyFsError,断言 revealFile 命中
// 正确的 IPC。
describe('revealFile IPC 分支', () => {
let fileOps;
let api;
let originalWindowApi;
beforeEach(() => {
document.body.innerHTML = '';
api = {
showItemInFolder: vi.fn().mockResolvedValue({ ok: true }),
openDir: vi.fn().mockResolvedValue({ ok: true }),
};
// window.api.friendlyFsError 在 revealFile 失败分支用到
originalWindowApi = globalThis.window && globalThis.window.api;
if (typeof globalThis.window === 'undefined') globalThis.window = {};
globalThis.window.api = { friendlyFsError: vi.fn().mockReturnValue('stub') };
fileOps = createFileOps({
state: { currentFile: null, isDirty: false },
controls: {
contextMenu: { show: vi.fn(), hide: vi.fn() },
viewer: {},
fileList: {},
editor: { isDirty: () => false },
},
api,
callbacks: {
openFile: vi.fn(),
updateWindowTitle: vi.fn(),
clearCurrentFile: vi.fn(),
},
});
});
afterEach(() => {
if (originalWindowApi === undefined) {
delete globalThis.window.api;
} else {
globalThis.window.api = originalWindowApi;
}
});
it('editable 文件 → 走 api.showItemInFolder,不调 openDir', async () => {
await fileOps.revealFile({ name: 'a.md', path: '/d/a.md', entryType: 'editable' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/a.md');
expect(api.openDir).not.toHaveBeenCalled();
});
it('binary 文件 → 走 api.showItemInFolder', async () => {
await fileOps.revealFile({ name: 'img.png', path: '/d/img.png', entryType: 'binary' });
expect(api.showItemInFolder).toHaveBeenCalledWith('/d/img.png');
expect(api.openDir).not.toHaveBeenCalled();
});
it('folder 条目 → 走 api.openDir,不调 showItemInFolder', async () => {
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(api.openDir).toHaveBeenCalledWith('/d/subdir');
expect(api.showItemInFolder).not.toHaveBeenCalled();
});
it('folder → openDir 返回失败 → 走 friendlyFsError toast(不静默吞)', async () => {
api.openDir.mockResolvedValue({ ok: false, code: 'NOT_A_DIRECTORY', message: '路径不是文件夹' });
await fileOps.revealFile({ name: 'subdir', path: '/d/subdir', entryType: 'folder' });
expect(globalThis.window.api.friendlyFsError).toHaveBeenCalledWith(
'NOT_A_DIRECTORY',
'路径不是文件夹'
);
});
it('缺 path 的 entry → 不发任何 IPC', async () => {
await fileOps.revealFile({ name: 'x', entryType: 'editable' });
expect(api.showItemInFolder).not.toHaveBeenCalled();
expect(api.openDir).not.toHaveBeenCalled();
});
});