diff --git a/tests/integration/phase6-smoke.test.tsx b/tests/integration/phase6-smoke.test.tsx new file mode 100644 index 0000000..894ac7d --- /dev/null +++ b/tests/integration/phase6-smoke.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { AppShell } from '@/components/layout/AppShell'; +import { useFileStore } from '@/stores/file-store'; +import { useEditorStore } from '@/stores/editor-store'; +import { useAppStore } from '@/stores/app-store'; +import { useCommandStore } from '@/stores/command-store'; + +// Mock react-resizable-panels (jsdom can't measure DOM for it) +vi.mock('@/components/ui/resizable', () => ({ + ResizablePanelGroup: ({ children, direction }: any) => ( +
+ {children} +
+ ), + ResizablePanel: ({ children, defaultSize }: any) => ( +
+ {children} +
+ ), + ResizableHandle: () =>
, +})); + +const menuListeners = new Map void>(); +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }), + read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }), + write: vi.fn().mockResolvedValue({ ok: true, data: undefined }), + list: vi.fn().mockResolvedValue({ + ok: true, + data: [{ name: 'README.md', path: '/root/README.md', isDirectory: false }], + }), + pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }), + pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }), + onChange: vi.fn(() => () => {}), + }, + menu: { + on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => { + menuListeners.set(channel, cb); + return () => { + if (menuListeners.get(channel) === cb) menuListeners.delete(channel); + }; + }), + }, + }, +})); + +function fireMenu(channel: string, ...args: unknown[]): void { + const listener = menuListeners.get(channel); + if (!listener) throw new Error(`No menu listener for ${channel}`); + listener(...args); +} + +describe('Phase 6 integration: menu → command → store', () => { + beforeEach(() => { + localStorage.clear(); + menuListeners.clear(); + useFileStore.setState({ + tree: null, + rootPath: null, + expanded: new Set(), + openTabs: [], + activeTabId: null, + }); + useEditorStore.setState({ buffers: new Map(), activeId: null }); + useAppStore.setState({ + sidebarVisible: true, + previewVisible: true, + zenMode: false, + paneSizes: { sidebar: 20, editor: 50, preview: 30 }, + }); + useCommandStore.setState({ handlers: {}, userBindings: {} }); + }); + + it('toolbar Open Folder button opens the folder dialog and populates the tree', async () => { + render(); + const btn = screen.getByTestId('toolbar-open-folder'); + await act(async () => { + fireEvent.click(btn); + }); + expect(useFileStore.getState().tree).not.toBeNull(); + expect(useFileStore.getState().rootPath).toBe('/root'); + }); + + it('toolbar Save button invokes the registered file.save command (which writes)', async () => { + // Seed an open buffer + useFileStore.setState({ + openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }], + activeTabId: '/root/doc.md', + }); + useEditorStore.setState({ + buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# x', dirty: true }]]), + activeId: '/root/doc.md', + }); + + render(); + const saveBtn = screen.getByTestId('toolbar-save'); + await act(async () => { + fireEvent.click(saveBtn); + }); + // After save, the buffer should be clean + expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false); + }); + + it('header toggle sidebar button flips sidebarVisible', async () => { + render(); + const btn = screen.getByTestId('header-toggle-sidebar'); + expect(btn).toHaveAttribute('aria-pressed', 'true'); + await act(async () => { + fireEvent.click(btn); + }); + expect(useAppStore.getState().sidebarVisible).toBe(false); + expect(btn).toHaveAttribute('aria-pressed', 'false'); + }); + + it('native menu file-save event triggers save through the command store', async () => { + useFileStore.setState({ + openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }], + activeTabId: '/root/doc.md', + }); + useEditorStore.setState({ + buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# y', dirty: true }]]), + activeId: '/root/doc.md', + }); + + render(); + await act(async () => { + fireMenu('file-save'); + }); + expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false); + }); + + it('native menu toggle-preview event flips previewVisible', () => { + render(); + expect(useAppStore.getState().previewVisible).toBe(true); + act(() => { + fireMenu('toggle-preview'); + }); + expect(useAppStore.getState().previewVisible).toBe(false); + }); + + it('userBindings survive a remount via zustand persist', async () => { + useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s'); + await useCommandStore.persist?.flush?.(); + + // Read raw stored value + const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}'); + expect(stored.state.userBindings['file.save']).toBe('mod+shift+s'); + }); + + it('commands registered in AppShell are accessible by id from any consumer', () => { + render(); + const handlers = useCommandStore.getState().handlers; + expect(typeof handlers['file.open']).toBe('function'); + expect(typeof handlers['file.openFolder']).toBe('function'); + expect(typeof handlers['file.save']).toBe('function'); + expect(typeof handlers['file.closeTab']).toBe('function'); + expect(typeof handlers['tab.next']).toBe('function'); + expect(typeof handlers['tab.prev']).toBe('function'); + expect(typeof handlers['view.toggleSidebar']).toBe('function'); + expect(typeof handlers['view.togglePreview']).toBe('function'); + }); +});