diff --git a/package.json b/package.json index b01b2b4b..64079517 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@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..bd406e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ 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/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -468,6 +471,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} @@ -3628,6 +3635,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..a8255044 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,19 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } 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 { Box, Stack } from '@mui/material'; import { useSnackbar } from 'notistack'; import { useState } from 'react'; +import { CalendarView } from './components/calendar'; +import { OverlapDialog } from './components/dialog'; +import { EventForm as EventFormComponent } from './components/eventForm'; +import { EventList } from './components/eventList'; +import { NotificationToast } from './components/notification'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; 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일 전' }, -]; function App() { const { @@ -145,514 +100,86 @@ function App() { } }; - 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 ( - - {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()} - - - - - 일정 검색 - setSearchTerm(e.target.value)} - /> - - - {filteredEvents.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - filteredEvents.map((event) => ( - - - - - {notifiedEvents.includes(event.id) && } - - {event.title} - - - {event.date} - - {event.startTime} - {event.endTime} - - {event.description} - {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})`} - - )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - - - - editEvent(event)}> - - - deleteEvent(event.id)}> - - - - - - )) - )} - + + + + + - 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} - - ))} - - )} + setIsOverlapDialogOpen(false)} + onConfirm={() => { + setIsOverlapDialogOpen(false); + 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([])} + /> ); } diff --git a/src/__tests__/components/NotificationToast.spec.tsx b/src/__tests__/components/NotificationToast.spec.tsx new file mode 100644 index 00000000..e1dcebe7 --- /dev/null +++ b/src/__tests__/components/NotificationToast.spec.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { NotificationToast } from '../../components/notification/NotificationToast'; + +describe('NotificationToast', () => { + const mockNotifications = [ + { id: '1', message: '10분 후 회의 일정이 시작됩니다.' }, + { id: '2', message: '15분 후 점심 약속 일정이 시작됩니다.' }, + ]; + + const mockOnRemoveNotification = vi.fn(); + + beforeEach(() => { + mockOnRemoveNotification.mockClear(); + }); + + it('알림이 없을 때 아무것도 렌더링하지 않는다', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('알림이 있을 때 모든 알림을 표시한다', () => { + render( + + ); + + expect(screen.getByText('10분 후 회의 일정이 시작됩니다.')).toBeInTheDocument(); + expect(screen.getByText('15분 후 점심 약속 일정이 시작됩니다.')).toBeInTheDocument(); + }); + + it('알림의 닫기 버튼을 클릭하면 onRemoveNotification이 올바른 인덱스로 호출된다', async () => { + const user = userEvent.setup(); + + render( + + ); + + const closeButtons = screen.getAllByRole('button'); + + // 첫 번째 알림의 닫기 버튼 클릭 + await user.click(closeButtons[0]); + expect(mockOnRemoveNotification).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/__tests__/components/OverlapDialog.spec.tsx b/src/__tests__/components/OverlapDialog.spec.tsx new file mode 100644 index 00000000..52e8639c --- /dev/null +++ b/src/__tests__/components/OverlapDialog.spec.tsx @@ -0,0 +1,122 @@ +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import { OverlapDialog } from '../../components/dialog/OverlapDialog'; +import { Event } from '../../types'; + +describe('OverlapDialog', () => { + const mockOverlappingEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '중요한 미팅', + date: '2025-01-15', + startTime: '09:30', + endTime: '11:00', + description: '중요한 클라이언트 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]; + + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + mockOnClose.mockClear(); + mockOnConfirm.mockClear(); + }); + + it('다이얼로그가 열려있지 않을 때 렌더링되지 않는다', () => { + render( + + ); + + expect(screen.queryByText('일정 겹침 경고')).not.toBeInTheDocument(); + }); + + it('다이얼로그가 열려있을 때 제목과 내용이 표시된다', () => { + render( + + ); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText(/다음 일정과 겹칩니다:/)).toBeInTheDocument(); + expect(screen.getByText(/계속 진행하시겠습니까?/)).toBeInTheDocument(); + }); + + it('겹치는 일정들의 정보가 모두 표시된다', () => { + render( + + ); + + expect(screen.getByText('기존 회의 (2025-01-15 09:00-10:00)')).toBeInTheDocument(); + expect(screen.getByText('중요한 미팅 (2025-01-15 09:30-11:00)')).toBeInTheDocument(); + }); + + it('취소 버튼을 클릭하면 onClose가 호출된다', async () => { + const user = userEvent.setup(); + + render( + + ); + + const cancelButton = screen.getByText('취소'); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('계속 진행 버튼을 클릭하면 onConfirm이 호출된다', async () => { + const user = userEvent.setup(); + + render( + + ); + + const confirmButton = screen.getByText('계속 진행'); + await user.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + expect(mockOnClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..0f7388b0 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,105 @@ import { act, renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + beforeEach(() => { + vi.setSystemTime(new Date('2025-10-01T00:00:00')); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); + + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); +}); + +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); +}); + +it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-10-08')); +}); + +it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-24')); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + act(() => { + result.current.navigate('next'); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + assertDate(result.current.currentDate, new Date('2025-11-01')); +}); + +it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('prev'); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + assertDate(result.current.currentDate, new Date('2025-09-01')); +}); + +it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + 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..83d467dd 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,249 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 프로젝트 진행상황 공유', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-01-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + { + id: '3', + title: '고객 미팅', + date: '2025-01-16', + startTime: '14:00', + endTime: '15:00', + description: '신규 프로젝트 제안', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 20, + }, + { + id: '4', + title: '운동', + date: '2025-01-22', + startTime: '18:00', + endTime: '19:00', + description: '헬스장에서 운동', + location: '피트니스 센터', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + { + id: '5', + title: '노래방', + date: '2025-01-17', + startTime: '18:00', + endTime: '19:00', + description: '노래방에서 노래하기', + location: '팀즈 노래방', + category: '팀활동', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, +]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +const currentDate = new Date('2025-01-15'); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + expect(result.current.searchTerm).toBe(''); + expect(result.current.filteredEvents).toEqual(mockEvents); +}); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.searchTerm).toBe('회의'); + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 프로젝트 진행상황 공유', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); +}); + +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + + act(() => { + result.current.setSearchTerm('팀'); + }); + + expect(result.current.searchTerm).toBe('팀'); + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 프로젝트 진행상황 공유', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-01-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + { + id: '5', + title: '노래방', + date: '2025-01-17', + startTime: '18:00', + endTime: '19:00', + description: '노래방에서 노래하기', + location: '팀즈 노래방', + category: '팀활동', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + ]); +}); + +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + expect(weekResult.current.filteredEvents).toEqual([ + { + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 프로젝트 진행상황 공유', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2025-01-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + { + id: '3', + title: '고객 미팅', + date: '2025-01-16', + startTime: '14:00', + endTime: '15:00', + description: '신규 프로젝트 제안', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 20, + }, + { + id: '5', + title: '노래방', + date: '2025-01-17', + startTime: '18:00', + endTime: '19:00', + description: '노래방에서 노래하기', + location: '팀즈 노래방', + category: '팀활동', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + ]); + + const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + + expect(monthResult.current.filteredEvents).toEqual(mockEvents); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.searchTerm).toBe('회의'); + expect(result.current.filteredEvents).toEqual([ + { + id: '1', + title: '팀 회의', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 프로젝트 진행상황 공유', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.searchTerm).toBe('점심'); + expect(result.current.filteredEvents).toEqual([ + { + id: '2', + title: '점심 약속', + date: '2025-01-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..f22c4910 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 { @@ -22,16 +22,172 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +beforeEach(() => { + enqueueSnackbarFn.mockClear(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '테스트 이벤트', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]).toEqual(mockEvents[0]); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + }); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + const newEvent: Omit = { + title: '새 이벤트', + date: '2025-01-16', + startTime: '14:00', + endTime: '15:00', + description: '새로운 이벤트', + location: '새 장소', + category: '새 카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }; -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + setupMockHandlerCreation(); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + await act(async () => { + await result.current.saveEvent(newEvent); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + const existingEvent: Event = { + id: '1', + title: '기존 이벤트', + date: '2025-01-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 설명', + location: '기존 장소', + category: '기존', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const updatedEvent: Event = { + ...existingEvent, + title: '수정된 이벤트', + endTime: '11:00', + }; + + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); +}); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + expect(result.current.events).toHaveLength(0); + }); +}); + +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, + }; + + server.use( + http.put(`/api/events/${nonExistentEvent.id}`, () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent(nonExistentEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/1', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + 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..acc8ac48 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -2,13 +2,123 @@ 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'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +describe('useNotifications', () => { + it('초기 상태에서는 알림이 없어야 한다', () => { + const mockEvents: Event[] = []; + const { result } = renderHook(() => useNotifications(mockEvents)); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); + }); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const mockEvent: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + const { result } = renderHook(() => useNotifications([mockEvent])); + + const eventDate = new Date('2025-01-15T10:00:00'); + + const notificationTime = new Date(eventDate); + notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime); + + vi.setSystemTime(notificationTime); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' }, + ]); + }); + + it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const mockEvent: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const { result } = renderHook(() => useNotifications([mockEvent])); + + const eventDate = new Date('2025-01-15T10:00:00'); + + const notificationTime = new Date(eventDate); + notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime); + + vi.setSystemTime(notificationTime); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' }, + ]); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toEqual([]); + }); + + it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const mockEvent: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-01-15', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const { result } = renderHook(() => useNotifications([mockEvent])); + + const eventDate = new Date('2025-01-15T10:00:00'); + + const notificationTime = new Date(eventDate); + notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime); + + vi.setSystemTime(notificationTime); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' }, + ]); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' }, + ]); + }); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..24666da6 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,8 +1,7 @@ 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'; import { ReactElement } from 'react'; @@ -12,16 +11,12 @@ import { setupMockHandlerUpdating, } from '../__mocks__/handlersUtils'; import App from '../App'; -import { server } from '../setupTests'; -import { Event } from '../types'; +import { EventForm } from '../types'; -const theme = createTheme(); - -// ! HINT. 이 유틸을 사용해 리액트 컴포넌트를 렌더링해보세요. const setup = (element: ReactElement) => { + const theme = createTheme(); const user = userEvent.setup(); - // ? Medium: 여기서 Provider로 묶어주는 동작은 의미있을까요? 있다면 어떤 의미일까요? return { ...render( @@ -33,63 +28,305 @@ const setup = (element: ReactElement) => { }; }; -// ! HINT. 이 유틸을 사용해 일정을 저장해보세요. -const saveSchedule = async ( - user: UserEvent, - form: Omit -) => { - const { title, date, startTime, endTime, location, description, category } = form; - - await user.click(screen.getAllByText('일정 추가')[0]); - - await user.type(screen.getByLabelText('제목'), title); - await user.type(screen.getByLabelText('날짜'), date); - await user.type(screen.getByLabelText('시작 시간'), startTime); - await user.type(screen.getByLabelText('종료 시간'), endTime); - await user.type(screen.getByLabelText('설명'), description); - await user.type(screen.getByLabelText('위치'), location); - await user.click(screen.getByLabelText('카테고리')); - await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox')); - await user.click(screen.getByRole('option', { name: `${category}-option` })); - - await user.click(screen.getByTestId('event-submit-button')); +const fillEventForm = async (user: UserEvent, eventData: Partial) => { + if (eventData.title) await user.type(screen.getByLabelText('제목'), eventData.title); + if (eventData.date) await user.type(screen.getByLabelText('날짜'), eventData.date); + if (eventData.startTime) await user.type(screen.getByLabelText('시작 시간'), eventData.startTime); + if (eventData.endTime) await user.type(screen.getByLabelText('종료 시간'), eventData.endTime); + if (eventData.description) await user.type(screen.getByLabelText('설명'), eventData.description); + if (eventData.location) await user.type(screen.getByLabelText('위치'), eventData.location); + + if (eventData.category) { + const selectCategory = screen.getByLabelText('카테고리'); + await user.click(within(selectCategory).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `${eventData.category}-option` })); + } }; -// ! HINT. "검색 결과가 없습니다"는 초기에 노출되는데요. 그럼 검증하고자 하는 액션이 실행되기 전에 검증해버리지 않을까요? 이 테스트를 신뢰성있게 만드려면 어떻게 할까요? describe('일정 CRUD 및 기본 기능', () => { - it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + it('폼 데이터를 입력하고 일정 추가 버튼을 클릭하면 새로운 일정이 리스트에 추가된다.', 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, + }, + ]); + + const { user } = setup(); + + await fillEventForm(user, { + title: '크리스마스 준비', + date: '2025-10-25', + startTime: '12:00', + endTime: '23:00', + description: '크리스마스 파티 준비하기', + location: '회의실 A', + category: '기타', + }); + + await user.click(screen.getByRole('button', { name: '일정 추가' })); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + expect(editButtons).toHaveLength(2); + expect(screen.getByText('크리스마스 파티 준비하기')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 수정하면 수정된 정보가 이벤트 리스트에 반영된다.', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + const locationInput = screen.getByLabelText('위치'); + + await user.click(editButtons[0]); + + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + + await user.clear(locationInput); + await user.type(locationInput, '회의실 D'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + expect(screen.getByText('회의실 D')).toBeInTheDocument(); + }); + + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + + const { user } = setup(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + const deleteButton = await screen.findByRole('button', { name: 'Delete event' }); + + await user.click(deleteButton); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it("view를 'week'로 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.", async () => { + const { user } = setup(); + + const viewSelect = screen.getByLabelText('뷰 타입 선택'); + + await user.click(within(viewSelect).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); + + expect(screen.queryByText('기존 회의')).not.toBeInTheDocument(); + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("view를 'week'로 선택 후 해당 주에 일정이 있다면 리스트, 달력에 표시된다", async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + const viewSelect = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelect).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); + + expect(within(screen.getByTestId('week-view')).getByText('기존 회의')).toBeInTheDocument(); + expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument(); + }); + + it("view가 'month'일 때 해당 월에 일정이 없으면, 일정이 표시되지 않는다.", async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-11-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + expect(screen.queryByText('기존 회의')).not.toBeInTheDocument(); + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("view가 'month'일 때 해당 월에 일정이 있다면 리스트, 달력에 표시된다", async () => { + setup(); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + expect( + await within(screen.getByTestId('month-view')).findByText('기존 회의') + ).toBeInTheDocument(); + expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const { user } = setup(); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + for (let i = 0; i < 9; i++) { + await user.click(screen.getByLabelText('Previous')); + } - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + expect(screen.getByText('신정')).toBeInTheDocument(); + expect(screen.getByText('신정')).toHaveStyle('color: rgb(211, 47, 47)'); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + const { user } = setup(); + + await user.type(screen.getByLabelText('일정 검색'), '식사'); + + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", 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, + }, + { + id: '2', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await user.type(screen.getByLabelText('일정 검색'), '팀 회의'); + + expect( + within(screen.getByTestId('event-list')).queryByText('기존 회의') + ).not.toBeInTheDocument(); + expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', 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, + }, + { + id: '2', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await user.type(screen.getByLabelText('일정 검색'), '팀 회의'); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + expect( + within(screen.getByTestId('event-list')).queryByText('기존 회의') + ).not.toBeInTheDocument(); + expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument(); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + await user.clear(screen.getByLabelText('일정 검색')); + + expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument(); + expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + const { user } = setup(); + + await fillEventForm(user, { + title: '새로운 회의', + date: '2025-10-15', + startTime: '09:30', + endTime: '10:30', + description: '새로운 팀 미팅', + location: '회의실 K', + }); + + await user.click(screen.getByRole('button', { name: '일정 추가' })); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + + await user.click(editButtons[0]); + await user.clear(screen.getByLabelText('시작 시간')); + await user.type(screen.getByLabelText('시작 시간'), '11:30'); + await user.clear(screen.getByLabelText('종료 시간')); + await user.type(screen.getByLabelText('종료 시간'), '12:30'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + vi.setSystemTime(new Date('2025-10-15T08:50:00')); + + setup(); + + await screen.findByText('기존 팀 미팅'); + + expect(await screen.findByText('10분 후 기존 회의 일정이 시작됩니다.')).toBeInTheDocument(); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..1e65b17c 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,351 @@ import { } from '../../utils/dateUtils'; 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일을 반환한다', () => { + // 윤년 = 1년이 366일이 되는 해 + // 윤년은 4년마다 한 번씩 온다. + expect(getDaysInMonth(2024, 2)).toBe(29); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + /** + * 캘린더에서 선택하는 날짜가 주입되는 방식이기 때문에, + * 유효하지 않은 입력이 매개변수로 주입되는 경우는 없다고 판단 + * + * 그리고 해당 테스트를 통해서 유의미한 결과값을 도출해낼 수 있는지 잘 모르겠음 + */ + it('유효하지 않은 월을 입력할 경우 자동으로 이월하여 올바른 월의 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 13)).toBe(31); // 2026년 1월의 일수 + expect(getDaysInMonth(2025, 25)).toBe(31); // 2027년 1월의 일수 + expect(getDaysInMonth(2025, -2)).toBe(31); // 2024년 10월의 일수 + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + /** + * 아래의 테스트 케이스들은 모두 + * 1. 주가 7일로 구성되는지 + * 2. 주의 시작일과 종료일이 올바른지 + * + * 를 검증하는 테스트들이다. 하지만, + * + * 1. 주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다 + * 2. 주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다 + * 3. 주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다 + * + * 다른 테스트 케이스들은 반례를 다루고 있는 반면, 위 3개의 테스트는 중복되는 테스트로 불필요한 테스트이다. + * + */ + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const result = getWeekDates(new Date('2025-01-15')); // 수요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2025-01-12').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2025-01-18').toDateString()); // 토요일 + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const result = getWeekDates(new Date('2025-01-13')); // 월요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2025-01-12').toDateString()); // 일요일 + expect(result[1].toDateString()).toBe(new Date('2025-01-13').toDateString()); // 월요일 + expect(result[6].toDateString()).toBe(new Date('2025-01-18').toDateString()); // 토요일 + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const result = getWeekDates(new Date('2025-01-19')); // 일요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2025-01-19').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2025-01-25').toDateString()); // 토요일 + }); + + /** + * 아래의 두 개의 테스트는 목적은 동일하지만, 위 주석친 사례와 달리 반례를 다루고 있음 + */ + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const result = getWeekDates(new Date('2025-12-30')); // 화요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2025-12-28').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2026-01-03').toDateString()); // 토요일 + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const result = getWeekDates(new Date('2025-01-01')); // 수요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2024-12-29').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2025-01-04').toDateString()); // 토요일 + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const result = getWeekDates(new Date('2024-02-29')); // 목요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2024-02-25').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2024-03-02').toDateString()); // 토요일 + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const result = getWeekDates(new Date('2025-01-31')); // 금요일 + expect(result).toHaveLength(7); + expect(result[0].toDateString()).toBe(new Date('2025-01-26').toDateString()); // 일요일 + expect(result[6].toDateString()).toBe(new Date('2025-02-01').toDateString()); // 토요일 + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const result = getWeeksAtMonth(new Date('2025-07-01')); + + expect(result).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 mockEvents: Event[] = [ + { + id: '1', + title: '1일 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '1일 이벤트', + location: '장소', + category: '카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '15일 이벤트', + date: '2025-01-15', + startTime: '14:00', + endTime: '15:00', + description: '15일 이벤트', + location: '장소', + category: '카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const result = getEventsForDay(mockEvents, 1); + expect(result).toEqual([ + { + id: '1', + title: '1일 이벤트', + date: '2025-01-01', + startTime: '09:00', + endTime: '10:00', + description: '1일 이벤트', + location: '장소', + category: '카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(mockEvents, 10); + expect(result).toEqual([]); + }); + + /** + * 날짜가 0 혹은 32 이상이 되는 경우는 존재하지 않음. + * 아래 두 개의 테스트는 불필요한 테스트 + */ + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(mockEvents, 0); + expect(result).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(mockEvents, 32); + expect(result).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + /** + * 아래 3개의 테스트들은 모두 동일한 목적을 가지는 테스트로 불필요한 테스트이다. + */ + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2025-07-15')); + expect(result).toBe('2025년 7월 3주'); + }); + + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2025-07-01')); + expect(result).toBe('2025년 7월 1주'); + }); + + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2025-07-31')); + expect(result).toBe('2025년 7월 5주'); + }); + + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2025-12-31')); + expect(result).toBe('2026년 1월 1주'); + }); + + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2024-02-29')); + expect(result).toBe('2024년 2월 5주'); + }); + + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const result = formatWeek(new Date('2025-02-28')); + expect(result).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const result = formatMonth(new Date('2025-07-10')); + expect(result).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); - - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); - - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); - - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); - - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); - - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + /** + * 1. 범위 내의 날짜 2025-07-10에 대해 true를 반환한다 + * 2. 범위의 시작일 2025-07-01에 대해 true를 반환한다 + * 3. 범위의 종료일 2025-07-31에 대해 true를 반환한다 + * + * 위 3개의 테스트는 모두 경계값 내부를 다루는 테스트로 불필요한 테스트이다. + */ + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + const testDate = new Date('2025-07-10'); + expect(isDateInRange(testDate, start, end)).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + const testDate = new Date('2025-07-01'); + expect(isDateInRange(testDate, start, end)).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + const testDate = new Date('2025-07-31'); + expect(isDateInRange(testDate, start, end)).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + const testDate = new Date('2025-06-30'); + expect(isDateInRange(testDate, start, end)).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + const testDate = new Date('2025-08-01'); + expect(isDateInRange(testDate, start, end)).toBe(false); + }); + + /** + * 범위 이전 날짜 검증에 대한 테스트와 동일한 목적을 가지는 테스트 + */ + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const start = new Date('2025-07-31'); + const end = new Date('2025-07-01'); + const testDate = new Date('2025-07-15'); + expect(isDateInRange(testDate, start, end)).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); - - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); - - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); - - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); - - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); - - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); - - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); - - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); - - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + // 기본 동작 + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5, 2)).toBe('05'); + }); + + // 기본 동작 + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10, 2)).toBe('10'); + }); + + // 다른 자릿수 + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); + + // 경계값 + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100, 2)).toBe('100'); + }); + + // 경계값 + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0, 2)).toBe('00'); + }); + + // 필요하지 않은 테스트 + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); + + // 필요하지 않은 테스트 + it('소수점이 있는 3.14를 5자리로 변환하면 "03.14"를 반환한다', () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); + + // 반례 + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(5)).toBe('05'); + }); + + // 필요하지 않은 테스트 + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(100, 2)).toBe('100'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); - - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); - - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); - - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const result = formatDate(new Date('2025-07-15')); + expect(result).toBe('2025-07-15'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const result = formatDate(new Date('2025-07-15'), 20); + expect(result).toBe('2025-07-20'); + }); + + /** + * "날짜를 YYYY-MM-DD 형식으로 포맷팅한다" 테스트에서 충분히 검증이 가능한 테스트이므로, + * 아래 2개의 테스트는 불필요한 테스트 + */ + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const result = formatDate(new Date('2025-03-15')); + expect(result).toBe('2025-03-15'); + }); + + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const result = formatDate(new Date('2025-07-05')); + expect(result).toBe('2025-07-05'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..80e4fdea 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,32 +5,298 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const result = parseDateTime('2025-07-01', '14:30'); + + expect(result).toBeInstanceOf(Date); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(6); + expect(result.getDate()).toBe(1); + expect(result.getHours()).toBe(14); + expect(result.getMinutes()).toBe(30); + }); + + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('invalid-date', '14:30'); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + expect(isNaN(result.getTime())).toBe(true); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-07-01', 'invalid-time'); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + expect(isNaN(result.getTime())).toBe(true); + }); + + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const result = parseDateTime('', '14:30'); + + expect(isNaN(result.getTime())).toBe(true); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const { start, end } = convertEventToDateRange(event); + + expect(start).toBeInstanceOf(Date); + expect(end).toBeInstanceOf(Date); + expect(start.getFullYear()).toBe(2025); + expect(start.getMonth()).toBe(6); + expect(start.getDate()).toBe(1); + expect(start.getHours()).toBe(9); + expect(start.getMinutes()).toBe(0); + expect(end.getHours()).toBe(10); + expect(end.getMinutes()).toBe(0); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: 'invalid-date', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const { start, end } = convertEventToDateRange(event); + + expect(isNaN(start.getTime())).toBe(true); + expect(isNaN(end.getTime())).toBe(true); + }); + + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-07-01', + startTime: 'invalid-time', + endTime: 'invalid-time', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + const { start, end } = convertEventToDateRange(event); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(isNaN(start.getTime())).toBe(true); + expect(isNaN(end.getTime())).toBe(true); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const event1: Event = { + id: '1', + title: '첫 번째 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '장소1', + category: '카테고리1', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + const event2: Event = { + id: '2', + title: '두 번째 이벤트', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '두 번째 이벤트', + location: '장소2', + category: '카테고리2', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const isOverlapped = isOverlapping(event1, event2); + expect(isOverlapped).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const event1: Event = { + id: '1', + title: '첫 번째 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트', + location: '장소1', + category: '카테고리1', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const event2: Event = { + id: '2', + title: '두 번째 이벤트', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '두 번째 이벤트', + location: '장소2', + category: '카테고리2', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const isOverlapped = isOverlapping(event1, event2); + expect(isOverlapped).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '새 이벤트', + location: '새 장소', + category: '새 카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '겹치는 이벤트1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '겹치는 이벤트1', + location: '장소1', + category: '카테고리1', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '겹치지 않는 이벤트', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + description: '겹치지 않는 이벤트', + location: '장소2', + category: '카테고리2', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '겹치는 이벤트2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '겹치는 이벤트2', + location: '장소3', + category: '카테고리3', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + + const result = findOverlappingEvents(newEvent, existingEvents); + + expect(result).toEqual([ + { + id: '1', + title: '겹치는 이벤트1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '겹치는 이벤트1', + location: '장소1', + category: '카테고리1', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '겹치는 이벤트2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '겹치는 이벤트2', + location: '장소3', + category: '카테고리3', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:00', + description: '새 이벤트', + location: '새 장소', + category: '새 카테고리', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '기존 이벤트1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 이벤트1', + location: '장소1', + category: '카테고리1', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '기존 이벤트2', + date: '2025-07-01', + startTime: '16:00', + endTime: '17:00', + description: '기존 이벤트2', + location: '장소2', + category: '카테고리2', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + + const result = findOverlappingEvents(newEvent, existingEvents); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(result).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..be324a6d 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -2,19 +2,262 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '미팅', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-08', + startTime: '11:00', + endTime: '12:00', + description: '세 번째 이벤트입니다', + location: '온라인', + category: '교육', + repeat: { type: 'none', interval: 0 }, + notificationTime: 20, + }, + { + id: '4', + title: '이벤트 4', + date: '2025-07-15', + startTime: '16:00', + endTime: '17:00', + description: '네 번째 이벤트입니다', + location: '회의실 C', + category: '프로젝트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + { + id: '5', + title: '이벤트 5', + date: '2025-07-31', + startTime: '10:00', + endTime: '11:00', + description: '다섯 번째 이벤트입니다', + location: '회의실 D', + category: '리뷰', + repeat: { type: 'none', interval: 0 }, + notificationTime: 25, + }, + ]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트 2', currentDate, 'month'); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + expect(result).toEqual([ + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '미팅', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const currentDate = new Date('2025-07-01'); // 2025년 7월 1일 (화요일) + const result = getFilteredEvents(mockEvents, '', currentDate, 'week'); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + // 2025-07-01 주는 6월 29일(일) ~ 7월 5일(토)이므로 이벤트 1, 2만 포함 + expect(result).toEqual([ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '미팅', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + expect(result).toEqual(mockEvents); + }); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트', currentDate, 'week'); + + // 검색어 '이벤트'로 필터링 후 주간 뷰 적용 + expect(result).toEqual([ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '미팅', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + ]); + }); + + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result).toEqual([ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '미팅', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-08', + startTime: '11:00', + endTime: '12:00', + description: '세 번째 이벤트입니다', + location: '온라인', + category: '교육', + repeat: { type: 'none', interval: 0 }, + notificationTime: 20, + }, + { + id: '4', + title: '이벤트 4', + date: '2025-07-15', + startTime: '16:00', + endTime: '17:00', + description: '네 번째 이벤트입니다', + location: '회의실 C', + category: '프로젝트', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, + }, + { + id: '5', + title: '이벤트 5', + date: '2025-07-31', + startTime: '10:00', + endTime: '11:00', + description: '다섯 번째 이벤트입니다', + location: '회의실 D', + category: '리뷰', + repeat: { type: 'none', interval: 0 }, + notificationTime: 25, + }, + ]); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, 'event', currentDate, 'month'); + + expect(result).toEqual([ + { + id: '1', + title: 'Event 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + }); + + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + // 7월 1일과 7월 31일 이벤트가 모두 포함되어야 함 + expect(result).toEqual(mockEvents); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents([], '이벤트', currentDate, 'month'); + + expect(result).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..f00f8886 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,46 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; -describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); +describe('fetchHolidays', () => { + it.each([ + { + month: 1, + expected: { + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }, + holidayNames: '신정, 설날', + }, + { month: 3, expected: { '2025-03-01': '삼일절' }, holidayNames: '삼일절' }, + { month: 5, expected: { '2025-05-05': '어린이날' }, holidayNames: '어린이날' }, + { month: 6, expected: { '2025-06-06': '현충일' }, holidayNames: '현충일' }, + { month: 8, expected: { '2025-08-15': '광복절' }, holidayNames: '광복절' }, + { + month: 10, + expected: { + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }, + holidayNames: '개천절, 추석, 한글날', + }, + { month: 12, expected: { '2025-12-25': '크리스마스' }, holidayNames: '크리스마스' }, + ])('$month월에는 $holidayNames 공휴일이 있다', ({ month, expected }) => { + const result = fetchHolidays(new Date(`2025-${month}`)); + expect(result).toEqual(expected); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it.each([ + { month: 2, expected: {} }, + { month: 4, expected: {} }, + { month: 7, expected: {} }, + { month: 9, expected: {} }, + { month: 11, expected: {} }, + ])('$month월에는 공휴일이 없다', ({ month, expected }) => { + const result = fetchHolidays(new Date(`2025-${month}`)); + expect(result).toEqual(expected); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..f8bf785b 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,49 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +const event: Event = { + id: '1', + date: '2025-10-01', + startTime: '10:00:00', + endTime: '11:00:00', + title: 'test', + notificationTime: 10, + description: 'test', + location: 'test', + category: 'test', + repeat: { type: 'none', interval: 0 }, +}; + describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T09:50:00'), []); + + expect(result).toEqual([event]); + }); + + it('이미 알림이 간 이벤트는 제외한다', () => { + const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T10:00:00'), ['1']); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + expect(result).toEqual([]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T09:40:00'), []); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + expect(result).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T10:00:00'), []); + + expect(result).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const result = createNotificationMessage(event); + + expect(result).toBe('10분 후 test 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..4a1e9499 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('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00:00', '13:00:00'); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00:00', '14:00:00'); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const result = getTimeErrorMessage('13:00:00', '14:00:00'); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBe(null); + expect(result.endTimeError).toBe(null); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', '14:00:00'); + + expect(result.startTimeError).toBe(null); + expect(result.endTimeError).toBe(null); + }); + + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('14:00:00', ''); + + expect(result.startTimeError).toBe(null); + expect(result.endTimeError).toBe(null); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', ''); + + expect(result.startTimeError).toBe(null); + expect(result.endTimeError).toBe(null); + }); }); diff --git a/src/components/calendar/CalendarView.tsx b/src/components/calendar/CalendarView.tsx new file mode 100644 index 00000000..724679b5 --- /dev/null +++ b/src/components/calendar/CalendarView.tsx @@ -0,0 +1,70 @@ +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { IconButton, MenuItem, Select, Stack, Typography } from '@mui/material'; + +import { MonthView } from './MonthView'; +import { WeekView } from './WeekView'; +import { Event } from '../../types'; + +interface CalendarViewProps { + view: 'week' | 'month'; + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; + onViewChange: (view: 'week' | 'month') => void; + onNavigate: (direction: 'prev' | 'next') => void; +} + +export const CalendarView = ({ + view, + currentDate, + filteredEvents, + notifiedEvents, + holidays, + onViewChange, + onNavigate, +}: CalendarViewProps) => { + return ( + + 일정 보기 + + + onNavigate('prev')}> + + + + onNavigate('next')}> + + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + ); +}; diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx new file mode 100644 index 00000000..d7f5cf9d --- /dev/null +++ b/src/components/calendar/MonthView.tsx @@ -0,0 +1,120 @@ +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, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils'; + +interface MonthViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +export const MonthView = ({ + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: MonthViewProps) => { + 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} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +}; diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx new file mode 100644 index 00000000..bd55d3cd --- /dev/null +++ b/src/components/calendar/WeekView.tsx @@ -0,0 +1,99 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; +import { formatWeek, getWeekDates } from '../../utils/dateUtils'; + +interface WeekViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; +} + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +export const WeekView = ({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) => { + 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} + + + + ); + })} + + ))} + + +
+
+
+ ); +}; diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 00000000..49f9fc0f --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1 @@ +export { CalendarView } from './CalendarView'; diff --git a/src/components/dialog/OverlapDialog.tsx b/src/components/dialog/OverlapDialog.tsx new file mode 100644 index 00000000..0a79b87e --- /dev/null +++ b/src/components/dialog/OverlapDialog.tsx @@ -0,0 +1,48 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +interface OverlapDialogProps { + isOpen: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onConfirm: () => void; +} + +export const OverlapDialog = ({ + isOpen, + overlappingEvents, + onClose, + onConfirm, +}: OverlapDialogProps) => { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +}; diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts new file mode 100644 index 00000000..09bed373 --- /dev/null +++ b/src/components/dialog/index.ts @@ -0,0 +1 @@ +export { OverlapDialog } from './OverlapDialog'; diff --git a/src/components/eventForm/EventForm.tsx b/src/components/eventForm/EventForm.tsx new file mode 100644 index 00000000..9f788a69 --- /dev/null +++ b/src/components/eventForm/EventForm.tsx @@ -0,0 +1,210 @@ +import { + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { Button } from '@mui/material'; +import { ChangeEvent } from 'react'; + +import { Event } from '../../types'; +import { getTimeErrorMessage } from '../../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventFormProps { + title: string; + setTitle: (title: string) => void; + date: string; + setDate: (date: string) => void; + startTime: string; + endTime: string; + description: string; + setDescription: (description: string) => void; + location: string; + setLocation: (location: string) => void; + category: string; + setCategory: (category: string) => void; + isRepeating: boolean; + setIsRepeating: (isRepeating: boolean) => void; + repeatType: string; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; + setNotificationTime: (notificationTime: number) => void; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: Event | null; + handleStartTimeChange: (e: ChangeEvent) => void; + handleEndTimeChange: (e: ChangeEvent) => void; + onSubmit: () => void; +} + +export const EventForm = ({ + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + onSubmit, +}: EventFormProps) => { + 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="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +}; diff --git a/src/components/eventForm/index.ts b/src/components/eventForm/index.ts new file mode 100644 index 00000000..19d3ca40 --- /dev/null +++ b/src/components/eventForm/index.ts @@ -0,0 +1 @@ +export { EventForm } from './EventForm'; diff --git a/src/components/eventList/EventList.tsx b/src/components/eventList/EventList.tsx new file mode 100644 index 00000000..aa406bf5 --- /dev/null +++ b/src/components/eventList/EventList.tsx @@ -0,0 +1,91 @@ +import { Notifications, Delete, Edit } from '@mui/icons-material'; +import { + Box, + FormControl, + FormLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; +import { getNotificationText, getRepeatText } from '../../utils/eventDisplayUtils'; + +interface EventListProps { + searchTerm: string; + setSearchTerm: (searchTerm: string) => void; + filteredEvents: Event[]; + notifiedEvents: string[]; + onEditEvent: (event: Event) => void; + onDeleteEvent: (eventId: string) => void; +} + +export const EventList = ({ + searchTerm, + setSearchTerm, + filteredEvents, + notifiedEvents, + onEditEvent, + onDeleteEvent, +}: EventListProps) => { + return ( + + + 일정 검색 + setSearchTerm(e.target.value)} + /> + + + {filteredEvents.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + filteredEvents.map((event) => ( + + + + + {notifiedEvents.includes(event.id) && } + + {event.title} + + + {event.date} + + {event.startTime} - {event.endTime} + + {event.description} + {event.location} + 카테고리: {event.category} + {event.repeat.type !== 'none' && ( + {getRepeatText(event.repeat)} + )} + 알림: {getNotificationText(event.notificationTime)} + + + onEditEvent(event)}> + + + onDeleteEvent(event.id)}> + + + + + + )) + )} + + ); +}; diff --git a/src/components/eventList/index.ts b/src/components/eventList/index.ts new file mode 100644 index 00000000..c535a542 --- /dev/null +++ b/src/components/eventList/index.ts @@ -0,0 +1 @@ +export { EventList } from './EventList'; diff --git a/src/components/notification/NotificationToast.tsx b/src/components/notification/NotificationToast.tsx new file mode 100644 index 00000000..d40c04e2 --- /dev/null +++ b/src/components/notification/NotificationToast.tsx @@ -0,0 +1,38 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface Notification { + id: string; + message: string; +} + +interface NotificationToastProps { + notifications: Notification[]; + onRemoveNotification: (index: number) => void; +} + +export const NotificationToast = ({ + notifications, + onRemoveNotification, +}: NotificationToastProps) => { + if (notifications.length === 0) return null; + + return ( + + {notifications.map((notification, index) => ( + onRemoveNotification(index)}> + + + } + > + {notification.message} + + ))} + + ); +}; diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts new file mode 100644 index 00000000..04ee234e --- /dev/null +++ b/src/components/notification/index.ts @@ -0,0 +1 @@ +export { NotificationToast } from './NotificationToast'; diff --git a/src/utils/eventDisplayUtils.ts b/src/utils/eventDisplayUtils.ts new file mode 100644 index 00000000..b421ccac --- /dev/null +++ b/src/utils/eventDisplayUtils.ts @@ -0,0 +1,35 @@ +import { Event } from '../types'; + +export const getNotificationText = (notificationTime: number): string => { + switch (notificationTime) { + case 1: + return '1분 전'; + case 10: + return '10분 전'; + case 60: + return '1시간 전'; + case 120: + return '2시간 전'; + case 1440: + return '1일 전'; + default: + return ''; + } +}; + +export const getRepeatText = (repeat: Event['repeat']): string => { + if (repeat.type === 'none') return ''; + + const typeText = { + daily: '일', + weekly: '주', + monthly: '월', + yearly: '년', + }[repeat.type]; + + let text = `반복: ${repeat.interval}${typeText}마다`; + if (repeat.endDate) { + text += ` (종료: ${repeat.endDate})`; + } + return text; +};