Files
inkling/tests/unit/store.promotion.test.ts
th-kim0823 53a1579266 feat(promotion): store promotionCandidates + accept/snooze/dismiss + settings 영속화
- SettingsService: promotion_dismissed_tags / promotion_snoozed_until_ms / sidebar_visible / sidebar_width 스키마 + getter/setter 추가
- NotebookRepository: getDefault() (created_at ASC LIMIT 1) 헬퍼 추가
- inboxApi: notebookRepo 옵션 dep + 5개 IPC 핸들러 (list-promotion-candidates / get-dismissed-tags / add-dismissed-tag / get-snoozed-until / set-snoozed-until)
- shared/types: PromotionCandidate 인터페이스 + InboxApi 5개 메서드 추가
- preload: 5개 ipcRenderer.invoke 패스스루
- store: promotionCandidates 상태 + loadPromotionCandidates / acceptPromotion / snoozePromotion / dismissPromotion 액션 + toTitleCase helper
- tests: store.promotion.test.ts 신설 (6개 케이스)

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

145 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// promotion IPC
listPromotionCandidates: vi.fn(async () => []),
getPromotionDismissedTags: vi.fn(async () => []),
addPromotionDismissedTag: vi.fn(async () => undefined),
getPromotionSnoozeUntil: vi.fn(async () => 0),
setPromotionSnoozeUntil: vi.fn(async () => undefined),
// refreshMeta deps
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),
countsByStatus: vi.fn(async () => ({ active: 0, completed: 0, archived: 0, trashed: 0 })),
getSettings: vi.fn(async () => ({ ai_enabled: true }))
},
notebookApi: {
list: vi.fn(async () => []),
create: vi.fn(async (i: { name: string; color?: string }) => ({
ok: true as const,
notebook: { id: 'nb-promo', name: i.name, color: i.color ?? null, createdAt: 't', updatedAt: 't', noteCount: 0 }
})),
moveNote: vi.fn(async () => ({ ok: true as const })),
rename: vi.fn(async () => ({ ok: true as const })),
setColor: vi.fn(async () => ({ ok: true as const })),
delete: vi.fn(async () => ({ ok: true as const }))
}
}));
import { useInbox } from '../../src/renderer/inbox/store.js';
import { inboxApi } from '../../src/renderer/inbox/api.js';
import { notebookApi } from '../../src/renderer/inbox/api.js';
type MockInboxApi = {
listPromotionCandidates: ReturnType<typeof vi.fn>;
getPromotionDismissedTags: ReturnType<typeof vi.fn>;
addPromotionDismissedTag: ReturnType<typeof vi.fn>;
getPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
setPromotionSnoozeUntil: ReturnType<typeof vi.fn>;
};
type MockNotebookApi = {
create: ReturnType<typeof vi.fn>;
moveNote: ReturnType<typeof vi.fn>;
};
const mockInbox = inboxApi as unknown as MockInboxApi;
const mockNotebook = notebookApi as unknown as MockNotebookApi;
describe('store promotion actions', () => {
beforeEach(() => {
vi.clearAllMocks();
useInbox.setState({ promotionCandidates: [], notebooks: [], sidebarVisible: false, selectedNotebookId: null } as never);
});
it('loadPromotionCandidates: 후보 목록 반환 + suggestedName toTitleCase 변환', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'machine-learning', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.suggestedName).toBe('Machine Learning');
expect(candidates[0]!.noteIds).toEqual(['n1', 'n2', 'n3']);
});
it('loadPromotionCandidates: snooze 유효 시 빈 배열', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce([]);
// 24h 후 만료되는 snooze
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(Date.now() + 24 * 60 * 60 * 1000);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
});
it('loadPromotionCandidates: dismissed tag 는 제외', async () => {
mockInbox.getPromotionDismissedTags.mockResolvedValueOnce(['work']);
mockInbox.getPromotionSnoozeUntil.mockResolvedValueOnce(0);
mockInbox.listPromotionCandidates.mockResolvedValueOnce([
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: '' },
{ tag: 'study', noteIds: ['n4', 'n5', 'n6'], suggestedName: '' }
]);
await useInbox.getState().loadPromotionCandidates();
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.tag).toBe('study');
});
it('dismissPromotion: addPromotionDismissedTag 호출 + state 에서 그 tag 제거', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' },
{ tag: 'study', noteIds: ['n4', 'n5'], suggestedName: 'Study' }
]
} as never);
await useInbox.getState().dismissPromotion('work');
expect(mockInbox.addPromotionDismissedTag).toHaveBeenCalledWith('work');
const candidates = useInbox.getState().promotionCandidates;
expect(candidates).toHaveLength(1);
expect(candidates[0]!.tag).toBe('study');
});
it('snoozePromotion: setPromotionSnoozeUntil 24h 후로 호출 + state 비우기', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Work' }
]
} as never);
const before = Date.now();
await useInbox.getState().snoozePromotion();
const [[ms]] = mockInbox.setPromotionSnoozeUntil.mock.calls as [[number]];
expect(ms).toBeGreaterThan(before + 23 * 60 * 60 * 1000);
expect(ms).toBeLessThan(before + 25 * 60 * 60 * 1000);
expect(useInbox.getState().promotionCandidates).toHaveLength(0);
});
it('acceptPromotion: notebook 생성 + moveNote 호출 + sidebar 열림 + selectedNotebookId 설정', async () => {
useInbox.setState({
promotionCandidates: [
{ tag: 'work', noteIds: ['n1', 'n2'], suggestedName: 'Work' }
],
notebooks: []
} as never);
await useInbox.getState().acceptPromotion('work', 'Work', '#0a4b80');
expect(mockNotebook.create).toHaveBeenCalledWith({ name: 'Work', color: '#0a4b80' });
expect(mockNotebook.moveNote).toHaveBeenCalledTimes(2);
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n1', 'nb-promo');
expect(mockNotebook.moveNote).toHaveBeenCalledWith('n2', 'nb-promo');
const state = useInbox.getState();
expect(state.sidebarVisible).toBe(true);
expect(state.selectedNotebookId).toBe('nb-promo');
expect(state.promotionCandidates.find((c) => c.tag === 'work')).toBeUndefined();
expect(state.notebooks.some((n) => n.id === 'nb-promo')).toBe(true);
});
});