// 集成测试:模拟完整的「右键文件 → 弹菜单 → 点击重命名」流程。 // 验证 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(); }); });