diff --git a/package.json b/package.json index b01b2b4b..b58f70bb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..34427df8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,15 +39,21 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@testing-library/user-event': specifier: ^14.5.2 - version: 14.5.2(@testing-library/dom@10.4.0) + version: 14.5.2(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.1.8 version: 19.1.8 @@ -95,7 +101,7 @@ importers: version: 5.2.0(eslint@9.30.0) eslint-plugin-storybook: specifier: ^9.0.14 - version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3) + version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.1)(prettier@3.3.3))(typescript@5.6.3) eslint-plugin-vitest: specifier: ^0.5.4 version: 0.5.4(@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3)(vitest@3.2.4) @@ -468,6 +474,10 @@ packages: resolution: {integrity: sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.33.0': + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -857,8 +867,8 @@ packages: '@swc/types@0.1.13': resolution: {integrity: sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==} - '@testing-library/dom@10.4.0': - resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} '@testing-library/jest-dom@6.6.3': @@ -3628,6 +3638,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': @@ -3949,15 +3961,15 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@testing-library/dom@10.4.0': + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.26.0 '@babel/runtime': 7.27.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 - chalk: 4.1.2 dom-accessibility-api: 0.5.16 lz-string: 1.5.0 + picocolors: 1.1.1 pretty-format: 27.5.1 '@testing-library/jest-dom@6.6.3': @@ -3970,23 +3982,23 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 - '@testing-library/dom': 10.4.0 + '@testing-library/dom': 10.4.1 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) - '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.1)': dependencies: - '@testing-library/dom': 10.4.0 + '@testing-library/dom': 10.4.1 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: - '@testing-library/dom': 10.4.0 + '@testing-library/dom': 10.4.1 '@types/aria-query@5.0.4': {} @@ -4994,11 +5006,11 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3): + eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.1)(prettier@3.3.3))(typescript@5.6.3): dependencies: '@typescript-eslint/utils': 8.35.0(eslint@9.30.0)(typescript@5.6.3) eslint: 9.30.0 - storybook: 9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3) + storybook: 9.0.14(@testing-library/dom@10.4.1)(prettier@3.3.3) transitivePeerDependencies: - supports-color - typescript @@ -6331,11 +6343,11 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3): + storybook@9.0.14(@testing-library/dom@10.4.1)(prettier@3.3.3): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.6.3 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 better-opn: 3.0.2 diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..9eed340a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,31 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import { Notifications, Delete, Edit } from '@mui/icons-material'; import { - Alert, - AlertTitle, Box, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, FormControl, - FormControlLabel, FormLabel, IconButton, - MenuItem, - Select, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, - Tooltip, Typography, } from '@mui/material'; -import { useSnackbar } from 'notistack'; -import { useState } from 'react'; +import React from 'react'; +import { EventForm } from './components/EventForm'; +import { EventManager } from './components/EventManager'; +import { EventOverlapDialog } from './components/EventOverlapDialog'; +import { NotificationStack } from './components/NotificationStack'; import { useCalendarView } from './hooks/useCalendarView.ts'; +import { useEventDisplay } from './hooks/useEventDisplay.tsx'; import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; +import { useEventOverlap } from './hooks/useEventOverlap'; +import { useEventValidation } from './hooks/useEventValidation'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; // import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; -import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; +import { Event, EventForm as EventFormData } from './types'; +import { weekDays } from './utils/constants'; +import { formatRepeatInfo, getNotificationLabel } from './utils/eventUtils'; function App() { const { @@ -102,23 +69,25 @@ function App() { const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); - const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); - const [overlappingEvents, setOverlappingEvents] = useState([]); - - const { enqueueSnackbar } = useSnackbar(); + // const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + // const [overlappingEvents, setOverlappingEvents] = useState([]); + const { isOverlapDialogOpen, overlappingEvents, checkAndHandleOverlap, closeOverlapDialog } = + useEventOverlap(); + const { validateEventForm } = useEventValidation(); + const { renderWeekView, renderMonthView } = useEventDisplay( + currentDate, + filteredEvents, + notifiedEvents, + holidays, + weekDays + ); const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { - enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); - return; - } - - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + if (!validateEventForm({ title, date, startTime, endTime, startTimeError, endTimeError })) { return; } - const eventData: Event | EventForm = { + const eventData: Event | EventFormData = { id: editingEvent ? editingEvent.id : undefined, title, date, @@ -135,386 +104,51 @@ function App() { notificationTime, }; - const overlapping = findOverlappingEvents(eventData, events); - if (overlapping.length > 0) { - setOverlappingEvents(overlapping); - setIsOverlapDialogOpen(true); - } else { - await saveEvent(eventData); - resetForm(); - } - }; - - const renderWeekView = () => { - const weekDates = getWeekDates(currentDate); - return ( - - {formatWeek(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - - {weekDates.map((date) => ( - - - {date.getDate()} - - {filteredEvents - .filter( - (event) => new Date(event.date).toDateString() === date.toDateString() - ) - .map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - ))} - - -
-
-
- ); - }; - - const renderMonthView = () => { - const weeks = getWeeksAtMonth(currentDate); - - return ( - - {formatMonth(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - {weeks.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const dateString = day ? formatDate(currentDate, day) : ''; - const holiday = holidays[dateString]; - - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); + await checkAndHandleOverlap(eventData, events, saveEvent, resetForm); }; return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - - - - 일정 보기 - - - navigate('prev')}> - - - - navigate('next')}> - - - - - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} - + { + handleStartTimeChange({ target: { value } } as React.ChangeEvent); + }} + onEndTimeChange={(value: string) => { + handleEndTimeChange({ target: { value } } as React.ChangeEvent); + }} + onDescriptionChange={setDescription} + onLocationChange={setLocation} + onCategoryChange={setCategory} + onIsRepeatingChange={setIsRepeating} + onNotificationTimeChange={setNotificationTime} + onSubmit={addOrUpdateEvent} + /> + + {event.location} 카테고리: {event.category} {event.repeat.type !== 'none' && ( - - 반복: {event.repeat.interval} - {event.repeat.type === 'daily' && '일'} - {event.repeat.type === 'weekly' && '주'} - {event.repeat.type === 'monthly' && '월'} - {event.repeat.type === 'yearly' && '년'} - 마다 - {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} - + 반복: {formatRepeatInfo(event.repeat)} )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - + 알림: {getNotificationLabel(event.notificationTime)} editEvent(event)}> @@ -590,69 +209,37 @@ function App() { - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - - - {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - - )} + { + closeOverlapDialog(); + saveEvent({ + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }); + }} + /> + + + setNotifications((prev) => prev.filter((_, i) => i !== index)) + } + /> ); } diff --git a/src/__tests__/components/advanced.EventForm.spec.tsx b/src/__tests__/components/advanced.EventForm.spec.tsx new file mode 100644 index 00000000..14c97da8 --- /dev/null +++ b/src/__tests__/components/advanced.EventForm.spec.tsx @@ -0,0 +1,107 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, describe, beforeEach, expect } from 'vitest'; + +import { EventForm } from '../../components/EventForm'; + +describe('EventForm', () => { + const defaultProps = { + title: '테스트 제목', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + isRepeating: false, + repeatType: 'none', + repeatInterval: 1, + repeatEndDate: null, + notificationTime: 10, + startTimeError: null, + endTimeError: null, + editingEvent: null, + onTitleChange: vi.fn(), + onDateChange: vi.fn(), + onStartTimeChange: vi.fn(), + onEndTimeChange: vi.fn(), + onDescriptionChange: vi.fn(), + onLocationChange: vi.fn(), + onCategoryChange: vi.fn(), + onIsRepeatingChange: vi.fn(), + onNotificationTimeChange: vi.fn(), + onSubmit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('컴포넌트가 렌더링된다', () => { + render(); + + expect(screen.getByDisplayValue('테스트 제목')).toBeInTheDocument(); + expect(screen.getByDisplayValue('2025-01-01')).toBeInTheDocument(); + expect(screen.getByDisplayValue('09:00')).toBeInTheDocument(); + }); + + test('제목 변경 시 onTitleChange가 호출된다', () => { + render(); + + const titleInput = screen.getByDisplayValue('테스트 제목'); + fireEvent.change(titleInput, { target: { value: '새로운 제목' } }); + + expect(defaultProps.onTitleChange).toHaveBeenCalledWith('새로운 제목'); + }); + + test('날짜 변경 시 onDateChange가 호출된다', () => { + render(); + + const dateInput = screen.getByDisplayValue('2025-01-01'); + fireEvent.change(dateInput, { target: { value: '2025-12-31' } }); + + expect(defaultProps.onDateChange).toHaveBeenCalledWith('2025-12-31'); + }); + + test('시간 변경 시 핸들러가 호출된다', () => { + render(); + + const startTimeInput = screen.getByDisplayValue('09:00'); + fireEvent.change(startTimeInput, { target: { value: '10:00' } }); + + expect(defaultProps.onStartTimeChange).toHaveBeenCalledWith('10:00'); + }); + + test('제출 버튼 클릭 시 onSubmit이 호출된다', () => { + render(); + + const submitButton = screen.getByRole('button'); + fireEvent.click(submitButton); + + expect(defaultProps.onSubmit).toHaveBeenCalled(); + }); + + test('일정 수정 모드에서 올바른 제목이 표시된다', () => { + const editingEvent = { + id: '1', + title: '기존 일정', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none' as const, interval: 0, endDate: undefined }, + notificationTime: 10, + }; + + render(); + + expect(screen.getByRole('heading', { name: '일정 수정' })).toBeInTheDocument(); + }); + + test('일정 추가 모드에서 올바른 제목이 표시된다', () => { + render(); + + expect(screen.getByRole('heading', { name: '일정 추가' })).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/advanced.EventManager.spec.tsx b/src/__tests__/components/advanced.EventManager.spec.tsx new file mode 100644 index 00000000..4a0cb0d3 --- /dev/null +++ b/src/__tests__/components/advanced.EventManager.spec.tsx @@ -0,0 +1,62 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, describe, expect } from 'vitest'; + +import { EventManager } from '../../components/EventManager'; + +describe('EventManager', () => { + const defaultProps = { + view: 'week' as const, + onViewChange: vi.fn(), + onNavigate: vi.fn(), + renderWeekView: () =>
주간 뷰
, + renderMonthView: () =>
월간 뷰
, + }; + + test('주간 뷰가 선택되면 주간 뷰가 렌더링된다', () => { + render(); + + expect(screen.getByText('주간 뷰')).toBeInTheDocument(); + expect(screen.queryByText('월간 뷰')).not.toBeInTheDocument(); + }); + + test('월간 뷰가 선택되면 월간 뷰가 렌더링된다', () => { + render(); + + expect(screen.getByText('월간 뷰')).toBeInTheDocument(); + expect(screen.queryByText('주간 뷰')).not.toBeInTheDocument(); + }); + + test('이전 버튼 클릭 시 onNavigate가 prev와 함께 호출된다', () => { + const mockNavigate = vi.fn(); + + render(); + + const prevButton = screen.getByRole('button', { name: 'Previous' }); + fireEvent.click(prevButton); + + expect(mockNavigate).toHaveBeenCalledWith('prev'); + }); + + test('다음 버튼 클릭 시 onNavigate가 next와 함께 호출된다', () => { + const mockNavigate = vi.fn(); + + render(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + fireEvent.click(nextButton); + + expect(mockNavigate).toHaveBeenCalledWith('next'); + }); + + test('뷰 타입 변경 시 onViewChange가 호출된다', () => { + const mockViewChange = vi.fn(); + + render(); + + const viewSelect = screen.getByRole('combobox'); + fireEvent.click(viewSelect); + + // Material-UI Select는 복잡하므로 간단히 클릭만 테스트 + expect(viewSelect).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/advanced.EventOverlapDialog.spec.tsx b/src/__tests__/components/advanced.EventOverlapDialog.spec.tsx new file mode 100644 index 00000000..44ec8cc1 --- /dev/null +++ b/src/__tests__/components/advanced.EventOverlapDialog.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, describe, expect } from 'vitest'; + +import { EventOverlapDialog } from '../../components/EventOverlapDialog'; + +describe('EventOverlapDialog', () => { + const overlappingEvents = [ + { + id: '1', + title: '기존 회의', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + + test('다이얼로그가 닫혀있으면 렌더링되지 않는다', () => { + render( + + ); + + expect(screen.queryByText('일정 겹침 경고')).not.toBeInTheDocument(); + }); + + test('다이얼로그가 열려있으면 겹치는 일정 정보가 표시된다', () => { + render( + + ); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText(/다음 일정과 겹칩니다/)).toBeInTheDocument(); + expect(screen.getByText('기존 회의 (2025-01-01 09:00-10:00)')).toBeInTheDocument(); + }); + + test('취소 버튼 클릭 시 onClose가 호출된다', () => { + const mockClose = vi.fn(); + + render( + + ); + + const cancelButton = screen.getByRole('button', { name: '취소' }); + fireEvent.click(cancelButton); + + expect(mockClose).toHaveBeenCalled(); + }); + + test('계속 진행 버튼 클릭 시 onContinue가 호출된다', () => { + const mockContinue = vi.fn(); + + render( + + ); + + const continueButton = screen.getByRole('button', { name: '계속 진행' }); + fireEvent.click(continueButton); + + expect(mockContinue).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/advanced.NotificationStack.spec.tsx b/src/__tests__/components/advanced.NotificationStack.spec.tsx new file mode 100644 index 00000000..9a1662d7 --- /dev/null +++ b/src/__tests__/components/advanced.NotificationStack.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi, test, describe, expect } from 'vitest'; + +import { NotificationStack } from '../../components/NotificationStack'; + +describe('NotificationStack', () => { + test('알림이 없으면 아무것도 렌더링하지 않는다', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + test('알림이 있으면 메시지가 표시된다', () => { + const notifications = [{ message: '첫 번째 알림' }, { message: '두 번째 알림' }]; + + render(); + + expect(screen.getByText('첫 번째 알림')).toBeInTheDocument(); + expect(screen.getByText('두 번째 알림')).toBeInTheDocument(); + }); + + test('알림 닫기 버튼 클릭 시 onCloseNotification이 호출된다', () => { + const mockClose = vi.fn(); + const notifications = [{ message: '테스트 알림' }]; + + render(); + + const closeButton = screen.getByRole('button', { name: '알림 닫기' }); + fireEvent.click(closeButton); + + expect(mockClose).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/__tests__/hooks/advanced.useEventDisplay.spec.ts b/src/__tests__/hooks/advanced.useEventDisplay.spec.ts new file mode 100644 index 00000000..ebf080aa --- /dev/null +++ b/src/__tests__/hooks/advanced.useEventDisplay.spec.ts @@ -0,0 +1,178 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { useEventDisplay } from '../../hooks/useEventDisplay'; +import { createEvent } from '../utils'; + +// Material-UI 컴포넌트 모킹 - 간단한 객체로 모킹 +vi.mock('@mui/material', () => ({ + Stack: ({ children, ...props }: Record) => ({ + type: 'div', + props: { 'data-testid': 'stack', ...props }, + children, + }), + Typography: ({ children, ...props }: Record) => ({ + type: 'div', + props: { 'data-testid': 'typography', ...props }, + children, + }), + Table: ({ children, ...props }: Record) => ({ + type: 'table', + props: { 'data-testid': 'table', ...props }, + children, + }), + TableHead: ({ children, ...props }: Record) => ({ + type: 'thead', + props: { 'data-testid': 'table-head', ...props }, + children, + }), + TableBody: ({ children, ...props }: Record) => ({ + type: 'tbody', + props: { 'data-testid': 'table-body', ...props }, + children, + }), + TableRow: ({ children, ...props }: Record) => ({ + type: 'tr', + props: { 'data-testid': 'table-row', ...props }, + children, + }), + TableCell: ({ children, ...props }: Record) => ({ + type: 'td', + props: { 'data-testid': 'table-cell', ...props }, + children, + }), + TableContainer: ({ children, ...props }: Record) => ({ + type: 'div', + props: { 'data-testid': 'table-container', ...props }, + children, + }), + Box: ({ children, ...props }: Record) => ({ + type: 'div', + props: { 'data-testid': 'box', ...props }, + children, + }), +})); + +// 아이콘 모킹 +vi.mock('@mui/icons-material', () => ({ + Notifications: () => ({ + type: 'span', + props: { 'data-testid': 'notifications-icon' }, + children: '🔔', + }), +})); + +// dateUtils 모킹 +vi.mock('../../utils/dateUtils', () => ({ + formatDate: vi.fn((_currentDate, day) => `2025-01-${day}`), + formatMonth: vi.fn(() => '2025년 1월'), + formatWeek: vi.fn(() => '2025년 1월 1주차'), + getEventsForDay: vi.fn(() => []), + getWeekDates: vi.fn(() => [ + new Date('2025-01-01'), + new Date('2025-01-02'), + new Date('2025-01-03'), + new Date('2025-01-04'), + new Date('2025-01-05'), + new Date('2025-01-06'), + new Date('2025-01-07'), + ]), + getWeeksAtMonth: vi.fn(() => [ + [1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14], + [15, 16, 17, 18, 19, 20, 21], + [22, 23, 24, 25, 26, 27, 28], + [29, 30, 31, null, null, null, null], + ]), +})); + +describe('useEventDisplay', () => { + let currentDate: Date; + let filteredEvents: ReturnType[]; + let notifiedEvents: string[]; + let holidays: { [key: string]: string }; + let weekDays: string[]; + + beforeEach(() => { + currentDate = new Date('2025-01-01'); + filteredEvents = [ + createEvent({ id: '1', date: '2025-01-01', startTime: '09:00', endTime: '10:00' }), + createEvent({ id: '2', date: '2025-01-02', startTime: '10:00', endTime: '11:00' }), + ]; + notifiedEvents = ['1']; + holidays = { '2025-01-01': '신정' }; + weekDays = ['일', '월', '화', '수', '목', '금', '토']; + vi.clearAllMocks(); + }); + + it('useEventDisplay가 올바른 함수들을 반환해야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + expect(result.current.renderWeekView).toBeDefined(); + expect(result.current.renderMonthView).toBeDefined(); + expect(typeof result.current.renderWeekView).toBe('function'); + expect(typeof result.current.renderMonthView).toBe('function'); + }); + + it('renderWeekView가 JSX를 반환해야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const weekView = result.current.renderWeekView(); + expect(weekView).toBeDefined(); + expect(weekView.props).toBeDefined(); + // 실제 JSX의 data-testid 값 확인 + expect(weekView.props['data-testid']).toBe('week-view'); + }); + + it('renderMonthView가 JSX를 반환해야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const monthView = result.current.renderMonthView(); + expect(monthView).toBeDefined(); + expect(monthView.props).toBeDefined(); + // 실제 JSX의 data-testid 값 확인 + expect(monthView.props['data-testid']).toBe('month-view'); + }); + + it('주간 뷰에서 weekDays가 올바르게 렌더링되어야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const weekView = result.current.renderWeekView(); + expect(weekView).toBeDefined(); + }); + + it('월간 뷰에서 달력 구조가 올바르게 렌더링되어야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const monthView = result.current.renderMonthView(); + expect(monthView).toBeDefined(); + }); + + it('이벤트가 있는 날짜에 알림 아이콘이 표시되어야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const weekView = result.current.renderWeekView(); + expect(weekView).toBeDefined(); + }); + + it('공휴일이 있는 날짜에 공휴일 정보가 표시되어야 한다', () => { + const { result } = renderHook(() => + useEventDisplay(currentDate, filteredEvents, notifiedEvents, holidays, weekDays) + ); + + const monthView = result.current.renderMonthView(); + expect(monthView).toBeDefined(); + }); +}); diff --git a/src/__tests__/hooks/advanced.useEventOverlap.spec.ts b/src/__tests__/hooks/advanced.useEventOverlap.spec.ts new file mode 100644 index 00000000..4538bb7c --- /dev/null +++ b/src/__tests__/hooks/advanced.useEventOverlap.spec.ts @@ -0,0 +1,129 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { useEventOverlap } from '../../hooks/useEventOverlap'; +import { findOverlappingEvents } from '../../utils/eventOverlap'; +import { createEvent } from '../utils'; + +// findOverlappingEvents 모킹 +vi.mock('../../utils/eventOverlap', () => ({ + findOverlappingEvents: vi.fn(), +})); + +describe('useEventOverlap', () => { + let mockOnSave: ReturnType; + let mockOnReset: ReturnType; + + beforeEach(() => { + mockOnSave = vi.fn(); + mockOnReset = vi.fn(); + vi.clearAllMocks(); + }); + + it('초기 상태가 올바르게 설정되어야 한다', () => { + const { result } = renderHook(() => useEventOverlap()); + + expect(result.current.isOverlapDialogOpen).toBe(false); + expect(result.current.overlappingEvents).toEqual([]); + }); + + it('중복 이벤트가 없을 때 onSave와 onReset을 호출해야 한다', async () => { + const { result } = renderHook(() => useEventOverlap()); + const eventData = createEvent({ date: '2025-01-01', startTime: '09:00', endTime: '10:00' }); + const events = [createEvent({ date: '2025-01-02', startTime: '09:00', endTime: '10:00' })]; + + // 중복 없음 모킹 + vi.mocked(findOverlappingEvents).mockReturnValue([]); + + await act(async () => { + const hasOverlap = await result.current.checkAndHandleOverlap( + eventData, + events, + mockOnSave, + mockOnReset + ); + expect(hasOverlap).toBe(false); + }); + + expect(mockOnSave).toHaveBeenCalledWith(eventData); + expect(mockOnReset).toHaveBeenCalled(); + expect(result.current.isOverlapDialogOpen).toBe(false); + }); + + it('중복 이벤트가 있을 때 다이얼로그를 열고 중복 이벤트를 설정해야 한다', async () => { + const { result } = renderHook(() => useEventOverlap()); + const eventData = createEvent({ date: '2025-01-01', startTime: '09:00', endTime: '10:00' }); + const overlappingEvent = createEvent({ + date: '2025-01-01', + startTime: '09:30', + endTime: '10:30', + }); + const events = [overlappingEvent]; + + // 중복 있음 모킹 + vi.mocked(findOverlappingEvents).mockReturnValue([overlappingEvent]); + + await act(async () => { + const hasOverlap = await result.current.checkAndHandleOverlap( + eventData, + events, + mockOnSave, + mockOnReset + ); + expect(hasOverlap).toBe(true); + }); + + expect(mockOnSave).not.toHaveBeenCalled(); + expect(mockOnReset).not.toHaveBeenCalled(); + expect(result.current.isOverlapDialogOpen).toBe(true); + expect(result.current.overlappingEvents).toEqual([overlappingEvent]); + }); + + it('closeOverlapDialog가 다이얼로그를 닫아야 한다', async () => { + const { result } = renderHook(() => useEventOverlap()); + const eventData = createEvent({ date: '2025-01-01', startTime: '09:00', endTime: '10:00' }); + const overlappingEvent = createEvent({ + date: '2025-01-01', + startTime: '09:30', + endTime: '10:30', + }); + const events = [overlappingEvent]; + + // 먼저 다이얼로그를 열기 위해 중복 이벤트가 있는 상황을 만듦 + vi.mocked(findOverlappingEvents).mockReturnValue([overlappingEvent]); + + await act(async () => { + await result.current.checkAndHandleOverlap(eventData, events, mockOnSave, mockOnReset); + }); + + expect(result.current.isOverlapDialogOpen).toBe(true); + + // 다이얼로그 닫기 + act(() => { + result.current.closeOverlapDialog(); + }); + + expect(result.current.isOverlapDialogOpen).toBe(false); + }); + + it('onSave가 Promise를 반환해야 한다', async () => { + const { result } = renderHook(() => useEventOverlap()); + const eventData = createEvent({ date: '2025-01-01', startTime: '09:00', endTime: '10:00' }); + const events: ReturnType[] = []; + + // 중복 없음 모킹 + vi.mocked(findOverlappingEvents).mockReturnValue([]); + + await act(async () => { + const hasOverlap = await result.current.checkAndHandleOverlap( + eventData, + events, + mockOnSave, + mockOnReset + ); + expect(hasOverlap).toBe(false); + }); + + expect(mockOnSave).toHaveBeenCalledWith(eventData); + }); +}); diff --git a/src/__tests__/hooks/advanced.useEventValidation.spec.ts b/src/__tests__/hooks/advanced.useEventValidation.spec.ts new file mode 100644 index 00000000..d4129cdb --- /dev/null +++ b/src/__tests__/hooks/advanced.useEventValidation.spec.ts @@ -0,0 +1,168 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { useEventValidation } from '../../hooks/useEventValidation'; + +// notistack 모킹 +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), +})); + +describe('useEventValidation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('validateEventForm이 올바른 폼 데이터를 검증해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const validFormData = { + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(validFormData); + expect(isValid).toBe(true); + expect(mockEnqueueSnackbar).not.toHaveBeenCalled(); + }); + + it('제목이 누락된 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('날짜가 누락된 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '테스트 이벤트', + date: '', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('시작 시간이 누락된 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('종료 시간이 누락된 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('시작 시간 에러가 있는 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); + + it('종료 시간 에러가 있는 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '테스트 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); + + it('모든 필수 필드가 누락된 경우 false를 반환하고 에러 메시지를 표시해야 한다', () => { + const { result } = renderHook(() => useEventValidation()); + + const invalidFormData = { + title: '', + date: '', + startTime: '', + endTime: '', + startTimeError: null, + endTimeError: null, + }; + + const isValid = result.current.validateEventForm(invalidFormData); + expect(isValid).toBe(false); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); +}); diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..3ce2ef59 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -4,21 +4,92 @@ import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.view).toBe('month'); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + // AS IS : currentDate는 오늘 날짜인 "2025-10-01"이어야 한다 + // -> 기본 설정이 2025-10-01로 되어 있는데 + // 검색해보니 시간을 고정하면 일관된 환경에서 테스트 가능하게 하려면 + // vi.setSystemTime(new Date('2025-10-01'));가 있어야 한다고 한다. + // TO BE + it('currentDate는 설정된 날짜인 2025-10-01 이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + assertDate(result.current.currentDate, new Date()); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.holidays).toEqual({ + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-03': '개천절', + '2025-10-09': '한글날', + }); + }); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + act(() => result.current.setView('week')); + expect(result.current.view).toBe('week'); +}); + +it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('next'); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + assertDate(result.current.currentDate, new Date('2025-10-08')); +}); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); +it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-24')); +}); + +it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-11-01')); +}); + +it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-01')); +}); + +it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + expect(result.current.holidays).toEqual({ '2025-03-01': '삼일절' }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..67d5e412 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,14 +1,95 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; -import { Event } from '../../types.ts'; +import { createEvent } from '../utils.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +// 고정설정? +const events = [ + createEvent({ + id: '1', + date: '2025-07-01', + title: '생일파티', + description: '생일 파티 이벤트', + }), + createEvent({ + id: '2', + date: '2025-07-02', + title: '이벤트', + }), + createEvent({ + id: '3', + date: '2025-07-13', + title: '전시 EVENT', + location: '코엑스 이벤트장', + }), + createEvent({ + id: '4', + date: '2025-07-24', + title: '회의', + }), + createEvent({ + id: '5', + date: '2025-07-31', + title: '점심', + }), +]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +let date: Date; -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); +beforeEach(() => { + date = new Date('2025-07-01'); +}); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); +describe('useSearch', () => { + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(events, date, 'month')); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + expect(result.current.filteredEvents).toEqual(events); + }); + + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(events, date, 'month')); + + act(() => result.current.setSearchTerm('EVENT')); + + expect(result.current.filteredEvents).toEqual([events[2]]); + }); + + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(events, date, 'month')); + + act(() => result.current.setSearchTerm('이벤트')); + + expect(result.current.filteredEvents).toEqual([events[0], events[1], events[2]]); + }); + + // AS IS : 현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다 + // -> 선택하기에 따라 결과가 다르니 분리하자 + // TO BE + describe('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다.', () => { + it('현재 뷰(주간)에 해당하는 이벤트만 반환', () => { + const { result } = renderHook(() => useSearch(events, date, 'week')); + + act(() => result.current.setSearchTerm('이벤트')); + + expect(result.current.filteredEvents).toEqual([events[0], events[1]]); + }); + it('현재 뷰(월간)에 해당하는 이벤트만 반환', () => { + const { result } = renderHook(() => useSearch(events, date, 'month')); + + act(() => result.current.setSearchTerm('이벤트')); + + expect(result.current.filteredEvents).toEqual([events[0], events[1], events[2]]); + }); + }); + + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(events, date, 'month')); + + act(() => result.current.setSearchTerm('회의')); + expect(result.current.filteredEvents).toEqual([events[3]]); + + act(() => result.current.setSearchTerm('점심')); + expect(result.current.filteredEvents).toEqual([events[4]]); + }); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..fd67cce1 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { @@ -9,6 +9,7 @@ import { import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; +import { createEvent } from '../utils.ts'; const enqueueSnackbarFn = vi.fn(); @@ -22,16 +23,150 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + // 초기 이벤트 데이터 정의 + const initialEvents = [ + createEvent({ + id: '1', + title: '기존 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }), + ]; + // MSW 핸들러에 초기값 전달 + setupMockHandlerCreation(initialEvents); + + const { result } = renderHook(() => useEventOperations(true)); + console.log(result.current.events); + await waitFor(() => { + console.log(result.current.events); + expect(result.current.events).toEqual(initialEvents); + }); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + // 초기 이벤트 데이터 정의 + const newEvent: Event = { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + // 초기 빈 배열의 목 데이터 생성 + setupMockHandlerCreation(); + + const { result } = renderHook(() => useEventOperations(false)); + + // 이벤트 저장 + await act(async () => { + await result.current.saveEvent(newEvent); + }); + expect(result.current.events).toEqual([newEvent]); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + // 미리 만들어놓은 2개의 목 데이터 생성 + setupMockHandlerUpdating(); + + // editing 상태이므로 true 전달 + const { result } = renderHook(() => useEventOperations(true)); + + // 이벤트 수정 + await act(async () => { + await result.current.saveEvent({ + id: '1', + title: '수정된 회의 제목', // 제목 수정 + date: '2025-10-15', + startTime: '09:00', + endTime: '10:30', // 종료시간 수정 + description: '수정됨', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); + }); + + const updated = result.current.events.find((e) => e.id === '1'); + + expect(updated?.title).toEqual('수정된 회의 제목'); + expect(updated?.endTime).toEqual('10:30'); +}); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + const { result } = renderHook(() => useEventOperations(true)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + await act(async () => { + result.current.deleteEvent('1'); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + expect(result.current.events).toEqual([]); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + result.current.fetchEvents(); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + const nonExistentEvent: Event = { + id: '999', + title: '존재하지 않는 이벤트', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '존재하지 않는 설명', + location: '존재하지 않는 장소', + category: '존재하지 않는', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + server.use( + http.put(`/api/events/${nonExistentEvent.id}`, () => { + return HttpResponse.error(); + }) + ); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent(nonExistentEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.deleteEvent('notExistEvent'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); +}); diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..a097cc8b 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,74 @@ import { act, renderHook } from '@testing-library/react'; import { useNotifications } from '../../hooks/useNotifications.ts'; -import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; +import { createEvent } from '../utils.ts'; +// import { Event } from '../../types.ts'; +// import { formatDate } from '../../utils/dateUtils.ts'; +// import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +const events = [ + createEvent({ date: '2025-09-01', startTime: '09:00', notificationTime: 1 }), + createEvent({ date: '2025-09-01', startTime: '10:00', notificationTime: 5 }), +]; -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-09-01 08:58:00')); +}); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); +afterEach(() => { + vi.clearAllTimers(); +}); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications([])); + expect(result.current.notifications).toEqual([]); +}); + +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const { result } = renderHook(() => useNotifications(events)); + act(() => { + vi.advanceTimersByTime(60 * 1000); + }); + + expect(result.current.notifications[0].id).toEqual('1'); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const { result } = renderHook(() => useNotifications([])); + act(() => { + result.current.setNotifications([ + { id: '1', message: '테스트' }, + { id: '2', message: '테스트' }, + ]); + }); + + expect(result.current.notifications.length).toEqual(2); + expect(result.current.notifications[0].id).toEqual('1'); + expect(result.current.notifications[1].id).toEqual('2'); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications.length).toEqual(1); + expect(result.current.notifications[0].id).toEqual('2'); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const { result } = renderHook(() => useNotifications(events)); + act(() => { + vi.advanceTimersByTime(60 * 1000); + }); + + expect(result.current.notifications.length).toEqual(1); + expect(result.current.notifications[0].id).toEqual('1'); + + const expected = result.current.notifications[0]; + act(() => { + vi.advanceTimersByTime(30 * 1000); + }); + + expect(result.current.notifications.length).toEqual(1); + expect(result.current.notifications[0]).toEqual(expected); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..0bc1daae 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,6 +1,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; @@ -18,6 +18,7 @@ import { Event } from '../types'; const theme = createTheme(); // ! HINT. 이 유틸을 사용해 리액트 컴포넌트를 렌더링해보세요. +// 앱을 받아서 렌더링 시켜주고 유저 이벤트 셋업 const setup = (element: ReactElement) => { const user = userEvent.setup(); @@ -32,7 +33,6 @@ const setup = (element: ReactElement) => { user, }; }; - // ! HINT. 이 유틸을 사용해 일정을 저장해보세요. const saveSchedule = async ( user: UserEvent, @@ -59,37 +59,373 @@ const saveSchedule = async ( describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation(); + + const { user } = setup(); + + await saveSchedule(user, { + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + }); + await user.click(screen.getByRole('button', { name: '일정 추가' })); + + expect( + await screen.findByText('기존 회의', { + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + await user.click(editButtons[0]); + + const title = screen.getByLabelText('제목'); + await user.clear(title); + await user.type(title, '룰루 난나'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + expect( + await screen.findByText('룰루 난나', { + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); + }); + + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + + const { user } = setup(); + const deleteButtons = await screen.findByRole('button', { name: 'Delete event' }); + await user.click(deleteButtons); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + setupMockHandlerCreation(); + const { user } = setup(); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-03', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const { user } = setup(); + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + expect( + await screen.findByText('기존 회의', { + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-11-10', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'month-option' })); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-10', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + await user.click(screen.getByLabelText('뷰 타입 선택')); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'month-option' })); + + expect( + await screen.findByText('기존 회의', { + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const { user } = setup(); + const previousButton = await screen.findAllByRole('button', { name: 'Previous' }); + + for (let i = 0; i < 9; i++) { + await user.click(previousButton[0]); + } + + expect(await screen.findByText('신정')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: [] }); + }) + ); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + const eventInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(eventInput); + await user.type(eventInput, '팀 회의'); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] satisfies Event[], + }); + }) + ); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.type(searchInput, '팀 회의'); + + expect( + await screen.findByText('팀 회의', { + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] satisfies Event[], + }); + }) + ); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.type(searchInput, '팀 회의'); + + await screen.findByText('팀 회의', { + selector: '[data-testid="event-list"] p', + }); + + await user.clear(searchInput); + + expect( + await screen.findByText('회의', { + exact: true, + selector: '[data-testid="event-list"] p', + }) + ).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ + events: [ + { + id: '1', + title: '기존 일정', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의 설명', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] satisfies Event[], + }); + }) + ); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + const { user } = setup(); + await screen.findByText('일정 로딩 완료!'); + await saveSchedule(user, { + title: '새 일정', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '새일정 미팅', + location: '회의실 B', + category: '업무', + }); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + const startInput = screen.getByLabelText('시작 시간'); + const endInput = screen.getByLabelText('종료 시간'); + + await user.click(editButtons[0]); + + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + + await user.clear(startInput); + await user.type(startInput, '11:00'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + await user.clear(endInput); + await user.type(endInput, '12:00'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + vi.setSystemTime(new Date('2025-10-15T08:50:00')); + setup(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('기존 회의')).toBeInTheDocument(); + + expect(await screen.findByText('10분 후 기존 회의 일정이 시작됩니다.')).toBeInTheDocument(); +}); diff --git a/src/__tests__/unit/advanced.constants.spec.ts b/src/__tests__/unit/advanced.constants.spec.ts new file mode 100644 index 00000000..79d80752 --- /dev/null +++ b/src/__tests__/unit/advanced.constants.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; + +import { notificationOptions, categories, weekDays } from '../../utils/constants'; + +describe('constants', () => { + describe('notificationOptions', () => { + it('알림 옵션이 올바른 구조를 가져야 한다', () => { + expect(notificationOptions).toHaveLength(5); + + notificationOptions.forEach((option) => { + expect(option).toHaveProperty('value'); + expect(option).toHaveProperty('label'); + expect(typeof option.value).toBe('number'); + expect(typeof option.label).toBe('string'); + }); + }); + + it('알림 옵션 값이 올바른 순서로 정렬되어야 한다', () => { + const values = notificationOptions.map((option) => option.value); + expect(values).toEqual([1, 10, 60, 120, 1440]); + }); + + it('알림 옵션 라벨이 올바르게 설정되어야 한다', () => { + const labels = notificationOptions.map((option) => option.label); + expect(labels).toEqual(['1분 전', '10분 전', '1시간 전', '2시간 전', '1일 전']); + }); + + it('알림 옵션 값이 분 단위로 정확해야 한다', () => { + expect(notificationOptions[0].value).toBe(1); // 1분 + expect(notificationOptions[1].value).toBe(10); // 10분 + expect(notificationOptions[2].value).toBe(60); // 1시간 (60분) + expect(notificationOptions[3].value).toBe(120); // 2시간 (120분) + expect(notificationOptions[4].value).toBe(1440); // 1일 (1440분) + }); + }); + + describe('categories', () => { + it('카테고리가 올바른 개수여야 한다', () => { + expect(categories).toHaveLength(4); + }); + + it('카테고리가 올바른 순서로 정렬되어야 한다', () => { + expect(categories).toEqual(['업무', '개인', '가족', '기타']); + }); + + it('모든 카테고리가 문자열이어야 한다', () => { + categories.forEach((category) => { + expect(typeof category).toBe('string'); + expect(category.length).toBeGreaterThan(0); + }); + }); + + it('카테고리에 중복이 없어야 한다', () => { + const uniqueCategories = new Set(categories); + expect(uniqueCategories.size).toBe(categories.length); + }); + }); + + describe('weekDays', () => { + it('요일이 7개여야 한다', () => { + expect(weekDays).toHaveLength(7); + }); + + it('요일이 올바른 순서로 정렬되어야 한다', () => { + expect(weekDays).toEqual(['일', '월', '화', '수', '목', '금', '토']); + }); + + it('모든 요일이 한 글자여야 한다', () => { + weekDays.forEach((day) => { + expect(typeof day).toBe('string'); + expect(day.length).toBe(1); + }); + }); + + it('요일에 중복이 없어야 한다', () => { + const uniqueDays = new Set(weekDays); + expect(uniqueDays.size).toBe(weekDays.length); + }); + + it('요일이 한국어로 표시되어야 한다', () => { + const koreanDays = ['일', '월', '화', '수', '목', '금', '토']; + expect(weekDays).toEqual(koreanDays); + }); + }); +}); diff --git a/src/__tests__/unit/advanced.eventUtils.spec.ts b/src/__tests__/unit/advanced.eventUtils.spec.ts new file mode 100644 index 00000000..a7a7bb7b --- /dev/null +++ b/src/__tests__/unit/advanced.eventUtils.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; + +import { getNotificationLabel, formatRepeatInfo } from '../../utils/eventUtils'; + +describe('eventUtils - Advanced Functions', () => { + describe('getNotificationLabel', () => { + it('1분 전 알림 시간에 대해 올바른 라벨을 반환해야 한다', () => { + const result = getNotificationLabel(1); + expect(result).toBe('1분 전'); + }); + + it('10분 전 알림 시간에 대해 올바른 라벨을 반환해야 한다', () => { + const result = getNotificationLabel(10); + expect(result).toBe('10분 전'); + }); + + it('1시간 전 알림 시간에 대해 올바른 라벨을 반환해야 한다', () => { + const result = getNotificationLabel(60); + expect(result).toBe('1시간 전'); + }); + + it('2시간 전 알림 시간에 대해 올바른 라벨을 반환해야 한다', () => { + const result = getNotificationLabel(120); + expect(result).toBe('2시간 전'); + }); + + it('1일 전 알림 시간에 대해 올바른 라벨을 반환해야 한다', () => { + const result = getNotificationLabel(1440); + expect(result).toBe('1일 전'); + }); + + it('존재하지 않는 알림 시간에 대해 undefined를 반환해야 한다', () => { + const result = getNotificationLabel(999); + expect(result).toBeUndefined(); + }); + + it('0분 알림 시간에 대해 undefined를 반환해야 한다', () => { + const result = getNotificationLabel(0); + expect(result).toBeUndefined(); + }); + + it('음수 알림 시간에 대해 undefined를 반환해야 한다', () => { + const result = getNotificationLabel(-10); + expect(result).toBeUndefined(); + }); + }); + + describe('formatRepeatInfo', () => { + it('반복 없음 타입에 대해 빈 문자열을 반환해야 한다', () => { + const repeat = { type: 'none', interval: 1 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe(''); + }); + + it('일간 반복에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { type: 'daily', interval: 2 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('2일마다'); + }); + + it('주간 반복에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { type: 'weekly', interval: 1 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('1주마다'); + }); + + it('월간 반복에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { type: 'monthly', interval: 3 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('3월마다'); + }); + + it('년간 반복에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { type: 'yearly', interval: 1 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('1년마다'); + }); + + it('종료 날짜가 있는 경우 종료 정보를 포함해야 한다', () => { + const repeat = { + type: 'weekly', + interval: 2, + endDate: '2025-12-31', + }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('2주마다 (종료: 2025-12-31)'); + }); + + it('종료 날짜가 없는 경우 종료 정보를 포함하지 않아야 한다', () => { + const repeat = { type: 'monthly', interval: 1 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('1월마다'); + }); + + it('존재하지 않는 반복 타입에 대해 기본 형식을 반환해야 한다', () => { + const repeat = { type: 'unknown', interval: 5 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('5마다'); + }); + + it('0 간격에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { type: 'daily', interval: 0 }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('0일마다'); + }); + + it('복잡한 반복 정보에 대해 올바른 형식을 반환해야 한다', () => { + const repeat = { + type: 'monthly', + interval: 6, + endDate: '2026-06-30', + }; + const result = formatRepeatInfo(repeat); + expect(result).toBe('6월마다 (종료: 2026-06-30)'); + }); + }); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..9a6b1f6a 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -1,4 +1,3 @@ -import { Event } from '../../types'; import { fillZero, formatDate, @@ -10,107 +9,266 @@ import { getWeeksAtMonth, isDateInRange, } from '../../utils/dateUtils'; +import { createEvent } from '../utils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); - - it('윤년의 2월에 대해 29일을 반환한다', () => {}); - - it('평년의 2월에 대해 28일을 반환한다', () => {}); - - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + expect(getDaysInMonth(2025, 0)).toBe(31); + expect(getDaysInMonth(2025, 13)).toBe(31); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + // toEqual : 객체나 배열 값 비교 + expect(getWeekDates(new Date('2025-08-20'))).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(new Date('2025-08-18'))).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(new Date('2025-08-17'))).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + expect(getWeekDates(new Date('2025-12-31'))).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + expect(getWeekDates(new Date('2026-01-01'))).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + expect(getWeekDates(new Date('2024-02-29'))).toEqual([ + new Date('2024-02-25'), + new Date('2024-02-26'), + new Date('2024-02-27'), + new Date('2024-02-28'), + new Date('2024-02-29'), + new Date('2024-03-01'), + new Date('2024-03-02'), + ]); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + expect(getWeekDates(new Date('2025-08-31'))).toEqual([ + new Date('2025-08-31'), + new Date('2025-09-01'), + new Date('2025-09-02'), + new Date('2025-09-03'), + new Date('2025-09-04'), + new Date('2025-09-05'), + new Date('2025-09-06'), + ]); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일이 포함된 월 전체 날짜를 반환해야 한다.', () => { + expect(getWeeksAtMonth(new Date('2025-07-01'))).toEqual([ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + const events = [ + createEvent({ id: '1', date: '2025-08-01' }), + createEvent({ id: '2', date: '2025-08-02' }), + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + expect(getEventsForDay(events, 1)).toEqual([events[0]]); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 10)).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 0)).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 32)).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-08-20'))).toBe('2025년 8월 3주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-08-03'))).toBe('2025년 8월 1주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-08-30'))).toBe('2025년 8월 4주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-12-31'))).toBe('2026년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2024-02-29'))).toBe('2024년 2월 5주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-02-28'))).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + expect(formatMonth(new Date('2025-07-10'))).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + const rangeStart = new Date('2025-07-01'); + const rangeEnd = new Date('2025-07-31'); + + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-10'), rangeStart, rangeEnd)).toBe(true); + }); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-01'), rangeStart, rangeEnd)).toBe(true); + }); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-31'), rangeStart, rangeEnd)).toBe(true); + }); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-06-30'), rangeStart, rangeEnd)).toBe(false); + }); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-08-01'), rangeStart, rangeEnd)).toBe(false); + }); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-15'), rangeEnd, rangeStart)).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5)).toBe('05'); + }); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10)).toBe('10'); + }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100)).toBe('100'); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0)).toBe('00'); + }); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(5)).toBe('05'); + }); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(1000, 3)).toBe('1000'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 18))).toBe('2025-08-18'); + }); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 18), 5)).toBe('2025-08-05'); + }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 18))).toBe('2025-08-18'); + }); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 5))).toBe('2025-08-05'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..3dd4e2cf 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,36 +1,100 @@ -import { Event } from '../../types'; import { convertEventToDateRange, findOverlappingEvents, isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; +import { createEvent } from '../utils'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + expect(parseDateTime('2025-07-01', '14:30')).toEqual(new Date('2025-07-01T14:30')); + }); + + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-00', '14:30')).toEqual(new Date('Invalid Date')); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-01', '25:30')).toEqual(new Date('Invalid Date')); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + expect(parseDateTime('', '25:30')).toEqual(new Date('Invalid Date')); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('시간 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-01', '')).toEqual(new Date('Invalid Date')); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const event = createEvent({ id: '1', date: '2025-01-01' }); + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('2025-01-01T09:00'), + end: new Date('2025-01-01T10:00'), + }); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event = createEvent({ id: '1', date: '2025-08-32' }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('Invalid Date'), + end: new Date('IInvalid Date'), + }); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event = createEvent({ + id: '1', + date: '2025-08-18', + title: 'wrongTimeEvent', + startTime: '24:00', + endTime: '25:00', + }); + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('2025-08-18T24:00'), + end: new Date('Invalid Date'), + }); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + const events = [ + createEvent({ id: '1', date: '2025-08-18' }), + createEvent({ id: '2', date: '2025-08-18' }), + createEvent({ id: '3', date: '2025-08-28' }), + ]; + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + expect(isOverlapping(events[0], events[1])).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + expect(isOverlapping(events[0], events[2])).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + const events = [ + createEvent({ id: '1', date: '2025-08-08' }), + createEvent({ id: '2', date: '2025-08-18' }), + createEvent({ id: '3', date: '2025-08-28' }), + createEvent({ id: '4', date: '2025-08-18' }), + ]; + + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent = createEvent({ id: '5', date: '2025-08-18' }); + + expect(findOverlappingEvents(newEvent, events)).toEqual([events[1], events[3]]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent = createEvent({ id: '5', date: '2025-09-08' }); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(findOverlappingEvents(newEvent, events)).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..692a8071 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,101 @@ -import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; +import { createEvent } from '../utils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + const monthEvents = [ + createEvent({ + id: '1', + date: '2025-07-01', + title: 'event 1', + }), + createEvent({ + id: '2', + date: '2025-07-02', + title: '이벤트 2', + }), + createEvent({ + id: '3', + date: '2025-07-13', + title: 'EVENT 3', + }), + createEvent({ + id: '4', + date: '2025-07-24', + title: '이벤트 4', + }), + createEvent({ + id: '5', + date: '2025-07-31', + title: 'Event5', + }), + ]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + const currentDate = new Date('2025-07-02'); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const searchTerm = '이벤트 2'; - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + expect(getFilteredEvents(monthEvents, searchTerm, currentDate, 'month')).toEqual([ + monthEvents[1], + ]); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + expect(getFilteredEvents(monthEvents, '', currentDate, 'week')).toEqual([ + monthEvents[0], + monthEvents[1], + ]); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + expect(getFilteredEvents(monthEvents, '', new Date('2025-07-02'), 'month')).toEqual( + monthEvents + ); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const searchTerm = '이벤트'; - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + expect(getFilteredEvents(monthEvents, searchTerm, currentDate, 'week')).toEqual([ + monthEvents[1], + ]); + }); + + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + expect(getFilteredEvents(monthEvents, '', new Date('2025-07-02'), 'month')).toEqual( + monthEvents + ); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const searchTerm = 'event'; + + expect(getFilteredEvents(monthEvents, searchTerm, new Date('2025-07-02'), 'month')).toEqual([ + monthEvents[0], + monthEvents[2], + monthEvents[4], + ]); + }); + + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const events = [ + createEvent({ + id: '7', + date: '2025-07-31', + title: '7월 이벤트', + }), + createEvent({ + id: '8', + date: '2025-08-01', + title: '8월 이벤트', + }), + ]; + + expect(getFilteredEvents(events, '', new Date('2025-07-31'), 'month')).toEqual([events[0]]); + expect(getFilteredEvents(events, '', new Date('2025-08-01'), 'month')).toEqual([events[1]]); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + expect(getFilteredEvents([], '', currentDate, 'week')).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..0b0da59e 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,19 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + expect(fetchHolidays(new Date('2025-08-15'))).toEqual({ '2025-08-15': '광복절' }); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + expect(fetchHolidays(new Date('2025-02-15'))).toEqual({}); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + expect(fetchHolidays(new Date('2025-01-15'))).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..f6bd892b 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,43 @@ -import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +import { createEvent } from '../utils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + const events = [ + createEvent({ + id: '1', + title: 'event 2', + date: '2025-08-18', + startTime: '08:50', + notificationTime: 5, + }), + createEvent({ + id: '2', + title: 'event 3', + date: '2025-08-18', + startTime: '09:50', + notificationTime: 10, + }), + ]; + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + expect(getUpcomingEvents(events, new Date('2025-08-18T08:45:00'), [])).toEqual([events[0]]); + }); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + expect(getUpcomingEvents(events, new Date('2025-08-18T08:55:00'), [])).toEqual([]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + expect(getUpcomingEvents(events, new Date('2025-08-18T08:10:00'), [])).toEqual([]); + }); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + expect(getUpcomingEvents(events, new Date('2025-08-18T10::00'), [])).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + const event = createEvent({ notificationTime: 10, title: '눈누난나' }); + it('올바른 알림 메시지를 생성해야 한다', () => { + expect(createNotificationMessage(event)).toBe('10분 후 눈누난나 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..d0bd683f 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,45 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('08:50:00', '08:45:00')).toEqual({ + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + }); + }); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('08:50:00', '08:50:00')).toEqual({ + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + expect(getTimeErrorMessage('08:24:00', '08:50:00')).toEqual({ + endTimeError: null, + startTimeError: null, + }); + }); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '08:50:00')).toEqual({ + endTimeError: null, + startTimeError: null, + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('종료 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('08:50:00', '')).toEqual({ + endTimeError: null, + startTimeError: null, + }); + }); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '')).toEqual({ + endTimeError: null, + startTimeError: null, + }); + }); }); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 8e419c87..f9911f26 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -1,3 +1,4 @@ +import { Event } from '../types'; import { fillZero } from '../utils/dateUtils'; export const assertDate = (date1: Date, date2: Date) => { @@ -10,3 +11,31 @@ export const parseHM = (timestamp: number) => { const m = fillZero(date.getMinutes()); return `${h}:${m}`; }; + +// 임시 이벤트 생성 +export const createEvent = ({ + id, + date, + title, + startTime, + endTime, + description, + location, + notificationTime, +}: Partial): Event => { + return { + id: id || '1', + date: date || '2025-08-01', + title: title || `event ${id}`, + startTime: startTime || '09:00', + endTime: endTime || '10:00', + description: description || 'description', + location: location || 'location', + category: 'category', + repeat: { + type: 'none', + interval: 0, + }, + notificationTime: notificationTime || 0, + }; +}; diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..1d358196 --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,253 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import React from 'react'; + +import { Event } from '../types'; +import { categories, notificationOptions } from '../utils/constants'; +import { getTimeErrorMessage } from '../utils/timeValidation'; + +interface EventFormProps { + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: string; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: Event | null; + // eslint-disable-next-line no-unused-vars + onTitleChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onDateChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onStartTimeChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onEndTimeChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onDescriptionChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onLocationChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onCategoryChange: (value: string) => void; + // eslint-disable-next-line no-unused-vars + onIsRepeatingChange: (value: boolean) => void; + // eslint-disable-next-line no-unused-vars + onNotificationTimeChange: (value: number) => void; + onSubmit: () => void; +} + +export const EventForm: React.FC = ({ + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + notificationTime, + startTimeError, + endTimeError, + editingEvent, + onTitleChange, + onDateChange, + onStartTimeChange, + onEndTimeChange, + onDescriptionChange, + onLocationChange, + onCategoryChange, + onIsRepeatingChange, + onNotificationTimeChange, + onSubmit, +}) => { + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + onTitleChange(e.target.value)} + /> + + + + 날짜 + onDateChange(e.target.value)} + /> + + + + + 시작 시간 + + onStartTimeChange(e.target.value)} + onBlur={() => getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + onEndTimeChange(e.target.value)} + onBlur={() => getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + onDescriptionChange(e.target.value)} + /> + + + + 위치 + onLocationChange(e.target.value)} + /> + + + + 카테고리 + + + + + onIsRepeatingChange(e.target.checked)} + /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} + {/* {isRepeating && ( + + + 반복 유형 + + + + + 반복 간격 + onRepeatIntervalChange(Number(e.target.value))} + slotProps={{ htmlInput: { min: 1 } }} + /> + + + 반복 종료일 + onRepeatEndDateChange(e.target.value)} + /> + + + + )} */} + + + + ); +}; diff --git a/src/components/EventManager.tsx b/src/components/EventManager.tsx new file mode 100644 index 00000000..48dfbffc --- /dev/null +++ b/src/components/EventManager.tsx @@ -0,0 +1,50 @@ +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { IconButton, MenuItem, Select, Stack, Typography } from '@mui/material'; +import React from 'react'; + +interface EventManagerProps { + view: 'week' | 'month'; + onViewChange: (view: 'week' | 'month') => void; // eslint-disable-line no-unused-vars + onNavigate: (direction: 'prev' | 'next') => void; // eslint-disable-line no-unused-vars + renderWeekView: () => React.ReactNode; + renderMonthView: () => React.ReactNode; +} + +export const EventManager: React.FC = ({ + view, + onViewChange, + onNavigate, + renderWeekView, + renderMonthView, +}) => { + return ( + + 일정 보기 + + + onNavigate('prev')}> + + + + onNavigate('next')}> + + + + + {view === 'week' && renderWeekView()} + {view === 'month' && renderMonthView()} + + ); +}; diff --git a/src/components/EventOverlapDialog.tsx b/src/components/EventOverlapDialog.tsx new file mode 100644 index 00000000..c1e35007 --- /dev/null +++ b/src/components/EventOverlapDialog.tsx @@ -0,0 +1,49 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; +import React from 'react'; + +import { Event } from '../types'; + +interface EventOverlapDialogProps { + isOpen: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onContinue: () => void; +} + +export const EventOverlapDialog: React.FC = ({ + isOpen, + overlappingEvents, + onClose, + onContinue, +}) => { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +}; diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx new file mode 100644 index 00000000..5e4f3b59 --- /dev/null +++ b/src/components/NotificationStack.tsx @@ -0,0 +1,44 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; +import React from 'react'; + +interface Notification { + message: string; +} + +interface NotificationStackProps { + notifications: Notification[]; + onCloseNotification: (index: number) => void; // eslint-disable-line no-unused-vars +} + +export const NotificationStack: React.FC = ({ + notifications, + onCloseNotification, +}) => { + if (notifications.length === 0) { + return null; + } + + return ( + + {notifications.map((notification, index) => ( + onCloseNotification(index)} + aria-label="알림 닫기" + > + + + } + > + {notification.message} + + ))} + + ); +}; diff --git a/src/hooks/useEventDisplay.tsx b/src/hooks/useEventDisplay.tsx new file mode 100644 index 00000000..72ba83ac --- /dev/null +++ b/src/hooks/useEventDisplay.tsx @@ -0,0 +1,200 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; +import { + formatDate, + formatMonth, + formatWeek, + getEventsForDay, + getWeekDates, + getWeeksAtMonth, +} from '../utils/dateUtils'; + +export const useEventDisplay = ( + currentDate: Date, + filteredEvents: Event[], + notifiedEvents: string[], + holidays: { [key: string]: string }, + weekDays: string[] +) => { + const renderWeekView = () => { + const weekDates = getWeekDates(currentDate); + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {filteredEvents + .filter( + (event) => new Date(event.date).toDateString() === date.toDateString() + ) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + ))} + + +
+
+
+ ); + }; + + const renderMonthView = () => { + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(filteredEvents, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); + }; + + return { renderWeekView, renderMonthView }; +}; diff --git a/src/hooks/useEventOverlap.ts b/src/hooks/useEventOverlap.ts new file mode 100644 index 00000000..0a9366c4 --- /dev/null +++ b/src/hooks/useEventOverlap.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; + +import { Event, EventForm } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; + +export const useEventOverlap = () => { + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + + const checkAndHandleOverlap = async ( + eventData: Event | EventForm, + events: Event[], + onSave: (data: Event | EventForm) => Promise, // eslint-disable-line no-unused-vars + onReset: () => void + ): Promise => { + const overlapping = findOverlappingEvents(eventData, events); + + if (overlapping.length > 0) { + setOverlappingEvents(overlapping); + setIsOverlapDialogOpen(true); + return true; // 중복 있음 + } + + await onSave(eventData); + onReset(); + return false; // 중복 없음 + }; + + const closeOverlapDialog = () => setIsOverlapDialogOpen(false); + + return { + isOverlapDialogOpen, + overlappingEvents, + checkAndHandleOverlap, + closeOverlapDialog, + }; +}; diff --git a/src/hooks/useEventValidation.ts b/src/hooks/useEventValidation.ts new file mode 100644 index 00000000..a279cf9f --- /dev/null +++ b/src/hooks/useEventValidation.ts @@ -0,0 +1,28 @@ +import { useSnackbar } from 'notistack'; + +export const useEventValidation = () => { + const { enqueueSnackbar } = useSnackbar(); + + const validateEventForm = (formData: { + title: string; + date: string; + startTime: string; + endTime: string; + startTimeError: string | null; + endTimeError: string | null; + }): boolean => { + if (!formData.title || !formData.date || !formData.startTime || !formData.endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return false; + } + + if (formData.startTimeError || formData.endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return false; + } + + return true; + }; + + return { validateEventForm }; +}; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index f9ec573b..7703fcbf 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -7,26 +7,25 @@ export const useNotifications = (events: Event[]) => { const [notifications, setNotifications] = useState<{ id: string; message: string }[]>([]); const [notifiedEvents, setNotifiedEvents] = useState([]); - const checkUpcomingEvents = () => { - const now = new Date(); - const upcomingEvents = getUpcomingEvents(events, now, notifiedEvents); - - setNotifications((prev) => [ - ...prev, - ...upcomingEvents.map((event) => ({ - id: event.id, - message: createNotificationMessage(event), - })), - ]); - - setNotifiedEvents((prev) => [...prev, ...upcomingEvents.map(({ id }) => id)]); - }; - const removeNotification = (index: number) => { setNotifications((prev) => prev.filter((_, i) => i !== index)); }; useEffect(() => { + const checkUpcomingEvents = () => { + const now = new Date(); + const upcomingEvents = getUpcomingEvents(events, now, notifiedEvents); + + setNotifications((prev) => [ + ...prev, + ...upcomingEvents.map((event) => ({ + id: event.id, + message: createNotificationMessage(event), + })), + ]); + + setNotifiedEvents((prev) => [...prev, ...upcomingEvents.map(({ id }) => id)]); + }; const interval = setInterval(checkUpcomingEvents, 1000); // 1초마다 체크 return () => clearInterval(interval); }, [events, notifiedEvents]); diff --git a/src/setupTests.ts b/src/setupTests.ts index fded6d65..67c1ce54 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -15,9 +15,18 @@ beforeAll(() => { }); beforeEach(() => { - expect.hasAssertions(); // ? Med: 이걸 왜 써야하는지 물어보자 - - vi.setSystemTime(new Date('2025-10-01')); // ? Med: 이걸 왜 써야하는지 물어보자 + expect.hasAssertions(); + // ? Med: 이걸 왜 써야하는지 물어보자 + // Jest 테스트 프레임워크에서 각 테스트가 실행되기 전에 최소한 하나의 expect 단언이 호출되었는지 확인하는 설정 + // 테스트 검증 강화: 각 테스트 케이스에서 최소한 한 번의 단언(assertion)이 실행되었음을 보장하여, 테스트 코드가 의도한 검증을 포함하고 있는지 확인합니다. + // 오류 방지: 아무런 expect 호출 없이 테스트가 통과되는 것을 방지하여, 코드의 잠재적 버그나 잘못된 테스트 로직을 조기에 발견하도록 돕습니다. + // 테스트 독립성 유지: beforeEach 함수는 각 테스트가 독립적인 환경에서 실행되도록 보장하며, `expect.hasAssertions()`를 추가함으로써 테스트가 제대로 진행되는지 추가적으로 확인합니다. + + vi.setSystemTime(new Date('2025-10-01')); + // ? Med: 이걸 왜 써야하는지 물어보자 + // 현재 날짜를 전달된 날짜로 정의함 + // 시간을 고정하면 일관된 환경에서 테스트 가능 + // -> (시간의 흐름으로) 실시간 변경으로 인해 테스트 시간 변동을 처리하도록 설계되지 않은 경우 간헐적으로 실패할 수 있음 }); afterEach(() => { diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 00000000..7a831215 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,10 @@ +export const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +export const categories = ['업무', '개인', '가족', '기타']; +export const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/utils/eventUtils.ts b/src/utils/eventUtils.ts index 9e75e947..2c2c3438 100644 --- a/src/utils/eventUtils.ts +++ b/src/utils/eventUtils.ts @@ -1,4 +1,5 @@ import { Event } from '../types'; +import { notificationOptions } from './constants'; import { getWeekDates, isDateInRange } from './dateUtils'; function filterEventsByDateRange(events: Event[], start: Date, end: Date): Event[] { @@ -56,3 +57,26 @@ export function getFilteredEvents( return searchedEvents; } + +// 심화과제 +// 알림 라벨 가져오기 +export const getNotificationLabel = (notificationTime: number) => { + return notificationOptions.find((option) => option.value === notificationTime)?.label; +}; + +// 반복 정보 포맷팅 +export const formatRepeatInfo = (repeat: { type: string; interval: number; endDate?: string }) => { + if (repeat.type === 'none') return ''; + + const typeLabels = { + daily: '일', + weekly: '주', + monthly: '월', + yearly: '년', + }; + + const typeLabel = typeLabels[repeat.type as keyof typeof typeLabels] || ''; + const endDateInfo = repeat.endDate ? ` (종료: ${repeat.endDate})` : ''; + + return `${repeat.interval}${typeLabel}마다${endDateInfo}`; +};