From c65d6c810ef2e2b51439d88d12b731d0b7ca8057 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 16:18:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(v029):=20settings:*=20IPC=20(ai-enabled/on?= =?UTF-8?q?boarding-completed/get)=20+=20App.tsx=20=EC=B2=AB=20launch=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 2 +- src/main/ipc/settingsApi.ts | 18 +++++++++- src/main/services/SettingsService.ts | 8 +++++ src/preload/index.ts | 4 +++ src/renderer/inbox/App.tsx | 18 ++++++++++ src/shared/types.ts | 8 +++++ tests/unit/App.test.tsx | 49 ++++++++++++++++++++++++---- 7 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 85284a2..d586a12 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -202,7 +202,7 @@ app.whenReady().then(async () => { // v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry). // backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록. registerSettingsApi({ - backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow + backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow }); let backupOnQuitDone = false; diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index c783f17..a1c86ad 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -8,6 +8,7 @@ import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; import type { TelemetryService } from '../services/TelemetryService.js'; +import type { SettingsService } from '../services/SettingsService.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; @@ -33,6 +34,7 @@ export interface SettingsIpcDeps { importSvc: ImportService; syncSvc: SyncService; telemetry: TelemetryService; + settings: SettingsService; getInboxWindow: () => BrowserWindow | null; } @@ -88,7 +90,21 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { }); if (!deps) return; - const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps; + const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps; + + // v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글. + // 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합. + ipcMain.handle('settings:get', async () => settings.getAll()); + + ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => { + await settings.setAiEnabled(enabled); + return { ok: true as const }; + }); + + ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => { + await settings.setOnboardingCompleted(completed); + return { ok: true as const }; + }); ipcMain.handle('settings:run-backup', async () => { try { diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index a637a06..88a320e 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -39,6 +39,14 @@ export class SettingsService { return this.cache; } + /** + * v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor. + * 첫 launch onboarding 분기에서 onboarding_completed 키 확인. + */ + async getAll(): Promise { + return this.load(); + } + async setOllama(value: OllamaSettings): Promise { const validated = OllamaSettingsSchema.parse(value); const current = await this.load(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 38c17b7..74355be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -74,6 +74,10 @@ const api: InklingApi = { // v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천. setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason), classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason), + // v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함). + getSettings: () => ipcRenderer.invoke('settings:get'), + setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled), + setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed), } }; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 8aa6e98..071b9e1 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -13,6 +13,7 @@ import { ExpiryBanner } from './components/ExpiryBanner.js'; import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; import { SettingsPage } from './components/SettingsPage.js'; +import { OnboardingWizard } from './components/OnboardingWizard.js'; export function App(): React.ReactElement { const { @@ -28,6 +29,20 @@ export function App(): React.ReactElement { const counts = useInbox((s) => s.counts); const setView = useInbox((s) => s.setView); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); + // v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시. + const [showOnboarding, setShowOnboarding] = useState(null); + + useEffect(() => { + void (async () => { + try { + const settings = await inboxApi.getSettings(); + setShowOnboarding(!settings.onboarding_completed); + } catch { + // 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향). + setShowOnboarding(false); + } + })(); + }, []); useEffect(() => { void loadInitial(); @@ -49,6 +64,9 @@ export function App(): React.ReactElement { // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); + if (showOnboarding === null) return <>; + if (showOnboarding) return setShowOnboarding(false)} />; + if (showSettings) return ; const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; diff --git a/src/shared/types.ts b/src/shared/types.ts index d8a2b63..30194c0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -145,6 +145,14 @@ export interface InboxApi { reason: string | null ): Promise<{ ok: true } | { ok: false; reason: string }>; classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>; + // v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글. + getSettings(): Promise<{ + ollama?: { endpoint: string; model: string }; + ai_enabled?: boolean; + onboarding_completed?: boolean; + }>; + setAiEnabled(enabled: boolean): Promise<{ ok: true }>; + setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>; } export interface InklingApi { diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index f289ebe..bea3e29 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -49,7 +49,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ 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) + 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 })), + setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })) } })); @@ -106,28 +110,59 @@ describe('App header — 4 tabs', () => { notes: [], trashNotes: [], trashCount: 0, showTrash: false, showSettings: false }); + // loadInitial 이 비동기로 counts 를 덮어씀 — onboarding wizard async gate (Task 12) 도입 + // 후 render 가 await 후 발생하므로 mock 의 countsByStatus 가 테스트 기대값을 반환하도록 갱신. + vi.mocked(inboxApi.countsByStatus).mockResolvedValue({ active: 5, completed: 3, archived: 2, trashed: 1 }); }); - it('renders 4 tabs with counts', () => { + it('renders 4 tabs with counts', async () => { render(); - expect(screen.getByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Inbox\(5\)/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /완료\(3\)/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /보관\(2\)/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /휴지통\(1\)/ })).toBeInTheDocument(); }); - it('clicking 완료 tab sets view=completed', () => { + it('clicking 완료 tab sets view=completed', async () => { render(); - fireEvent.click(screen.getByRole('button', { name: /완료/ })); + fireEvent.click(await screen.findByRole('button', { name: /완료/ })); expect(useInbox.getState().view).toBe('completed'); }); - it('aria-pressed reflects current view', () => { + it('aria-pressed reflects current view', async () => { useInbox.setState({ view: 'archived' }); render(); - const archivedBtn = screen.getByRole('button', { name: /보관/ }); + const archivedBtn = await screen.findByRole('button', { name: /보관/ }); expect(archivedBtn.getAttribute('aria-pressed')).toBe('true'); const inboxBtn = screen.getByRole('button', { name: /Inbox/ }); expect(inboxBtn.getAttribute('aria-pressed')).toBe('false'); }); }); + +describe('App — onboarding wizard', () => { + beforeEach(() => { + cleanup(); + useInbox.setState({ + view: 'inbox', + counts: { active: 0, completed: 0, archived: 0, trashed: 0 }, + showSettings: false, showTrash: false, + notes: [], trashNotes: [], trashCount: 0 + }); + // 각 테스트가 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('button', { name: /Inbox/ }); + expect(screen.queryByText(/Inkling 사용 시작/)).toBeNull(); + }); +});