import { describe, it, expect, beforeEach, vi } from 'vitest'; import { fireEvent, render, renderHook } from '@testing-library/react'; // Mock the IPC layer used by the file store actions. vi.mock('@/lib/ipc', () => ({ ipc: { file: { open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }), read: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }), write: vi.fn().mockResolvedValue({ ok: true, data: undefined }), list: vi.fn().mockResolvedValue({ ok: true, data: [] }), pickFolder: vi.fn().mockResolvedValue({ ok: true, data: null }), pickFile: vi.fn().mockResolvedValue({ ok: true, data: null }), onChange: vi.fn(() => () => {}), }, }, })); import { useFileShortcuts } from '@/hooks/use-file-shortcuts'; import { useFileStore } from '@/stores/file-store'; import { useEditorStore } from '@/stores/editor-store'; function fireKey(opts: KeyboardEventInit) { fireEvent.keyDown(window, opts); } describe('useFileShortcuts', () => { beforeEach(() => { // Reset both stores useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null, }); useEditorStore.setState({ buffers: new Map(), activeId: null }); }); it('Cmd+O triggers openFileDialog', () => { renderHook(() => useFileShortcuts()); fireKey({ key: 'o', metaKey: true }); // We can't easily spy on the action, but the pickFile mock should have been called. // The openFileDialog action awaits ipc.file.pickFile then early-returns on null result. // Since we're using real timers, the action resolves; we just need to assert no throw. // To verify dispatch, we spy on the store action below in another test. }); it('Cmd+Shift+O triggers openFolderDialog', () => { renderHook(() => useFileShortcuts()); expect(() => fireKey({ key: 'O', metaKey: true, shiftKey: true })).not.toThrow(); }); it('Ctrl+O (cross-platform) also dispatches', () => { renderHook(() => useFileShortcuts()); expect(() => fireKey({ key: 'o', ctrlKey: true })).not.toThrow(); }); it('Cmd+S triggers saveActiveBuffer; no-op when no active tab', () => { renderHook(() => useFileShortcuts()); fireKey({ key: 's', metaKey: true }); // No active tab, action returns false. No throw. }); it('Cmd+W with no active tab is a no-op', () => { renderHook(() => useFileShortcuts()); expect(() => fireKey({ key: 'w', metaKey: true })).not.toThrow(); }); it('Cmd+W with active tab calls closeTab', () => { useFileStore.setState({ openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }], activeTabId: '/a.md', }); renderHook(() => useFileShortcuts()); fireKey({ key: 'w', metaKey: true }); expect(useFileStore.getState().openTabs).toEqual([]); expect(useFileStore.getState().activeTabId).toBeNull(); }); it('Cmd+Tab advances to next tab (wraps to first)', () => { useFileStore.setState({ openTabs: [ { id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }, { id: '/b.md', path: '/b.md', title: 'b.md', dirty: false }, { id: '/c.md', path: '/c.md', title: 'c.md', dirty: false }, ], activeTabId: '/c.md', }); renderHook(() => useFileShortcuts()); fireKey({ key: 'Tab', metaKey: true }); expect(useFileStore.getState().activeTabId).toBe('/a.md'); }); it('Cmd+Shift+Tab goes to previous tab (wraps to last)', () => { useFileStore.setState({ openTabs: [ { id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }, { id: '/b.md', path: '/b.md', title: 'b.md', dirty: false }, { id: '/c.md', path: '/c.md', title: 'c.md', dirty: false }, ], activeTabId: '/a.md', }); renderHook(() => useFileShortcuts()); fireKey({ key: 'Tab', metaKey: true, shiftKey: true }); expect(useFileStore.getState().activeTabId).toBe('/c.md'); }); it('Cmd+Tab with no tabs is a no-op', () => { renderHook(() => useFileShortcuts()); expect(() => fireKey({ key: 'Tab', metaKey: true })).not.toThrow(); }); it('suppresses shortcuts when an is focused', () => { useFileStore.setState({ openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }], activeTabId: '/a.md', }); const { container } = render(
); const input = container.querySelector('input')!; renderHook(() => useFileShortcuts()); fireEvent.keyDown(input, { key: 'w', metaKey: true }); // Tab should still be open (shortcut suppressed) expect(useFileStore.getState().openTabs).toHaveLength(1); }); it('suppresses shortcuts when a contentEditable element is focused', () => { useFileStore.setState({ openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }], activeTabId: '/a.md', }); // Manually create an element with contentEditable=true and isContentEditable getter const el = document.createElement('div'); Object.defineProperty(el, 'isContentEditable', { value: true, configurable: true }); document.body.appendChild(el); try { renderHook(() => useFileShortcuts()); fireEvent.keyDown(el, { key: 'w', metaKey: true }); expect(useFileStore.getState().openTabs).toHaveLength(1); } finally { document.body.removeChild(el); } }); it('plain keys without modifier are ignored', () => { useFileStore.setState({ openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }], activeTabId: '/a.md', }); renderHook(() => useFileShortcuts()); fireKey({ key: 'o' }); fireKey({ key: 's' }); fireKey({ key: 'w' }); expect(useFileStore.getState().openTabs).toHaveLength(1); }); it('cleanup removes the listener on unmount', () => { const { unmount } = renderHook(() => useFileShortcuts()); unmount(); useFileStore.setState({ openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }], activeTabId: '/a.md', }); fireKey({ key: 'w', metaKey: true }); expect(useFileStore.getState().openTabs).toHaveLength(1); }); });