- 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>
145 lines
6.4 KiB
TypeScript
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);
|
|
});
|
|
});
|