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..43526b4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,10 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import Close from '@mui/icons-material/Close'; import { Alert, AlertTitle, Box, Button, Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, FormControl, FormControlLabel, FormLabel, @@ -17,41 +12,23 @@ import { MenuItem, Select, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, Tooltip, Typography, } from '@mui/material'; -import { useSnackbar } from 'notistack'; -import { useState } from 'react'; -import { useCalendarView } from './hooks/useCalendarView.ts'; +import CalenderContainer from './components/CalenderContainer'; +import OverlapDialog from './components/OverlapDialog'; import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; +import { useEventSubmit } from './hooks/useEventSubmit.ts'; import { useNotifications } from './hooks/useNotifications.ts'; -import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; -import { findOverlappingEvents } from './utils/eventOverlap'; +import { useOverlapDialog } from './hooks/useOverlapDialog.ts'; +import { validateEvent } from './utils/eventValidation.ts'; import { getTimeErrorMessage } from './utils/timeValidation'; const categories = ['업무', '개인', '가족', '기타']; -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - const notificationOptions = [ { value: 1, label: '1분 전' }, { value: 10, label: '10분 전' }, @@ -92,230 +69,45 @@ function App() { handleEndTimeChange, resetForm, editEvent, + eventData, } = useEventForm(); const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null) ); + const { + isOverlapDialogOpen, + overlappingEvents, + isOverlap, + closeOverlapDialog, + openOverlapDialog, + } = useOverlapDialog(); - const { notifications, notifiedEvents, setNotifications } = useNotifications(events); - const { view, setView, currentDate, holidays, navigate } = useCalendarView(); - const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); - - const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); - const [overlappingEvents, setOverlappingEvents] = useState([]); - - const { enqueueSnackbar } = useSnackbar(); - - const 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); + const { notifications, notifiedEvents, removeNotification } = useNotifications(events); - 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]; + const eventFormValidation = validateEvent({ + title, + date, + startTime, + endTime, + startTimeError, + endTimeError, + }); - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); - }; + const { addOrUpdateEvent } = useEventSubmit({ + eventData, + eventFormValidation, + events, + saveEvent, + resetForm, + openOverlapDialog, + isOverlap, + }); return ( + {/* 일정 추가&수정 폼 */} {editingEvent ? '일정 수정' : '일정 추가'} @@ -487,151 +279,41 @@ function App() { - - 일정 보기 - - - 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}) - - ))} - 계속 진행하시겠습니까? - - - - - - - - + {/* 일정 겹침 경고 모달 */} + { + 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, + }); + }} + /> + {/* 알림 토스트*/} {notifications.length > 0 && ( {notifications.map((notification, index) => ( @@ -640,10 +322,7 @@ function App() { severity="info" sx={{ width: 'auto' }} action={ - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > + removeNotification(index)}> } diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json index 5ab618a0..b7f4c8b0 100644 --- a/src/__mocks__/response/realEvents.json +++ b/src/__mocks__/response/realEvents.json @@ -1,64 +1 @@ -{ - "events": [ - { - "id": "2b7545a6-ebee-426c-b906-2329bc8d62bd", - "title": "팀 회의", - "date": "2025-08-20", - "startTime": "10:00", - "endTime": "11:00", - "description": "주간 팀 미팅", - "location": "회의실 A", - "category": "업무", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "09702fb3-a478-40b3-905e-9ab3c8849dcd", - "title": "점심 약속", - "date": "2025-08-21", - "startTime": "12:30", - "endTime": "13:30", - "description": "동료와 점심 식사", - "location": "회사 근처 식당", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "da3ca408-836a-4d98-b67a-ca389d07552b", - "title": "프로젝트 마감", - "date": "2025-08-25", - "startTime": "09:00", - "endTime": "18:00", - "description": "분기별 프로젝트 마감", - "location": "사무실", - "category": "업무", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "dac62941-69e5-4ec0-98cc-24c2a79a7f81", - "title": "생일 파티", - "date": "2025-08-28", - "startTime": "19:00", - "endTime": "22:00", - "description": "친구 생일 축하", - "location": "친구 집", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - }, - { - "id": "80d85368-b4a4-47b3-b959-25171d49371f", - "title": "운동", - "date": "2025-08-22", - "startTime": "18:00", - "endTime": "19:00", - "description": "주간 운동", - "location": "헬스장", - "category": "개인", - "repeat": { "type": "none", "interval": 0 }, - "notificationTime": 1 - } - ] -} +{"events":[{"id":"2b7545a6-ebee-426c-b906-2329bc8d62bd","title":"팀 회의","date":"2025-08-20","startTime":"10:00","endTime":"11:00","description":"주간 팀 미팅","location":"회의실 A","category":"업무","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"09702fb3-a478-40b3-905e-9ab3c8849dcd","title":"점심 약속","date":"2025-08-21","startTime":"12:30","endTime":"13:30","description":"동료와 점심 식사","location":"회사 근처 식당","category":"개인","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"da3ca408-836a-4d98-b67a-ca389d07552b","title":"프로젝트 마감","date":"2025-08-25","startTime":"09:00","endTime":"18:00","description":"분기별 프로젝트 마감","location":"사무실","category":"업무","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"dac62941-69e5-4ec0-98cc-24c2a79a7f81","title":"생일 파티","date":"2025-08-29","startTime":"19:00","endTime":"22:00","description":"","location":"","category":"개인","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"80d85368-b4a4-47b3-b959-25171d49371f","title":"운동","date":"2025-08-22","startTime":"18:00","endTime":"19:00","description":"주간 운동","location":"헬스장","category":"개인","repeat":{"type":"none","interval":0},"notificationTime":1},{"id":"94e29be1-ec34-4ef1-b2c4-62eaf55609b9","title":"밥먹음","date":"2025-08-21","startTime":"23:30","endTime":"23:48","description":"ㅇㅇ","location":"","category":"개인","repeat":{"type":"none","interval":1},"notificationTime":10},{"id":"d3090d17-a547-4282-bdfe-1c1e4bee7c18","title":"ggre","date":"2025-08-27","startTime":"23:10","endTime":"23:42","description":"gdgd","location":"gdfgd","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10},{"id":"1d6a2d4f-66af-4141-9883-77293aca8149","title":"ㅎ하루끝","date":"2025-08-21","startTime":"23:50","endTime":"23:58","description":"","location":"","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10},{"id":"fd5cc4e8-ab16-4d2a-85ac-682696990848","title":"11:49","date":"2025-08-21","startTime":"23:45","endTime":"23:46","description":"ㅇ","location":"","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":1},{"id":"4d593c56-1591-47ba-aaee-37272520956e","title":"11:49","date":"2025-08-22","startTime":"23:45","endTime":"23:46","description":"","location":"","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":1},{"id":"a4c1ad57-1548-4eff-959e-58fd3ca8a910","title":"33","date":"2001-08-21","startTime":"12:43","endTime":"12:46","description":"","location":"","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10}]} \ No newline at end of file diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..757c20a4 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -4,21 +4,108 @@ import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); //훅이 반환하는 값을 result.current 객체 통해서 사용할 수 있다! - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + const octHolidays = { + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }; + + expect(result.current.holidays).toEqual(octHolidays); + }); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +/** + * Q: 순서를 바꿔도 실행되는 테스트가 좋다고했는데, 이럴 경우 순서에 맞게 테스트 흐름이 있는것 같아서 고민입니다. + * 이럴경우 주간 describe를 묶어서 beforeAll 에 주간세팅을 넣어줘도 되지않을까.. 근데 반복되는코드 괜히 묶을라 말고 그냥 복붙해서 쓰는게 더 좋다하셨으니 우선은 진행 ㄱㄱ + */ +describe('view 변경 후 변경된 날짜(currentDate) 확인', () => { + it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + // 주간 뷰로 변경 + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); + }); + + it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + // 주간 뷰로 변경 + act(() => { + result.current.setView('week'); + }); + + // 다음을 눌러서 view 이동 + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-10-08')); + }); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + // 주간 뷰로 변경 + act(() => { + result.current.setView('week'); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + // 이전 을 눌러서 view 이동 + act(() => { + result.current.navigate('prev'); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + assertDate(result.current.currentDate, new Date('2025-09-24')); + }); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-11-01')); + }); + + it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-01')); + }); + + it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + expect(result.current.holidays).toEqual({ '2025-03-01': '삼일절' }); //3월은 휴일이 한개! + }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..49bdf520 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -1,14 +1,68 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; -import { Event } from '../../types.ts'; +import { + MOCK_EVENTS, + LUNCH_0822, + LUNCH_0828, + MEETING_0829, + METTING_0823, + MEETING_0926, + LUNCH_0904, +} from '../mockEvents.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +const TEST_DATE = new Date('2025-08-21'); -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(MOCK_EVENTS, TEST_DATE, 'month')); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + const currentEvents = MOCK_EVENTS.filter((event) => event.date.startsWith('2025-08')); //8월 일정만 필터링 -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + expect(result.current.filteredEvents).toEqual(currentEvents); +}); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); +it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(MOCK_EVENTS, TEST_DATE, 'month')); + + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.filteredEvents).toEqual([LUNCH_0822, LUNCH_0828]); +}); + +//Q. 위에 테스트랑 중복되는 테스트 같음 +it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(MOCK_EVENTS, TEST_DATE, 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + // LUNCH_0828는 설명에 회의 키워드가 있다. + expect(result.current.filteredEvents).toEqual([METTING_0823, MEETING_0829, LUNCH_0828]); +}); + +it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const SEP_DATE = new Date('2025-09-01'); + + const { result } = renderHook(() => useSearch(MOCK_EVENTS, SEP_DATE, 'month')); //9월 일정 조회 + + expect(result.current.filteredEvents).toEqual([MEETING_0926, LUNCH_0904]); +}); + +it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(MOCK_EVENTS, TEST_DATE, 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toEqual([METTING_0823, MEETING_0829, LUNCH_0828]); + + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.filteredEvents).toEqual([LUNCH_0822, LUNCH_0828]); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..7fa0a083 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -9,6 +9,7 @@ import { import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; +import { MOCK_EVENTS, METTING_0823 } from '../mockEvents.ts'; const enqueueSnackbarFn = vi.fn(); @@ -17,21 +18,140 @@ vi.mock('notistack', async () => { return { ...actual, useSnackbar: () => ({ + //해당 모듈을 오버드라이브하여 enqueueSnackbarFn 로 교체 enqueueSnackbar: enqueueSnackbarFn, }), }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + setupMockHandlerCreation(MOCK_EVENTS); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await act(async () => { + await result.current.fetchEvents(); + }); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + expect(result.current.events).toEqual(MOCK_EVENTS); +}); + +//Q.적절하게..... 저장...? 추가 인듯 +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + //빈 배열로 초기화 + setupMockHandlerCreation([]); + + const { result } = renderHook(() => useEventOperations(false)); + + //초기에는 빈 상태 + expect(result.current.events).toEqual([]); + + //이벤트 추가 + await act(async () => { + await result.current.saveEvent(METTING_0823); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + //추가된 이벤트 확인 + expect(result.current.events).toEqual([METTING_0823]); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + // 정의된 2개의 이벤트가 존재함 + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + await act(async () => { + await result.current.fetchEvents(); + }); + + //이벤트 title, endTime 수정 + const updatedEvent: Event = { + ...result.current.events[0], + title: '기존 회의(시간 수정됨)', + endTime: '11:00', + }; -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + expect(result.current.events[0].title).toEqual(updatedEvent.title); + expect(result.current.events[0].endTime).toEqual(updatedEvent.endTime); +}); + +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + //1개의 이벤트가 존재 + setupMockHandlerDeletion(); + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + expect(result.current.events).toHaveLength(1); + + const deletedEventId = result.current.events[0].id; + + await act(async () => { + await result.current.deleteEvent(deletedEventId); + }); + + expect(result.current.events).toHaveLength(0); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + //호출되었는지 체킹 + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + setupMockHandlerCreation(MOCK_EVENTS); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.fetchEvents(); + }); + + //존재하지 않는 100번 이벤트 업데이트 + const updatedEvent: Event = { + ...result.current.events[0], + id: '100', + title: '기존 회의(시간 수정됨)', + }; + + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.delete('/api/events/:id', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); +}); diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..148fa3c3 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,78 @@ import { act, renderHook } from '@testing-library/react'; import { useNotifications } from '../../hooks/useNotifications.ts'; -import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; +import { setTimeEventBeforeSecond } from '../../utils/dateUtils.ts'; +import { MOCK_EVENTS, LUNCH_0822 } from '../mockEvents.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +beforeEach(() => { + vi.setSystemTime( + setTimeEventBeforeSecond( + new Date(LUNCH_0822.date), + LUNCH_0822.startTime, + LUNCH_0822.notificationTime + ) + ); +}); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications(MOCK_EVENTS)); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const { result } = renderHook(() => useNotifications([LUNCH_0822])); + + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); + + // 시간 1초 앞당기기 + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const { result } = renderHook(() => useNotifications([LUNCH_0822])); + + // 시간 1초 앞당기기 + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); + + // 알림 제거 + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(1); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const { result } = renderHook(() => useNotifications([LUNCH_0822])); + + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); +}); diff --git a/src/__tests__/hooks/useOverlapDialog.spec.ts b/src/__tests__/hooks/useOverlapDialog.spec.ts new file mode 100644 index 00000000..3c71304b --- /dev/null +++ b/src/__tests__/hooks/useOverlapDialog.spec.ts @@ -0,0 +1,81 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useOverlapDialog } from '../../hooks/useOverlapDialog'; +import { Event, EventForm } from '../../types'; + +const BASE_EVENT_FORM: Event = { + id: '1', + title: '팀 회의', + date: '2025-08-22', + startTime: '14:00', + endTime: '15:00', + description: '필참', + location: 'A104', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, +}; + +const EVENT_A: EventForm = { + title: '기존 회의 A', + date: '2025-08-22', + startTime: '16:00', + endTime: '17:00', + description: '회의 A', + location: 'B101', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, +}; + +const EVENT_B_OVERLAP: EventForm = { + title: '기존 회의 B (겹침)', + date: '2025-08-22', + startTime: '14:30', + endTime: '14:45', + description: '회의 B', + location: 'B102', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, +}; + +it('openOverlapDialog 호출 시 목록이 설정되고 모달이 열린다', () => { + const { result } = renderHook(() => useOverlapDialog()); + + act(() => { + result.current.openOverlapDialog([BASE_EVENT_FORM]); + }); + + expect(result.current.isOverlapDialogOpen).toBe(true); + expect(result.current.overlappingEvents).toEqual([BASE_EVENT_FORM]); +}); + +it('closeOverlapDialog 호출 시 모달이 닫힌다', () => { + const { result } = renderHook(() => useOverlapDialog()); + + act(() => { + result.current.openOverlapDialog([BASE_EVENT_FORM]); + }); + act(() => { + result.current.closeOverlapDialog(); + }); + + expect(result.current.isOverlapDialogOpen).toBe(false); +}); + +it('isOverlap: 겹치면 true를 반환한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + + const isOverlapping = result.current.isOverlap(EVENT_B_OVERLAP, [BASE_EVENT_FORM]); + + expect(isOverlapping).toBe(true); +}); + +it('isOverlap: 겹치지 않으면 false를 반환한다', () => { + const { result } = renderHook(() => useOverlapDialog()); + + const isOverlapping = result.current.isOverlap(EVENT_A, [BASE_EVENT_FORM]); + + expect(isOverlapping).toBe(false); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..f3b63b6c 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -2,7 +2,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { render, screen, within, act } 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(); @@ -55,41 +53,314 @@ const saveSchedule = async ( await user.click(screen.getByTestId('event-submit-button')); }; +const updateSchedule = async ( + user: UserEvent, + form: Omit +) => { + const { title, date, startTime, endTime, location, description, category } = form; + // 기존 값 초기화 + await user.clear(screen.getByLabelText('제목')); + await user.clear(screen.getByLabelText('설명')); + await user.clear(screen.getByLabelText('위치')); + await user.clear(screen.getByLabelText('날짜')); + await user.clear(screen.getByLabelText('시작 시간')); + await user.clear(screen.getByLabelText('종료 시간')); + + // 재작성 + await user.type(screen.getByLabelText('제목'), title); + await user.type(screen.getByLabelText('날짜'), date); + await user.type(screen.getByLabelText('시작 시간'), startTime); + await user.type(screen.getByLabelText('종료 시간'), endTime); + await user.type(screen.getByLabelText('설명'), description); + await user.type(screen.getByLabelText('위치'), location); + await user.click(screen.getByLabelText('카테고리')); + await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `${category}-option` })); + + await user.click(screen.getByTestId('event-submit-button')); +}; + // ! HINT. "검색 결과가 없습니다"는 초기에 노출되는데요. 그럼 검증하고자 하는 액션이 실행되기 전에 검증해버리지 않을까요? 이 테스트를 신뢰성있게 만드려면 어떻게 할까요? describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation(); + + const { user } = setup(); + + await saveSchedule(user, { + title: '항해 주간 회의', + date: '2025-10-04', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '회의합니다.', + category: '업무', + }); + + await screen.findByText('일정이 추가되었습니다.'); + + const eventListBox = screen.getByTestId('event-list'); + expect(within(eventListBox).getByText('항해 주간 회의')).toBeInTheDocument(); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + const { user } = setup(); + setupMockHandlerUpdating(); //Q. 이걸 뒤에 놔야 화면이 로드된 후 데이터가 들어오나? + + await user.click(await screen.findByLabelText('Edit event')); + + await updateSchedule(user, { + title: '항해 주간 회의', + date: '2025-10-01', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '회의합니다.', + category: '업무', + }); + + await screen.findByText('일정이 수정되었습니다.'); + + const eventList = within(screen.getByTestId('event-list')); + expect(eventList.getByText('항해 주간 회의')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + const { user } = setup(); + setupMockHandlerDeletion(); + + await user.click(await screen.findByLabelText('Delete event')); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + const eventList = within(screen.getByTestId('event-list')); + expect(eventList.queryByText('삭제할 이벤트')).not.toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + const { user } = setup(); + + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `week-option` })); + + //해당 주에 일정이 없다. + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + setupMockHandlerCreation(); + const { user } = setup(); + + await saveSchedule(user, { + title: '항해 주간 회의', + date: '2025-10-04', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '회의합니다.', + category: '업무', + }); + + await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `week-option` })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + //해당 주에 일정이 있다. + const weekViewBox = screen.getByTestId('week-view'); + expect(within(weekViewBox).getByText('항해 주간 회의')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setup(); + + //해당 달에 일정이 없다. + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + setupMockHandlerCreation(); + const { user } = setup(); + + await saveSchedule(user, { + title: '항해 주간 회의', + date: '2025-10-04', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '회의합니다.', + category: '업무', + }); + + //해당 달에 일정이 있다. + const monthViewBox = screen.getByTestId('month-view'); + expect(within(monthViewBox).getByText('항해 주간 회의')).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const { user } = setup(); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + // Previous 버튼을 9번 클릭 + for (let i = 0; i < 9; i++) { + await user.click(await screen.findByLabelText('Previous')); + } - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + expect(screen.getByText('2025년 1월')).toBeInTheDocument(); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + //해당 달에 일정이 있다. + const monthViewBox = screen.getByTestId('month-view'); + expect(within(monthViewBox).getByText('신정')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + beforeEach(() => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-04', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '팀 회의2', + date: '2025-10-05', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '이건 주간 회의', + date: '2025-10-06', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + }); + + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + const { user } = setup(); + + // id="search"인 검색 입력창에 존재하지 않는 검색어 입력 + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '테스트코드으악'); + + // 검색 결과가 없다는 메시지가 표시되는지 확인 + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '팀 회의'); + + const eventList = within(screen.getByTestId('event-list')); + expect(eventList.getByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const { user } = setup(); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.clear(searchInput); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + const eventList = within(screen.getByTestId('event-list')); + expect(eventList.getAllByText(/회의/)).toHaveLength(3); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '2', + title: '팀 회의2', + date: '2025-10-05', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + const { user } = setup(); + + await saveSchedule(user, { + title: '팀 회의 충돌!!!', + date: '2025-10-05', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + }); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + const { user } = setup(); + + const eventList = await screen.findByTestId('event-list'); + + const editButtons = await within(eventList).findAllByLabelText('Edit event'); + await user.click(editButtons[0]); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + await user.clear(screen.getByLabelText('시작 시간')); + await user.clear(screen.getByLabelText('종료 시간')); + await user.type(screen.getByLabelText('시작 시간'), '11:00'); + await user.type(screen.getByLabelText('종료 시간'), '12:00'); + + await user.click(screen.getByTestId('event-submit-button')); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + vi.setSystemTime(new Date('2025-10-01T13:49:59')); + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '14:00', + endTime: '15:00', + location: 'A104', + description: '필참', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + await screen.findByText('일정 로딩 완료!'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.getByText('10분 후 팀 회의 일정이 시작됩니다.')).toBeInTheDocument(); +}); diff --git a/src/__tests__/mockEvents.ts b/src/__tests__/mockEvents.ts new file mode 100644 index 00000000..7e4e8ab7 --- /dev/null +++ b/src/__tests__/mockEvents.ts @@ -0,0 +1,118 @@ +import { Event } from '../types'; + +const METTING_0823: Event = { + id: '1', + title: '주간 회의', + date: '2025-08-23', + startTime: '14:00', + endTime: '15:00', + description: '주간 회의~', + location: 'A103', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, +}; +const LUNCH_0822: Event = { + id: '2', + title: '점심 약속', + date: '2025-08-22', + startTime: '12:00', + endTime: '13:00', + description: '친구랑 점심 약속~', + location: '롯데리아', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, +}; +const MEETING_0829: Event = { + id: '3', + title: '월간 회의', + date: '2025-08-29', + startTime: '14:00', + endTime: '15:00', + description: '월간 마무리 회의~', + location: 'A104', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const LUNCH_0828: Event = { + id: '4', + title: '점심 데이트', + date: '2025-08-28', + startTime: '12:00', + endTime: '13:00', + description: '친구랑 점심 데이트, 후딱 먹고 회의가야함', + location: '버거킹', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const MEETING_0926: Event = { + id: '5', + title: '9월 월간 회의', + date: '2025-09-26', + startTime: '14:00', + endTime: '15:00', + description: '9월 월간 회의~', + location: 'A104', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const LUNCH_0904: Event = { + id: '6', + title: '9월 점심 약속', + date: '2025-09-04', + startTime: '12:00', + endTime: '13:00', + description: '9월 점심 약속~', + location: '야외 카페', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const LUNCH_0901: Event = { + id: '7', + title: '9월 1일 점심 약속', + date: '2025-09-01', + startTime: '12:00', + endTime: '13:00', + description: '9월 점심 약속~', + location: '야외 카페', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const DINNER_0901: Event = { + id: '8', + title: '9월 1일 저녁 약속', + date: '2025-09-01', + startTime: '12:00', + endTime: '13:00', + description: '9월 점심 약속~', + location: '야외 카페', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 30, +}; +const MOCK_EVENTS: Event[] = [ + METTING_0823, + LUNCH_0822, + MEETING_0829, + LUNCH_0828, + MEETING_0926, + LUNCH_0904, +]; + +export { + MOCK_EVENTS, + METTING_0823, + LUNCH_0822, + MEETING_0829, + LUNCH_0828, + MEETING_0926, + LUNCH_0904, + LUNCH_0901, + DINNER_0901, +}; diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..660beec4 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -10,107 +10,267 @@ import { getWeeksAtMonth, isDateInRange, } from '../../utils/dateUtils'; +import { DINNER_0901, LUNCH_0901, LUNCH_0904 } from '../mockEvents'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); - it('4월은 30일 일수를 반환한다', () => {}); + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2000, 2)).toBe(29); + }); - it('평년의 2월에 대해 28일을 반환한다', () => {}); + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + expect(getDaysInMonth(2025, 0)).toBe(31); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + //일요일 부터 시작 + expect(getWeekDates(new Date('2025-08-22'))).toEqual([ + new Date('2025-08-17'), + new Date('2025-08-18'), + new Date('2025-08-19'), + new Date('2025-08-20'), + new Date('2025-08-21'), + new Date('2025-08-22'), + new Date('2025-08-23'), + ]); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(new Date('2025-07-28'))).toEqual([ + new Date('2025-07-27'), + new Date('2025-07-28'), + new Date('2025-07-29'), + new Date('2025-07-30'), + new Date('2025-07-31'), + new Date('2025-08-01'), + new Date('2025-08-02'), + ]); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(new Date('2025-08-31'))).toEqual([ + new Date('2025-08-31'), + new Date('2025-09-01'), + new Date('2025-09-02'), + new Date('2025-09-03'), + new Date('2025-09-04'), + new Date('2025-09-05'), + new Date('2025-09-06'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + expect(getWeekDates(new Date('2025-12-31'))).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + expect(getWeekDates(new Date('2026-01-01'))).toEqual([ + new Date('2025-12-28'), + new Date('2025-12-29'), + new Date('2025-12-30'), + new Date('2025-12-31'), + new Date('2026-01-01'), + new Date('2026-01-02'), + new Date('2026-01-03'), + ]); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + expect(getWeekDates(new Date('2000-02-29'))).toEqual([ + new Date('2000-02-27'), + new Date('2000-02-28'), + new Date('2000-02-29'), + new Date('2000-03-01'), + new Date('2000-03-02'), + new Date('2000-03-03'), + new Date('2000-03-04'), + ]); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + expect(getWeekDates(new Date('2025-08-31'))).toEqual([ + new Date('2025-08-31'), + new Date('2025-09-01'), + new Date('2025-09-02'), + new Date('2025-09-03'), + new Date('2025-09-04'), + new Date('2025-09-05'), + new Date('2025-09-06'), + ]); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + expect(getWeeksAtMonth(new Date('2025-07-01'))[0]).toEqual([null, null, 1, 2, 3, 4, 5]); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + const events: Event[] = [LUNCH_0901, DINNER_0901, LUNCH_0904]; + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + expect(getEventsForDay(events, 1)).toEqual([LUNCH_0901, DINNER_0901]); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 2)).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 0)).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 32)).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-08-15'))).toBe('2025년 8월 2주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-08-04'))).toBe('2025년 8월 1주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-07-31'))).toBe('2025년 7월 5주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-12-31'))).toBe('2026년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2000-02-29'))).toBe('2000년 3월 1주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-02-28'))).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + expect(formatMonth(new Date('2025-07-10'))).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); - - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); - - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); - - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); - - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); - - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + const startDate = new Date('2025-07-01'); + const endDate = new Date('2025-07-31'); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-10'), startDate, endDate)).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-01'), startDate, endDate)).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-31'), startDate, endDate)).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-06-30'), startDate, endDate)).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-08-01'), startDate, endDate)).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + expect( + isDateInRange(new Date('2025-07-01'), new Date('2025-08-01'), new Date('2025-07-01')) + ).toBe(false); + expect( + isDateInRange(new Date('2025-08-01'), new Date('2025-08-01'), new Date('2025-07-01')) + ).toBe(false); + expect( + isDateInRange(new Date('2025-07-21'), new Date('2025-08-01'), new Date('2025-07-01')) + ).toBe(false); + expect( + isDateInRange(new Date('2025-09-21'), new Date('2025-08-01'), new Date('2025-07-01')) + ).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); - - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); - - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); - - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); - - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); - - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); - - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); - - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); - - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + // size default 값이 2 + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5)).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10)).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); + + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100)).toBe('100'); + }); + + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0)).toBe('00'); + }); + + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); + + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(1)).toBe('01'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(100, 2)).toBe('100'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); - - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); - - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); - - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + expect(formatDate(new Date())).toBe('2025-10-01'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + expect(formatDate(new Date(), 4)).toBe('2025-10-04'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date('2025-08-02'))).toBe('2025-08-02'); + }); + + //Q. 위의 테스트케이스와 한번에 해도 되지 않을까 + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date('2025-08-02'))).toBe('2025-08-02'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..d2545eea 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -1,36 +1,88 @@ -import { Event } from '../../types'; import { convertEventToDateRange, findOverlappingEvents, isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; +import { DINNER_0901, LUNCH_0901, MOCK_EVENTS } from '../mockEvents'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + expect(parseDateTime('2025-07-01', '14:30')).toEqual(new Date('2025-07-01T14:30:00')); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-17-01', '12:30')).toEqual(new Date('Invalid Date')); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-01', '25:30')).toEqual(new Date('Invalid Date')); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + expect(parseDateTime('', '12:30')).toEqual(new Date('Invalid Date')); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + expect(convertEventToDateRange(LUNCH_0901)).toEqual({ + start: new Date('2025-09-01T12:00:00'), + end: new Date('2025-09-01T13:00:00'), + }); + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + expect(convertEventToDateRange({ ...LUNCH_0901, date: '2025-17-01' })).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + expect( + convertEventToDateRange({ ...LUNCH_0901, startTime: '32:00', endTime: '33:00' }) + ).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + const overLappingEvent = { + ...LUNCH_0901, + startTime: '12:00', + endTime: '14:00', + }; + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + expect(isOverlapping(overLappingEvent, LUNCH_0901)).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const unOverLappingEvent = { + ...LUNCH_0901, + startTime: '14:00', + endTime: '17:00', + }; + expect(isOverlapping(unOverLappingEvent, LUNCH_0901)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + const overLappingEvent = { + ...LUNCH_0901, + id: '100', + startTime: '12:00', + endTime: '14:00', + }; + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + expect( + findOverlappingEvents(overLappingEvent, [LUNCH_0901, DINNER_0901, ...MOCK_EVENTS]) + ).toEqual([LUNCH_0901, DINNER_0901]); + }); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + expect(findOverlappingEvents(LUNCH_0901, MOCK_EVENTS)).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..e00de9ee 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,118 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; +import { MOCK_EVENTS } from '../mockEvents'; +const event2_0701: Event = { + id: '100', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: 'event2 description', + location: 'event2 location', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, +}; +const event3_0702: Event = { + id: '101', + title: '이벤트 3', + date: '2025-07-02', + startTime: '10:00', + endTime: '11:00', + description: 'event3 description', + location: 'event3 location', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, +}; +const event4_0801: Event = { + id: '102', + title: '이벤트 4', + date: '2025-08-01', + startTime: '10:00', + endTime: '11:00', + description: 'event4 description', + location: 'event4 location', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, +}; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + expect( + getFilteredEvents([...MOCK_EVENTS, event2_0701], '이벤트 2', new Date('2025-07-01'), 'month') + ).toEqual([event2_0701]); + }); - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + expect( + getFilteredEvents([...MOCK_EVENTS, event2_0701], '', new Date('2025-07-01'), 'week') + ).toEqual([event2_0701]); + }); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + expect( + getFilteredEvents( + [...MOCK_EVENTS, event2_0701, event3_0702], + '', + new Date('2025-07-01'), + 'month' + ) + ).toEqual([event2_0701, event3_0702]); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + expect( + getFilteredEvents([...MOCK_EVENTS, event2_0701], '이벤트', new Date('2025-07-01'), 'week') + ).toEqual([event2_0701]); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + expect( + getFilteredEvents( + [...MOCK_EVENTS, event2_0701, event3_0702], + '', + new Date('2025-07-01'), + 'month' + ) + ).toEqual([event2_0701, event3_0702]); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + expect( + getFilteredEvents( + [...MOCK_EVENTS, event2_0701, event3_0702], + 'EVENT', + new Date('2025-07-01'), + 'month' + ) + ).toEqual([event2_0701, event3_0702]); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + //7월일때 + expect( + getFilteredEvents( + [event2_0701, event3_0702, event4_0801], + '', + new Date('2025-07-01'), + 'month' + ) + ).toEqual([event2_0701, event3_0702]); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + //8월로 넘어갔을때 + expect( + getFilteredEvents( + [event2_0701, event3_0702, event4_0801], + '', + new Date('2025-08-01'), + 'month' + ) + ).toEqual([event4_0801]); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + expect(getFilteredEvents([], '', new Date('2025-07-01'), 'month')).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..0479577d 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,20 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + expect(fetchHolidays(new Date('2025-08-01'))).toEqual({ '2025-08-15': '광복절' }); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + expect(fetchHolidays(new Date('2025-07-01'))).toEqual({}); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + expect(fetchHolidays(new Date('2025-01-01'))).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..6573c162 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,39 @@ -import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; - +import { METTING_0823 } from '../mockEvents'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const now = new Date('2025-08-23T13:50:00'); + + expect(getUpcomingEvents([METTING_0823], now, [])).toEqual([METTING_0823]); + }); + + it('이미 알림이 간 이벤트는 제외한다', () => { + const now = new Date('2025-08-23T13:50:00'); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + expect(getUpcomingEvents([METTING_0823], now, [METTING_0823.id])).toEqual([]); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + // 알림 울리기 1분전 세팅 + const now = new Date('2025-08-23T13:49:00'); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + expect(getUpcomingEvents([METTING_0823], now, [])).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-08-23T14:00:00'); + + expect(getUpcomingEvents([METTING_0823], now, [])).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const notificationTime = METTING_0823.notificationTime; + const title = METTING_0823.title; + + expect(createNotificationMessage(METTING_0823)).toBe( + `${notificationTime}분 후 ${title} 일정이 시작됩니다.` + ); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..c1f59955 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,53 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; -describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); +describe('getTimeErrorMessage', () => { + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const start = '14:00'; + const end = '13:00'; - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(getTimeErrorMessage(start, end)).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const start = '14:00'; + const end = '14:00'; - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(getTimeErrorMessage(start, end)).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const start = '13:00'; + const end = '14:00'; - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(getTimeErrorMessage(start, end)).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const start = ''; + const end = '14:00'; + + expect(getTimeErrorMessage(start, end)).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const start = ''; + const end = ''; + + expect(getTimeErrorMessage(start, end)).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); }); diff --git a/src/__tests__/unit/eventValidation.spec.ts b/src/__tests__/unit/eventValidation.spec.ts new file mode 100644 index 00000000..28dd6195 --- /dev/null +++ b/src/__tests__/unit/eventValidation.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; + +import { validateEvent, EventValidationInput } from '../../utils/eventValidation'; + +describe('validateEvent', () => { + it('모든 값이 유효하면 {ok:true} 를 반환한다', () => { + const validInput: EventValidationInput = { + title: '회의', + date: '2025-08-22', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + + const result = validateEvent(validInput); + expect(result).toEqual({ ok: true }); + }); + + it('필수값이 비어있으면 에러 메시지를 반환한다', () => { + const invalidInput: EventValidationInput = { + title: '', + date: '2025-08-22', + startTime: '09:00', + endTime: '10:00', + startTimeError: null, + endTimeError: null, + }; + const result = validateEvent(invalidInput); + expect(result.ok).toBe(false); + expect(result.errorMessage).toBe('필수 정보를 모두 입력해주세요.'); + }); + + it('시간 에러가 있으면 에러 메시지를 반환한다', () => { + const invalidTimeInput: EventValidationInput = { + title: '회의', + date: '2025-08-22', + startTime: '13:00', + endTime: '12:00', + startTimeError: '시작 시간이 종료 시간보다 늦습니다.', + endTimeError: null, + }; + + expect(validateEvent(invalidTimeInput)).toEqual({ + ok: false, + errorMessage: '시간 설정을 확인해주세요.', + }); + }); +}); diff --git a/src/components/CalendarDateBox.tsx b/src/components/CalendarDateBox.tsx new file mode 100644 index 00000000..68c39196 --- /dev/null +++ b/src/components/CalendarDateBox.tsx @@ -0,0 +1,36 @@ +import { Notifications } from '@mui/icons-material'; +import { Box, Stack, Typography } from '@mui/material'; + +import { Event } from '../types'; + +export default function CalendarDateBox({ + event, + isNotified, +}: { + event: Event; + isNotified: boolean; +}) { + return ( + + + {isNotified && } + + {event.title} + + + + ); +} diff --git a/src/components/CalenderContainer.tsx b/src/components/CalenderContainer.tsx new file mode 100644 index 00000000..5ba43c09 --- /dev/null +++ b/src/components/CalenderContainer.tsx @@ -0,0 +1,115 @@ +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { + IconButton, + Typography, + Stack, + Select, + MenuItem, + FormControl, + FormLabel, + TextField, +} from '@mui/material'; + +import EventItem from './EventItem'; +import MonthView from './MonthView'; +import WeekView from './WeekView'; +import { useCalendarView } from '../hooks/useCalendarView'; +import { useSearch } from '../hooks/useSearch'; +import { Event } from '../types'; + +interface CalenderContainerProps { + events: Event[]; + notifiedEvents: string[]; + notificationOptions: { value: number; label: string }[]; + editEvent: (event: Event) => void; + deleteEvent: (id: string) => void; +} + +export default function CalenderContainer({ + events, + notifiedEvents, + notificationOptions, + editEvent, + deleteEvent, +}: CalenderContainerProps) { + const { view, setView, currentDate, holidays, navigate } = useCalendarView(); + const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); + + return ( + <> + + 일정 보기 + + + navigate('prev')}> + + + + navigate('next')}> + + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + {/* 일정 목록&검색 섹션 */} + + + 일정 검색 + setSearchTerm(e.target.value)} + /> + + + {filteredEvents.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + filteredEvents.map((event) => ( + + )) + )} + + + ); +} diff --git a/src/components/EventItem.tsx b/src/components/EventItem.tsx new file mode 100644 index 00000000..04fd5a8b --- /dev/null +++ b/src/components/EventItem.tsx @@ -0,0 +1,67 @@ +import { Delete, Edit, Notifications } from '@mui/icons-material'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; + +import { Event } from '../types'; + +interface EventItemProps { + event: Event; + notifiedEvents: string[]; + editEvent: (event: Event) => void; + deleteEvent: (id: string) => void; + notificationOptions: { value: number; label: string }[]; +} +export default function EventItem({ + event, + notifiedEvents, + editEvent, + deleteEvent, + notificationOptions, +}: EventItemProps) { + return ( + + + + + {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)}> + + + + + + ); +} diff --git a/src/components/MonthView.tsx b/src/components/MonthView.tsx new file mode 100644 index 00000000..7d2b3873 --- /dev/null +++ b/src/components/MonthView.tsx @@ -0,0 +1,98 @@ +import { + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { weekDays } from '../constants'; +import { Event } from '../types'; +import CalendarDateBox from './CalendarDateBox'; +import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../utils/dateUtils'; + +interface MonthViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export default function MonthView({ + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: MonthViewProps) { + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(filteredEvents, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +} diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..db6a0bc4 --- /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 { + open: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onConfirm: () => void; +} + +export default function OverlapDialog({ + open, + overlappingEvents, + onClose, + onConfirm, +}: OverlapDialogProps) { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +} diff --git a/src/components/WeekView.tsx b/src/components/WeekView.tsx new file mode 100644 index 00000000..7030e1dd --- /dev/null +++ b/src/components/WeekView.tsx @@ -0,0 +1,73 @@ +import { + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { weekDays } from '../constants'; +import { Event } from '../types'; +import CalendarDateBox from './CalendarDateBox'; +import { formatWeek, getWeekDates } from '../utils/dateUtils'; + +interface WeekViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; +} + +export default function WeekView({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) { + const weekDates = getWeekDates(currentDate); + + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {filteredEvents + .filter((event) => new Date(event.date).toDateString() === date.toDateString()) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + ); + })} + + ))} + + +
+
+
+ ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..7f0382af --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const weekDays = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts index 9dfcc46a..442e929a 100644 --- a/src/hooks/useEventForm.ts +++ b/src/hooks/useEventForm.ts @@ -1,6 +1,6 @@ import { ChangeEvent, useState } from 'react'; -import { Event, RepeatType } from '../types'; +import { Event, EventForm, RepeatType } from '../types'; import { getTimeErrorMessage } from '../utils/timeValidation'; type TimeErrorRecord = Record<'startTimeError' | 'endTimeError', string | null>; @@ -69,7 +69,24 @@ export const useEventForm = (initialEvent?: Event) => { setNotificationTime(event.notificationTime); }; + 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, + }; return { + eventData, title, setTitle, date, diff --git a/src/hooks/useEventSubmit.ts b/src/hooks/useEventSubmit.ts new file mode 100644 index 00000000..ba11bef3 --- /dev/null +++ b/src/hooks/useEventSubmit.ts @@ -0,0 +1,46 @@ +import { useSnackbar } from 'notistack'; + +import { Event, EventForm } from '../types'; +import { EventValidationResult } from '../utils/eventValidation'; + +interface UseEventSubmitProps { + eventData: Event | EventForm; + eventFormValidation: EventValidationResult; + events: Event[]; + openOverlapDialog: (events: Event[]) => void; + saveEvent: (event: Event) => void; + resetForm: () => void; + isOverlap: (eventData: Event | EventForm, events: Event[]) => boolean; +} + +export const useEventSubmit = ({ + eventData, + eventFormValidation, + saveEvent, + resetForm, + events, + openOverlapDialog, + isOverlap, +}: UseEventSubmitProps) => { + const { enqueueSnackbar } = useSnackbar(); + + const addOrUpdateEvent = async () => { + if (!eventFormValidation.ok) { + enqueueSnackbar(eventFormValidation.errorMessage || '유효성 검사 실패', { variant: 'error' }); + return; + } + + const isOverlapping = isOverlap(eventData, events); + if (isOverlapping) { + openOverlapDialog(events); + return; + } + + await saveEvent(eventData as Event); + resetForm(); + }; + + return { + addOrUpdateEvent, + }; +}; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index f9ec573b..aa3ebf3d 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Event } from '../types'; import { createNotificationMessage, getUpcomingEvents } from '../utils/notificationUtils'; @@ -7,7 +7,7 @@ export const useNotifications = (events: Event[]) => { const [notifications, setNotifications] = useState<{ id: string; message: string }[]>([]); const [notifiedEvents, setNotifiedEvents] = useState([]); - const checkUpcomingEvents = () => { + const checkUpcomingEvents = useCallback(() => { const now = new Date(); const upcomingEvents = getUpcomingEvents(events, now, notifiedEvents); @@ -20,7 +20,7 @@ export const useNotifications = (events: Event[]) => { ]); setNotifiedEvents((prev) => [...prev, ...upcomingEvents.map(({ id }) => id)]); - }; + }, [events, notifiedEvents]); const removeNotification = (index: number) => { setNotifications((prev) => prev.filter((_, i) => i !== index)); @@ -29,7 +29,7 @@ export const useNotifications = (events: Event[]) => { useEffect(() => { const interval = setInterval(checkUpcomingEvents, 1000); // 1초마다 체크 return () => clearInterval(interval); - }, [events, notifiedEvents]); + }, [checkUpcomingEvents]); return { notifications, notifiedEvents, setNotifications, removeNotification }; }; diff --git a/src/hooks/useOverlapDialog.ts b/src/hooks/useOverlapDialog.ts new file mode 100644 index 00000000..fe7b60bb --- /dev/null +++ b/src/hooks/useOverlapDialog.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; + +import { Event, EventForm } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; + +export const useOverlapDialog = () => { + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + + const isOverlap = (eventData: Event | EventForm, events: Event[]) => { + const overlapping = findOverlappingEvents(eventData, events); + + if (overlapping.length > 0) { + return true; + } + return false; + }; + + const openOverlapDialog = (events: Event[]) => { + setOverlappingEvents(events); + setIsOverlapDialogOpen(true); + }; + + const closeOverlapDialog = () => { + setIsOverlapDialogOpen(false); + }; + + return { + isOverlap, + isOverlapDialogOpen, + overlappingEvents, + openOverlapDialog, + closeOverlapDialog, + }; +}; diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..74b428aa 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -108,3 +108,19 @@ export function formatDate(currentDate: Date, day?: number) { fillZero(day ?? currentDate.getDate()), ].join('-'); } + +//이벤트 알림 1초전으로 세팅하는 함수 +/** + * @param date 이벤트 날짜 + * @param startTime 이벤트 시작 시간 + * @param notificationTime 알림 시간: 분 단위로 들어온다 + * @returns 알림 시간 1초 전 시간 + */ +export function setTimeEventBeforeSecond(date: Date, startTime: string, notificationTime: number) { + const [h, m] = startTime.split(':').map(Number); + const eventDate = new Date(date); + eventDate.setHours(h, m, 0, 0); + eventDate.setMinutes(eventDate.getMinutes() - notificationTime); + eventDate.setSeconds(-1); + return eventDate.getTime(); +} diff --git a/src/utils/eventValidation.ts b/src/utils/eventValidation.ts new file mode 100644 index 00000000..bac7a071 --- /dev/null +++ b/src/utils/eventValidation.ts @@ -0,0 +1,32 @@ +export interface EventValidationInput { + title: string; + date: string; + startTime: string; + endTime: string; + startTimeError: string | null; + endTimeError: string | null; +} + +export interface EventValidationResult { + ok: boolean; + errorMessage?: string; +} + +export function validateEvent({ + title, + date, + startTime, + endTime, + startTimeError, + endTimeError, +}: EventValidationInput): EventValidationResult { + if (!title || !date || !startTime || !endTime) { + return { ok: false, errorMessage: '필수 정보를 모두 입력해주세요.' }; + } + + if (startTimeError || endTimeError) { + return { ok: false, errorMessage: '시간 설정을 확인해주세요.' }; + } + + return { ok: true }; +}