diff --git a/src/renderer/inbox/components/PromotionBanner.tsx b/src/renderer/inbox/components/PromotionBanner.tsx new file mode 100644 index 0000000..7f9c15e --- /dev/null +++ b/src/renderer/inbox/components/PromotionBanner.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { useInbox } from '../store.js'; +import { Banner } from './Banner.js'; + +const COLOR_PALETTE = ['#0a4b80', '#236b1a', '#946100', '#a55', '#5a3a8c']; + +export function PromotionBanner(): React.ReactElement | null { + const candidates = useInbox((s) => s.promotionCandidates); + const accept = useInbox((s) => s.acceptPromotion); + const snooze = useInbox((s) => s.snoozePromotion); + const dismiss = useInbox((s) => s.dismissPromotion); + const [editing, setEditing] = useState<{ tag: string; name: string; color: string } | null>(null); + + if (candidates.length === 0) return null; + const c = candidates[0]!; + + return ( + + {editing === null ? ( +
+ 💡 {c.tag} 관련 노트 {c.noteIds.length}개가 모였어요. 새 노트북 {c.suggestedName} 로 분리할까요? +
+ + + +
+
+ ) : ( +
+ setEditing({ ...editing, name: e.target.value })} + style={{ flex: 1, fontSize: 13, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4, minWidth: 120 }} + autoFocus + /> +
+ {COLOR_PALETTE.map((col) => ( +
+ + +
+ )} +
+ ); +} diff --git a/tests/unit/PromotionBanner.test.tsx b/tests/unit/PromotionBanner.test.tsx new file mode 100644 index 0000000..f5ba6bc --- /dev/null +++ b/tests/unit/PromotionBanner.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +const accept = vi.fn(async () => {}); +const snooze = vi.fn(async () => {}); +const dismiss = vi.fn(async () => {}); + +let mockState: { + promotionCandidates: Array<{ tag: string; noteIds: string[]; suggestedName: string }>; + acceptPromotion: typeof accept; + snoozePromotion: typeof snooze; + dismissPromotion: typeof dismiss; +} = { + promotionCandidates: [], + acceptPromotion: accept, + snoozePromotion: snooze, + dismissPromotion: dismiss +}; + +vi.mock('../../src/renderer/inbox/store.js', () => ({ + useInbox: (selector?: (s: typeof mockState) => unknown) => selector ? selector(mockState) : mockState +})); + +import { PromotionBanner } from '../../src/renderer/inbox/components/PromotionBanner'; + +describe('PromotionBanner', () => { + beforeEach(() => { + cleanup(); + accept.mockClear(); snooze.mockClear(); dismiss.mockClear(); + mockState = { + promotionCandidates: [], + acceptPromotion: accept, + snoozePromotion: snooze, + dismissPromotion: dismiss + }; + }); + + it('candidates 비어있으면 null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('첫 candidate 의 tag/suggestedName 표시', () => { + mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }]; + render(); + expect(screen.getByText('mlx-ops')).toBeInTheDocument(); + expect(screen.getByText('Mlx Ops')).toBeInTheDocument(); + }); + + it('수락 클릭 → 편집 모드 → 만들기 → acceptPromotion 호출', () => { + mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }]; + render(); + fireEvent.click(screen.getByRole('button', { name: '수락' })); + fireEvent.click(screen.getByRole('button', { name: '만들기' })); + expect(accept).toHaveBeenCalledWith('mlx-ops', 'Mlx Ops', expect.any(String)); + }); + + it('나중에 클릭 → snoozePromotion 호출', () => { + mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }]; + render(); + fireEvent.click(screen.getByRole('button', { name: '나중에' })); + expect(snooze).toHaveBeenCalled(); + }); + + it('숨기기 클릭 → dismissPromotion(tag) 호출', () => { + mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }]; + render(); + fireEvent.click(screen.getByRole('button', { name: '숨기기' })); + expect(dismiss).toHaveBeenCalledWith('mlx-ops'); + }); + + it('편집 모드: 이름 빈 시 만들기 disabled', () => { + mockState.promotionCandidates = [{ tag: 'mlx-ops', noteIds: ['n1', 'n2', 'n3'], suggestedName: 'Mlx Ops' }]; + render(); + fireEvent.click(screen.getByRole('button', { name: '수락' })); + fireEvent.change(screen.getByLabelText('노트북 이름'), { target: { value: '' } }); + expect(screen.getByRole('button', { name: '만들기' })).toBeDisabled(); + }); +});