diff --git a/eslint.config.js b/eslint.config.js index 0a019971..0159ba27 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -70,7 +70,7 @@ export default [ ...typescriptPlugin.configs.recommended.rules, // ESLint rules - 'no-unused-vars': 'warn', + 'no-unused-vars': 'off', // React rules 'react/prop-types': 'off', @@ -112,6 +112,7 @@ export default [ rules: { ...vitestPlugin.configs.recommended.rules, 'vitest/expect-expect': 'off', + 'vitest/no-commented-out-tests': 'off', }, languageOptions: { globals: { 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..24cdcc07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,95 +1,46 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import { ChevronLeft, ChevronRight, Delete, Edit, Notifications } 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 MonthView from './components/MonthView.tsx'; +import MutateEventForm from './components/MutateEventForm.tsx'; +import NotificationList from './components/NotificationList.tsx'; +import OverlapDialog from './components/OverlapDialog.tsx'; +import WeekView from './components/WeekView.tsx'; +import { NOTIFICATION_OPTIONS } from './constants/ui.ts'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; -import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; +import { Event } from './types'; function App() { const { title, - setTitle, date, - setDate, startTime, endTime, description, - setDescription, location, - setLocation, category, - setCategory, isRepeating, - setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, - setNotificationTime, - startTimeError, - endTimeError, editingEvent, setEditingEvent, - handleStartTimeChange, - handleEndTimeChange, resetForm, editEvent, } = useEventForm(); @@ -99,393 +50,22 @@ function App() { ); const { notifications, notifiedEvents, setNotifications } = useNotifications(events); - const { view, setView, currentDate, holidays, navigate } = useCalendarView(); + const { view, setView, currentDate, 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); - - return ( - - {formatMonth(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - {weeks.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const dateString = day ? formatDate(currentDate, day) : ''; - const holiday = holidays[dateString]; - - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); - }; - return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - + 일정 보기 @@ -512,8 +92,12 @@ function App() { - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} 알림:{' '} { - notificationOptions.find( + NOTIFICATION_OPTIONS.find( (option) => option.value === event.notificationTime )?.label } @@ -590,69 +174,33 @@ function App() { - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - - - {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - - )} + setIsOverlapDialogOpen(false)} + overlappingEvents={overlappingEvents} + onOk={() => {}} + onCancel={() => { + setIsOverlapDialogOpen(false); + saveEvent({ + id: editingEvent ? editingEvent.id : undefined, + title, + date, + startTime, + endTime, + description, + location, + category, + repeat: { + type: isRepeating ? repeatType : 'none', + interval: repeatInterval, + endDate: repeatEndDate || undefined, + }, + notificationTime, + }); + }} + /> + + setNotifications([])} /> ); } diff --git a/src/__mocks__/response/realEvents.json b/src/__mocks__/response/realEvents.json index 5ab618a0..18dfb1a5 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-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},{"id":"6f5dd4c1-a633-4106-848f-6ae497850e1d","title":"dfdf","date":"2025-08-22","startTime":"03:38","endTime":"04:38","description":"sfsf","location":"sfsf","category":"업무","repeat":{"type":"none","interval":1},"notificationTime":10}]} \ No newline at end of file diff --git a/src/__tests__/components/WeekView.spec.tsx b/src/__tests__/components/WeekView.spec.tsx new file mode 100644 index 00000000..dc2b738e --- /dev/null +++ b/src/__tests__/components/WeekView.spec.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import WeekView from '../../components/WeekView'; +import { Event } from '../../types'; + +const mockEvents: Event[] = [ + { + id: '1', + title: '회의', + date: '2024-07-15', + startTime: '10:00', + endTime: '11:00', + description: '팀 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: '점심 약속', + date: '2024-07-17', + startTime: '12:00', + endTime: '13:00', + description: '친구와 점심', + location: '레스토랑', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, +]; + +const mockCurrentDate = new Date('2024-07-15'); + +const mockUseEventOperations = { + events: mockEvents, + saveEvent: vi.fn(), + deleteEvent: vi.fn(), +}; + +vi.mock('../../hooks/useEventOperations', () => ({ + useEventOperations: () => mockUseEventOperations, +})); + +const mockUseNotifications = { + notifications: [], + notifiedEvents: ['1'], + setNotifications: vi.fn(), +}; + +vi.mock('../../hooks/useNotifications', () => ({ + useNotifications: () => mockUseNotifications, +})); + +const mockUseCalendarView = { + view: 'week' as const, + setView: vi.fn(), + currentDate: mockCurrentDate, + holidays: {}, + navigate: vi.fn(), +}; + +vi.mock('../../hooks/useCalendarView', () => ({ + useCalendarView: () => mockUseCalendarView, +})); + +const mockUseSearch = { + searchTerm: '', + filteredEvents: mockEvents, + setSearchTerm: vi.fn(), +}; + +vi.mock('../../hooks/useSearch', () => ({ + useSearch: () => mockUseSearch, +})); + +vi.mock('../../utils/dateUtils', () => ({ + formatWeek: () => `2024년 7월 2주차`, + getWeekDates: () => [ + new Date('2024-07-14'), + new Date('2024-07-15'), + new Date('2024-07-16'), + new Date('2024-07-17'), + new Date('2024-07-18'), + new Date('2024-07-19'), + new Date('2024-07-20'), + ], +})); + +const defaultProps = { + editingEvent: null, + setEditingEvent: vi.fn(), +}; + +describe('WeekView', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('주간 뷰가 올바르게 렌더링되어야 한다', () => { + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 7월 2주차')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('요일 헤더가 올바르게 표시되어야 한다', () => { + render(); + + const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + weekDays.forEach((day) => { + expect(screen.getByText(day)).toBeInTheDocument(); + }); + }); + + it('각 날짜가 올바르게 표시되어야 한다', () => { + render(); + + const dates = ['14', '15', '16', '17', '18', '19', '20']; + dates.forEach((date) => { + expect(screen.getByText(date)).toBeInTheDocument(); + }); + }); + + it('이벤트가 올바른 날짜에 표시되어야 한다', () => { + render(); + + expect(screen.getByText('회의')).toBeInTheDocument(); + expect(screen.getByText('점심 약속')).toBeInTheDocument(); + }); + + it('알림이 설정된 이벤트에 알림 아이콘이 표시되어야 한다', () => { + render(); + + const notificationIcons = screen.getAllByTestId('NotificationsIcon'); + expect(notificationIcons).toHaveLength(1); + }); +}); diff --git a/src/__tests__/data/dummy.ts b/src/__tests__/data/dummy.ts new file mode 100644 index 00000000..8752f029 --- /dev/null +++ b/src/__tests__/data/dummy.ts @@ -0,0 +1,14 @@ +import { Event } from '../../types'; + +export const dummyEvent: Event = { + date: '2025-08-15', + startTime: '14:00', + endTime: '15:00', + id: '1', + title: 'test', + description: 'test', + location: 'test', + category: 'test', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, +}; diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..2622fd51 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -1,24 +1,118 @@ import { act, renderHook } from '@testing-library/react'; -import { useCalendarView } from '../../hooks/useCalendarView.ts'; -import { assertDate } from '../utils.ts'; +import { useCalendarView } from '../../hooks/useCalendarView'; describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); + // ! 변경 + it('currentDate는 오늘 날짜여야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.currentDate).toEqual(new Date()); + }); + + it('holidays는 현재 월의 휴일이 로드되어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + if (result.current.currentDate.getMonth() + 1 === 1) { + expect(result.current.holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + } else if (result.current.currentDate.getMonth() + 1 === 10) { + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + } + }); +}); + +it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); }); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); +it('주간 뷰에서 다음으로 navigate시 7일 후 날짜로 지정이 된다', () => { + const { result } = renderHook(() => useCalendarView()); + + // 먼저 week 뷰로 변경 + act(() => { + result.current.setView('week'); + }); + + const initialDate = new Date(result.current.currentDate); + + act(() => { + result.current.navigate('next'); + }); + + const expectedDate = new Date(initialDate); + expectedDate.setDate(expectedDate.getDate() + 7); + + expect(result.current.currentDate).toEqual(expectedDate); +}); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); +it('월간 뷰에서 다음으로 navigate시 한 달 후 날짜여야 한다', () => { + const { result } = renderHook(() => useCalendarView()); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + const initialDate = new Date(result.current.currentDate); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + act(() => { + result.current.navigate('next'); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + const expectedDate = new Date(initialDate); + expectedDate.setDate(1); // 1일로 설정 + expectedDate.setMonth(expectedDate.getMonth() + 1); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + expect(result.current.currentDate).toEqual(expectedDate); +}); + +it('월간 뷰에서 이전으로 navigate시 한 달 전 날짜여야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + const initialDate = new Date(result.current.currentDate); + + act(() => { + result.current.navigate('prev'); + }); + + const expectedDate = new Date(initialDate); + expectedDate.setDate(1); // 1일로 설정 + expectedDate.setMonth(expectedDate.getMonth() - 1); + + expect(result.current.currentDate).toEqual(expectedDate); +}); + +it('currentDate가 변경되면 해당 월의 휴일로 업데이트되어야 한다', async () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); + + // useEffect가 실행되어 holidays가 업데이트될 때까지 기다림 + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.holidays).toEqual({ + '2025-03-01': '삼일절', + }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..6f4f6906 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -2,13 +2,114 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; +import { dummyEvent } from '../data/dummy'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +describe('useSearch', () => { + let events: Event[]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); + let event1: Event; + let event2: Event; + let event3: Event; + let event4: Event; + let event5: Event; -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + beforeEach(() => { + event1 = { + ...dummyEvent, + id: '1', + title: 'EVENT 1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + }; + event2 = { + ...dummyEvent, + id: '2', + title: 'event 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + }; + event3 = { + ...dummyEvent, + id: '3', + title: '미팅', + date: '2025-07-15', + startTime: '09:00', + endTime: '10:00', + }; + event4 = { + ...dummyEvent, + id: '4', + title: '점심 약속', + date: '2025-07-31', + startTime: '12:00', + endTime: '13:00', + }; + event5 = { + ...dummyEvent, + id: '5', + title: '회의', + date: '2025-07-15', + startTime: '09:00', + endTime: '10:00', + }; + events = [event1, event2, event3, event4, event5]; + }); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(events, new Date(2025, 6, 15), 'month')); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + act(() => { + result.current.setSearchTerm(''); + }); + + expect(result.current.filteredEvents).toEqual(events); + }); + + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(events, new Date(2025, 6, 15), 'month')); + + act(() => { + result.current.setSearchTerm('event 2'); + }); + + expect(result.current.filteredEvents).toEqual([event2]); + }); + + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(events, new Date(2025, 6, 15), 'month')); + + act(() => { + result.current.setSearchTerm('미팅'); + }); + + expect(result.current.filteredEvents).toEqual([event3]); + }); + + it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(events, new Date(2025, 6, 15), 'month')); + + act(() => { + result.current.setSearchTerm('event 2'); + }); + + expect(result.current.filteredEvents).toEqual([event2]); + }); + + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(events, new Date(2025, 6, 15), 'month')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toEqual([event5]); + + act(() => { + result.current.setSearchTerm('점심'); + }); + + expect(result.current.filteredEvents).toEqual([event4]); + }); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..f4343877 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,5 +1,5 @@ -import { act, renderHook } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; import { setupMockHandlerCreation, @@ -8,7 +8,7 @@ import { } from '../../__mocks__/handlersUtils.ts'; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; -import { Event } from '../../types.ts'; +import { RepeatType } from '../../types.ts'; const enqueueSnackbarFn = vi.fn(); @@ -22,16 +22,148 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + setupMockHandlerCreation(); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + }); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + expect(result.current.events).toEqual([]); +}); + +it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + setupMockHandlerCreation(); + + const { result } = renderHook(() => useEventOperations(false)); + + const newEvent = { + title: '새로운 회의', + date: '2025-10-16', + startTime: '14:00', + endTime: '15:00', + description: '새로운 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as RepeatType, interval: 0 }, + notificationTime: 10, + }; -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + await act(async () => { + await result.current.saveEvent(newEvent); + }); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { variant: 'success' }); +}); + +it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(2); + }); + + const updatedEvent = { + id: '1', + title: '수정된 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '11:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as RepeatType, interval: 0 }, + notificationTime: 10, + }; + + await act(async () => { + await result.current.saveEvent(updatedEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { variant: 'success' }); +}); + +it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + setupMockHandlerDeletion(); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + }); + + await act(async () => { + await result.current.deleteEvent('1'); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); +}); + +it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.error(); + }) + ); + + renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); +}); + +it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: [] }); + }), + http.put('/api/events/:id', () => { + return HttpResponse.error(); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + const nonExistentEvent = { + id: '999', + title: '존재하지 않는 이벤트', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '존재하지 않는 이벤트', + location: '어딘가', + category: '기타', + repeat: { type: 'none' as RepeatType, interval: 0 }, + notificationTime: 10, + }; + + await act(async () => { + await result.current.saveEvent(nonExistentEvent); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); +}); + +it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: [] }); + }), + 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..13fe7664 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -2,13 +2,112 @@ 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('초기 상태에서는 알림이 없어야 한다', () => {}); +afterEach(() => { + vi.useRealTimers(); +}); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const events: Event[] = []; + const { result } = renderHook(() => useNotifications(events)); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + vi.useRealTimers(); + vi.useFakeTimers(); + + const baseTime = new Date('2025-08-01T10:00:00'); + vi.setSystemTime(baseTime); + + const events: Event[] = [ + { + id: '1', + title: '미팅', + date: '2025-08-01', + startTime: '10:10', // 10분 후 시작 + endTime: '11:00', + description: '팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + + const { result } = renderHook(() => useNotifications(events)); + + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); + + act(() => { + vi.advanceTimersByTime(1100); // 1초보다 조금 더 진행해서 interval이 실행되도록 + }); + + expect(result.current.notifications.length).toBeGreaterThan(0); + expect(result.current.notifiedEvents).toContain('1'); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const events: Event[] = []; + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + result.current.setNotifications([ + { id: '1', message: '첫 번째 알림' }, + { id: '2', message: '두 번째 알림' }, + { id: '3', message: '세 번째 알림' }, + ]); + }); + + expect(result.current.notifications).toHaveLength(3); + + act(() => { + result.current.removeNotification(1); + }); + + expect(result.current.notifications).toHaveLength(2); + expect(result.current.notifications[0].id).toBe('1'); + expect(result.current.notifications[1].id).toBe('3'); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + vi.useRealTimers(); + vi.useFakeTimers(); + + const baseTime = new Date('2025-08-01T10:00:00'); + vi.setSystemTime(baseTime); + + const events: Event[] = [ + { + id: '1', + title: '미팅', + date: '2025-08-01', + startTime: '10:10', // 10분 후 시작 + endTime: '11:00', + description: '팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + + const { result } = renderHook(() => useNotifications(events)); + + act(() => { + vi.advanceTimersByTime(1100); + }); + + const initialNotificationCount = result.current.notifications.length; + expect(result.current.notifiedEvents).toContain('1'); + + act(() => { + vi.advanceTimersByTime(1100); + }); + + expect(result.current.notifications.length).toBe(initialNotificationCount); + expect(result.current.notifiedEvents).toContain('1'); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..964d7df8 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,27 +1,37 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; import { ReactElement } from 'react'; +import App from '../App'; +// eslint-disable-next-line import/order import { setupMockHandlerCreation, setupMockHandlerDeletion, setupMockHandlerUpdating, } from '../__mocks__/handlersUtils'; -import App from '../App'; -import { server } from '../setupTests'; import { Event } from '../types'; const theme = createTheme(); -// ! HINT. 이 유틸을 사용해 리액트 컴포넌트를 렌더링해보세요. +const DEFAULT_EVENT = { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, +}; + const setup = (element: ReactElement) => { const user = userEvent.setup(); - // ? Medium: 여기서 Provider로 묶어주는 동작은 의미있을까요? 있다면 어떤 의미일까요? return { ...render( @@ -33,7 +43,6 @@ const setup = (element: ReactElement) => { }; }; -// ! HINT. 이 유틸을 사용해 일정을 저장해보세요. const saveSchedule = async ( user: UserEvent, form: Omit @@ -41,7 +50,6 @@ const saveSchedule = async ( const { title, date, startTime, endTime, location, description, category } = form; await user.click(screen.getAllByText('일정 추가')[0]); - await user.type(screen.getByLabelText('제목'), title); await user.type(screen.getByLabelText('날짜'), date); await user.type(screen.getByLabelText('시작 시간'), startTime); @@ -51,45 +59,335 @@ const saveSchedule = async ( 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. "검색 결과가 없습니다"는 초기에 노출되는데요. 그럼 검증하고자 하는 액션이 실행되기 전에 검증해버리지 않을까요? 이 테스트를 신뢰성있게 만드려면 어떻게 할까요? +const expectEventInList = async (title: string) => { + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText(title)).toBeInTheDocument(); +}; + +const expectNoResults = async () => { + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); +}; + +const selectView = async (user: UserEvent, viewType: 'week' | 'month') => { + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: `${viewType}-option` })); +}; + describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. - }); + setupMockHandlerCreation([]); + const { user } = setup(); + + await saveSchedule(user, { + title: DEFAULT_EVENT.title, + date: DEFAULT_EVENT.date, + startTime: DEFAULT_EVENT.startTime, + endTime: DEFAULT_EVENT.endTime, + description: DEFAULT_EVENT.description, + location: DEFAULT_EVENT.location, + category: DEFAULT_EVENT.category, + }); + + await expectEventInList(DEFAULT_EVENT.title); + }, 30000); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + const { user } = setup(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + const titleInput = screen.getByLabelText('제목'); + + await user.click(editButtons[0]); + + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + + const newTitle = '기존 회의3'; + await user.clear(titleInput); + await user.type(titleInput, newTitle); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + await expectEventInList(newTitle); + }, 30000); + + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + const { user } = setup(); + await within(screen.getByTestId('event-list')).findByText('삭제할 이벤트'); + + const deleteButtons = await screen.findAllByRole('button', { name: 'Delete event' }); + await user.click(deleteButtons[0]); + + await expectNoResults(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + const { user } = setup(); + await selectView(user, 'week'); + await expectNoResults(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-03', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + const { user } = setup(); + + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('기존 회의')).toBeInTheDocument(); + }, 30000); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-11-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + const { user } = setup(); + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByRole('option', { name: 'month-option' })); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + setup(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('기존 회의')).toBeInTheDocument(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + const { user } = setup(); + const previousButtons = await screen.findAllByRole('button', { name: 'Previous' }); + + for (let i = 0; i < 9; i++) { + await user.click(previousButtons[0]); + } + + expect(await screen.findByText('신정')).toBeInTheDocument(); + }, 30000); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + const eventInput = screen.getByLabelText('일정 검색'); + + await user.type(eventInput, '항해'); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '기존 회의', + date: '2025-10-20', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 C', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + const eventInput = screen.getByLabelText('일정 검색'); + + await user.type(eventInput, '팀 회의'); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + const { user } = setup(); + const eventInput = screen.getByLabelText('일정 검색'); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + await user.type(eventInput, '항해'); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + + await user.clear(eventInput); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('기존 회의')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + const { user } = setup(); + + await saveSchedule(user, { + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + }); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }, 30000); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + const { user } = setup(); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + const startInput = screen.getByLabelText('시작 시간'); + const endInput = screen.getByLabelText('종료 시간'); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + await user.click(editButtons[0]); + + expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument(); + + await user.clear(startInput); + await user.type(startInput, '11:00'); + + await user.clear(endInput); + await user.type(endInput, '12:00'); + await user.click(screen.getByRole('button', { name: '일정 수정' })); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }, 30000); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + setupMockHandlerCreation([ + { + id: '1', + title: '기존 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]); + + vi.setSystemTime(new Date('2025-10-15T08:50:00')); + setup(); + + const eventList = within(screen.getByTestId('event-list')); + expect(await eventList.findByText('기존 회의')).toBeInTheDocument(); + + expect(await screen.findByText('10분 후 기존 회의 일정이 시작됩니다.')).toBeInTheDocument(); +}, 30000); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..1a237945 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -11,106 +11,539 @@ import { isDateInRange, } from '../../utils/dateUtils'; -describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); - - it('윤년의 2월에 대해 29일을 반환한다', () => {}); +const TEST_YEAR = 2025; +const TEST_MONTH = 8; + +export const TEST_DATES = { + SUNDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 17), + MONDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 18), + TUESDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 19), + WEDNESDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 20), + THURSDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 21), + FRIDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 22), + SATURDAY: new Date(TEST_YEAR, TEST_MONTH - 1, 23), +}; + +export const EXPECTED_WEEK_DATES = [ + TEST_DATES.SUNDAY, + TEST_DATES.MONDAY, + TEST_DATES.TUESDAY, + TEST_DATES.WEDNESDAY, + TEST_DATES.THURSDAY, + TEST_DATES.FRIDAY, + TEST_DATES.SATURDAY, +]; - it('평년의 2월에 대해 28일을 반환한다', () => {}); - - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); +describe('getDaysInMonth', () => { + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); + + // ! 추가 30일 일수 테스트 + it('6월, 9월, 11월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 6)).toBe(30); + expect(getDaysInMonth(2025, 9)).toBe(30); + expect(getDaysInMonth(2025, 11)).toBe(30); + }); + + // ! 추가 31일 일수 테스트 + it('1월, 3월, 5월, 7월, 8월, 10월, 12월은 31일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + expect(getDaysInMonth(2025, 3)).toBe(31); + expect(getDaysInMonth(2025, 5)).toBe(31); + expect(getDaysInMonth(2025, 7)).toBe(31); + expect(getDaysInMonth(2025, 8)).toBe(31); + expect(getDaysInMonth(2025, 10)).toBe(31); + expect(getDaysInMonth(2025, 12)).toBe(31); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + expect(getDaysInMonth(2020, 2)).toBe(29); + expect(getDaysInMonth(2016, 2)).toBe(29); + expect(getDaysInMonth(2000, 2)).toBe(29); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2023, 2)).toBe(28); + expect(getDaysInMonth(2022, 2)).toBe(28); + expect(getDaysInMonth(2021, 2)).toBe(28); + expect(getDaysInMonth(2100, 2)).toBe(28); + }); + + it('유효하지 않은 월에 대해 0을 반환한다.', () => { + expect(getDaysInMonth(2025, 13)).toBe(0); + expect(getDaysInMonth(2025, -1)).toBe(0); + expect(getDaysInMonth(2025, 100)).toBe(0); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(TEST_DATES.WEDNESDAY)).toEqual(EXPECTED_WEEK_DATES); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(TEST_DATES.MONDAY)).toEqual(EXPECTED_WEEK_DATES); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + expect(getWeekDates(TEST_DATES.SUNDAY)).toEqual(EXPECTED_WEEK_DATES); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const expected = [ + new Date(TEST_YEAR, 12 - 1, 28), + new Date(TEST_YEAR, 12 - 1, 29), + new Date(TEST_YEAR, 12 - 1, 30), + new Date(TEST_YEAR, 12 - 1, 31), + new Date(TEST_YEAR + 1, 0, 1), + new Date(TEST_YEAR + 1, 0, 2), + new Date(TEST_YEAR + 1, 0, 3), + ]; + + expect(getWeekDates(new Date(TEST_YEAR, 12 - 1, 31))).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const expected = [ + new Date(TEST_YEAR - 1, 12 - 1, 29), + new Date(TEST_YEAR - 1, 12 - 1, 30), + new Date(TEST_YEAR - 1, 12 - 1, 31), + new Date(TEST_YEAR, 0, 1), + new Date(TEST_YEAR, 0, 2), + new Date(TEST_YEAR, 0, 3), + new Date(TEST_YEAR, 0, 4), + ]; + + expect(getWeekDates(new Date(TEST_YEAR, 0, 1))).toEqual(expected); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const expected = [ + new Date(2024, 2 - 1, 25), + new Date(2024, 2 - 1, 26), + new Date(2024, 2 - 1, 27), + new Date(2024, 2 - 1, 28), + new Date(2024, 2 - 1, 29), + new Date(2024, 3 - 1, 1), + new Date(2024, 3 - 1, 2), + ]; + + expect(getWeekDates(new Date(2024, 2 - 1, 29))).toEqual(expected); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const expected = [ + new Date(TEST_YEAR, TEST_MONTH - 1, 31), + new Date(TEST_YEAR, TEST_MONTH, 1), + new Date(TEST_YEAR, TEST_MONTH, 2), + new Date(TEST_YEAR, TEST_MONTH, 3), + new Date(TEST_YEAR, TEST_MONTH, 4), + new Date(TEST_YEAR, TEST_MONTH, 5), + new Date(TEST_YEAR, TEST_MONTH, 6), + ]; + + expect(getWeekDates(new Date(TEST_YEAR, TEST_MONTH - 1, 31))).toEqual(expected); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const expected: Array> = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]; + + expect(getWeeksAtMonth(new Date(2025, 6, 1))).toEqual(expected); + }); + + it('월의 첫날이 일요일인 경우를 올바르게 처리한다', () => { + const expected: Array> = [ + [1, 2, 3, 4, 5, 6, 7], + [8, 9, 10, 11, 12, 13, 14], + [15, 16, 17, 18, 19, 20, 21], + [22, 23, 24, 25, 26, 27, 28], + [29, 30, null, null, null, null, null], + ]; + + expect(getWeeksAtMonth(new Date(2025, 5, 1))).toEqual(expected); + }); + + it('월의 마지막날이 토요일인 경우를 올바르게 처리한다', () => { + const expected: Array> = [ + [null, null, null, null, 1, 2, 3], + [4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17], + [18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31], + ]; + + expect(getWeeksAtMonth(new Date(2025, 4, 1))).toEqual(expected); + }); + + it('윤년 2월을 올바르게 처리한다', () => { + const expected: Array> = [ + [null, null, null, null, 1, 2, 3], + [4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17], + [18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, null, null], + ]; + + expect(getWeeksAtMonth(new Date(2024, 1, 1))).toEqual(expected); + }); + + it('평년 2월을 올바르게 처리한다', () => { + const expected: Array> = [ + [null, null, null, null, null, null, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, 25, 26, 27, 28, null], + ]; + + expect(getWeeksAtMonth(new Date(2025, 1, 1))).toEqual(expected); + }); + + // ! 제거: 첫번째 목요일을 1주로 치는 경우 6주가 필요한 월은 존재하지 않음 + // it.skip('6주가 필요한 월을 올바르게 처리한다', () => { + // const expected: Array> = [ + // [null, null, null, null, 1, 2, 3], + // [4, 5, 6, 7, 8, 9, 10], + // [11, 12, 13, 14, 15, 16, 17], + // [18, 19, 20, 21, 22, 23, 24], + // [25, 26, 27, 28, null, null, null], + // ]; + + // expect(getWeeksAtMonth(new Date(2025, 7, 1))).toEqual(expected); + // }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + let events: Event[]; + + beforeEach(() => { + events = [ + { + id: '1', + title: 'Event 1', + date: '2025-08-01', + startTime: '10:00', + endTime: '11:00', + description: 'Description 1', + location: 'Location 1', + category: 'Category 1', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: 'Event 2', + date: '2025-08-02', + startTime: '10:00', + endTime: '11:00', + description: 'Description 2', + location: 'Location 2', + category: 'Category 2', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '3', + title: 'Event 3', + date: '2025-08-03', + startTime: '10:00', + endTime: '11:00', + description: 'Description 3', + location: 'Location 3', + category: 'Category 3', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + }); + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + expect(getEventsForDay(events, 1)).toEqual([events[0]]); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 4)).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 0)).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + expect(getEventsForDay(events, 32)).toEqual([]); + }); + + // ! 추가 + it('같은 날짜에 여러 이벤트가 있을 경우 모두 반환한다', () => { + const multipleEvents: Event[] = [ + ...events, + { + id: '4', + title: 'Event 4', + date: events[0].date, + startTime: '14:00', + endTime: '15:00', + description: 'Description 4', + location: 'Location 4', + category: 'Category 4', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + expect(getEventsForDay(multipleEvents, 1)).toEqual([multipleEvents[0], multipleEvents[3]]); + }); + + // ! 추가 + it('빈 이벤트 배열에 대해 빈 배열을 반환한다', () => { + expect(getEventsForDay([], 1)).toEqual([]); + expect(getEventsForDay([], 0)).toEqual([]); + expect(getEventsForDay([], 32)).toEqual([]); + }); + + // ! 추가 + it('다양한 날짜 형식의 이벤트를 올바르게 처리한다', () => { + const mixedDateEvents: Event[] = [ + { + id: '1', + title: 'Event 1', + date: '2025-08-15', + startTime: '10:00', + endTime: '11:00', + description: 'Description 1', + location: 'Location 1', + category: 'Category 1', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: 'Event 2', + date: '2025-08-31', + startTime: '10:00', + endTime: '11:00', + description: 'Description 2', + location: 'Location 2', + category: 'Category 2', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + expect(getEventsForDay(mixedDateEvents, 15)).toEqual([mixedDateEvents[0]]); + expect(getEventsForDay(mixedDateEvents, 31)).toEqual([mixedDateEvents[1]]); + expect(getEventsForDay(mixedDateEvents, 16)).toEqual([]); + }); + + // ! 추가 + it('경계값 날짜를 올바르게 처리한다', () => { + const boundaryEvents: Event[] = [ + { + id: '1', + title: 'Event 1', + date: '2025-08-01', + startTime: '10:00', + endTime: '11:00', + description: 'Description 1', + location: 'Location 1', + category: 'Category 1', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + { + id: '2', + title: 'Event 2', + date: '2025-08-31', + startTime: '10:00', + endTime: '11:00', + description: 'Description 2', + location: 'Location 2', + category: 'Category 2', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }, + ]; + + expect(getEventsForDay(boundaryEvents, 1)).toEqual([boundaryEvents[0]]); + expect(getEventsForDay(boundaryEvents, 31)).toEqual([boundaryEvents[1]]); + expect(getEventsForDay(boundaryEvents, 0)).toEqual([]); + expect(getEventsForDay(boundaryEvents, 32)).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 7, 15))).toBe('2025년 8월 2주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 7, 1))).toBe('2025년 7월 5주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 7, 31))).toBe('2025년 9월 1주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2025, 11, 31))).toBe('2026년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2024, 1, 29))).toBe('2024년 2월 5주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date(2023, 1, 28))).toBe('2023년 3월 1주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + expect(formatMonth(new Date(2025, 6, 10))).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + let rangeStart: Date; + let rangeEnd: Date; + + beforeEach(() => { + rangeStart = new Date(2025, 6, 1); + rangeEnd = new Date(2025, 6, 31); + }); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date(2025, 6, 10), rangeStart, rangeEnd)).toBe(true); + }); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date(2025, 6, 1), rangeStart, rangeEnd)).toBe(true); + }); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date(2025, 6, 31), rangeStart, rangeEnd)).toBe(true); + }); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date(2025, 5, 30), rangeStart, rangeEnd)).toBe(false); + }); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date(2025, 7, 1), rangeStart, rangeEnd)).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date(2025, 6, 31), rangeEnd, rangeStart)).toBe(false); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + expect(fillZero(5, 2)).toBe('05'); + }); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + expect(fillZero(10, 2)).toBe('10'); + }); - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + expect(fillZero(3, 3)).toBe('003'); + }); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + expect(fillZero(100, 2)).toBe('100'); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + expect(fillZero(0, 2)).toBe('00'); + }); - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + expect(fillZero(1, 5)).toBe('00001'); + }); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + expect(fillZero(3.14, 5)).toBe('03.14'); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + expect(fillZero(123)).toBe('123'); + }); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(1234, 3)).toBe('1234'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); - - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); - - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); - - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 21))).toBe('2025-08-21'); + }); + + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + expect(formatDate(new Date(2025, 7, 21), 1)).toBe('2025-08-01'); + }); + + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 0, 1))).toBe('2025-01-01'); + }); + + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date(2025, 0, 1))).toBe('2025-01-01'); + }); + + // ! 추가 + it('다양한 월의 날짜를 올바르게 포맷팅한다', () => { + const testCases = [ + { date: new Date(2025, 0, 15), expected: '2025-01-15' }, + { date: new Date(2025, 5, 30), expected: '2025-06-30' }, + { date: new Date(2025, 11, 31), expected: '2025-12-31' }, + ]; + + testCases.forEach(({ date, expected }) => { + expect(formatDate(date)).toBe(expected); + }); + }); + + // ! 추가 + it('경계값 날짜를 올바르게 포맷팅한다', () => { + expect(formatDate(new Date(2025, 0, 1))).toBe('2025-01-01'); + expect(formatDate(new Date(2025, 11, 31))).toBe('2025-12-31'); + expect(formatDate(new Date(2025, 1, 28))).toBe('2025-02-28'); + expect(formatDate(new Date(2024, 1, 29))).toBe('2024-02-29'); + }); + + // ! 추가 + it('day 파라미터로 다양한 일자를 지정할 수 있다', () => { + expect(formatDate(new Date(2025, 7, 21), 1)).toBe('2025-08-01'); + expect(formatDate(new Date(2025, 7, 21), 15)).toBe('2025-08-15'); + expect(formatDate(new Date(2025, 7, 21), 31)).toBe('2025-08-31'); + }); + + // ! 추가 + it('day 파라미터가 한 자리 수일 때도 올바르게 처리한다', () => { + expect(formatDate(new Date(2025, 7, 21), 5)).toBe('2025-08-05'); + expect(formatDate(new Date(2025, 7, 21), 9)).toBe('2025-08-09'); + }); + + // ! 추가 + it('다양한 연도의 날짜를 올바르게 포맷팅한다', () => { + expect(formatDate(new Date(2020, 0, 1))).toBe('2020-01-01'); + expect(formatDate(new Date(2030, 11, 31))).toBe('2030-12-31'); + expect(formatDate(new Date(1999, 5, 15))).toBe('1999-06-15'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..980861a8 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,32 +5,149 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; +import { dummyEvent } from '../data/dummy'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + expect(parseDateTime('2025-07-01', '14:30')).toEqual(new Date('2025-07-01T14:30')); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-0', '14:30')).toEqual(new Date('Invalid Date')); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + expect(parseDateTime('2025-07-0', '14:3')).toEqual(new Date('Invalid Date')); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + expect(parseDateTime('', '14:30')).toEqual(new Date('Invalid Date')); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const event: Event = { + ...dummyEvent, + date: '2025-07-01', + startTime: '14:30', + endTime: '15:30', + }; + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('2025-07-01T14:30'), + end: new Date('2025-07-01T15:30'), + }); + }); + + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + ...dummyEvent, + date: '2025-07-0', + startTime: '14:30', + endTime: '15:30', + }; - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const event: Event = { + ...dummyEvent, + date: '2025-07-0', + startTime: '14:3', + endTime: '15:30', + }; + + expect(convertEventToDateRange(event)).toEqual({ + start: new Date('Invalid Date'), + end: new Date('Invalid Date'), + }); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + let event1: Event; + let event2: Event; + let event3: Event; + + beforeEach(() => { + event1 = { + ...dummyEvent, + date: '2025-08-15', + startTime: '14:00', + endTime: '15:00', + }; + event2 = { + ...dummyEvent, + date: '2025-08-15', + startTime: '14:30', + endTime: '15:30', + }; + event3 = { + ...dummyEvent, + date: '2025-08-15', + startTime: '16:00', + endTime: '17:00', + }; + }); + + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + expect(isOverlapping(event1, event2)).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + expect(isOverlapping(event1, event3)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + let events: Event[]; + + beforeEach(() => { + events = [ + { + ...dummyEvent, + id: 'event-1', + date: '2025-08-15', + startTime: '14:00', + endTime: '15:00', + }, + + { + ...dummyEvent, + id: 'event-2', + date: '2025-08-15', + startTime: '16:00', + endTime: '17:00', + }, + ]; + }); + + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent = { + ...dummyEvent, + id: 'new-event', + date: '2025-08-15', + startTime: '14:30', + endTime: '15:30', + }; + + expect(findOverlappingEvents(newEvent, events)).toEqual([events[0]]); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent = { + ...dummyEvent, + id: 'new-event', + date: '2025-08-15', + startTime: '17:00', + endTime: '18:00', + }; - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + expect(findOverlappingEvents(newEvent, events)).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..a4919fb1 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,115 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; +import { dummyEvent } from '../data/dummy'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + let events: Event[]; - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + let event1: Event; + let event2: Event; + let event3: Event; + let event4: Event; - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + beforeEach(() => { + event1 = { + ...dummyEvent, + id: '1', + title: 'EVENT 1', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + }; + event2 = { + ...dummyEvent, + id: '2', + title: 'event 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + }; + event3 = { + ...dummyEvent, + id: '3', + title: '미팅', + date: '2025-07-15', + startTime: '09:00', + endTime: '10:00', + }; + event4 = { + ...dummyEvent, + id: '4', + title: '점심 약속', + date: '2025-07-31', + startTime: '12:00', + endTime: '13:00', + }; - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + events = [event1, event2, event3, event4]; + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const result = getFilteredEvents(events, 'event 2', new Date(2025, 6, 1), 'month'); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + expect(result).toEqual([event2]); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const result = getFilteredEvents(events, '', new Date(2025, 6, 1), 'week'); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + expect(result).toEqual([event1, event2]); + }); + + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const result = getFilteredEvents(events, '', new Date(2025, 6, 15), 'month'); + + expect(result).toEqual([event1, event2, event3, event4]); + }); + + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const result = getFilteredEvents(events, 'EVENT', new Date(2025, 6, 1), 'week'); + + expect(result).toEqual([event1, event2]); + }); + + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const result = getFilteredEvents(events, '', new Date(2025, 6, 15), 'month'); + + expect(result).toEqual(events); + }); + + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const result = getFilteredEvents(events, 'EVENT', new Date(2025, 6, 15), 'month'); + + expect(result).toEqual([event1, event2]); + }); + + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const boundaryEvent1 = { + ...dummyEvent, + id: '5', + title: '월초 이벤트', + date: '2025-07-01', + startTime: '00:00', + endTime: '01:00', + }; + const boundaryEvent2 = { + ...dummyEvent, + id: '6', + title: '월말 이벤트', + date: '2025-07-31', + startTime: '23:00', + endTime: '23:59', + }; + const allEvents = [...events, boundaryEvent1, boundaryEvent2]; + + const result = getFilteredEvents(allEvents, '', new Date(2025, 6, 15), 'month'); + + expect(result).toEqual(allEvents); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const result = getFilteredEvents([], '이벤트', new Date(2025, 6, 15), 'month'); + + expect(result).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..686fef58 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,85 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const testMonth = 0; + const expected = { + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }; + + const result = fetchHolidays(new Date(2025, testMonth, 1)); + + expect(result).toEqual(expected); + }); + + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const testMonth = 1; + + const result = fetchHolidays(new Date(2025, testMonth, 15)); + + expect(result).toEqual({}); + }); + + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const testMonth = 9; + const expected = { + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }; + + const result = fetchHolidays(new Date(2025, testMonth, 1)); + + expect(result).toEqual(expected); + }); + + it('단일 공휴일이 있는 월에 대해 해당 공휴일만 반환한다', () => { + const testMonth = 4; + const expected = { + '2025-05-05': '어린이날', + }; + + const result = fetchHolidays(new Date(2025, testMonth, 10)); + + expect(result).toEqual(expected); + }); + + it('월의 어느 날짜를 입력해도 같은 결과를 반환한다', () => { + const testMonth = 0; + const expected = { + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }; + + const result1 = fetchHolidays(new Date(2025, testMonth, 1)); + const result2 = fetchHolidays(new Date(2025, testMonth, 15)); + const result3 = fetchHolidays(new Date(2025, testMonth, 31)); + + expect(result1).toEqual(expected); + expect(result2).toEqual(expected); + expect(result3).toEqual(expected); + }); + + it('다른 연도의 같은 월에 대해 해당 연도의 공휴일을 반환한다', () => { + const testMonth = 0; + const expected = { + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }; - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + const result2025 = fetchHolidays(new Date(2025, testMonth, 1)); + const result2026 = fetchHolidays(new Date(2026, testMonth, 5)); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + expect(result2025).toEqual(expected); + expect(result2026).toEqual({}); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..016b10ed 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,165 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +import { dummyEvent } from '../data/dummy'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + let events: Event[]; + let event1: Event; + let event2: Event; + let event3: Event; + let event4: Event; + let event5: Event; + let now: Date; - it('이미 알림이 간 이벤트는 제외한다', () => {}); + beforeEach(() => { + event1 = { + ...dummyEvent, + id: '1', + title: '미팅', + date: '2025-08-15', + startTime: '10:00', + notificationTime: 10, + }; + event2 = { + ...dummyEvent, + id: '2', + title: '점심 약속', + date: '2025-08-15', + startTime: '12:00', + notificationTime: 30, + }; + event3 = { + ...dummyEvent, + id: '3', + title: '회의', + date: '2025-08-15', + startTime: '14:00', + notificationTime: 5, + }; + event4 = { + ...dummyEvent, + id: '4', + title: '저녁 약속', + date: '2025-08-15', + startTime: '18:00', + notificationTime: 60, + }; + event5 = { + ...dummyEvent, + id: '5', + title: '티타임', + date: '2025-08-15', + startTime: '11:50', + notificationTime: 30, + }; - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + events = [event1, event2, event3, event4, event5]; - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + now = new Date('2025-08-15T09:50:00'); + }); + + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([event1]); + }); + + it('이미 알림이 간 이벤트는 제외한다', () => { + const notifiedEvents = ['1']; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); + + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-08-15T09:30:00'); // event1 시작 40분 전 (알림 시간은 10분 전) + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-08-15T10:05:00'); // event1 시작 5분 후 (알림 시간은 10분 전) + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([]); + }); + + it('여러 이벤트의 알림 시간이 동시에 도래한 경우 모두 반환한다', () => { + const now = new Date('2025-08-15T11:40:00'); + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([event2, event5]); + }); + + it('알림 시간이 정확히 일치하는 이벤트만 반환한다', () => { + const now = new Date('2025-08-15T13:55:00'); + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents(events, now, notifiedEvents); + + expect(result).toEqual([event3]); + }); + + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const notifiedEvents: string[] = []; + + const result = getUpcomingEvents([], now, notifiedEvents); + + expect(result).toEqual([]); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event: Event = { + ...dummyEvent, + title: '중요한 미팅', + notificationTime: 15, + }; + + const result = createNotificationMessage(event); + + expect(result).toBe('15분 후 중요한 미팅 일정이 시작됩니다.'); + }); + + it('다양한 알림 시간에 대해 올바른 메시지를 생성한다', () => { + const event1: Event = { + ...dummyEvent, + title: '5분 전 알림', + notificationTime: 5, + }; + const event2: Event = { + ...dummyEvent, + title: '60분 전 알림', + notificationTime: 60, + }; + + const result1 = createNotificationMessage(event1); + const result2 = createNotificationMessage(event2); + + expect(result1).toBe('5분 후 5분 전 알림 일정이 시작됩니다.'); + expect(result2).toBe('60분 후 60분 전 알림 일정이 시작됩니다.'); + }); + + it('특수 문자가 포함된 제목에 대해서도 올바른 메시지를 생성한다', () => { + const event: Event = { + ...dummyEvent, + title: '미팅 (회의실 A)', + notificationTime: 10, + }; + + const result = createNotificationMessage(event); + + expect(result).toBe('10분 후 미팅 (회의실 A) 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..3c7897af 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,77 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('14:00', '10:00')).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + expect(getTimeErrorMessage('10:00', '10:00')).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + expect(getTimeErrorMessage('10:00', '14:00')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '14:00')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('종료 시간이 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('10:00', '')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + expect(getTimeErrorMessage('', '')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('자정을 넘어가는 시간에 대해서도 올바르게 검증한다', () => { + expect(getTimeErrorMessage('23:30', '00:30')).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); + + it('분 단위까지 정확히 비교한다', () => { + expect(getTimeErrorMessage('10:30', '10:29')).toEqual({ + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }); + }); + + it('1분 차이의 유효한 시간을 올바르게 검증한다', () => { + expect(getTimeErrorMessage('10:00', '10:01')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); + + it('빈 문자열과 공백 문자열을 모두 처리한다', () => { + expect(getTimeErrorMessage(' ', '14:00')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + expect(getTimeErrorMessage('10:00', ' ')).toEqual({ + startTimeError: null, + endTimeError: null, + }); + }); }); diff --git a/src/components/MonthView.tsx b/src/components/MonthView.tsx new file mode 100644 index 00000000..902f2d76 --- /dev/null +++ b/src/components/MonthView.tsx @@ -0,0 +1,122 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { WEEK_DAYS } from '../constants/calendar'; +import { useCalendarView } from '../hooks/useCalendarView'; +import { useEventOperations } from '../hooks/useEventOperations'; +import { useNotifications } from '../hooks/useNotifications'; +import { useSearch } from '../hooks/useSearch'; +import { Event } from '../types'; +import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../utils/dateUtils'; + +interface MonthViewProps { + editingEvent: Event | null; + setEditingEvent: (event: Event | null) => void; +} + +export default function MonthView({ editingEvent, setEditingEvent }: MonthViewProps) { + const { events } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null)); + const { notifiedEvents } = useNotifications(events); + + const { view, currentDate, holidays } = useCalendarView(); + const { filteredEvents } = useSearch(events, currentDate, view); + + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {WEEK_DAYS.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(filteredEvents, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +} diff --git a/src/components/MutateEventForm.tsx b/src/components/MutateEventForm.tsx new file mode 100644 index 00000000..1fdadd87 --- /dev/null +++ b/src/components/MutateEventForm.tsx @@ -0,0 +1,233 @@ +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { enqueueSnackbar } from 'notistack'; + +import { CATEGORIES } from '../constants/form'; +import { NOTIFICATION_OPTIONS } from '../constants/ui'; +import { useEventForm } from '../hooks/useEventForm'; +import { Event, EventForm } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; +import { getTimeErrorMessage } from '../utils/timeValidation'; + +interface MutateEventFormProps { + events: Event[]; + saveEvent: (event: Event) => void; + resetForm: () => void; + setIsOverlapDialogOpen: (isOpen: boolean) => void; + setOverlappingEvents: (events: Event[]) => void; +} + +export default function MutateEventForm({ + events, + saveEvent, + resetForm, + setIsOverlapDialogOpen, + setOverlappingEvents, +}: MutateEventFormProps) { + const { + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + repeatEndDate, + repeatInterval, + repeatType, + handleStartTimeChange, + handleEndTimeChange, + } = useEventForm(); + + 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 as Event); + resetForm(); + } + }; + + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} diff --git a/src/components/NotificationList.tsx b/src/components/NotificationList.tsx new file mode 100644 index 00000000..b8cb091b --- /dev/null +++ b/src/components/NotificationList.tsx @@ -0,0 +1,30 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface NotificationListProps { + notifications: { id: string; message: string }[]; + onClose: () => void; +} + +export default function NotificationList({ notifications, onClose }: NotificationListProps) { + if (notifications.length <= 0) return null; + + return ( + + {notifications.map((notification, index) => ( + + + + } + > + {notification.message} + + ))} + + ); +} diff --git a/src/components/OverlapDialog.tsx b/src/components/OverlapDialog.tsx new file mode 100644 index 00000000..c6b9d9e7 --- /dev/null +++ b/src/components/OverlapDialog.tsx @@ -0,0 +1,49 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../types'; + +interface OverlapDialogProps { + isOpen: boolean; + onClose: () => void; + overlappingEvents: Event[]; + onCancel: () => void; + onOk: () => void; +} + +export default function OverlapDialog({ + isOpen, + onClose, + overlappingEvents, + onOk, +}: 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..8bb9a43a --- /dev/null +++ b/src/components/WeekView.tsx @@ -0,0 +1,106 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { WEEK_DAYS } from '../constants/calendar'; +import { useCalendarView } from '../hooks/useCalendarView'; +import { useEventOperations } from '../hooks/useEventOperations'; +import { useNotifications } from '../hooks/useNotifications'; +import { useSearch } from '../hooks/useSearch'; +import { Event } from '../types'; +import { formatWeek, getWeekDates } from '../utils/dateUtils'; + +interface WeekViewProps { + editingEvent: Event | null; + setEditingEvent: (event: Event | null) => void; +} + +export default function WeekView({ editingEvent, setEditingEvent }: WeekViewProps) { + const { events } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null)); + const { notifiedEvents } = useNotifications(events); + + const { view, currentDate } = useCalendarView(); + const { filteredEvents } = useSearch(events, currentDate, view); + + const weekDates = getWeekDates(currentDate); + return ( + + {formatWeek(currentDate)} + + + + + {WEEK_DAYS.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {filteredEvents + .filter((event) => new Date(event.date).toDateString() === date.toDateString()) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + ))} + + +
+
+
+ ); +} diff --git a/src/constants/calendar.ts b/src/constants/calendar.ts new file mode 100644 index 00000000..d71a78d2 --- /dev/null +++ b/src/constants/calendar.ts @@ -0,0 +1 @@ +export const WEEK_DAYS = ['일', '월', '화', '수', '목', '금', '토']; diff --git a/src/constants/form.ts b/src/constants/form.ts new file mode 100644 index 00000000..bced084b --- /dev/null +++ b/src/constants/form.ts @@ -0,0 +1 @@ +export const CATEGORIES = ['업무', '개인', '가족', '기타']; diff --git a/src/constants/ui.ts b/src/constants/ui.ts new file mode 100644 index 00000000..3ce002a4 --- /dev/null +++ b/src/constants/ui.ts @@ -0,0 +1,7 @@ +export const NOTIFICATION_OPTIONS = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index f9ec573b..b5b6f459 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -29,6 +29,7 @@ export const useNotifications = (events: Event[]) => { useEffect(() => { const interval = setInterval(checkUpcomingEvents, 1000); // 1초마다 체크 return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [events, notifiedEvents]); return { notifications, notifiedEvents, setNotifications, removeNotification }; diff --git a/src/setupTests.ts b/src/setupTests.ts index fded6d65..fc6bb155 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,5 +1,6 @@ -import { setupServer } from 'msw/node'; import '@testing-library/jest-dom'; +import { setupServer } from 'msw/node'; +import { vi } from 'vitest'; import { handlers } from './__mocks__/handlers'; @@ -15,6 +16,8 @@ beforeAll(() => { }); beforeEach(() => { + // * 현재 테스트에서 최소 하나의 assertion(검증)이 실행될 것을 보장 + // * 실수 방지 및 테스트 품질 향상 효과 expect.hasAssertions(); // ? Med: 이걸 왜 써야하는지 물어보자 vi.setSystemTime(new Date('2025-10-01')); // ? Med: 이걸 왜 써야하는지 물어보자 diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index be78512c..c1be6006 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -4,6 +4,7 @@ import { Event } from '../types.ts'; * 주어진 년도와 월의 일수를 반환합니다. */ export function getDaysInMonth(year: number, month: number): number { + if (month <= 0 || month > 12) return 0; return new Date(year, month, 0).getDate(); } @@ -13,13 +14,15 @@ export function getDaysInMonth(year: number, month: number): number { export function getWeekDates(date: Date): Date[] { const day = date.getDay(); const diff = date.getDate() - day; - const sunday = new Date(date.setDate(diff)); + const sunday = new Date(date.getFullYear(), date.getMonth(), diff); const weekDates = []; + for (let i = 0; i < 7; i++) { const nextDate = new Date(sunday); nextDate.setDate(sunday.getDate() + i); weekDates.push(nextDate); } + return weekDates; } diff --git a/src/utils/helperUtils.ts b/src/utils/helperUtils.ts new file mode 100644 index 00000000..f6189dee --- /dev/null +++ b/src/utils/helperUtils.ts @@ -0,0 +1,3 @@ +export function range(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +}