Files
inkling/tests/unit/App.test.tsx
th-kim0823 da6d296b77 fix(settings): sidebar_visible/width 영속화 — IPC + store hydration 추가
final code review 의 Important issue 대응. SettingsService 의 setSidebarVisible/
setSidebarWidth getter/setter 는 이미 있었지만 IPC handler + store hydration
missing 으로 매 launch 시 사이드바 닫힌 상태로 시작하던 회귀.

- settings:set-sidebar-visible / set-sidebar-width IPC 핸들러 추가
- InboxApi.getSettings 응답에 sidebar_visible/sidebar_width 포함
- preload 의 setSidebarVisible/setSidebarWidth invoke 노출
- store.loadInitial 가 settings.sidebar_visible/sidebar_width 로 hydrate
- store.toggleSidebar 가 IPC 호출하여 영속화
- test mock 에 setSidebarVisible/setSidebarWidth 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:23:54 +09:00

216 lines
9.5 KiB
TypeScript

// @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(<App />);
expect(await screen.findByText('설정')).toBeInTheDocument();
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
});
it('header gear icon click sets showSettings=true', async () => {
render(<App />);
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(<App />);
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(<App />);
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(<App />);
await screen.findByRole('tab', { name: /Inbox/ });
expect(screen.queryByRole('tab', { name: /보관/ })).toBeNull();
});
it('clicking 완료 tab sets view=completed', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('tab', { name: /완료/ }));
expect(useInbox.getState().view).toBe('completed');
});
it('inbox tab has aria-selected="true" when active', async () => {
render(<App />);
const inboxTab = await screen.findByRole('tab', { name: /Inbox/ });
expect(inboxTab).toHaveAttribute('aria-selected', 'true');
});
it('Cmd+B 키 이벤트가 toggleSidebar 호출', async () => {
const initialVisible = useInbox.getState().sidebarVisible;
render(<App />);
await screen.findByRole('tab', { name: /Inbox/ });
// 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(<App />);
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(<App />);
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(<App />);
await screen.findByRole('tab', { name: /Inbox/ });
expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull();
});
});