diff --git a/.prettierrc b/.prettierrc index 6485d119..de65345b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -8,7 +8,7 @@ "jsxSingleQuote": false, "trailingComma": "es5", "arrowParens": "always", - "endOfLine": "lf", + "endOfLine": "auto", "bracketSpacing": true, "jsxBracketSameLine": false, "requirePragma": false, diff --git a/eslint.config.js b/eslint.config.js index 0a019971..6c3e11c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -88,13 +88,7 @@ export default [ 'newlines-between': 'always', }, ], - - // Prettier rules - ...prettierConfig.rules, 'prettier/prettier': 'error', - - // Storybook rules - ...storybookPlugin.configs.recommended.rules, }, }, @@ -146,4 +140,5 @@ export default [ }, }, }, + prettierConfig, ]; diff --git a/package.json b/package.json index b01b2b4b..68a7441b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", @@ -51,6 +52,7 @@ "eslint-plugin-vitest": "^0.5.4", "globals": "16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.6.2", "typescript": "^5.2.2", "vite": "^7.0.2", "vite-plugin-eslint": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..5292986e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -86,7 +89,7 @@ importers: version: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0) eslint-plugin-prettier: specifier: ^5.5.1 - version: 5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.3.3) + version: 5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.6.2) eslint-plugin-react: specifier: ^7.37.0 version: 7.37.2(eslint@9.30.0) @@ -95,7 +98,7 @@ importers: version: 5.2.0(eslint@9.30.0) eslint-plugin-storybook: specifier: ^9.0.14 - version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3) + version: 9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.6.3) eslint-plugin-vitest: specifier: ^0.5.4 version: 0.5.4(@typescript-eslint/eslint-plugin@8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3))(eslint@9.30.0)(typescript@5.6.3)(vitest@3.2.4) @@ -105,6 +108,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 typescript: specifier: ^5.2.2 version: 5.6.3 @@ -468,6 +474,10 @@ packages: resolution: {integrity: sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.33.0': + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2562,8 +2572,8 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true @@ -3628,6 +3638,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': @@ -4958,10 +4970,10 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-prettier@5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.3.3): + eslint-plugin-prettier@5.5.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.30.0))(eslint@9.30.0)(prettier@3.6.2): dependencies: eslint: 9.30.0 - prettier: 3.3.3 + prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.8 optionalDependencies: @@ -4994,11 +5006,11 @@ snapshots: string.prototype.matchall: 4.0.11 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3))(typescript@5.6.3): + eslint-plugin-storybook@9.0.14(eslint@9.30.0)(storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2))(typescript@5.6.3): dependencies: '@typescript-eslint/utils': 8.35.0(eslint@9.30.0)(typescript@5.6.3) eslint: 9.30.0 - storybook: 9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3) + storybook: 9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2) transitivePeerDependencies: - supports-color - typescript @@ -5984,7 +5996,7 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.3.3: {} + prettier@3.6.2: {} pretty-format@27.5.1: dependencies: @@ -6331,7 +6343,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.3.3): + storybook@9.0.14(@testing-library/dom@10.4.0)(prettier@3.6.2): dependencies: '@storybook/global': 5.0.0 '@testing-library/jest-dom': 6.6.3 @@ -6345,7 +6357,7 @@ snapshots: semver: 7.6.3 ws: 8.18.0 optionalDependencies: - prettier: 3.3.3 + prettier: 3.6.2 transitivePeerDependencies: - '@testing-library/dom' - bufferutil diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..775b4a62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,56 +1,17 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; -import { - Alert, - AlertTitle, - Box, - Button, - Checkbox, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - FormControl, - FormControlLabel, - FormLabel, - IconButton, - MenuItem, - Select, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TextField, - Tooltip, - Typography, -} from '@mui/material'; -import { useSnackbar } from 'notistack'; +import { Box, Stack } from '@mui/material'; import { useState } from 'react'; +import Calendar from './components/calendar/Calendar.tsx'; +import OverlapDialog from './components/dialog/OverlapDialog.tsx'; +import EventEditForm from './components/event-form/EventEditForm.tsx'; +import EventList from './components/event-list/EventList.tsx'; +import NotificationList from './components/notification/NotificationList.tsx'; 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 { Event } from './types'; import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; const notificationOptions = [ { value: 1, label: '1분 전' }, @@ -61,38 +22,7 @@ const notificationOptions = [ ]; 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(); + const [editingEvent, setEditingEvent] = useState(null); const { events, saveEvent, deleteEvent } = useEventOperations(Boolean(editingEvent), () => setEditingEvent(null) @@ -105,553 +35,53 @@ function App() { const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); const [overlappingEvents, setOverlappingEvents] = useState([]); - const { enqueueSnackbar } = useSnackbar(); - - const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { - enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); - return; - } - - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); - return; - } - - const eventData: Event | EventForm = { - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - notificationTime, - }; - - const overlapping = findOverlappingEvents(eventData, events); - if (overlapping.length > 0) { - setOverlappingEvents(overlapping); - setIsOverlapDialogOpen(true); - } else { - await saveEvent(eventData); - resetForm(); - } - }; - - const renderWeekView = () => { - const weekDates = getWeekDates(currentDate); - return ( - - {formatWeek(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - - {weekDates.map((date) => ( - - - {date.getDate()} - - {filteredEvents - .filter( - (event) => new Date(event.date).toDateString() === date.toDateString() - ) - .map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - ))} - - -
-
-
- ); - }; - - const renderMonthView = () => { - const weeks = getWeeksAtMonth(currentDate); - - return ( - - {formatMonth(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - {weeks.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => { - const dateString = day ? formatDate(currentDate, day) : ''; - const holiday = holidays[dateString]; - - return ( - - {day && ( - <> - - {day} - - {holiday && ( - - {holiday} - - )} - {getEventsForDay(filteredEvents, day).map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - )} - - ); - })} - - ))} - -
-
-
- ); + const editEvent = (event: Event) => { + setEditingEvent(event); }; return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - - - - 일정 보기 - - - navigate('prev')}> - - - - navigate('next')}> - - - - - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} - - - - - 일정 검색 - setSearchTerm(e.target.value)} - /> - - - {filteredEvents.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - filteredEvents.map((event) => ( - - - - - {notifiedEvents.includes(event.id) && } - - {event.title} - - - {event.date} - - {event.startTime} - {event.endTime} - - {event.description} - {event.location} - 카테고리: {event.category} - {event.repeat.type !== 'none' && ( - - 반복: {event.repeat.interval} - {event.repeat.type === 'daily' && '일'} - {event.repeat.type === 'weekly' && '주'} - {event.repeat.type === 'monthly' && '월'} - {event.repeat.type === 'yearly' && '년'} - 마다 - {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} - - )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - - - - editEvent(event)}> - - - deleteEvent(event.id)}> - - - - - - )) - )} - + + + + - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - + {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - + )} ); diff --git a/src/__tests__/components/MonthView.test.tsx b/src/__tests__/components/MonthView.test.tsx new file mode 100644 index 00000000..47b363b1 --- /dev/null +++ b/src/__tests__/components/MonthView.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, within } from '@testing-library/react'; + +import MonthView from '../../components/calendar/MonthView'; +import { Event } from '../../types'; + +describe('MonthView Component', () => { + const currentDate = new Date('2025-08-20'); + + const mockEvents: Event[] = [ + { + id: '1', + title: '중요한 회의', + date: '2025-08-22', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, + }, + { + id: '2', + title: '테스트 이벤트', + date: '2025-08-22', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, + }, + ]; + + const mockHolidays = { + '2025-08-15': '광복절', + }; + + it('현재 월, 요일, 날짜들이 정확히 렌더링되어야 한다', () => { + render( + + ); + + expect(screen.getByText('2025년 8월')).toBeInTheDocument(); + + ['일', '월', '화', '수', '목', '금', '토'].forEach((day) => { + expect(screen.getByText(day)).toBeInTheDocument(); + }); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + }); + + it('지정된 날짜에 이벤트들이 정확히 표시되어야 한다', () => { + render( + + ); + + const day22Cell = screen.getByText('22').closest('td'); + expect(within(day22Cell!).getByText('중요한 회의')).toBeInTheDocument(); + expect(within(day22Cell!).getByText('테스트 이벤트')).toBeInTheDocument(); + }); + + it('지정된 날짜에 공휴일이 정확히 표시되어야 한다', () => { + render( + + ); + + const day15Cell = screen.getByText('15').closest('td'); + expect(within(day15Cell!).getByText('광복절')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/WeekView.test.tsx b/src/__tests__/components/WeekView.test.tsx new file mode 100644 index 00000000..7b07fe8e --- /dev/null +++ b/src/__tests__/components/WeekView.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, within } from '@testing-library/react'; + +import WeekView from '../../components/calendar/WeekView'; +import { Event } from '../../types'; + +describe('WeekView Component', () => { + const currentDate = new Date('2025-08-20'); + + const mockEvents: Event[] = [ + { + id: '1', + title: '중요한 회의', + date: '2025-08-22', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, + }, + { + id: '2', + title: '테스트 이벤트', + date: '2025-08-22', + startTime: '14:00', + endTime: '15:00', + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + notificationTime: 0, + }, + ]; + + it('현재 월, 요일, 날짜들이 정확히 렌더링되어야 한다', () => { + render(); + + ['일', '월', '화', '수', '목', '금', '토'].forEach((day) => { + expect(screen.getByText(day)).toBeInTheDocument(); + }); + + expect(screen.getByText('20')).toBeInTheDocument(); + }); + + it('지정된 날짜에 이벤트들이 정확히 표시되어야 한다', () => { + render(); + + const day22Cell = screen.getByText('22').closest('td'); + expect(within(day22Cell!).getByText('중요한 회의')).toBeInTheDocument(); + expect(within(day22Cell!).getByText('테스트 이벤트')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..d4ad7521 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -3,22 +3,72 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; -describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); +describe('useCalendarView', () => { + describe('초기 상태', () => { + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.view).toBe('month'); + }); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); -}); - -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); + }); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + expect(result.current.view).toBe('week'); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setView('week'); + }); + act(() => { + result.current.navigate('prev'); + }); + assertDate(result.current.currentDate, new Date('2025-09-24')); + }); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + 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' 날짜여야 한다", () => {}); + 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 () => {}); + it("currentDate가 '2025-03-01'로 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); + act(() => { + result.current.setCurrentDate(new Date('2025-03-15')); + }); + 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..fa9172e7 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,99 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +const testEvents: Event[] = [ + { + id: '1', + title: '7월 팀 회의', + date: '2025-07-01', + description: '주간 목표 논의', + location: '사무실', + startTime: '10:00', + endTime: '11:00', + notificationTime: 10, + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '2', + title: '점심 약속', + date: '2025-07-15', + description: '클라이언트와 점심 식사', + location: '레스토랑', + startTime: '12:00', + endTime: '13:00', + notificationTime: 30, + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '3', + title: '8월 전체 회의', + date: '2025-08-05', + description: '분기 실적 발표', + location: '대회의실', + startTime: '14:00', + endTime: '15:00', + notificationTime: 60, + category: '', + repeat: { type: 'none', interval: 0 }, + }, +]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); +describe('useSearch', () => { + const date = new Date('2025-07-10'); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(testEvents, date, 'month')); + expect(result.current.filteredEvents).toHaveLength(2); + }); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(testEvents, date, 'month')); + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].id).toBe('2'); + }); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(testEvents, date, 'month')); + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents[0].id).toBe('2'); + act(() => { + result.current.setSearchTerm('클라이언트'); + }); + expect(result.current.filteredEvents[0].id).toBe('2'); + act(() => { + result.current.setSearchTerm('레스토랑'); + }); + expect(result.current.filteredEvents[0].id).toBe('2'); + }); + + it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const date = new Date('2025-07-01'); + const { result } = renderHook(() => useSearch(testEvents, date, 'week')); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].id).toBe('1'); + }); + + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(testEvents, date, 'month')); + expect(result.current.filteredEvents).toHaveLength(2); + + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toContain('회의'); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toContain('점심'); + }); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..0f44a028 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; import { @@ -6,6 +6,7 @@ import { setupMockHandlerDeletion, setupMockHandlerUpdating, } from '../../__mocks__/handlersUtils.ts'; +import Events from '../../__mocks__/response/events.json'; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; @@ -22,16 +23,119 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +describe('useEventOperations', () => { + afterEach(() => { + vi.clearAllMocks(); + }); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: Events.events }); + }) + ); + const { result } = renderHook(() => useEventOperations(false)); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + await waitFor(() => { + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0].title).toBe('기존 회의'); + }); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + }); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + setupMockHandlerCreation(); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + const newEvent = { + title: '새 이벤트', + date: '2025-10-01', + startTime: '12:00', + endTime: '13:00', + notificationTime: 10, + }; -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + await act(async () => { + await result.current.saveEvent(newEvent as Event); + }); + + expect(result.current.events).toHaveLength(1); + expect(result.current.events[0].title).toBe('새 이벤트'); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); + }); + + it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + const updatedEvent = { ...Events.events[0], title: '수정된 팀 회의', endTime: '11:30' }; + + setupMockHandlerUpdating(); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent(updatedEvent as Event); + }); + + expect(result.current.events[0].title).toBe('수정된 팀 회의'); + expect(result.current.events[0].endTime).toBe('11:30'); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); + }); + + it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + const eventIdToDelete = Events.events[0].id; + + setupMockHandlerDeletion(); + + const { result } = renderHook(() => useEventOperations(false)); + + await act(async () => { + await result.current.deleteEvent(eventIdToDelete); + }); + + expect(result.current.events).toHaveLength(0); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); + }); + + it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + + renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); + }); + + it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + const nonExistentEvent = { id: '999', title: '없는 이벤트' }; + + server.use(http.put('/api/events/999', () => new HttpResponse(null, { status: 404 }))); + + const { result } = renderHook(() => useEventOperations(true)); + + await act(async () => { + await result.current.saveEvent(nonExistentEvent as Event); + }); + + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); + }); + + it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + server.use(http.delete('/api/events/1', () => new HttpResponse(null, { status: 500 }))); + + 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..690252d9 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,84 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +const testEvents: Event[] = [ + { + id: 'event-1', + title: '15분 후 회의', + date: '2025-10-01', + startTime: '00:15:00', + endTime: '00:30:00', + notificationTime: 15, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: 'event-2', + title: '1시간 후 점심', + date: '2025-10-01', + startTime: '01:00:00', + endTime: '02:00:00', + notificationTime: 30, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, +]; -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +describe('useNotifications', () => { + it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications(testEvents)); + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); + }); -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); + it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', async () => { + const { result } = renderHook(() => useNotifications(testEvents)); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + act(() => { + vi.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(result.current.notifications).toHaveLength(1); + }); + expect(result.current.notifications[0].message).toBe('15분 후 15분 후 회의 일정이 시작됩니다.'); + expect(result.current.notifiedEvents).toContain('event-1'); + }); + + it('index를 기준으로 알림을 적절하게 제거할 수 있다', async () => { + const { result } = renderHook(() => useNotifications(testEvents)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(result.current.notifications).toHaveLength(1); + }); + + act(() => { + result.current.removeNotification(0); + }); + expect(result.current.notifications).toHaveLength(0); + }); + + it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', async () => { + const { result } = renderHook(() => useNotifications(testEvents)); + + act(() => { + vi.advanceTimersByTime(1000); + }); + await waitFor(() => { + expect(result.current.notifications).toHaveLength(1); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.notifications).toHaveLength(1); + }); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 0b559b44..42885d35 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,6 +1,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within, waitFor, act } from '@testing-library/react'; import { UserEvent, userEvent } from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { SnackbarProvider } from 'notistack'; @@ -22,6 +22,8 @@ const setup = (element: ReactElement) => { const user = userEvent.setup(); // ? Medium: 여기서 Provider로 묶어주는 동작은 의미있을까요? 있다면 어떤 의미일까요? + // ! 테스트 환경에서도 실제 앱 환경과 동일하게 Provider로 감싸주어야 해당 기능들이 정상적으로 동작하며, 의존성으로 인한 오류를 방지할 수 있다! + return { ...render( @@ -42,7 +44,8 @@ const saveSchedule = async ( await user.click(screen.getAllByText('일정 추가')[0]); - await user.type(screen.getByLabelText('제목'), title); + const titleInput = await screen.findByLabelText('제목'); + await user.type(titleInput, title); await user.type(screen.getByLabelText('날짜'), date); await user.type(screen.getByLabelText('시작 시간'), startTime); await user.type(screen.getByLabelText('종료 시간'), endTime); @@ -58,38 +61,296 @@ const saveSchedule = async ( // ! HINT. "검색 결과가 없습니다"는 초기에 노출되는데요. 그럼 검증하고자 하는 액션이 실행되기 전에 검증해버리지 않을까요? 이 테스트를 신뢰성있게 만드려면 어떻게 할까요? describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + setupMockHandlerCreation(); + const { user } = setup(); + + const newSchedule = { + title: '새로운 팀 회의', + date: '2025-10-15', + startTime: '14:00', + endTime: '15:00', + description: '새로운 회의입니다.', + location: '회의실 A', + category: '업무', + }; + + await saveSchedule(user, newSchedule); + + await waitFor(async () => { + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText(newSchedule.title)).toBeInTheDocument(); + expect(screen.getByText('2025-10-15')).toBeInTheDocument(); + expect(screen.getByText('14:00 - 15:00')).toBeInTheDocument(); + expect(screen.getByText(newSchedule.description)).toBeInTheDocument(); + expect(screen.getByText(newSchedule.location)).toBeInTheDocument(); + }); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + setupMockHandlerUpdating(); + const { user } = setup(); + + const eventList = await screen.findByTestId('event-list'); + const editButtons = within(eventList).getAllByTestId('EditIcon'); + await user.click(editButtons[0]); + + const titleInput = await screen.findByLabelText('제목'); + await user.clear(titleInput); + await user.type(titleInput, '수정된 회의'); + + await user.click(screen.getByTestId('event-submit-button')); + + await waitFor(async () => { + const updatedEventList = await screen.findByTestId('event-list'); + + expect(within(updatedEventList).getByText('수정된 회의')).toBeInTheDocument(); + expect(within(updatedEventList).queryByText('기존 회의')).not.toBeInTheDocument(); + }); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + setupMockHandlerDeletion(); + const { user } = setup(); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + const eventList = await screen.findByTestId('event-list'); + const deleteButtons = within(eventList).getAllByTestId('DeleteIcon'); + await user.click(deleteButtons[0]); + + await waitFor(() => { + expect(screen.queryByText('삭제할 이벤트')).not.toBeInTheDocument(); + }); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + server.use(http.get('/api/events', () => HttpResponse.json({ events: [] }))); + const { user } = setup(); + + await user.click(screen.getByText('Month')); + await user.click(screen.getByRole('option', { name: 'week-option' })); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + const mockEvent = { + id: 'week-test-1', + title: '주별 뷰 테스트 이벤트', + date: '2025-10-01', + startTime: '11:00', + endTime: '12:00', + description: '', + location: '', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + server.use(http.get('/api/events', () => HttpResponse.json({ events: [mockEvent] }))); + const { user } = setup(); + + await user.click(screen.getByText('Month')); + await user.click(screen.getByRole('option', { name: 'week-option' })); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText(mockEvent.title)).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + server.use(http.get('/api/events', () => HttpResponse.json({ events: [] }))); + setup(); + + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + const mockEvent = { + id: 'month-test-1', + title: '월별 뷰 테스트 이벤트', + date: '2025-10-01', + startTime: '15:00', + endTime: '16:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + server.use(http.get('/api/events', () => HttpResponse.json({ events: [mockEvent] }))); + setup(); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText(mockEvent.title)).toBeInTheDocument(); + }); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + server.use(http.get('/api/events', () => HttpResponse.json({ events: [] }))); + const { user } = setup(); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + const prevMonthButton = screen.getByTestId('ChevronLeftIcon'); + for (let i = 0; i < 9; i++) { + await user.click(prevMonthButton); + } + expect(await screen.findByText('신정')).toBeInTheDocument(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + beforeEach(() => { + const mockEvents = [ + { + id: 'search-1', + title: '팀 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '', + location: '', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + { + id: 'search-2', + title: '개인 프로젝트 마감', + date: '2025-10-16', + startTime: '11:00', + endTime: '12:00', + description: '', + location: '', + category: '개인', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(http.get('/api/events', () => HttpResponse.json({ events: mockEvents }))); + }); + + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '존재하지 않는 일정'); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); + + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const { user } = setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '팀 회의'); + + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('개인 프로젝트 마감')).not.toBeInTheDocument(); + }); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const { user } = setup(); + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + + await user.type(searchInput, '팀 회의'); + await waitFor(() => { + expect(screen.queryByText('개인 프로젝트 마감')).not.toBeInTheDocument(); + }); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + await user.clear(searchInput); + + await waitFor(() => { + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('개인 프로젝트 마감')).toBeInTheDocument(); + }); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + const initialEvents = [ + { + id: 'conflict-1', + title: '기존 일정', + date: '2025-10-20', + startTime: '10:00', + endTime: '11:00', + description: '', + location: '', + category: '업무', + repeat: { type: '', interval: 0 }, + notificationTime: 10, + }, + ]; + setupMockHandlerCreation(initialEvents as Event[]); + + const { user } = setup(); + + const conflictingSchedule = { + title: '겹치는 새 일정', + date: '2025-10-20', + startTime: '10:30', + endTime: '11:30', + description: '겹치는 일정입니다.', + location: '회의실', + category: '기타', + }; + + await saveSchedule(user, conflictingSchedule); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + setupMockHandlerUpdating(); + + const { user } = setup(); + + const eventList = await screen.findByTestId('event-list'); + const editButtons = within(eventList).getAllByTestId('EditIcon'); + await user.click(editButtons[0]); + + const startTimeInput = await screen.findByLabelText('시작 시간'); + const endTimeInput = await screen.findByLabelText('종료 시간'); + await user.clear(startTimeInput); + await user.type(startTimeInput, '11:00'); + await user.clear(endTimeInput); + await user.type(endTimeInput, '12:00'); + + await user.click(screen.getByTestId('event-submit-button')); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +describe('알람 기능', () => { + it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + vi.setSystemTime(new Date('2025-10-21T14:00:00')); + + setupMockHandlerCreation(); + const { user } = setup(); + + await user.click(screen.getAllByText('일정 추가')[0]); + + await user.type(await screen.findByLabelText('제목'), '알림 기능 테스트'); + await user.type(screen.getByLabelText('날짜'), '2025-10-21'); + await user.type(screen.getByLabelText('시작 시간'), '15:00'); + await user.type(screen.getByLabelText('종료 시간'), '16:00'); + + await user.click(await screen.findByText('1분 전')); + await user.click(await screen.findByText('10분 전')); + + await user.click(screen.getByTestId('event-submit-button')); + + act(() => { + vi.advanceTimersByTime(50 * 60 * 1000); + }); + await waitFor(() => { + const notification = screen.getAllByTestId('alert'); + expect( + within(notification[0]).getByText(/10분 후 알림 기능 테스트 일정이 시작됩니다./) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..12b547bf 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -11,106 +11,265 @@ import { isDateInRange, } from '../../utils/dateUtils'; +const mockEvents: Event[] = [ + { + id: '2b7545a6-ebee-426c-b906-2329bc8d62bd', + title: '팀 회의', + date: '2025-08-01', + 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, + }, +]; + describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + expect(getDaysInMonth(2025, 1)).toBe(31); + }); + + it('4월은 30일 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 4)).toBe(30); + }); - it('4월은 30일 일수를 반환한다', () => {}); + it('윤년의 2월에 대해 29일을 반환한다', () => { + expect(getDaysInMonth(2024, 2)).toBe(29); + }); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); + it('평년의 2월에 대해 28일을 반환한다', () => { + expect(getDaysInMonth(2025, 2)).toBe(28); + }); - it('평년의 2월에 대해 28일을 반환한다', () => {}); + it('0월을 입력하면 이전 해 12월의 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 0)).toBe(31); + }); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); + it('13월을 입력하면 다음 해 1월의 일수를 반환한다', () => { + expect(getDaysInMonth(2025, 13)).toBe(31); + }); }); describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); - - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); - - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); - - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = '2025-08-20'; + const days = getWeekDates(new Date(date)).map((d) => d.getDate()); + expect(days).toEqual([17, 18, 19, 20, 21, 22, 23]); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = '2025-08-18'; + const days = getWeekDates(new Date(date)).map((d) => d.getDate()); + expect(days).toEqual([17, 18, 19, 20, 21, 22, 23]); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = '2025-08-17'; + const days = getWeekDates(new Date(date)).map((d) => d.getDate()); + expect(days).toEqual([17, 18, 19, 20, 21, 22, 23]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 반환한다 (연말)', () => { + const date = '2024-12-31'; + const days = getWeekDates(new Date(date)).map((d) => formatDate(d)); + expect(days).toEqual([ + '2024-12-29', + '2024-12-30', + '2024-12-31', + '2025-01-01', + '2025-01-02', + '2025-01-03', + '2025-01-04', + ]); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 반환한다 (연초)', () => { + const date = '2025-01-01'; + const days = getWeekDates(new Date(date)).map((d) => formatDate(d)); + expect(days).toEqual([ + '2024-12-29', + '2024-12-30', + '2024-12-31', + '2025-01-01', + '2025-01-02', + '2025-01-03', + '2025-01-04', + ]); + }); + + it('윤년의 2월 29일을 포함한 주의 날짜를 정확히 반환한다', () => { + const date = '2024-02-29'; + const days = getWeekDates(new Date(date)).map((d) => d.getDate()); + expect(days).toEqual([25, 26, 27, 28, 29, 1, 2]); + }); + + it('월의 마지막 날짜를 포함한 주의 날짜를 정확히 반환한다', () => { + const date = '2025-08-31'; + const days = getWeekDates(new Date(date)).map((d) => d.getDate()); + expect(days).toEqual([31, 1, 2, 3, 4, 5, 6]); + }); }); describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); + it('2025년 7월 1일이 속한 월의 날짜들을 정확히 반환해야 한다', () => { + const date = '2025-07-01'; + const days = getWeeksAtMonth(new Date(date)); + expect(days.length).toBe(5); + expect(days[0]).toEqual([null, null, 1, 2, 3, 4, 5]); + expect(days[4]).toEqual([27, 28, 29, 30, 31, null, null]); + }); }); describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); - - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); - - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const events = getEventsForDay(mockEvents, 1); + expect(events.length).toBe(1); + expect(events[0].title).toBe('팀 회의'); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const events = getEventsForDay(mockEvents, 10); + expect(events).toEqual([]); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const events = getEventsForDay(mockEvents, 0); + expect(events).toEqual([]); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const events = getEventsForDay(mockEvents, 32); + expect(events).toEqual([]); + }); }); describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-07-15'))).toBe('2025년 7월 3주'); + }); - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-07-01'))).toBe('2025년 7월 1주'); + }); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-07-31'))).toBe('2025년 7월 5주'); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2024-12-31'))).toBe('2025년 1월 1주'); + }); - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2024-02-29'))).toBe('2024년 2월 5주'); + }); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + expect(formatWeek(new Date('2025-02-28'))).toBe('2025년 2월 4주'); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + expect(formatMonth(new Date('2025-07-10'))).toBe('2025년 7월'); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-10'), start, end)).toBe(true); + }); - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-01'), start, end)).toBe(true); + }); - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-31'), start, end)).toBe(true); + }); - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-06-30'), start, end)).toBe(false); + }); - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-08-01'), start, end)).toBe(false); + }); - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + expect(isDateInRange(new Date('2025-07-15'), end, start)).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(7)).toBe('07'); + }); - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(100, 2)).toBe('100'); + }); }); describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + expect(formatDate(new Date('2025-08-19'))).toBe('2025-08-19'); + }); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + expect(formatDate(new Date('2025-08-19'), 5)).toBe('2025-08-05'); + }); - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date('2025-01-10'))).toBe('2025-01-10'); + }); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + expect(formatDate(new Date('2025-10-01'))).toBe('2025-10-01'); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..58d3bc5a 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,32 +5,177 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const date = '2025-07-01'; + const time = '14:30'; + const result = parseDateTime(date, time); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(6); + expect(result.getDate()).toBe(1); + expect(result.getHours()).toBe(14); + expect(result.getMinutes()).toBe(30); + }); - it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-13-01', '14:30'); + expect(result.toString()).toBe('Invalid Date'); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-07-01', '25:00'); + expect(result.toString()).toBe('Invalid Date'); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const result = parseDateTime('', '14:30'); + expect(result.toString()).toBe('Invalid Date'); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-07-01', + startTime: '14:00', + endTime: '16:00', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }; + + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const { start, end } = convertEventToDateRange(event); + expect(start).toEqual(new Date('2025-07-01T14:00:00')); + expect(end).toEqual(new Date('2025-07-01T16:00:00')); + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, date: 'invalid-date' }; + const { start, end } = convertEventToDateRange(invalidEvent); + expect(start.toString()).toBe('Invalid Date'); + expect(end.toString()).toBe('Invalid Date'); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, startTime: '99:00', endTime: '99:00' }; + const { start, end } = convertEventToDateRange(invalidEvent); + expect(start.toString()).toBe('Invalid Date'); + expect(end.toString()).toBe('Invalid Date'); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + const baseEvent: Event = { + id: 'base', + title: 'Base Event', + date: '2025-07-01', + startTime: '10:00', + endTime: '12:00', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }; + + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const overlappingEvent: Event = { + ...baseEvent, + id: 'overlapping', + startTime: '11:00', + endTime: '13:00', + }; + expect(isOverlapping(baseEvent, overlappingEvent)).toBe(true); + }); - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const overlappingEvent = { + ...baseEvent, + id: 'overlapping', + startTime: '13:00', + endTime: '15:00', + }; + expect(isOverlapping(baseEvent, overlappingEvent)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + const testEvents: Event[] = [ + { + id: '1', + date: '2025-07-01', + startTime: '09:00', + endTime: '11:00', + title: '이벤트 1', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '2', + date: '2025-07-01', + startTime: '11:30', + endTime: '13:00', + title: '이벤트 2', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '3', + date: '2025-07-01', + startTime: '15:30', + endTime: '16:00', + title: '이벤트 3', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + ]; + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: '4', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:45', + title: '새 이벤트', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }; + const overlapping = findOverlappingEvents(newEvent, testEvents); + expect(overlapping).toHaveLength(2); + expect(overlapping.some((e) => e.id === '1')).toBe(true); + expect(overlapping.some((e) => e.id === '2')).toBe(true); + }); - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: '4', + date: '2025-07-01', + startTime: '22:00', + endTime: '23:45', + title: '새 이벤트', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }; + const overlapping = findOverlappingEvents(newEvent, testEvents); + expect(overlapping).toHaveLength(0); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..c46ba413 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -1,20 +1,114 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; +const testEvents: Event[] = [ + { + id: '1', + title: '7월 첫 주 이벤트', + date: '2025-07-01', + startTime: '10:15:00', + endTime: '11:00:00', + notificationTime: 15, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '2', + title: '중요한 이벤트 2', + date: '2025-07-15', + startTime: '12:00:00', + endTime: '13:00:00', + notificationTime: 30, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '3', + title: '7월 마지막 이벤트', + date: '2025-07-31', + startTime: '10:05:00', + endTime: '11:00:00', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '4', + title: '8월 event', + date: '2025-08-05', + startTime: '10:30:00', + endTime: '11:30:00', + notificationTime: 30, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, +]; + describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const date = new Date('2025-07-15'); + const filtered = getFilteredEvents(testEvents, '이벤트 2', date, 'month'); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('2'); + }); + + it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const date = new Date('2025-07-01'); + const filtered = getFilteredEvents(testEvents, '', date, 'week'); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('1'); + }); - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const date = new Date('2025-07-01'); + const filtered = getFilteredEvents(testEvents, '', date, 'month'); + expect(filtered).toHaveLength(3); + expect(filtered.some((e) => e.id === '1')).toBe(true); + expect(filtered.some((e) => e.id === '2')).toBe(true); + expect(filtered.some((e) => e.id === '3')).toBe(true); + }); - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const date = new Date('2025-07-01'); + const filtered = getFilteredEvents(testEvents, '이벤트', date, 'week'); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('1'); + }); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + it('검색어가 없을 때 모든 이벤트를 반환한다', () => { + const date = new Date('2025-07-01'); + const filtered = getFilteredEvents(testEvents, '', date, 'month'); + expect(filtered).toHaveLength(3); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const date = new Date('2025-08-01'); + const filtered = getFilteredEvents(testEvents, 'EVENT', date, 'month'); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('4'); + }); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const lastDay = new Date('2025-07-31'); + const filtered = getFilteredEvents(testEvents, '', lastDay, 'month'); + expect(filtered.some((e) => e.id === '3')).toBe(true); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + const firstDay = new Date('2025-07-01'); + const filtered2 = getFilteredEvents(testEvents, '', firstDay, 'month'); + expect(filtered2.some((e) => e.id === '1')).toBe(true); + }); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const date = new Date('2025-07-01'); + const filtered = getFilteredEvents([], '검색어', date, 'month'); + expect(filtered).toEqual([]); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..5c1eef01 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,26 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const date = new Date('2025-05-01'); + expect(fetchHolidays(date)).toEqual({ + '2025-05-05': '어린이날', + }); + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const date = new Date('2025-02-01'); + expect(fetchHolidays(date)).toEqual({}); + }); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const date = new Date('2025-10-01'); + expect(fetchHolidays(date)).toEqual({ + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-03': '개천절', + '2025-10-09': '한글날', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..c74ac0ed 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -1,16 +1,91 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; +const testEvents: Event[] = [ + { + id: '1', + title: '회의', + date: '2025-08-19', + startTime: '10:15:00', + endTime: '11:00:00', + notificationTime: 15, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '2', + title: '점심', + date: '2025-08-19', + startTime: '12:00:00', + endTime: '13:00:00', + notificationTime: 30, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '3', + title: '운동', + date: '2025-08-19', + startTime: '10:15:00', + endTime: '11:00:00', + notificationTime: 10, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, + { + id: '4', + title: '미팅', + date: '2025-08-19', + startTime: '10:30:00', + endTime: '11:30:00', + notificationTime: 30, + description: '', + location: '', + category: '', + repeat: { type: 'none', interval: 0 }, + }, +]; + +const now = new Date('2025-08-19T10:00:00'); + describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const notifiedEvents: string[] = []; + const upcoming = getUpcomingEvents(testEvents, now, notifiedEvents); + expect(upcoming).toHaveLength(2); + expect(upcoming.some((e) => e.id === '1')).toBe(true); + expect(upcoming.some((e) => e.id === '4')).toBe(true); + }); - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + const notifiedEvents: string[] = ['1']; + const upcoming = getUpcomingEvents(testEvents, now, notifiedEvents); + expect(upcoming).toHaveLength(1); + expect(upcoming.some((e) => e.id === '4')).toBe(true); + }); - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const notifiedEvents: string[] = []; + const upcoming = getUpcomingEvents(testEvents, now, notifiedEvents); + expect(upcoming.some((e) => e.id === '2')).toBe(false); + }); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const notifiedEvents: string[] = []; + const upcoming = getUpcomingEvents(testEvents, now, notifiedEvents); + expect(upcoming.some((e) => e.id === '3')).toBe(false); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const message = createNotificationMessage(testEvents[0]); + expect(message).toBe('15분 후 회의 일정이 시작됩니다.'); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..eb5ed15c 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,61 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + const ERROR_MESSAGE = { + startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.', + endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.', + }; - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + const SUCCESS_RESULT = { + startTimeError: null, + endTimeError: null, + }; - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const startTime = '10:00'; + const endTime = '09:00'; - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(ERROR_MESSAGE); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const startTime = '10:00'; + const endTime = '10:00'; - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(ERROR_MESSAGE); + }); + + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const startTime = '09:00'; + const endTime = '10:00'; + + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(SUCCESS_RESULT); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const startTime = ''; + const endTime = '10:00'; + + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(SUCCESS_RESULT); + }); + + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const startTime = '10:00'; + const endTime = ''; + + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(SUCCESS_RESULT); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const startTime = ''; + const endTime = ''; + + const result = getTimeErrorMessage(startTime, endTime); + expect(result).toEqual(SUCCESS_RESULT); + }); }); diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx new file mode 100644 index 00000000..79f5ad24 --- /dev/null +++ b/src/components/calendar/Calendar.tsx @@ -0,0 +1,62 @@ +import ChevronLeft from '@mui/icons-material/ChevronLeft'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import { Stack, Typography, IconButton, Select, MenuItem } from '@mui/material'; + +import MonthView from './MonthView'; +import WeekView from './WeekView'; + +const Calendar = ({ + currentDate, + holidays, + filteredEvents, + notifiedEvents, + view, + setView, + navigate, +}) => { + return ( + + 일정 보기 + + + navigate('prev')}> + + + + navigate('next')}> + + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + ); +}; + +export default Calendar; diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx new file mode 100644 index 00000000..1dc327a1 --- /dev/null +++ b/src/components/calendar/MonthView.tsx @@ -0,0 +1,109 @@ +import Notifications from '@mui/icons-material/Notifications'; +import { + Stack, + Typography, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Box, +} from '@mui/material'; + +import { getWeeksAtMonth, formatMonth, formatDate, getEventsForDay } from '../../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +const MonthView = ({ currentDate, holidays, filteredEvents, notifiedEvents }) => { + 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} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +}; + +export default MonthView; diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx new file mode 100644 index 00000000..043de665 --- /dev/null +++ b/src/components/calendar/WeekView.tsx @@ -0,0 +1,93 @@ +import Notifications from '@mui/icons-material/Notifications'; +import { + Stack, + Typography, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Box, +} from '@mui/material'; + +import { getWeekDates, formatWeek } from '../../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +const WeekView = ({ currentDate, filteredEvents, notifiedEvents }) => { + 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} + + + + ); + })} + + ))} + + +
+
+
+ ); +}; + +export default WeekView; diff --git a/src/components/dialog/OverlapDialog.tsx b/src/components/dialog/OverlapDialog.tsx new file mode 100644 index 00000000..b128db1d --- /dev/null +++ b/src/components/dialog/OverlapDialog.tsx @@ -0,0 +1,48 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + Typography, + DialogActions, + Button, +} from '@mui/material'; + +const OverlapDialog = ({ + isOverlapDialogOpen, + setIsOverlapDialogOpen, + overlappingEvents, + saveEvent, + eventData, +}) => { + return ( + setIsOverlapDialogOpen(false)}> + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +}; + +export default OverlapDialog; diff --git a/src/components/event-form/EventEditForm.tsx b/src/components/event-form/EventEditForm.tsx new file mode 100644 index 00000000..527ebb99 --- /dev/null +++ b/src/components/event-form/EventEditForm.tsx @@ -0,0 +1,252 @@ +import { + Stack, + Typography, + FormControl, + FormLabel, + TextField, + Tooltip, + Select, + MenuItem, + FormControlLabel, + Checkbox, + Button, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; + +import { useEventForm } from '../../hooks/useEventForm'; +import { Event, EventForm } from '../../types'; +import { getTimeErrorMessage } from '../../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +const EventEditForm = ({ + findOverlappingEvents, + setIsOverlapDialogOpen, + setOverlappingEvents, + saveEvent, + events, + notificationOptions, + editingEvent, +}) => { + const { formState, handleChange, resetForm } = useEventForm(editingEvent); + + const { startTimeError, endTimeError } = getTimeErrorMessage( + formState.startTime, + formState.endTime + ); + + const { enqueueSnackbar } = useSnackbar(); + + const addOrUpdateEvent = async () => { + if (!formState.title || !formState.date || !formState.startTime || !formState.endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return; + } + + if (startTimeError || endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return; + } + + const eventData: Event | EventForm = { + ...editingEvent, + ...formState, + id: editingEvent ? editingEvent.id : undefined, + repeat: { + type: formState.isRepeating ? formState.repeatType : 'none', + interval: formState.repeatInterval, + endDate: formState.repeatEndDate || undefined, + }, + }; + + const overlapping = findOverlappingEvents(eventData, events); + if (overlapping.length > 0) { + setOverlappingEvents(overlapping); + setIsOverlapDialogOpen(true); + } else { + await saveEvent(eventData); + resetForm(); + } + }; + + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + + + + + 날짜 + + + + + + 시작 시간 + + getTimeErrorMessage(formState.startTime, formState.endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(formState.startTime, formState.endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + + + + + 위치 + + + + + 카테고리 + + + + + + } + label="반복 일정" + /> + + + + 알림 설정 + + + + {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} + {/* {isRepeating && ( + + + 반복 유형 + + + + + 반복 간격 + setRepeatInterval(Number(e.target.value))} + slotProps={{ htmlInput: { min: 1 } }} + /> + + + 반복 종료일 + setRepeatEndDate(e.target.value)} + /> + + + + )} */} + + + + ); +}; + +export default EventEditForm; diff --git a/src/components/event-list/EventList.tsx b/src/components/event-list/EventList.tsx new file mode 100644 index 00000000..ce3469ad --- /dev/null +++ b/src/components/event-list/EventList.tsx @@ -0,0 +1,83 @@ +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import Notifications from '@mui/icons-material/Notifications'; +import { Stack, Typography, Box, IconButton } from '@mui/material'; + +import SearchBar from './SearchBar'; + +const EventList = ({ + searchTerm, + setSearchTerm, + filteredEvents, + notifiedEvents, + deleteEvent, + editEvent, + notificationOptions, +}) => { + return ( + + + + {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)}> + + + + + + )) + )} + + ); +}; + +export default EventList; diff --git a/src/components/event-list/SearchBar.tsx b/src/components/event-list/SearchBar.tsx new file mode 100644 index 00000000..333deb02 --- /dev/null +++ b/src/components/event-list/SearchBar.tsx @@ -0,0 +1,18 @@ +import { FormControl, FormLabel, TextField } from '@mui/material'; + +const SearchBar = ({ searchTerm, setSearchTerm }) => { + return ( + + 일정 검색 + setSearchTerm(e.target.value)} + /> + + ); +}; + +export default SearchBar; diff --git a/src/components/notification/CustomAlert.tsx b/src/components/notification/CustomAlert.tsx new file mode 100644 index 00000000..5e50f57a --- /dev/null +++ b/src/components/notification/CustomAlert.tsx @@ -0,0 +1,24 @@ +import Close from '@mui/icons-material/Close'; +import { Alert, IconButton, AlertTitle } from '@mui/material'; + +const CustomAlert = ({ index, notification, setNotifications }) => { + return ( + setNotifications((prev) => prev.filter((_, i) => i !== index))} + > + + + } + > + {notification.message} + + ); +}; + +export default CustomAlert; diff --git a/src/components/notification/NotificationList.tsx b/src/components/notification/NotificationList.tsx new file mode 100644 index 00000000..1a94f34d --- /dev/null +++ b/src/components/notification/NotificationList.tsx @@ -0,0 +1,20 @@ +import { Stack } from '@mui/material'; + +import CustomAlert from './CustomAlert'; + +const NotificationList = ({ notifications, setNotifications }) => { + return ( + + {notifications.map((notification, index) => ( + + ))} + + ); +}; + +export default NotificationList; diff --git a/src/hooks/useEventForm.ts b/src/hooks/useEventForm.ts index 9dfcc46a..d078b6cd 100644 --- a/src/hooks/useEventForm.ts +++ b/src/hooks/useEventForm.ts @@ -1,106 +1,37 @@ -import { ChangeEvent, useState } from 'react'; - -import { Event, RepeatType } from '../types'; -import { getTimeErrorMessage } from '../utils/timeValidation'; - -type TimeErrorRecord = Record<'startTimeError' | 'endTimeError', string | null>; - -export const useEventForm = (initialEvent?: Event) => { - const [title, setTitle] = useState(initialEvent?.title || ''); - const [date, setDate] = useState(initialEvent?.date || ''); - const [startTime, setStartTime] = useState(initialEvent?.startTime || ''); - const [endTime, setEndTime] = useState(initialEvent?.endTime || ''); - const [description, setDescription] = useState(initialEvent?.description || ''); - const [location, setLocation] = useState(initialEvent?.location || ''); - const [category, setCategory] = useState(initialEvent?.category || '업무'); - const [isRepeating, setIsRepeating] = useState(initialEvent?.repeat.type !== 'none'); - const [repeatType, setRepeatType] = useState(initialEvent?.repeat.type || 'none'); - const [repeatInterval, setRepeatInterval] = useState(initialEvent?.repeat.interval || 1); - const [repeatEndDate, setRepeatEndDate] = useState(initialEvent?.repeat.endDate || ''); - const [notificationTime, setNotificationTime] = useState(initialEvent?.notificationTime || 10); - - const [editingEvent, setEditingEvent] = useState(null); - - const [{ startTimeError, endTimeError }, setTimeError] = useState({ - startTimeError: null, - endTimeError: null, +import { useState, useEffect, ChangeEvent } from 'react'; + +import { Event } from '../types'; + +export const useEventForm = (eventToEdit: Event | null) => { + const getInitialState = (event: Event | null) => ({ + title: event?.title || '', + date: event?.date || '', + startTime: event?.startTime || '', + endTime: event?.endTime || '', + description: event?.description || '', + location: event?.location || '', + category: event?.category || '', + isRepeating: event?.repeat.type !== 'none', + repeatType: event?.repeat.type || 'none', + repeatInterval: event?.repeat.interval || 0, + repeatEndDate: event?.repeat.endDate || '', + notificationTime: event?.notificationTime || 1, }); - const handleStartTimeChange = (e: ChangeEvent) => { - const newStartTime = e.target.value; - setStartTime(newStartTime); - setTimeError(getTimeErrorMessage(newStartTime, endTime)); - }; + const [formState, setFormState] = useState(getInitialState(eventToEdit)); - const handleEndTimeChange = (e: ChangeEvent) => { - const newEndTime = e.target.value; - setEndTime(newEndTime); - setTimeError(getTimeErrorMessage(startTime, newEndTime)); - }; + useEffect(() => { + setFormState(getInitialState(eventToEdit)); + }, [eventToEdit]); - const resetForm = () => { - setTitle(''); - setDate(''); - setStartTime(''); - setEndTime(''); - setDescription(''); - setLocation(''); - setCategory('업무'); - setIsRepeating(false); - setRepeatType('none'); - setRepeatInterval(1); - setRepeatEndDate(''); - setNotificationTime(10); + const handleChange = (e: ChangeEvent) => { + const { name, value } = e.target; + setFormState((prev) => ({ ...prev, [name]: value })); }; - const editEvent = (event: Event) => { - setEditingEvent(event); - setTitle(event.title); - setDate(event.date); - setStartTime(event.startTime); - setEndTime(event.endTime); - setDescription(event.description); - setLocation(event.location); - setCategory(event.category); - setIsRepeating(event.repeat.type !== 'none'); - setRepeatType(event.repeat.type); - setRepeatInterval(event.repeat.interval); - setRepeatEndDate(event.repeat.endDate || ''); - setNotificationTime(event.notificationTime); + const resetForm = () => { + setFormState(getInitialState(null)); }; - return { - title, - setTitle, - date, - setDate, - startTime, - setStartTime, - endTime, - setEndTime, - description, - setDescription, - location, - setLocation, - category, - setCategory, - isRepeating, - setIsRepeating, - repeatType, - setRepeatType, - repeatInterval, - setRepeatInterval, - repeatEndDate, - setRepeatEndDate, - notificationTime, - setNotificationTime, - startTimeError, - endTimeError, - editingEvent, - setEditingEvent, - handleStartTimeChange, - handleEndTimeChange, - resetForm, - editEvent, - }; + return { formState, handleChange, resetForm }; };