refactor(v032): recall IPC handle→on + fix sibling test mocks (#36)

- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
  (fire-and-forget honest pattern, return value 의존자 0)
- preload: ipcRenderer.invoke → send (matching on the main side)
- shared/types: Promise<void> → void on both recall emit methods
- store.ts: drop await on emitRecallSnoozed (now void)
- inboxApi-*.test.ts: add ipcMain.on to electron mock (broken by above)
- tests/unit/recall-ipc.test.ts: new TDD test for handle→on migration

Note: #20 CaptureService telemetry .catch debug log skipped —
CaptureService has no logger field; adding one would require non-trivial
constructor signature change. Reported as CONCERN below.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-10 14:23:19 +09:00
parent aa7eb9d99f
commit 4db7a0bce0
9 changed files with 78 additions and 11 deletions

View File

@@ -153,8 +153,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
ipcMain.handle('inbox:loadOllamaSettings', async () => {
const s = await deps.settings.load();

View File

@@ -43,8 +43,8 @@ const api: InklingApi = {
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
loadOllamaSettings: () => ipcRenderer.invoke('inbox:loadOllamaSettings'),
saveOllamaSettings: (v: { endpoint: string; model: string }) => ipcRenderer.invoke('inbox:saveOllamaSettings', v),
// v0.2.7 Task 13 — 외부 (트레이) 에서 view 전환 요청 listener.

View File

@@ -280,7 +280,7 @@ export const useInbox = create<InboxState>((set, get) => ({
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);
inboxApi.emitRecallSnoozed(candidate.id);
}
},
// v0.2.11 Cut D — FTS5 search + review aggregate actions.

View File

@@ -152,8 +152,8 @@ export interface InboxApi {
listRecallCandidate(): Promise<Note | null>;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
emitRecallShown(id: string): void;
emitRecallSnoozed(id: string): void;
loadOllamaSettings(): Promise<{ endpoint: string; model: string } | null>;
saveOllamaSettings(v: { endpoint: string; model: string }): Promise<{ ok: true } | { ok: false; reason: string }>;
// v0.2.7 Task 13 — 외부 (트레이 등) 에서 view 전환 요청 구독.

View File

@@ -11,7 +11,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: { openPath: mockOpenPath }

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({
default: {
ipcMain: { handle: vi.fn() }
ipcMain: { handle: vi.fn(), on: vi.fn() }
}
}));

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() } } }));
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn(), on: vi.fn() } } }));
import electron from 'electron';
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';

View File

@@ -12,7 +12,8 @@ vi.mock('electron', () => ({
ipcMain: {
handle: (ch: string, fn: (...args: unknown[]) => unknown) => {
handlers[ch] = fn;
}
},
on: (_ch: string, _fn: unknown) => {}
},
dialog: {},
shell: {}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Track ipcMain.handle and ipcMain.on calls
const { handleCalls, onCalls } = vi.hoisted(() => ({
handleCalls: [] as string[],
onCalls: [] as string[]
}));
vi.mock('electron', () => ({
default: {
ipcMain: {
handle: (ch: string, _fn: unknown) => { handleCalls.push(ch); },
on: (ch: string, _fn: unknown) => { onCalls.push(ch); }
},
dialog: {},
shell: {}
}
}));
import { registerInboxApi } from '../../src/main/ipc/inboxApi.js';
import type { InboxIpcDeps } from '../../src/main/ipc/inboxApi.js';
function makeDeps(): InboxIpcDeps {
return {
repo: {} as never,
continuity: {} as never,
capture: {
emitRecallShown: vi.fn(async () => {}),
emitRecallSnoozed: vi.fn(async () => {})
} as never,
health: {} as never,
intent: {} as never,
getInboxWindow: () => null,
settings: {} as never,
providerHolder: {} as never,
paths: { profileDir: '/profile' }
};
}
describe('recall IPC channels (fire-and-forget)', () => {
beforeEach(() => {
handleCalls.length = 0;
onCalls.length = 0;
});
it('inbox:emitRecallShown is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallShown');
expect(handleCalls).not.toContain('inbox:emitRecallShown');
});
it('inbox:emitRecallSnoozed is registered with ipcMain.on (not handle)', () => {
registerInboxApi(makeDeps());
expect(onCalls).toContain('inbox:emitRecallSnoozed');
expect(handleCalls).not.toContain('inbox:emitRecallSnoozed');
});
it('each recall channel is registered exactly once with ipcMain.on', () => {
registerInboxApi(makeDeps());
const shownCount = onCalls.filter((ch) => ch === 'inbox:emitRecallShown').length;
const snoozedCount = onCalls.filter((ch) => ch === 'inbox:emitRecallSnoozed').length;
expect(shownCount).toBe(1);
expect(snoozedCount).toBe(1);
});
});