diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 51c8c67..f830c2d 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -190,6 +190,31 @@ export function registerInboxApi(deps: InboxIpcDeps): void { trashed: deps.repo.countByStatus('trashed') })); + // v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함). + // Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는 + // NoteRepository.setStatus 내부에서 처리 (deleted_at sync). + ipcMain.handle( + 'inbox:set-status', + async (_e, id: string, status: NoteStatus, reason: string | null) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) { + return { ok: false as const, reason: 'invalid status' as const }; + } + deps.repo.setStatus(id, status, reason); + return { ok: true as const }; + } + ); + + // v0.2.9 Cut B Task 8 — AI 자동 분류 stub. Task 9 에서 본격 구현 + // (Ollama provider 호출 + structured 출력). 본 task 는 Modal 의 + // 호출 path 만 working state 로 만들기 위한 안전 default. + ipcMain.handle('ai:classify-status', async (_e, _id: string, _reason: string) => { + return { + recommended: 'archived' as const, + rationale: '본 task 는 stub. Task 9 에서 정식 구현.' + }; + }); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/preload/index.ts b/src/preload/index.ts index e93fdea..38c17b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,9 @@ const api: InklingApi = { // v0.2.9 Cut B Task 4 — status 별 list + counts. listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'), + // 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), } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index cad1cba..d8a2b63 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -138,6 +138,13 @@ export interface InboxApi { // v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count. listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise; countsByStatus(): Promise<{ active: number; completed: number; archived: number; trashed: number }>; + // v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천. + setStatus( + id: string, + status: NoteStatus, + reason: string | null + ): Promise<{ ok: true } | { ok: false; reason: string }>; + classifyStatus(id: string, reason: string): Promise<{ recommended: NoteStatus; rationale: string }>; } export interface InklingApi { diff --git a/tests/unit/inboxApi-setStatus.test.ts b/tests/unit/inboxApi-setStatus.test.ts new file mode 100644 index 0000000..045652f --- /dev/null +++ b/tests/unit/inboxApi-setStatus.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { handlers, mockSetStatus } = vi.hoisted(() => ({ + handlers: {} as Record unknown>, + mockSetStatus: vi.fn() +})); + +vi.mock('electron', () => ({ + default: { + ipcMain: { + handle: (ch: string, fn: (...args: unknown[]) => unknown) => { + handlers[ch] = fn; + } + }, + dialog: {}, + shell: {} + } +})); + +import { registerInboxApi } from '../../src/main/ipc/inboxApi'; + +function makeDeps(): Parameters[0] { + // Minimal stub — `inbox:set-status` 핸들러는 deps.repo.setStatus 만 참조. + return { + repo: { + setStatus: mockSetStatus, + list: vi.fn(), + listByStatus: vi.fn(), + countByStatus: vi.fn(() => 0) + } as never, + continuity: {} as never, + capture: {} as never, + health: {} as never, + intent: {} as never, + getInboxWindow: () => null, + settings: {} as never, + providerHolder: {} as never, + paths: { profileDir: '/profile' } + }; +} + +describe('inbox:set-status IPC', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + mockSetStatus.mockReset(); + }); + + it('forwards valid status + reason to repo.setStatus', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, 'n1', 'completed', '결재 끝'); + expect(r).toEqual({ ok: true }); + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝'); + }); + + it('forwards null reason as-is', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = await handler(null, 'n1', 'archived', null); + expect(r).toEqual({ ok: true }); + expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null); + }); + + it('rejects invalid status without calling repo', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['inbox:set-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = (await handler(null, 'n1', 'invalid', null)) as { ok: boolean; reason?: string }; + expect(r.ok).toBe(false); + expect(r.reason).toBe('invalid status'); + expect(mockSetStatus).not.toHaveBeenCalled(); + }); +}); + +describe('ai:classify-status IPC (stub)', () => { + beforeEach(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + }); + + it('returns recommendation shape (stub default)', async () => { + registerInboxApi(makeDeps()); + const handler = handlers['ai:classify-status']; + if (handler === undefined) throw new Error('handler not registered'); + const r = (await handler(null, 'n1', '결재 끝')) as { + recommended: string; + rationale: string; + }; + expect(typeof r.recommended).toBe('string'); + expect(['active', 'completed', 'archived', 'trashed']).toContain(r.recommended); + expect(typeof r.rationale).toBe('string'); + expect(r.rationale.length).toBeGreaterThan(0); + }); +});