// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import '@testing-library/jest-dom/vitest'; import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; vi.mock('../../src/renderer/inbox/api.js', () => ({ notebookApi: { list: vi.fn(async () => []) }, inboxApi: { listNotes: vi.fn(async () => []), listByStatus: vi.fn(async () => []), countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, trashed: 0 })), getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), getPendingCount: vi.fn(async () => 0), getOllamaStatus: vi.fn(async () => ({ ok: true })), getTodayCount: vi.fn(async () => 0), getTrashCount: vi.fn(async () => 0), listExpired: vi.fn(async () => []), getFailedCount: vi.fn(async () => 0), listRecallCandidate: vi.fn(async () => null), onNoteUpdated: vi.fn(() => () => undefined), onOllamaStatus: vi.fn(() => () => undefined), onNavigate: vi.fn(() => () => undefined), // 4 섹션 mounted 시 호출되는 stub loadOllamaSettings: vi.fn(async () => ({ endpoint: '', model: '' })), saveOllamaSettings: vi.fn(async () => ({ ok: true })), ollamaRecheck: vi.fn(async () => ({ ok: true })), getAutostart: vi.fn(async () => ({ openAtLogin: false, diagnostic: { withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, execPath: '/p' } })), setAutostart: vi.fn(async () => ({ openAtLogin: false, diagnostic: { withArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, noArgs: { openAtLogin: false, executableWillLaunchAtLogin: false }, execPath: '/p' } })), runBackup: vi.fn(async () => ({ ok: true })), runExport: vi.fn(async () => ({ ok: true })), runImport: vi.fn(async () => ({ ok: true })), runSync: vi.fn(async () => ({ ok: true })), runExportTelemetry: vi.fn(async () => ({ ok: true })), getAppInfo: vi.fn(async () => ({ version: '0.2.7', electron: '?', node: '?', os: '?', profileDir: '?' })), openProfileDir: vi.fn(async () => undefined), copyAppInfo: vi.fn(async () => undefined), // v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시. getSettings: vi.fn(async () => ({ onboarding_completed: true })), setAiEnabled: vi.fn(async () => ({ ok: true as const })), setSidebarVisible: vi.fn(async () => ({ ok: true as const })), setSidebarWidth: vi.fn(async () => ({ ok: true as const })), setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })), // v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출. getDisabledCount: vi.fn(async () => 0), enqueueDisabled: vi.fn(async () => ({ count: 0 })), // v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출. getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), configureSync: vi.fn(async () => ({ ok: true as const })), testSyncConnection: vi.fn(async () => ({ ok: true as const })), // v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출. getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })), setVisionModel: vi.fn(async () => ({ ok: true as const })), refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] })), // v0.4 Task 15 — loadPromotionCandidates 초기화 stub. listPromotionCandidates: vi.fn(async () => []), getPromotionDismissedTags: vi.fn(async () => []), getPromotionSnoozeUntil: vi.fn(async () => 0) } })); import { App } from '../../src/renderer/inbox/App'; import { useInbox } from '../../src/renderer/inbox/store'; import { inboxApi } from '../../src/renderer/inbox/api.js'; describe('App — settings view', () => { beforeEach(() => { cleanup(); useInbox.setState({ view: 'inbox', counts: { active: 0, completed: 0, trashed: 0 }, showSettings: false, showTrash: false, notes: [], trashNotes: [], trashCount: 0, sidebarVisible: false, notebooks: [], promotionCandidates: [] }); }); it('renders SettingsPage when showSettings=true', async () => { useInbox.setState({ showSettings: true }); render(); expect(await screen.findByText('설정')).toBeInTheDocument(); expect(screen.getByText('AI 제공자')).toBeInTheDocument(); }); it('header gear icon click sets showSettings=true', async () => { render(); fireEvent.click(await screen.findByLabelText('설정 열기')); expect(useInbox.getState().showSettings).toBe(true); }); it('inbox:navigate "settings" event sets showSettings=true', async () => { const navHandlers: Array<(view: 'inbox' | 'trash' | 'settings') => void> = []; vi.mocked(inboxApi.onNavigate).mockImplementation((cb) => { navHandlers.push(cb); return () => { const i = navHandlers.indexOf(cb); if (i >= 0) navHandlers.splice(i, 1); }; }); render(); await waitFor(() => expect(navHandlers.length).toBeGreaterThan(0)); navHandlers.forEach((h) => h('settings')); await waitFor(() => expect(useInbox.getState().showSettings).toBe(true)); }); }); describe('App header — 3 tabs (v0.4)', () => { beforeEach(() => { cleanup(); useInbox.setState({ view: 'inbox', counts: { active: 5, completed: 3, trashed: 1 }, notes: [], trashNotes: [], trashCount: 0, showTrash: false, showSettings: false, sidebarVisible: false, notebooks: [], promotionCandidates: [] }); // loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입 // 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신. // v0.4 Task 16 — countsByStatus 응답에서 archived 제거 (NoteStatus 에서 삭제됨). vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, trashed: 1 }); }); it('renders 3 tabs (Inbox/완료/휴지통) with counts', async () => { render(); expect(await screen.findByRole('tab', { name: /Inbox\(5\)/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /완료\(3\)/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /휴지통\(1\)/ })).toBeInTheDocument(); }); it('보관 탭이 헤더에 없음', async () => { render(); await screen.findByRole('tab', { name: /Inbox/ }); expect(screen.queryByRole('tab', { name: /보관/ })).toBeNull(); }); it('clicking 완료 tab sets view=completed', async () => { render(); fireEvent.click(await screen.findByRole('tab', { name: /완료/ })); expect(useInbox.getState().view).toBe('completed'); }); it('inbox tab has aria-selected="true" when active', async () => { render(); const inboxTab = await screen.findByRole('tab', { name: /Inbox/ }); expect(inboxTab).toHaveAttribute('aria-selected', 'true'); }); it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => { // loadInitial 의 getSettings hydrate 후 state 가 정해진 시점 기준으로 토글 검증. vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true, sidebar_visible: false }); render(); await screen.findByRole('tab', { name: /Inbox/ }); const initialVisible = useInbox.getState().sidebarVisible; // jsdom 에서 navigator.platform = '' → isMac=false → ctrlKey 로 판단. window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true })); // toggleSidebar 가 호출되면 sidebarVisible 이 반전됨. expect(useInbox.getState().sidebarVisible).toBe(!initialVisible); }); it('Sidebar 컴포넌트가 렌더 트리에 포함됨 (sidebarVisible=true)', async () => { // loadInitial 의 getSettings 가 sidebar_visible=true 반환 (Strict Mode 중복 호출 대비 mockResolvedValue). vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true, sidebar_visible: true }); render(); await screen.findByRole('tab', { name: /Inbox/ }); // loadInitial 비동기 hydrate 가 완료될 때까지 기다림 await waitFor(() => expect(document.querySelector('aside')).not.toBeNull()); }); }); describe('App — onboarding wizard', () => { beforeEach(() => { cleanup(); useInbox.setState({ view: 'inbox', counts: { active: 0, completed: 0, trashed: 0 }, showSettings: false, showTrash: false, notes: [], trashNotes: [], trashCount: 0, sidebarVisible: false, notebooks: [], promotionCandidates: [] }); // 각 테스트가 getSettings 의 default mock 을 직접 override. vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true }); }); it('renders OnboardingWizard when onboarding_completed=false', async () => { vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: false }); render(); await screen.findByText(/Inkling 사용 시작/); expect(screen.getByRole('dialog', { name: /시작 안내/ })).toBeInTheDocument(); }); it('does not render OnboardingWizard when onboarding_completed=true', async () => { vi.mocked(inboxApi.getSettings).mockResolvedValue({ onboarding_completed: true }); render(); await screen.findByRole('tab', { name: /Inbox/ }); expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull(); }); });