diff --git a/package.json b/package.json index b01b2b4b..d3fd7c3b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@eslint/js": "^9.33.0", "@mui/icons-material": "7.2.0", "@mui/material": "7.2.0", "express": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..af349b60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@emotion/styled': specifier: ^11.11.5 version: 11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@mui/icons-material': specifier: 7.2.0 version: 7.2.0(@mui/material@7.2.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) @@ -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..9bd43ffc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,17 @@ -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 { useSnackbar } from 'notistack'; -import { useState } from 'react'; +import { Box, Stack } from '@mui/material'; +import { CalendarView } from './components/CalendarView'; +import { EventForm } from './components/EventForm'; +import { EventList } from './components/EventList'; +import { NotificationStack } from './components/NotificationStack'; +import { OverlapDialog } from './components/OverlapDialog'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; +import { useEventHandlers } from './hooks/useEventHandlers.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; +import { useOverlapDialog } from './hooks/useOverlapDialog.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 { @@ -77,11 +30,8 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -101,558 +51,93 @@ function App() { const { notifications, notifiedEvents, setNotifications } = useNotifications(events); const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); + const { isOverlapDialogOpen, overlappingEvents, openOverlapDialog, closeOverlapDialog } = + useOverlapDialog(); - const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); - const [overlappingEvents, setOverlappingEvents] = useState([]); - - const { enqueueSnackbar } = useSnackbar(); - - const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { - enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); - return; - } - - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); - return; - } - - const eventData: Event | EventForm = { - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - 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} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); - }; + const { addOrUpdateEvent, handleOverlapDialogContinue } = useEventHandlers({ + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + startTimeError, + endTimeError, + editingEvent, + events, + saveEvent, + resetForm, + openOverlapDialog, + closeOverlapDialog, + }); 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} - - ))} - - )} + + + + setNotifications((prev) => prev.filter((_, i) => i !== index)) + } + /> ); } diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..4bb9dac8 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -3,22 +3,94 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-10-01')); +}); + describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.view).toBe('month'); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-09': '한글날', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + }); + }); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +describe('view 변경', () => { + it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + expect(result.current.view).toBe('week'); + }); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('next'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-10-08')); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('prev'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-09-24')); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('month'); + }); + act(() => { + result.current.navigate('next'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-11-01')); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('month'); + }); + act(() => { + result.current.navigate('prev'); + }); + expect(result.current.currentDate).toEqual(new Date('2025-09-01')); + }); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + 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.useEventHandlers.spec.ts b/src/__tests__/hooks/easy.useEventHandlers.spec.ts new file mode 100644 index 00000000..9802d936 --- /dev/null +++ b/src/__tests__/hooks/easy.useEventHandlers.spec.ts @@ -0,0 +1,168 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { useEventHandlers } from '../../hooks/useEventHandlers.ts'; +import { Event } from '../../types.ts'; + +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: vi.fn(), + }), +})); + +describe('useEventHandlers', () => { + const mockEvents: Event[] = [ + { + id: '1', + date: '2025-10-01', + title: '기존 회의', + description: '팀 회의', + location: '회의실 A', + category: '업무', + startTime: '10:00', + endTime: '11:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + ]; + + const defaultParams = { + title: '새 일정', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '설명', + location: '위치', + category: '업무', + isRepeating: false, + repeatType: 'none' as const, + repeatInterval: 1, + repeatEndDate: '', + notificationTime: 60, + startTimeError: null, + endTimeError: null, + editingEvent: null, + events: mockEvents, + saveEvent: vi.fn(), + resetForm: vi.fn(), + openOverlapDialog: vi.fn(), + closeOverlapDialog: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('addOrUpdateEvent 함수가 제공되어야 한다', () => { + const { result } = renderHook(() => useEventHandlers(defaultParams)); + expect(result.current.addOrUpdateEvent).toBeDefined(); + expect(typeof result.current.addOrUpdateEvent).toBe('function'); + }); + + it('handleOverlapDialogContinue 함수가 제공되어야 한다', () => { + const { result } = renderHook(() => useEventHandlers(defaultParams)); + expect(result.current.handleOverlapDialogContinue).toBeDefined(); + expect(typeof result.current.handleOverlapDialogContinue).toBe('function'); + }); + + it('필수 정보가 없을 때 addOrUpdateEvent 호출 시 snackbar 에러 표시 후 리턴해야 한다', async () => { + const paramsWithEmptyTitle = { ...defaultParams, title: '' }; + const { result } = renderHook(() => useEventHandlers(paramsWithEmptyTitle)); + + await act(async () => { + await result.current.addOrUpdateEvent(); + }); + + expect(defaultParams.saveEvent).not.toHaveBeenCalled(); + expect(defaultParams.openOverlapDialog).not.toHaveBeenCalled(); + }); + + it('시간 에러가 있을 때 addOrUpdateEvent 호출 시 snackbar 에러 표시 후 리턴해야 한다', async () => { + const paramsWithTimeError = { ...defaultParams, startTimeError: '시간 오류' }; + const { result } = renderHook(() => useEventHandlers(paramsWithTimeError)); + + await act(async () => { + await result.current.addOrUpdateEvent(); + }); + + expect(defaultParams.saveEvent).not.toHaveBeenCalled(); + expect(defaultParams.openOverlapDialog).not.toHaveBeenCalled(); + }); + + it('겹치는 일정이 없을 때 addOrUpdateEvent 호출 시 바로 저장해야 한다', async () => { + const paramsWithNoOverlap = { ...defaultParams, startTime: '14:00', endTime: '15:00' }; + const { result } = renderHook(() => useEventHandlers(paramsWithNoOverlap)); + + await act(async () => { + await result.current.addOrUpdateEvent(); + }); + + expect(paramsWithNoOverlap.saveEvent).toHaveBeenCalledTimes(1); + expect(paramsWithNoOverlap.resetForm).toHaveBeenCalledTimes(1); + expect(paramsWithNoOverlap.openOverlapDialog).not.toHaveBeenCalled(); + }); + + it('겹치는 일정이 있을 때 addOrUpdateEvent 호출 시 겹침 다이얼로그를 열어야 한다', async () => { + // 기존 일정과 겹치는 시간 설정 (10:00-11:00과 10:30-11:30이 겹침) + const paramsWithOverlap = { ...defaultParams, startTime: '10:30', endTime: '11:30' }; + const { result } = renderHook(() => useEventHandlers(paramsWithOverlap)); + + await act(async () => { + await result.current.addOrUpdateEvent(); + }); + + expect(paramsWithOverlap.openOverlapDialog).toHaveBeenCalledTimes(1); + expect(paramsWithOverlap.saveEvent).not.toHaveBeenCalled(); + expect(paramsWithOverlap.resetForm).not.toHaveBeenCalled(); + }); + + it('handleOverlapDialogContinue 호출 시 다이얼로그를 닫고 일정을 저장해야 한다', async () => { + const { result } = renderHook(() => useEventHandlers(defaultParams)); + + await act(async () => { + await result.current.handleOverlapDialogContinue(); + }); + + expect(defaultParams.closeOverlapDialog).toHaveBeenCalledTimes(1); + expect(defaultParams.saveEvent).toHaveBeenCalledTimes(1); + expect(defaultParams.resetForm).toHaveBeenCalledTimes(1); + }); + + it('수정 모드일 때 올바른 이벤트 데이터를 생성해야 한다', async () => { + const editingEvent: Event = { + id: 'edit-1', + date: '2025-10-01', + title: '수정할 일정', + description: '수정할 설명', + location: '수정할 위치', + category: '업무', + startTime: '16:00', + endTime: '17:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }; + + const paramsWithEdit = { + ...defaultParams, + editingEvent, + startTime: '16:00', + endTime: '17:00', + }; + + const { result } = renderHook(() => useEventHandlers(paramsWithEdit)); + + await act(async () => { + await result.current.addOrUpdateEvent(); + }); + + const expectedEventData = expect.objectContaining({ + id: 'edit-1', + title: '새 일정', + date: '2025-10-01', + startTime: '16:00', + endTime: '17:00', + }); + + expect(paramsWithEdit.saveEvent).toHaveBeenCalledWith(expectedEventData); + }); +}); diff --git a/src/__tests__/hooks/easy.useOverlapDialog.spec.ts b/src/__tests__/hooks/easy.useOverlapDialog.spec.ts new file mode 100644 index 00000000..ab445948 --- /dev/null +++ b/src/__tests__/hooks/easy.useOverlapDialog.spec.ts @@ -0,0 +1,96 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useOverlapDialog } from '../../hooks/useOverlapDialog.ts'; +import { Event } from '../../types.ts'; + +describe('useOverlapDialog', () => { + const mockEvents: Event[] = [ + { + id: '1', + date: '2025-10-01', + title: '기존 회의', + description: '팀 회의', + location: '회의실 A', + category: '업무', + startTime: '10:00', + endTime: '11:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '2', + date: '2025-10-01', + title: '또 다른 회의', + description: '프로젝트 회의', + location: '회의실 B', + category: '업무', + startTime: '14:00', + endTime: '15:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + ]; + + it('초기 상태에서 다이얼로그가 닫혀있어야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + expect(result.current.isOverlapDialogOpen).toBe(false); + }); + + it('초기 상태에서 overlappingEvents가 빈 배열이어야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + expect(result.current.overlappingEvents).toEqual([]); + }); + + it('openOverlapDialog 호출 시 다이얼로그가 열려야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + act(() => { + result.current.openOverlapDialog(mockEvents); + }); + expect(result.current.isOverlapDialogOpen).toBe(true); + }); + + it('openOverlapDialog 호출 시 overlappingEvents가 설정되어야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + act(() => { + result.current.openOverlapDialog(mockEvents); + }); + expect(result.current.overlappingEvents).toEqual(mockEvents); + }); + + it('closeOverlapDialog 호출 시 다이얼로그가 닫혀야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + act(() => { + result.current.openOverlapDialog(mockEvents); + }); + act(() => { + result.current.closeOverlapDialog(); + }); + expect(result.current.isOverlapDialogOpen).toBe(false); + }); + + it('빈 배열로 openOverlapDialog 호출 시에도 정상 동작해야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + act(() => { + result.current.openOverlapDialog([]); + }); + expect(result.current.isOverlapDialogOpen).toBe(true); + expect(result.current.overlappingEvents).toEqual([]); + }); + + it('다이얼로그가 열린 상태에서 다른 이벤트로 재호출 시 이벤트가 업데이트되어야 한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + const firstEvents = [mockEvents[0]]; + const secondEvents = [mockEvents[1]]; + + act(() => { + result.current.openOverlapDialog(firstEvents); + }); + expect(result.current.overlappingEvents).toEqual(firstEvents); + + act(() => { + result.current.openOverlapDialog(secondEvents); + }); + expect(result.current.overlappingEvents).toEqual(secondEvents); + expect(result.current.isOverlapDialogOpen).toBe(true); + }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..78e3d3de 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,94 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +describe('useSearch', () => { + const mockEvents: Event[] = [ + { + id: '1', + date: '2025-10-01', + title: '회의', + description: '팀 회의', + location: '회의실 A', + category: '업무', + startTime: '10:00', + endTime: '11:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '2', + date: '2025-10-02', + title: '점심 미팅', + description: '외부 고객사 미팅', + location: '레스토랑', + category: '업무', + startTime: '12:00', + endTime: '13:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '3', + date: '2025-10-02', + title: '긴급 회의', + description: '오류 대응 긴급 회의', + location: '회의실 B', + category: '업무', + startTime: '09:00', + endTime: '11:00', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + ]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm(''); + }); + expect(result.current.filteredEvents).toHaveLength(3); + expect(result.current.filteredEvents).toEqual(mockEvents); + }); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm('레스토랑'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents).toEqual([mockEvents[1]]); + }); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm('미팅'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents).toEqual([mockEvents[1]]); + }); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(2); + expect(result.current.filteredEvents).toEqual([mockEvents[0], mockEvents[2]]); + }); + + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-10-01'), 'week')); + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(2); + expect(result.current.filteredEvents).toEqual([mockEvents[0], mockEvents[2]]); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents).toEqual([mockEvents[1]]); + }); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..723178ef 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,198 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +describe('useEventOperations', () => { + beforeEach(() => { + // mock 이벤트 초기화 + enqueueSnackbarFn.mockClear(); + }); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + it('저장되어있는 초기 이벤트가 없을 경우 빈 배열을 가져온다.', async () => { + const initEvents: Event[] = []; + setupMockHandlerCreation(initEvents); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + expect(result.current.events).toEqual([]); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + it('저장되어있는 초기 이벤트가 있을 경우 데이터를 적절하게 불러온다', async () => { + const createEvents: Event[] = [ + { + id: '1', + title: '회의 시간', + date: '2025-07-15', + startTime: '10:10', + endTime: '11:10', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, // 10분전 + }, + ]; -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + await act(async () => { + setupMockHandlerCreation(createEvents); + }); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + expect(result.current.events).toEqual([createEvents[0]]); + }); + + it('이벤트 정보를 받아 기존 이벤트 배열에 정상적으로 저장이 된다', async () => { + const initEvents: Event[] = []; + setupMockHandlerCreation(initEvents); + + const newEvent: Event = { + id: '1', + title: '새로운 회의', + date: '2025-07-16', + startTime: '12:00', + endTime: '13:00', + description: '새로운 팀 미팅', + location: '회의실 D', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 15, + }; + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.saveEvent(newEvent); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0]).toEqual(newEvent); + expect(enqueueSnackbarFn).toHaveBeenLastCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); + }); + + it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); + + // 초기 이벤트 로드 기다린 이후에 확인 + await waitFor(() => { + expect(result.current.events).toHaveLength(2); + }); + + const [event1, event2] = result.current.events; + + await act(async () => { + await result.current.saveEvent({ + ...event1, + title: '업데이트된 회의', + endTime: '10:30', + }); + }); + + expect(result.current.events).toHaveLength(2); + expect(result.current.events[0]).not.toEqual(event1); + expect(result.current.events[0].title).toBe('업데이트된 회의'); + expect(result.current.events[0].endTime).toBe('10:30'); + expect(result.current.events[1]).toEqual(event2); + expect(enqueueSnackbarFn).toHaveBeenLastCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); + }); + + it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); + + const { result } = renderHook(() => useEventOperations(false)); + + // 초기 이벤트 로드 기다린 이후에 확인 + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + const eventToDelete = result.current.events[0]; + + await act(async () => { + await result.current.deleteEvent(eventToDelete.id); + }); + + expect(result.current.events).toHaveLength(0); + expect(enqueueSnackbarFn).toHaveBeenLastCalledWith('일정이 삭제되었습니다.', { + variant: 'info', + }); + }); + + it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: [] }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + expect(enqueueSnackbarFn).toHaveBeenLastCalledWith('이벤트 로딩 실패', { + variant: 'error', + }); + }); + + it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + server.use( + http.put('/api/events/:id', () => { + return HttpResponse.json({}, { status: 404 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent({ + id: 'no-id', + title: '수정할 이벤트', + date: '2025-07-17', + startTime: '14:00', + endTime: '15:00', + description: '수정할 이벤트 설명', + location: '회의실 E', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { + variant: 'error', + }); + }); + }); + + it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.json({}, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + await waitFor(() => { + 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..03b4c2a3 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -2,13 +2,87 @@ 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('초기 상태에서는 알림이 없어야 한다', () => {}); +const initEvent: Event[] = [ + { + id: '1', + title: '회의 시간', + date: '2025-10-01', + startTime: '00:10', + endTime: '02:10', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, +]; -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +describe('useNotifications 초기 상태', () => { + // setupTest에서 시간 mock 세팅 -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications(initEvent)); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + expect(result.current.notifications).toEqual([]); + }); +}); + +describe('useNotifications', () => { + // setupTest에서 시간 mock 세팅 + + it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', async () => { + const { result } = renderHook(() => useNotifications(initEvent)); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0]).toEqual({ + id: '1', + message: '10분 후 회의 시간 일정이 시작됩니다.', + }); + }); + + it('index를 기준으로 알림을 적절하게 제거할 수 있다', async () => { + const { result } = renderHook(() => useNotifications(initEvent)); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(0); + }); + + it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', async () => { + const { result } = renderHook(() => useNotifications(initEvent)); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 회의 시간 일정이 시작됩니다.' }, + ]); + + // 시간이 지나서 또 발생했는지 확인 + await act(async () => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current.notifications).toEqual([ + { id: '1', message: '10분 후 회의 시간 일정이 시작됩니다.' }, + ]); + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toEqual(['1']); + }); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..db7cb537 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, waitFor } 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,7 +11,6 @@ import { setupMockHandlerUpdating, } from '../__mocks__/handlersUtils'; import App from '../App'; -import { server } from '../setupTests'; import { Event } from '../types'; const theme = createTheme(); @@ -59,37 +57,514 @@ const saveSchedule = async ( describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation(); + + const { user } = setup(); + + const newEvent = { + title: '새로운 회의', + date: '2025-10-15', + startTime: '10:00', + endTime: '11:00', + description: '팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + await saveSchedule(user, newEvent); + await screen.findByText('일정 로딩 완료!'); + + await waitFor(() => { + expect(screen.queryByText('검색 결과가 없습니다')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(document.body.textContent).toContain('일정이 추가되었습니다.'); + }); + + await screen.findByText('일정 로딩 완료!'); + + expect(document.body.textContent).toContain(newEvent.title); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + // 기존 이벤트가 있는지 확인 + await waitFor(() => { + expect(screen.queryByText('검색 결과가 없습니다')).not.toBeInTheDocument(); + expect(document.body.textContent).toContain('기존 회의'); + }); + + // 기존 이벤트 수정 - 필수로 1개의 이벤트는 있음 + const editButton = document.querySelector('[aria-label="Edit event"]'); + await user.click(editButton!); + + const title = screen.getByLabelText('제목'); + await user.click(title); + await user.type(screen.getByLabelText('제목'), '수정된 회의'); + await user.click(screen.getByTestId('event-submit-button')); + + await waitFor(() => { + expect(document.body.textContent).toContain('일정이 수정되었습니다.'); + }); + + await screen.findByText('일정 로딩 완료!'); + + expect(document.body.textContent).toContain('수정된 회의'); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + // 기존 이벤트가 있는지 확인 + await waitFor(() => { + expect(screen.queryByText('검색 결과가 없습니다')).not.toBeInTheDocument(); + expect(document.body.textContent).toContain('삭제할 이벤트'); + }); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + await user.click(screen.getByLabelText('Delete event')); + + await waitFor(() => { + expect(document.body.textContent).toContain('일정이 삭제되었습니다.'); + }); + + await screen.findByText('일정 로딩 완료!'); + + expect(screen.queryByText('기존 회의')).not.toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-06', // 10월 2주 + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + expect(screen.queryByText('2025년 10월 1주')).toBeInTheDocument(); + expect(document.body.textContent).toContain('결과가 없습니다.'); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-02', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); + + expect(screen.queryByText('2025년 10월 1주')).toBeInTheDocument(); + expect(document.body.textContent).toContain('주간 회의'); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setupMockHandlerCreation(); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + await screen.findByText('검색 결과가 없습니다.'); + + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'month-option' })); + + expect(screen.queryByText('2025년 10월')).toBeInTheDocument(); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const { user } = setup(); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + await screen.findByText('일정 로딩 완료!'); + expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument(); + + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'month-option' })); + + expect(screen.queryByText('2025년 10월')).toBeInTheDocument(); + expect(document.body.textContent).toContain('주간 회의'); + expect(document.body.textContent).toContain('2025-10-06'); + expect(document.body.textContent).toContain('09:00 - 10:00'); + expect(document.body.textContent).toContain('팀 주간 회의'); + expect(document.body.textContent).toContain('회의실 B'); + expect(document.body.textContent).toContain('업무'); + expect(document.body.textContent).toContain('알림: 10분 전'); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + setupMockHandlerCreation(); + + const { user } = setup(); + + const prevButton = screen.getByLabelText('Previous'); + + for (let i = 0; i < 9; i++) { + await user.click(prevButton); + } + + await screen.findByText('일정 로딩 완료!'); + expect(document.body.textContent).toContain('신정'); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('등록된 일정이 없을 때 검색 후 해당하는 검색 결과가 없으면 "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + setupMockHandlerCreation(); + + const { user } = setup(); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + await screen.findByText('일정 로딩 완료!'); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + expect(screen.queryByText('검색 결과가 없습니다.')).toBeInTheDocument(); + + await user.click(screen.getByPlaceholderText('검색어를 입력하세요')); + await user.type(screen.getByPlaceholderText('검색어를 입력하세요'), '회의'); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('등록된 일정이 있을 때 검색 후 해당하는 검색 결과가 없으면 "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument(); + + await user.click(screen.getByPlaceholderText('검색어를 입력하세요')); + await user.type(screen.getByPlaceholderText('검색어를 입력하세요'), '식사'); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('등록된 일정이 있을 때 검색 후 해당 제목을 가진 일정이 리스트에 노출된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의1', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 식사', + date: '2025-10-06', + startTime: '12:00', + endTime: '13:00', + description: '점심 먹자', + location: '사내 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '주간 회의', + date: '2025-10-12', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의2', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + expect(document.body.textContent).toContain('주간 회의'); + expect(document.body.textContent).toContain('점심 식사'); + expect(screen.queryByText('점심 먹자')).toBeInTheDocument(); + expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument(); + + await user.click(screen.getByPlaceholderText('검색어를 입력하세요')); + await user.type(screen.getByPlaceholderText('검색어를 입력하세요'), '주간 회의'); + + expect(screen.queryByText('검색 결과가 없습니다.')).not.toBeInTheDocument(); + expect(screen.queryByText('점심 먹자')).not.toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '주간 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의1', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 식사', + date: '2025-10-06', + startTime: '12:00', + endTime: '13:00', + description: '점심 먹자', + location: '사내 식당', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '주간 회의', + date: '2025-10-12', + startTime: '09:00', + endTime: '10:00', + description: '팀 주간 회의2', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.click(searchInput); + await user.type(searchInput, '식사'); + expect(screen.queryByText('점심 먹자')).toBeInTheDocument(); + expect(screen.queryByText('팀 주간 회의1')).not.toBeInTheDocument(); + expect(screen.queryByText('팀 주간 회의2')).not.toBeInTheDocument(); + + await user.clear(searchInput); + expect(screen.queryByText('점심 먹자')).toBeInTheDocument(); + expect(screen.queryByText('팀 주간 회의1')).toBeInTheDocument(); + expect(screen.queryByText('팀 주간 회의2')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + const newEvent = { + title: '새로운 회의', + date: '2025-10-06', + startTime: '09:30', + endTime: '10:30', + description: '새로운 팀 미팅', + location: '회의실 D', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + await saveSchedule(user, newEvent); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + await screen.findByText('일정 로딩 완료!'); + + const editButton = document.querySelector('[aria-label="Edit event"]'); + await user.click(editButton!); + + const startTime = screen.getByLabelText('시작 시간'); + const endTime = screen.getByLabelText('종료 시간'); + await user.clear(startTime); + await user.type(startTime, '11:30'); + await user.clear(endTime); + await user.type(endTime, '12:30'); + await user.click(screen.getByTestId('event-submit-button')); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +describe('알림 기능', () => { + it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + vi.setSystemTime(new Date('2025-10-06T08:50:00Z')); + + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + await screen.findByText('일정 로딩 완료!'); + + await waitFor(() => { + expect(document.body.textContent).toContain('10분 후 기존 회의 일정이 시작됩니다.'); + }); + }); + + it('회의 일정에 맞춰 알람 텍스트가 노출되면 해당 일정 제목에 아이콘이 표시된다.', async () => { + vi.setSystemTime(new Date('2025-10-06T08:50:00Z')); + + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + await screen.findByText('일정 로딩 완료!'); + + await waitFor(() => { + expect(document.body.textContent).toContain('10분 후 기존 회의 일정이 시작됩니다.'); + }); + + const eventList = screen.getByTestId('event-list'); + const notificationIcon = within(eventList).getByTestId('NotificationsIcon'); + expect(notificationIcon).toBeInTheDocument(); + }); + + it('지난 일정에 대해서는 알람 텍스트가 노출되지 않는다.', async () => { + vi.setSystemTime(new Date('2025-10-06T09:01:00Z')); + + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-06', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + await screen.findByText('일정 로딩 완료!'); + + const eventList = screen.getByTestId('event-list'); + const notificationIcon = within(eventList).queryByTestId('NotificationsIcon'); + expect(notificationIcon).not.toBeInTheDocument(); + expect(screen.queryByText('10분 후 기존 회의 일정이 시작됩니다.')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..80bb0816 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,476 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); - - it('윤년의 2월에 대해 29일을 반환한다', () => {}); - - it('평년의 2월에 대해 28일을 반환한다', () => {}); - - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('2025년 1월은 31일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + expect(getDaysInMonth(2025, 1)).not.toBe(32); + }); + + it('2025년 4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); + + it('2024년 윤년의 2월에 대해 29일 일수를 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + }); + + it('2025년 평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); + + it('유효하지 않은 월은 일수 0을 반환한다', () => { + expect(getDaysInMonth(2025, 13)).toBe(0); + expect(getDaysInMonth(2025, -1)).toBe(0); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 해당 주의 올바른 일주일을 반환한다', () => { + const wednesday = new Date('2025-08-20'); + + expect(getWeekDates(wednesday)).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('주의 시작(월요일)에 해당 주의 올바른 일주일을 반환한다', () => { + const monday = new Date('2025-08-18'); + + expect(getWeekDates(monday)).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('주의 끝(일요일)에 해당 주의 올바른 일주일을 반환한다', () => { + const sunday = new Date('2025-08-23'); + + expect(getWeekDates(sunday)).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('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const lastDayOfYear = new Date('2025-12-31'); + + expect(getWeekDates(lastDayOfYear)).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('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const firstDayOfYear = new Date('2026-01-01'); + + expect(getWeekDates(firstDayOfYear)).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일을 포함한 주를 올바르게 처리한다', () => { + const leapYearFeburary = new Date('2024-02-29'); + + expect(getWeekDates(leapYearFeburary)).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('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const lastDayOfMonth = new Date('2025-08-31'); + + expect(getWeekDates(lastDayOfMonth)).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일의 올바른 주 정보를 반환해야 한다', () => { + const date = new Date(2025, 6, 1); + const weeks = getWeeksAtMonth(date); + + expect(weeks).toHaveLength(5); + + expect(weeks[0]).toEqual([null, null, 1, 2, 3, 4, 5]); + expect(weeks[1]).toEqual([6, 7, 8, 9, 10, 11, 12]); + expect(weeks[2]).toEqual([13, 14, 15, 16, 17, 18, 19]); + expect(weeks[3]).toEqual([20, 21, 22, 23, 24, 25, 26]); + expect(weeks[4]).toEqual([27, 28, 29, 30, 31, null, null]); + + weeks.forEach((week) => { + expect(week).toHaveLength(7); + }); + + const allDays = weeks.flat().filter((day) => day !== null); + const expectedDays = Array.from({ length: 31 }, (_, i) => i + 1); + expect(allDays).toEqual(expectedDays); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:30', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: '프레젠테이션', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:30', + description: '분기 발표', + location: '대회의실', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '3', + title: '점심 약속', + date: '2025-07-15', + startTime: '12:00', + endTime: '13:30', + description: '고객과 점심', + location: '레스토랑 B', + category: '기타', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '4', + title: '워크샵', + date: '2025-06-30', + startTime: '10:00', + endTime: '17:00', + description: '기술 워크샵', + location: '교육센터', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '5', + title: '회식', + date: '2025-08-01', + startTime: '18:00', + endTime: '21:00', + description: '팀 회식', + location: '음식점 C', + category: '기타', + repeat: { type: 'none', interval: 1 }, + notificationTime: 120, + }, + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const result = getEventsForDay(mockEvents, 1); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('1'); + expect(result[0].title).toBe('회의'); + expect(result[0]).toEqual(mockEvents[0]); + + expect(result[1].id).toBe('2'); + expect(result[1].title).toBe('프레젠테이션'); + expect(result[1]).toEqual(mockEvents[1]); + + expect(result[2].id).toBe('5'); + expect(result[2].title).toBe('회식'); + expect(result[2]).toEqual(mockEvents[4]); + + result.forEach((event) => { + expect(new Date(event.date).getDate()).toBe(1); + }); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const result = getEventsForDay(mockEvents, 25); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + expect(Array.isArray(result)).toBe(true); + }); + + it('정상적인 날짜가 아닐 경우 빈 배열을 반환한다', () => { + // 0일 + const result1 = getEventsForDay(mockEvents, 0); + + expect(result1).toEqual([]); + expect(result1).toHaveLength(0); + expect(Array.isArray(result1)).toBe(true); + + // 40일 + const result2 = getEventsForDay(mockEvents, 40); + + expect(result2).toEqual([]); + expect(result2).toHaveLength(0); + expect(Array.isArray(result2)).toBe(true); + }); +}); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); +describe('getEventsForDay 예외 케이스', () => { + it('전체 이벤트가 없다면 날짜가 1일인 경우 빈 배열을 반환한다', () => { + const emptyEvents: Event[] = []; + const result = getEventsForDay(emptyEvents, 1); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + expect(Array.isArray(result)).toBe(true); + }); + + it('조회하는 날짜가 정상적이지 않은 날짜라면 빈 배열을 반환한다', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:30', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 1, + }, + { + id: '2', + title: '휴가', + date: '2025-07-30', + startTime: '09:30', + endTime: '18:30', + description: '병원 방문', + location: '집', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 60, + }, + ]; + + // -1일 + const minusDays = getEventsForDay(mockEvents, -1); + + expect(minusDays).toEqual([]); + expect(minusDays).toHaveLength(0); + expect(Array.isArray(minusDays)).toBe(true); + + // 32일 + const overDays = getEventsForDay(mockEvents, 32); + expect(overDays).toEqual([]); + expect(overDays).toHaveLength(0); + expect(Array.isArray(overDays)).toBe(true); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); - - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2025-08-15')); + expect(targetDate).toBe('2025년 8월 2주'); + }); + + it('월의 첫 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2025-08-01')); + expect(targetDate).toBe('2025년 7월 5주'); + }); + + it('월의 마지막 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2025-08-31')); + expect(targetDate).toBe('2025년 9월 1주'); + }); + + it('연도가 넘어가는 마지막 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2025-12-31')); + expect(targetDate).toBe('2026년 1월 1주'); + }); + + it('윤년 2월의 마지막 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2024-02-29')); + expect(targetDate).toBe('2024년 2월 5주'); + }); + + it('평년 2월의 마지막 날짜에 대해 올바른 주 정보를 반환한다', () => { + const targetDate = formatWeek(new Date('2025-02-28')); + expect(targetDate).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'의 텍스트 형태로 반환한다", () => { + const targetDate = formatMonth(new Date('2025-07-10')); + expect(targetDate).toBe('2025년 7월'); + }); + + it("윤년 2월을 '2024년 2월' 텍스트 형태로 반환한다", () => { + const result = formatMonth(new Date('2024-02-29')); + expect(result).toBe('2024년 2월'); + }); + + it('Date 생성자로 만든 객체를 올바른 텍스트 형태로 반환한다', () => { + const result = formatMonth(new Date(2025, 6, 15)); + expect(result).toBe('2025년 7월'); + expect(result).toBeTypeOf('string'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + const rangeStart = new Date('2025-07-01'); + const rangeEnd = new Date('2025-07-31'); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const result = isDateInRange(targetDate, rangeStart, rangeEnd); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + expect(result).toBe(true); + }); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const result = isDateInRange(targetDate, rangeStart, rangeEnd); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + expect(result).toBe(true); + }); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); -}); - -describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const targetDate = new Date('2025-07-31'); + const result = isDateInRange(targetDate, rangeStart, rangeEnd); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + expect(result).toBe(true); + }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-06-30'); + const result = isDateInRange(targetDate, rangeStart, rangeEnd); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + expect(result).toBe(false); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-08-01'); + const result = isDateInRange(targetDate, rangeStart, rangeEnd); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + expect(result).toBe(false); + }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); - - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const targetDate = new Date('2025-07-10'); + const result = isDateInRange(targetDate, rangeEnd, rangeStart); + expect(result).toBe(false); + }); +}); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); +describe('fillZero', () => { + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + const result = fillZero(5, 2); + expect(result).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + const result = fillZero(10, 2); + expect(result).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + const result = fillZero(3, 3); + expect(result).toBe('003'); + }); + + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + const result = fillZero(100, 2); + expect(result).toBe('100'); + }); + + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + const result = fillZero(0, 2); + expect(result).toBe('00'); + }); + + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + const result = fillZero(1, 5); + expect(result).toBe('00001'); + }); + + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + const result = fillZero(3.14, 5); + expect(result).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + const result = fillZero(1); + expect(result).toBe('01'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + const result = fillZero(10000, 2); + expect(result).toBe('10000'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); - - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); - - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); - - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const targetDate = formatDate(new Date(2025, 8, 20)); + expect(targetDate).toBe('2025-09-20'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const targetDate = formatDate(new Date(2025, 8), 12); + expect(targetDate).toBe('2025-09-12'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const targetDate = formatDate(new Date(2025, 8), 12); + expect(targetDate).toBe('2025-09-12'); + }); + + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const targetDate = formatDate(new Date(2025, 8, 1)); + expect(targetDate).toBe('2025-09-01'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..3c96f043 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,36 +1,263 @@ -import { Event } from '../../types'; +import { Event, EventForm } from '../../types'; import { convertEventToDateRange, findOverlappingEvents, isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const targetDateTime = parseDateTime('2025-07-01', '14:30'); + + // Date 객체인지 확인 + expect(targetDateTime).toBeInstanceOf(Date); + expect(targetDateTime).toEqual(new Date('2025-07-01 14:30')); + }); + + it('잘못된 날짜 형식에 대해 Invalid Date 겍체를 반환한다', () => { + const targetDateTime = parseDateTime('2025-100-11', '12:30'); + + // Date 객체인지 확인 + expect(targetDateTime).toBeInstanceOf(Date); + expect(String(targetDateTime)).toBe('Invalid Date'); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const targetDateTime = parseDateTime('2025-06-11', '44:30'); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + // Date 객체인지 확인 + expect(targetDateTime).toBeInstanceOf(Date); + expect(String(targetDateTime)).toBe('Invalid Date'); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const targetDateTime = parseDateTime('', '12:30'); + + // Date 객체인지 확인 + expect(targetDateTime).toBeInstanceOf(Date); + expect(String(targetDateTime)).toBe('Invalid Date'); + }); + + it('시간 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const targetDateTime = parseDateTime('2025-06-11', ''); + + // Date 객체인지 확인 + expect(targetDateTime).toBeInstanceOf(Date); + expect(String(targetDateTime)).toBe('Invalid Date'); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const mockEvent: Event = { + id: '1', + title: '회의', + date: '2025-07-15', + startTime: '09:00', + endTime: '10:30', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const targetEvent = convertEventToDateRange(mockEvent); + + // 시작 시간 2025-07-15 09:00 + expect(targetEvent.start).toBeInstanceOf(Date); + expect(targetEvent.start.getFullYear()).toBe(2025); + expect(targetEvent.start.getMonth()).toBe(6); + expect(targetEvent.start.getDate()).toBe(15); + expect(targetEvent.start.getHours()).toBe(9); + expect(targetEvent.start.getMinutes()).toBe(0); + + // 종료 시간 2025-07-15 10:30 + expect(targetEvent.end).toBeInstanceOf(Date); + expect(targetEvent.end.getFullYear()).toBe(2025); + expect(targetEvent.end.getMonth()).toBe(6); + expect(targetEvent.end.getDate()).toBe(15); + expect(targetEvent.end.getHours()).toBe(10); + expect(targetEvent.end.getMinutes()).toBe(30); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const mockEvent: Event = { + id: '1', + title: '회의2', + date: '2025-123-123', + startTime: '09:00', + endTime: '10:30', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + const targetEvent = convertEventToDateRange(mockEvent); + + expect(targetEvent.start).toBeInstanceOf(Date); + expect(String(targetEvent.start)).toBe('Invalid Date'); + + expect(targetEvent.end).toBeInstanceOf(Date); + expect(String(targetEvent.end)).toBe('Invalid Date'); + }); + + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const mockEvent: Event = { + id: '1', + title: '회의', + date: '2025-12-23', + startTime: '123:123', + endTime: '123:123', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + const targetEvent = convertEventToDateRange(mockEvent); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(targetEvent.start).toBeInstanceOf(Date); + expect(String(targetEvent.start)).toBe('Invalid Date'); + + expect(targetEvent.end).toBeInstanceOf(Date); + expect(String(targetEvent.end)).toBe('Invalid Date'); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + const createTestEvent = (startTime: string, endTime: string): EventForm => ({ + title: '테스트 회의', + date: '2025-07-15', + startTime, + endTime, + description: '테스트용 회의', + location: '테스트 회의실', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }); + + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const event1 = createTestEvent('09:00', '11:00'); + const event2 = createTestEvent('10:00', '12:00'); + + const result = isOverlapping(event1, event2); + expect(result).toBe(true); + + // 어느 한 쪽이 전체를 포함 + const event3 = createTestEvent('08:00', '13:00'); + const event4 = createTestEvent('09:00', '10:00'); + + expect(isOverlapping(event3, event4)).toBe(true); + expect(isOverlapping(event4, event3)).toBe(true); + + // 같은 시작 시간 + const event5 = createTestEvent('09:00', '10:00'); + const event6 = createTestEvent('09:00', '11:00'); + + expect(isOverlapping(event5, event6)).toBe(true); + + // 같은 종료 시간 + const event7 = createTestEvent('08:00', '11:00'); + const event8 = createTestEvent('09:00', '11:00'); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + expect(isOverlapping(event7, event8)).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const event1 = createTestEvent('08:00', '10:00'); + const event2 = createTestEvent('10:00', '12:00'); + + expect(isOverlapping(event1, event2)).toBe(false); + expect(isOverlapping(event2, event1)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + const prevEvents: Event[] = [ + { + id: '1', + title: '오전 회의', + date: '2025-07-15', + startTime: '09:00', + endTime: '10:00', + description: '팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '2', + title: '점심 회의', + date: '2025-07-15', + startTime: '12:00', + endTime: '13:00', + description: '점심 미팅', + location: '레스토랑', + category: '기타', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '3', + title: '오후 회의', + date: '2025-07-15', + startTime: '15:00', + endTime: '16:00', + description: '프로젝트 리뷰', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + ]; + + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: '4', + title: '긴 회의', + date: '2025-07-15', + startTime: '09:30', + endTime: '12:30', + description: '긴 미팅', + location: '대회의실', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 60, + }; + + const result = findOverlappingEvents(newEvent, prevEvents); + expect(result).toHaveLength(2); + + const overlappingIds = result.map((event) => event.id); + expect(overlappingIds).toContain('1'); + expect(overlappingIds).toContain('2'); + expect(overlappingIds).not.toContain('3'); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: EventForm = { + title: '저녁 이벤트', + date: '2025-07-15', + startTime: '18:00', + endTime: '19:00', + description: '저녁 모임', + location: '카페', + category: '기타', + repeat: { type: 'none', interval: 0 }, + notificationTime: 60, + }; + + const result = findOverlappingEvents(newEvent, prevEvents); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(result).toEqual([]); + expect(result).toHaveLength(0); + expect(Array.isArray(result)).toBe(true); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..f16cda59 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -2,19 +2,217 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '회의 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '팀 회의입니다', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '2', + title: '점심 약속', + date: '2025-07-02', + startTime: '12:00', + endTime: '13:00', + description: '동료와 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '3', + title: '프로젝트 이벤트', + date: '2025-07-15', + startTime: '14:00', + endTime: '15:00', + description: '프로젝트 리뷰', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '4', + title: '저녁 모임', + date: '2025-07-16', + startTime: '18:00', + endTime: '20:00', + description: '팀 저녁 모임1', + location: '레스토랑', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '5', + title: '워크샵', + date: '2025-08-01', + startTime: '10:00', + endTime: '17:00', + description: '기술 워크샵 이벤트', + location: '교육센터', + category: '기타', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '6', + title: '저녁 모임', + date: '2025-06-30', + startTime: '18:00', + endTime: '20:00', + description: '팀 저녁 모임2', + location: '레스토랑', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + ]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it('현재 날짜 기준의 월 이벤트 중 검색한 타이틀에 맞는 이벤트만 반환한다', () => { + // 7월에 모임이 1개 있는지 확인 + const currentDate = new Date('2025-07-14'); + const result = getFilteredEvents(mockEvents, '모임', currentDate, 'month'); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + expect(result).toHaveLength(1); + expect(result).toEqual([ + { + category: '개인', + date: '2025-07-16', + description: '팀 저녁 모임1', + endTime: '20:00', + id: '4', + location: '레스토랑', + notificationTime: 1, + repeat: { + interval: 1, + type: 'none', + }, + startTime: '18:00', + title: '저녁 모임', + }, + ]); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it('현재 날짜 기준의 일주일 이벤트 중 검색한 타이틀에 맞는 이벤트만 반환한다', () => { + // 7월 14일 기준 모임이 1개 있는지 확인 + const targetDate1 = new Date('2025-07-14'); + const result1 = getFilteredEvents(mockEvents, '모임', targetDate1, 'week'); + expect(result1).toHaveLength(1); + expect(result1[0].title).toContain('모임'); + expect(result1[0].title).not.toContain('워크샵'); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + // 7월 31일 기준 모임이 0개 있는지 확인 + const targetDate2 = new Date('2025-07-31'); + const result2 = getFilteredEvents(mockEvents, '모임', targetDate2, 'week'); + expect(result2).toHaveLength(0); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('주간 뷰에서 검색어가 없을 때 2025-07-01 날짜가 속해있는 일주일의 이벤트만 반환한다', () => { + const currentDate = new Date('2025-07-01'); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + const result = getFilteredEvents(mockEvents, '', currentDate, 'week'); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + expect(result).toHaveLength(3); + + const eventIds = result.map((event) => event.id); + expect(eventIds).toContain('1'); + expect(eventIds).toContain('2'); + expect(eventIds).toContain('6'); + expect(eventIds).not.toContain('3'); + expect(eventIds).not.toContain('4'); + expect(eventIds).not.toContain('5'); + }); + + it('월간 뷰에서 검색어가 없을 때 2025년 7월의 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-15'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result).toHaveLength(4); + result.forEach((event) => { + expect(event.date).toContain('2025-07'); + }); + }); + + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + // 7월 1일이 해당되는 주에 이벤트가 있는지 확인 + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트', currentDate, 'week'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + expect(result[0].title).toContain('이벤트'); + expect(result[0].title).not.toContain('워크샵'); + }); + + // 상위의 주간 뷰, 월간 뷰 테스트와 동일하여 삭제 + it.skip('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-01'); + + const result = getFilteredEvents(mockEvents, '', currentDate, 'week'); + + expect(result).toHaveLength(3); + + const eventIds = result.map((event) => event.id); + expect(eventIds).toContain('1'); + expect(eventIds).toContain('2'); + expect(eventIds).toContain('6'); + expect(eventIds).not.toContain('3'); + expect(eventIds).not.toContain('4'); + expect(eventIds).not.toContain('5'); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const testCase = ['a', 'A']; + const currentDate = new Date('2025-07-01'); + testCase.forEach((test) => { + const result = getFilteredEvents(mockEvents, test, currentDate, 'month'); + // 'a', 'A'로 검색시 모두 '1'을 id로 하는 이벤트를 찾는다. + expect(result.some((event) => event.id === '1')).toBe(true); + }); + }); + + it('월의 끝과 시작의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const targetDate1 = new Date('2025-06-30'); + const targetDate2 = new Date('2025-07-01'); + + // 6월 이벤트 1개만 있는지 확인 + const result1 = getFilteredEvents(mockEvents, '', targetDate1, 'month'); + expect(result1).toHaveLength(1); + expect(result1[0].id).toBe('6'); + + // 7월 이벤트 4개 있는지 확인 + const result2 = getFilteredEvents(mockEvents, '', targetDate2, 'month'); + expect(result2).toHaveLength(4); + result2.forEach((event) => { + expect(event.date).toContain('2025-07'); + }); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const emptyEvents: Event[] = []; + const viewFilter: Array<'month' | 'week'> = ['month', 'week']; + + const targetDate = new Date('2025-07-01'); + + viewFilter.forEach((view) => { + // 검색어가 없음 + const result1 = getFilteredEvents(emptyEvents, '', targetDate, view); + expect(result1).toHaveLength(0); + expect(result1).toEqual([]); + + // 검색어가 있음 + const result2 = getFilteredEvents(emptyEvents, '모임', targetDate, view); + expect(result2).toHaveLength(0); + expect(result2).toEqual([]); + }); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..baf27496 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,67 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const targetDate = new Date('2025-01-15'); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + const result = fetchHolidays(targetDate); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + expect(Object.keys(result)).toHaveLength(4); + expect(result).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + + expect(result['2025-01-01']).toBe('신정'); + expect(result['2025-01-29']).toBe('설날'); + expect(result['2025-01-30']).toBe('설날'); + expect(result['2025-01-31']).toBe('설날'); + + expect(result).not.toHaveProperty('2025-03-01'); + expect(result).not.toHaveProperty('2025-12-25'); + }); + + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const testMonths = [ + new Date('2025-02-01'), + new Date('2025-04-01'), + new Date('2025-07-01'), + new Date('2025-09-01'), + new Date('2025-11-01'), + ]; + + testMonths.forEach((date) => { + const targetDate = new Date(date); + const monthResult = fetchHolidays(targetDate); + expect(monthResult).toEqual({}); + expect(Object.keys(monthResult)).toHaveLength(0); + }); + }); + + // 첫 테스트로 대체 가능하여 삭제 + it.skip('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const targetDate = new Date('2025-10-15'); + + const result = fetchHolidays(targetDate); + + expect(Object.keys(result)).toHaveLength(5); + + expect(result).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + + expect(result['2025-10-03']).toBe('개천절'); + expect(result['2025-10-05']).toBe('추석'); + expect(result['2025-10-06']).toBe('추석'); + expect(result['2025-10-07']).toBe('추석'); + expect(result['2025-10-09']).toBe('한글날'); + + expect(result).not.toHaveProperty('2025-08-15'); + expect(result).not.toHaveProperty('2025-12-25'); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..45757200 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -2,15 +2,182 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '회의 시간', + date: '2025-07-15', + startTime: '10:10', + endTime: '11:10', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, // 10분전 + }, + { + id: '2', + title: '점심 시간', + date: '2025-07-15', + startTime: '11:00', + endTime: '11:30', + description: '점심 약속', + location: '레스토랑', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, // 1시간전 + }, + { + id: '3', + title: '외부 미팅', + date: '2025-07-15', + startTime: '11:01', + endTime: '12:00', + description: '긴급 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }, + { + id: '4', + title: '프레젠테이션', + date: '2025-07-15', + startTime: '08:00', + endTime: '09:00', + description: '프로젝트 발표', + location: '대회의실', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + ]; - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + // 지정된 시간에 도달한 이벤트가 2개인지 확인 + const nowDateTime = new Date('2025-07-15T10:00:00'); + const notifiedEvents: string[] = []; + const result = getUpcomingEvents(mockEvents, nowDateTime, notifiedEvents); + const resultIds = result.map((event) => event.id); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + expect(result).toHaveLength(2); + expect(result[0].title).toBe('회의 시간'); + expect(result[1].title).toBe('점심 시간'); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + expect(resultIds).toContain('1'); + expect(resultIds).toContain('2'); + expect(resultIds).not.toContain('3'); + expect(resultIds).not.toContain('4'); + }); + + it('이미 알림이 간 이벤트가 있다면 제외한다', () => { + // 지정된 시간에 도달한 이벤트가 1개인지 확인 + const nowDateTime = new Date('2025-07-15T10:00:00'); + // id 1은 제외 + const notifiedEvents: string[] = ['1']; + const result = getUpcomingEvents(mockEvents, nowDateTime, notifiedEvents); + const resultIds = result.map((event) => event.id); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('점심 시간'); + + expect(resultIds).toContain('2'); + expect(resultIds).not.toContain('1'); + expect(resultIds).not.toContain('3'); + expect(resultIds).not.toContain('4'); + }); + + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const nowDateTime = new Date('2025-07-15T09:00:00'); + const notifiedEvents: string[] = []; + const result = getUpcomingEvents(mockEvents, nowDateTime, notifiedEvents); + const resultIds = result.map((event) => event.id); + + expect(result).toHaveLength(0); + expect(resultIds).not.toContain('1'); + expect(resultIds).not.toContain('2'); + expect(resultIds).not.toContain('3'); + expect(resultIds).not.toContain('4'); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const nowDateTime = new Date('2025-07-15T10:00:00'); + const notifiedEvents: string[] = []; + const result = getUpcomingEvents(mockEvents, nowDateTime, notifiedEvents); + const resultIds = result.map((event) => event.id); + + expect(result).toHaveLength(2); + + // 이미 지난 이벤트 체크 + expect(resultIds).not.toContain('4'); + result.forEach((event) => { + expect(event.title).not.toEqual({ + id: '4', + title: '프레젠테이션', + date: '2025-07-15', + startTime: '08:00', + endTime: '09:00', + description: '프로젝트 발표', + location: '대회의실', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }); + }); + }); + + // 엣지케이스 + it('빈 이벤트 배열에 대해 빈 배열을 반환한다', () => { + const now = new Date('2025-07-15T10:00:00'); + const result = getUpcomingEvents([], now, []); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('모든 이벤트가 이미 알림된 경우 빈 배열을 반환한다', () => { + const now = new Date('2025-07-15T10:00:00'); + const allNotified = ['1', '2', '3', '4']; + const result = getUpcomingEvents(mockEvents, now, allNotified); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('이벤트 타이틀에 맞는 올바른 알림 메시지를 생성해야 한다', () => { + const mockEvents: Event = { + id: '1', + title: '팀 회의', + date: '2025-07-15', + startTime: '10:00', + endTime: '11:00', + description: '주간 팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }; + const result = createNotificationMessage(mockEvents); + expect(result).toBe('60분 후 팀 회의 일정이 시작됩니다.'); + }); + + it('이모지가 포함된 제목도 올바르게 처리한다', () => { + const specialEvent: Event = { + id: '1', + title: '🚨 회의 (긴급)', + date: '2025-07-15', + startTime: '10:15', + endTime: '11:00', + description: '긴급 회의', + location: '회의실', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + const result = createNotificationMessage(specialEvent); + expect(result).toBe('10분 후 🚨 회의 (긴급) 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..3ed9e955 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,88 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + const START_TIME_ERROR_MSG = '시작 시간은 종료 시간보다 빨라야 합니다.'; + const END_TIME_ERROR_MSG = '종료 시간은 시작 시간보다 늦어야 합니다.'; + const TIME_FORMAT_ERROR_MSG = '올바른 시간 형식을 입력해주세요. (예: 09:30)'; - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const startTime = '14:00'; + const endTime = '10:00'; - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + const result = getTimeErrorMessage(startTime, endTime); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBe(START_TIME_ERROR_MSG); + expect(result.endTimeError).toBe(END_TIME_ERROR_MSG); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const startTime = '10:00'; + const endTime = '10:00'; - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + const result = getTimeErrorMessage(startTime, endTime); + + expect(result.startTimeError).toBe(START_TIME_ERROR_MSG); + expect(result.endTimeError).toBe(END_TIME_ERROR_MSG); + }); + + it('시작 시간이 종료 시간보다 빠를 때 에러 메세지가 아닌 null을 반환한다', () => { + const startTime = '10:00'; + const endTime = '14:00'; + + const result = getTimeErrorMessage(startTime, endTime); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간이 비어있을 때 에러 메세지가 아닌 null을 반환한다', () => { + const startTime = ''; + const endTime = '10:00'; + + const result = getTimeErrorMessage(startTime, endTime); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('종료 시간이 비어있을 때 에러 메세지가 아닌 null을 반환한다', () => { + const startTime = '10:00'; + const endTime = ''; + + const result = getTimeErrorMessage(startTime, endTime); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 에러 메세지가 아닌 null을 반환한다', () => { + const startTime = ''; + const endTime = ''; + + const result = getTimeErrorMessage(startTime, endTime); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + describe('getTimeErrorMessage 엣지 케이스', () => { + // 엣지 케이스 + it('시작 시간이 잘못된 형식일 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('invalid time', '09:00'); + expect(result.startTimeError).toBe(TIME_FORMAT_ERROR_MSG); + expect(result.endTimeError).toBeNull(); + }); + + it('종료 시간이 잘못된 형식일 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('09:00', 'invalid time'); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBe(TIME_FORMAT_ERROR_MSG); + }); + + it('시작 시간과 종료 시간이 모두 잘못된 형식일 때 시작 시간 에러만 반환한다', () => { + const result = getTimeErrorMessage('invalid time', '아무말'); + expect(result.startTimeError).toBe(TIME_FORMAT_ERROR_MSG); + expect(result.endTimeError).toBeNull(); + }); + }); }); diff --git a/src/components/CalendarView.tsx b/src/components/CalendarView.tsx new file mode 100644 index 00000000..85a29a85 --- /dev/null +++ b/src/components/CalendarView.tsx @@ -0,0 +1,246 @@ +import { Notifications, ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { + Box, + IconButton, + MenuItem, + Select, + 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'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface CalendarViewProps { + view: 'week' | 'month'; + setView: (view: 'week' | 'month') => void; + currentDate: Date; + holidays: Record; + filteredEvents: Event[]; + notifiedEvents: string[]; + navigate: (direction: 'prev' | 'next') => void; +} + +export const CalendarView = ({ + view, + setView, + currentDate, + holidays, + filteredEvents, + notifiedEvents, + navigate, +}: CalendarViewProps) => { + 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 ( + + 일정 보기 + + + navigate('prev')}> + + + + navigate('next')}> + + + + + {view === 'week' && renderWeekView()} + {view === 'month' && renderMonthView()} + + ); +}; diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..f9ec2a37 --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,207 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} 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; + notificationTime: number; + setNotificationTime: (time: 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/EventList.tsx b/src/components/EventList.tsx new file mode 100644 index 00000000..d0db8a20 --- /dev/null +++ b/src/components/EventList.tsx @@ -0,0 +1,112 @@ +import { Notifications, Delete, Edit } from '@mui/icons-material'; +import { + Box, + FormControl, + FormLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventListProps { + searchTerm: string; + setSearchTerm: (term: 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' && ( + + 반복: {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 + } + + + + onEditEvent(event)}> + + + onDeleteEvent(event.id)}> + + + + + + )) + )} + + ); +}; diff --git a/src/components/NotificationStack.tsx b/src/components/NotificationStack.tsx new file mode 100644 index 00000000..c471a3a7 --- /dev/null +++ b/src/components/NotificationStack.tsx @@ -0,0 +1,32 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface NotificationStackProps { + notifications: { id: string; message: string }[]; + onDismiss: (index: number) => void; +} + +export const NotificationStack = ({ notifications, onDismiss }: NotificationStackProps) => { + if (notifications.length === 0) { + return null; + } + + return ( + + {notifications.map((notification, index) => ( + onDismiss(index)}> + + + } + > + {notification.message} + + ))} + + ); +}; diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..4c623837 --- /dev/null +++ b/src/components/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; + onClose: () => void; + overlappingEvents: Event[]; + onContinue: () => void; +} + +export const OverlapDialog = ({ + isOpen, + onClose, + overlappingEvents, + onContinue, +}: OverlapDialogProps) => { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +}; diff --git a/src/hooks/useEventHandlers.ts b/src/hooks/useEventHandlers.ts new file mode 100644 index 00000000..2dbce0b1 --- /dev/null +++ b/src/hooks/useEventHandlers.ts @@ -0,0 +1,115 @@ +import { useSnackbar } from 'notistack'; + +import { Event, EventForm, RepeatType } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; + +interface UseEventHandlersParams { + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: RepeatType; + repeatInterval: number; + repeatEndDate: string; + notificationTime: number; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: Event | null; + events: Event[]; + saveEvent: (eventData: Event | EventForm) => Promise; + resetForm: () => void; + openOverlapDialog: (events: Event[]) => void; + closeOverlapDialog: () => void; +} + +export const useEventHandlers = ({ + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + startTimeError, + endTimeError, + editingEvent, + events, + saveEvent, + resetForm, + openOverlapDialog, + closeOverlapDialog, +}: UseEventHandlersParams) => { + const { enqueueSnackbar } = useSnackbar(); + + const addOrUpdateEvent = async () => { + if (!title || !date || !startTime || !endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return; + } + + if (startTimeError || endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return; + } + + const eventData: Event | EventForm = { + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }; + + const overlapping = findOverlappingEvents(eventData, events); + if (overlapping.length > 0) { + openOverlapDialog(overlapping); + } else { + await saveEvent(eventData); + resetForm(); + } + }; + + const handleOverlapDialogContinue = () => { + 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, + }); + resetForm(); + }; + + return { + addOrUpdateEvent, + handleOverlapDialogContinue, + }; +}; diff --git a/src/hooks/useOverlapDialog.ts b/src/hooks/useOverlapDialog.ts new file mode 100644 index 00000000..6e6480fb --- /dev/null +++ b/src/hooks/useOverlapDialog.ts @@ -0,0 +1,24 @@ +import { useState } from 'react'; + +import { Event } from '../types'; + +export const useOverlapDialog = () => { + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + + const openOverlapDialog = (events: Event[]) => { + setOverlappingEvents(events); + setIsOverlapDialogOpen(true); + }; + + const closeOverlapDialog = () => { + setIsOverlapDialogOpen(false); + }; + + return { + isOverlapDialogOpen, + overlappingEvents, + openOverlapDialog, + closeOverlapDialog, + }; +}; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..7f7b7a19 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -4,6 +4,8 @@ import { Event } from '../types.ts'; * 주어진 년도와 월의 일수를 반환합니다. */ export function getDaysInMonth(year: number, month: number): number { + // 1월보다 작고 12월보다 큰 달은 없기에 0으로 통일 + if (month < 1 || month > 12) return 0; return new Date(year, month, 0).getDate(); } @@ -52,6 +54,8 @@ export function getWeeksAtMonth(currentDate: Date) { } export function getEventsForDay(events: Event[], date: number): Event[] { + // 1일보다 작거나 31보다 큰 날짜는 없기 대문에 빈 배열 반환 + if (date < 1 || date > 31) return []; return events.filter((event) => new Date(event.date).getDate() === date); } diff --git a/src/utils/timeValidation.ts b/src/utils/timeValidation.ts index 20dde5de..5b7fe8d8 100644 --- a/src/utils/timeValidation.ts +++ b/src/utils/timeValidation.ts @@ -3,7 +3,24 @@ export interface TimeValidationResult { endTimeError: string | null; } +const TIME_FORMAT_REGEX = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + export function getTimeErrorMessage(start: string, end: string): TimeValidationResult { + // 시간 포멧 검증 + if (start && !TIME_FORMAT_REGEX.test(start)) { + return { + startTimeError: '올바른 시간 형식을 입력해주세요. (예: 09:30)', + endTimeError: null, + }; + } + + if (end && !TIME_FORMAT_REGEX.test(end)) { + return { + startTimeError: null, + endTimeError: '올바른 시간 형식을 입력해주세요. (예: 09:30)', + }; + } + if (!start || !end) { return { startTimeError: null, endTimeError: null }; }