feat(v029): inbox:set-status + ai:classify-status (stub) IPC

Cut B Task 8 — Modal/NoteCard 메뉴 path 의 IPC backbone.

- inbox:set-status: id + status + reason → repo.setStatus, invalid status 거부
- ai:classify-status: stub (Task 9 에서 Ollama provider 호출로 정식 구현)
- types.ts InboxApi.setStatus / classifyStatus 시그니처 + preload wire-up
- 4 단위 테스트 (valid/null reason/invalid status/stub shape)
This commit is contained in:
altair823
2026-05-09 15:59:43 +09:00
parent 92375edc31
commit d4dce9bf34
4 changed files with 130 additions and 0 deletions

View File

@@ -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 });

View File

@@ -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),
}
};

View File

@@ -138,6 +138,13 @@ export interface InboxApi {
// v0.2.9 Cut B Task 4 — status 별 노트 목록 + status 별 count.
listByStatus(status: NoteStatus, opts?: { limit?: number }): Promise<Note[]>;
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 {

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { handlers, mockSetStatus } = vi.hoisted(() => ({
handlers: {} as Record<string, (...args: unknown[]) => 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<typeof registerInboxApi>[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);
});
});