diff --git a/eslint.config.js b/eslint.config.js index 0a019971..c6519cc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -70,24 +70,16 @@ export default [ ...typescriptPlugin.configs.recommended.rules, // ESLint rules - 'no-unused-vars': 'warn', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error'], // React rules 'react/prop-types': 'off', ...reactHooksPlugin.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'off', - // Import rules - 'import/order': [ - 'error', - { - groups: ['builtin', 'external', ['parent', 'sibling'], 'index'], - alphabetize: { - order: 'asc', - caseInsensitive: true, - }, - 'newlines-between': 'always', - }, - ], + // Import rules disabled due to conflict with Prettier + // 'import/order': 'off', // Prettier rules ...prettierConfig.rules, diff --git a/package.json b/package.json index b01b2b4b..455c4f2e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "test:easy": "vitest --run easy", + "test:easy:watch": "vitest easy", "build": "tsc -b && vite build", "lint:eslint": "eslint . --ext ts,tsx --report-unused-disable-directives", "lint:tsc": "tsc --pretty", @@ -29,6 +31,7 @@ "react-dom": "19.1.0" }, "devDependencies": { + "@eslint/js": "^9.33.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093f3ec7..bd406e25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: 19.1.0 version: 19.1.0(react@19.1.0) devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -468,6 +471,10 @@ packages: resolution: {integrity: sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.33.0': + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3628,6 +3635,8 @@ snapshots: '@eslint/js@9.30.0': {} + '@eslint/js@9.33.0': {} + '@eslint/object-schema@2.1.6': {} '@eslint/plugin-kit@0.3.3': diff --git a/src/App.tsx b/src/App.tsx index 195c5b05..79e41900 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,64 +1,19 @@ -import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material'; +import { Box, Stack } from '@mui/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'; + EventForm, + EventList, + OverlapDialog, + NotificationStack, + CalendarLayout, +} from './components'; import { useCalendarView } from './hooks/useCalendarView.ts'; import { useEventForm } from './hooks/useEventForm.ts'; +import { useEventManagement } from './hooks/useEventManagement.ts'; import { useEventOperations } from './hooks/useEventOperations.ts'; import { useNotifications } from './hooks/useNotifications.ts'; import { useSearch } from './hooks/useSearch.ts'; -// import { Event, EventForm, RepeatType } from './types'; -import { Event, EventForm } from './types'; -import { - formatDate, - formatMonth, - formatWeek, - getEventsForDay, - getWeekDates, - getWeeksAtMonth, -} from './utils/dateUtils'; -import { findOverlappingEvents } from './utils/eventOverlap'; -import { getTimeErrorMessage } from './utils/timeValidation'; - -const categories = ['업무', '개인', '가족', '기타']; - -const weekDays = ['일', '월', '화', '수', '목', '금', '토']; - -const notificationOptions = [ - { value: 1, label: '1분 전' }, - { value: 10, label: '10분 전' }, - { value: 60, label: '1시간 전' }, - { value: 120, label: '2시간 전' }, - { value: 1440, label: '1일 전' }, -]; function App() { const { @@ -77,11 +32,8 @@ function App() { isRepeating, setIsRepeating, repeatType, - // setRepeatType, repeatInterval, - // setRepeatInterval, repeatEndDate, - // setRepeatEndDate, notificationTime, setNotificationTime, startTimeError, @@ -102,557 +54,96 @@ function App() { const { view, setView, currentDate, holidays, navigate } = useCalendarView(); const { searchTerm, filteredEvents, setSearchTerm } = useSearch(events, currentDate, view); - const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); - const [overlappingEvents, setOverlappingEvents] = useState([]); - - const { enqueueSnackbar } = useSnackbar(); - - const addOrUpdateEvent = async () => { - if (!title || !date || !startTime || !endTime) { - enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); - return; - } - - if (startTimeError || endTimeError) { - enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); - return; - } - - const eventData: Event | EventForm = { - id: editingEvent ? editingEvent.id : undefined, - title, - date, - startTime, - endTime, - description, - location, - category, - repeat: { - type: isRepeating ? repeatType : 'none', - interval: repeatInterval, - endDate: repeatEndDate || undefined, - }, - notificationTime, - }; - - const overlapping = findOverlappingEvents(eventData, events); - if (overlapping.length > 0) { - setOverlappingEvents(overlapping); - setIsOverlapDialogOpen(true); - } else { - await saveEvent(eventData); - resetForm(); - } - }; - - const renderWeekView = () => { - const weekDates = getWeekDates(currentDate); - return ( - - {formatWeek(currentDate)} - - - - - {weekDays.map((day) => ( - - {day} - - ))} - - - - - {weekDates.map((date) => ( - - - {date.getDate()} - - {filteredEvents - .filter( - (event) => new Date(event.date).toDateString() === date.toDateString() - ) - .map((event) => { - const isNotified = notifiedEvents.includes(event.id); - return ( - - - {isNotified && } - - {event.title} - - - - ); - })} - - ))} - - -
-
-
- ); + const formData = { + title, + date, + startTime, + endTime, + description, + location, + category, + isRepeating, + repeatType, + repeatInterval, + repeatEndDate, + notificationTime, + startTimeError: startTimeError || '', + endTimeError: endTimeError || '', + editingEvent, }; - 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 { + isOverlapDialogOpen, + overlappingEvents, + addOrUpdateEvent, + handleOverlapConfirm, + closeOverlapDialog, + } = useEventManagement({ + events, + saveEvent, + resetForm, + formData, + }); return ( - - {editingEvent ? '일정 수정' : '일정 추가'} - - - 제목 - setTitle(e.target.value)} - /> - - - - 날짜 - setDate(e.target.value)} - /> - - - - - 시작 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!startTimeError} - /> - - - - 종료 시간 - - getTimeErrorMessage(startTime, endTime)} - error={!!endTimeError} - /> - - - - - - 설명 - setDescription(e.target.value)} - /> - - - - 위치 - setLocation(e.target.value)} - /> - - - - 카테고리 - - - - - setIsRepeating(e.target.checked)} - /> - } - label="반복 일정" - /> - - - - 알림 설정 - - - - {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */} - {/* {isRepeating && ( - - - 반복 유형 - - - - - 반복 간격 - setRepeatInterval(Number(e.target.value))} - slotProps={{ htmlInput: { min: 1 } }} - /> - - - 반복 종료일 - setRepeatEndDate(e.target.value)} - /> - - - - )} */} - - - - - - 일정 보기 - - - navigate('prev')}> - - - - navigate('next')}> - - - - - {view === 'week' && renderWeekView()} - {view === 'month' && renderMonthView()} - - - - - 일정 검색 - setSearchTerm(e.target.value)} - /> - - - {filteredEvents.length === 0 ? ( - 검색 결과가 없습니다. - ) : ( - filteredEvents.map((event) => ( - - - - - {notifiedEvents.includes(event.id) && } - - {event.title} - - - {event.date} - - {event.startTime} - {event.endTime} - - {event.description} - {event.location} - 카테고리: {event.category} - {event.repeat.type !== 'none' && ( - - 반복: {event.repeat.interval} - {event.repeat.type === 'daily' && '일'} - {event.repeat.type === 'weekly' && '주'} - {event.repeat.type === 'monthly' && '월'} - {event.repeat.type === 'yearly' && '년'} - 마다 - {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`} - - )} - - 알림:{' '} - { - notificationOptions.find( - (option) => option.value === event.notificationTime - )?.label - } - - - - editEvent(event)}> - - - deleteEvent(event.id)}> - - - - - - )) - )} - + + + + + - setIsOverlapDialogOpen(false)}> - 일정 겹침 경고 - - - 다음 일정과 겹칩니다: - {overlappingEvents.map((event) => ( - - {event.title} ({event.date} {event.startTime}-{event.endTime}) - - ))} - 계속 진행하시겠습니까? - - - - - - - - - {notifications.length > 0 && ( - - {notifications.map((notification, index) => ( - setNotifications((prev) => prev.filter((_, i) => i !== index))} - > - - - } - > - {notification.message} - - ))} - - )} + + + setNotifications((prev) => prev.filter((_, i) => i !== index))} + /> ); } diff --git a/src/__mocks__/handlers.ts b/src/__mocks__/handlers.ts index 42d6d4b7..30da72bb 100644 --- a/src/__mocks__/handlers.ts +++ b/src/__mocks__/handlers.ts @@ -5,12 +5,40 @@ import { Event } from '../types'; // ! HARD // ! 각 응답에 대한 MSW 핸들러를 작성해주세요. GET 요청은 이미 작성되어 있는 events json을 활용해주세요. + +let mockEvents: Event[] = structuredClone(events) as Event[]; + export const handlers = [ - http.get('/api/events', () => {}), + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Event; + mockEvents.push(newEvent); + return HttpResponse.json({ event: newEvent }, { status: 201 }); + }), + http.put('/api/events/:id', async ({ request, params }) => { + const updatedEvent = (await request.json()) as Event; + const index = mockEvents.findIndex((event) => event.id === params.id); - http.post('/api/events', async ({ request }) => {}), + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - http.put('/api/events/:id', async ({ params, request }) => {}), + mockEvents[index] = { ...updatedEvent, id: params.id as string }; + return HttpResponse.json({ event: updatedEvent }, { status: 200 }); + }), + http.delete('/api/events/:id', async ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } - http.delete('/api/events/:id', ({ params }) => {}), + mockEvents.splice(index, 1); + return HttpResponse.json({ status: 200 }); + }), ]; + +export const initMockEvents = () => { + mockEvents = structuredClone(events) as Event[]; +}; diff --git a/src/__mocks__/handlersUtils.ts b/src/__mocks__/handlersUtils.ts index 405837ec..89f90167 100644 --- a/src/__mocks__/handlersUtils.ts +++ b/src/__mocks__/handlersUtils.ts @@ -1,10 +1,66 @@ +import { http, HttpResponse } from 'msw'; + import { Event } from '../types'; // ! Hard // ! 이벤트는 생성, 수정 되면 fetch를 다시 해 상태를 업데이트 합니다. 이를 위한 제어가 필요할 것 같은데요. 어떻게 작성해야 테스트가 병렬로 돌아도 안정적이게 동작할까요? // ! 아래 이름을 사용하지 않아도 되니, 독립적이게 테스트를 구동할 수 있는 방법을 찾아보세요. 그리고 이 로직을 PR에 설명해주세요. -export const setupMockHandlerCreation = (initEvents = [] as Event[]) => {}; -export const setupMockHandlerUpdating = () => {}; +export const createMockHandlers = (initialEvents: Event[] = []) => { + return () => { + let mockEvents: Event[] = structuredClone(initialEvents); + + return [ + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + + http.post('/api/events', async ({ request }) => { + const newEvent = (await request.json()) as Omit; + const eventWithId: Event = { + ...newEvent, + id: Math.random().toString(36).substring(2, 15), + }; + mockEvents.push(eventWithId); + return HttpResponse.json( + { + success: true, + event: eventWithId, + message: '일정이 성공적으로 추가되었습니다.', + }, + { status: 201 } + ); + }), + + http.put('/api/events/:id', async ({ request, params }) => { + const updatedEvent = (await request.json()) as Partial; + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } + + mockEvents[index] = { ...mockEvents[index], ...updatedEvent, id: params.id as string }; + return HttpResponse.json( + { + success: true, + event: mockEvents[index], + message: '일정이 성공적으로 수정되었습니다.', + }, + { status: 200 } + ); + }), + + http.delete('/api/events/:id', ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index === -1) { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + } -export const setupMockHandlerDeletion = () => {}; + const [deletedEvent] = mockEvents.splice(index, 1); + return HttpResponse.json({ event: deletedEvent }, { status: 200 }); + }), + ]; + }; +}; diff --git a/src/__tests__/components/advanced.EventForm.spec.tsx b/src/__tests__/components/advanced.EventForm.spec.tsx new file mode 100644 index 00000000..2f13ec0f --- /dev/null +++ b/src/__tests__/components/advanced.EventForm.spec.tsx @@ -0,0 +1,220 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { vi } from 'vitest'; +import { EventForm } from '../../components/form/EventForm'; + +// notistack의 useSnackbar를 모킹 +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), +})); + +describe('EventForm', () => { + const mockProps = { + title: '', + setTitle: vi.fn(), + date: '2024-01-01', + setDate: vi.fn(), + startTime: '09:00', + endTime: '10:00', + description: '', + setDescription: vi.fn(), + location: '', + setLocation: vi.fn(), + category: '업무', + setCategory: vi.fn(), + isRepeating: false, + setIsRepeating: vi.fn(), + notificationTime: 10, + setNotificationTime: vi.fn(), + startTimeError: null, + endTimeError: null, + editingEvent: null, + handleStartTimeChange: vi.fn(), + handleEndTimeChange: vi.fn(), + addOrUpdateEvent: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('일정 추가 모드에서 모든 폼 UI 요소들이 올바르게 렌더링되어야 한다', () => { + render(); + + // 제목이 올바르게 표시되어야 함 + expect(screen.getByRole('heading', { name: '일정 추가' })).toBeInTheDocument(); + + // 필수 입력 필드들이 존재해야 함 + expect(screen.getByLabelText('제목')).toBeInTheDocument(); + expect(screen.getByLabelText('날짜')).toBeInTheDocument(); + expect(screen.getByLabelText('시작 시간')).toBeInTheDocument(); + expect(screen.getByLabelText('종료 시간')).toBeInTheDocument(); + expect(screen.getByLabelText('설명')).toBeInTheDocument(); + expect(screen.getByLabelText('위치')).toBeInTheDocument(); + expect(screen.getByLabelText('카테고리')).toBeInTheDocument(); + expect(screen.getByLabelText('알림 설정')).toBeInTheDocument(); + + // 반복 일정 체크박스가 존재해야 함 + expect(screen.getByLabelText('반복 일정')).toBeInTheDocument(); + + // 제출 버튼이 올바른 텍스트와 함께 존재해야 함 + const submitButton = screen.getByTestId('event-submit-button'); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveTextContent('일정 추가'); + }); + }); + + describe('사용자 입력 처리', () => { + it('사용자 입력 시 해당 setter 함수들이 올바르게 호출되어야 한다', () => { + render(); + + // 제목 입력 테스트 + const titleInput = screen.getByLabelText('제목'); + fireEvent.change(titleInput, { target: { value: '새로운 일정' } }); + expect(mockProps.setTitle).toHaveBeenCalledWith('새로운 일정'); + + // 날짜 변경 테스트 + const dateInput = screen.getByLabelText('날짜'); + fireEvent.change(dateInput, { target: { value: '2024-01-15' } }); + expect(mockProps.setDate).toHaveBeenCalledWith('2024-01-15'); + + // 설명 입력 테스트 + const descriptionInput = screen.getByLabelText('설명'); + fireEvent.change(descriptionInput, { target: { value: '일정에 대한 상세 설명' } }); + expect(mockProps.setDescription).toHaveBeenCalledWith('일정에 대한 상세 설명'); + + // 위치 입력 테스트 + const locationInput = screen.getByLabelText('위치'); + fireEvent.change(locationInput, { target: { value: '회사 회의실' } }); + expect(mockProps.setLocation).toHaveBeenCalledWith('회사 회의실'); + + // 반복 일정 체크박스 테스트 + const repeatCheckbox = screen.getByLabelText('반복 일정'); + fireEvent.click(repeatCheckbox); + expect(mockProps.setIsRepeating).toHaveBeenCalledWith(true); + }); + + it('시간 입력 시 해당 핸들러 함수들이 호출되어야 한다', () => { + render(); + + // 시작 시간 변경 테스트 + const startTimeInput = screen.getByLabelText('시작 시간'); + fireEvent.change(startTimeInput, { target: { value: '10:00' } }); + expect(mockProps.handleStartTimeChange).toHaveBeenCalled(); + + // 종료 시간 변경 테스트 + const endTimeInput = screen.getByLabelText('종료 시간'); + fireEvent.change(endTimeInput, { target: { value: '11:00' } }); + expect(mockProps.handleEndTimeChange).toHaveBeenCalled(); + }); + }); + + describe('폼 제출', () => { + it('제출 버튼 클릭 시 addOrUpdateEvent 함수가 호출되어야 한다', () => { + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe('유효성 검사', () => { + it('필수 정보가 누락된 상태에서 제출 시 유효성 검사가 실행되어야 한다', () => { + const propsWithEmptyRequiredFields = { + ...mockProps, + title: '', // 제목 누락 + date: '', // 날짜 누락 + startTime: '', // 시작 시간 누락 + endTime: '', // 종료 시간 누락 + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // addOrUpdateEvent 함수가 호출되어야 함 (유효성 검사는 상위 컴포넌트에서 처리) + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + + it('시간 에러가 있는 상태에서 제출 시 유효성 검사가 실행되어야 한다', () => { + const propsWithTimeErrors = { + ...mockProps, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // addOrUpdateEvent 함수가 호출되어야 함 (유효성 검사는 상위 컴포넌트에서 처리) + expect(mockProps.addOrUpdateEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe('에러 토스트 표시', () => { + it('필수 정보가 누락된 경우 에러 토스트가 표시된다', () => { + // addOrUpdateEvent 함수가 실제로 validateForm 로직을 실행하도록 모킹 + const mockAddOrUpdateEvent = vi.fn().mockImplementation(() => { + // 필수 정보 누락 시 에러 토스트 표시 + mockEnqueueSnackbar('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + const propsWithMockValidation = { + ...mockProps, + title: '', // 제목 누락 + date: '', // 날짜 누락 + startTime: '', // 시작 시간 누락 + endTime: '', // 종료 시간 누락 + addOrUpdateEvent: mockAddOrUpdateEvent, + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + }); + + it('시간 에러가 있는 경우 에러 토스트가 표시된다', () => { + // addOrUpdateEvent 함수가 실제로 validateForm 로직을 실행하도록 모킹 + const mockAddOrUpdateEvent = vi.fn().mockImplementation(() => { + // 시간 에러가 있는 경우 에러 토스트 표시 + mockEnqueueSnackbar('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); + + const propsWithMockValidation = { + ...mockProps, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + addOrUpdateEvent: mockAddOrUpdateEvent, + }; + + render(); + + const submitButton = screen.getByTestId('event-submit-button'); + fireEvent.click(submitButton); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + }); + }); +}); diff --git a/src/__tests__/components/advanced.MonthView.spec.tsx b/src/__tests__/components/advanced.MonthView.spec.tsx new file mode 100644 index 00000000..718407a6 --- /dev/null +++ b/src/__tests__/components/advanced.MonthView.spec.tsx @@ -0,0 +1,146 @@ +import { render, screen } from '@testing-library/react'; +import { MonthView } from '../../components/calendar/MonthView'; +import { Event } from '../../types'; + +// dateUtils 함수들을 모킹 +vi.mock('../../utils/dateUtils', () => ({ + formatDate: vi.fn((_date: Date, day: number) => `2024-01-${day.toString().padStart(2, '0')}`), + formatMonth: vi.fn(() => '2024년 1월'), + getEventsForDay: vi.fn((events: Event[], day: number) => + events.filter((event) => event.date === `2024-01-${day.toString().padStart(2, '0')}`) + ), + getWeeksAtMonth: vi.fn(() => [ + [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, null], + ]), +})); + +describe('MonthView', () => { + const mockProps = { + currentDate: new Date('2024-01-15'), + filteredEvents: [ + { + id: '1', + title: '테스트 일정', + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] as Event[], + notifiedEvents: ['1'], + holidays: { + '2024-01-01': '신정', + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('월 뷰가 올바르게 렌더링되어야 한다', () => { + render(); + + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 1월')).toBeInTheDocument(); + }); + + it('요일 헤더가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('일')).toBeInTheDocument(); + expect(screen.getByText('월')).toBeInTheDocument(); + expect(screen.getByText('화')).toBeInTheDocument(); + expect(screen.getByText('수')).toBeInTheDocument(); + expect(screen.getByText('목')).toBeInTheDocument(); + expect(screen.getByText('금')).toBeInTheDocument(); + expect(screen.getByText('토')).toBeInTheDocument(); + }); + + it('일정이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('테스트 일정')).toBeInTheDocument(); + }); + + it('공휴일이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('신정')).toBeInTheDocument(); + }); + }); + + describe('엣지 케이스 테스트', () => { + it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const emptyProps = { ...mockProps, filteredEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('빈 공휴일 객체로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noHolidayProps = { ...mockProps, holidays: {} }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('빈 알림 이벤트 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noNotificationProps = { ...mockProps, notifiedEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('매우 많은 일정이 있어도 렌더링이 정상적으로 되어야 한다', () => { + const manyEvents = Array.from({ length: 50 }, (_, i) => ({ + id: `event-${i}`, + title: `일정 ${i}`, + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: `설명 ${i}`, + location: `위치 ${i}`, + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + })) as Event[]; + + const manyEventsProps = { ...mockProps, filteredEvents: manyEvents }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + }); + + describe('다양한 날짜 테스트', () => { + it('12월로 렌더링해도 정상 작동해야 한다', () => { + const decemberProps = { ...mockProps, currentDate: new Date('2024-12-15') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('연초(1월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const januaryProps = { ...mockProps, currentDate: new Date('2024-01-01') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + + it('윤년 2월 날짜로 렌더링해도 정상 작동해야 한다', () => { + const leapYearProps = { ...mockProps, currentDate: new Date('2024-02-29') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('month-view')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/components/advanced.NotificationStack.spec.tsx b/src/__tests__/components/advanced.NotificationStack.spec.tsx new file mode 100644 index 00000000..5bbf9514 --- /dev/null +++ b/src/__tests__/components/advanced.NotificationStack.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import { NotificationStack } from '../../components/notification/NotificationStack'; + +describe('NotificationStack', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('알림이 없을 때는 아무것도 렌더링하지 않아야 한다', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('알림이 있을 때는 모든 알림을 렌더링해야 한다', () => { + const notifications = [{ message: '첫 번째 알림' }, { message: '두 번째 알림' }]; + + render(); + + expect(screen.getByText('첫 번째 알림')).toBeInTheDocument(); + expect(screen.getByText('두 번째 알림')).toBeInTheDocument(); + }); + }); + + describe('사용자 상호작용', () => { + it('닫기 버튼을 클릭하면 onClose가 올바른 인덱스와 함께 호출되어야 한다', () => { + const notifications = [{ message: '첫 번째 알림' }, { message: '두 번째 알림' }]; + + render(); + + const closeButtons = screen.getAllByRole('button'); + + // 첫 번째 알림의 닫기 버튼 클릭 + fireEvent.click(closeButtons[0]); + expect(mockOnClose).toHaveBeenCalledWith(0); + + // 두 번째 알림의 닫기 버튼 클릭 + fireEvent.click(closeButtons[1]); + expect(mockOnClose).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/src/__tests__/components/advanced.OverlapDialog.spec.tsx b/src/__tests__/components/advanced.OverlapDialog.spec.tsx new file mode 100644 index 00000000..de79bb6e --- /dev/null +++ b/src/__tests__/components/advanced.OverlapDialog.spec.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import { OverlapDialog } from '../../components/event/OverlapDialog'; +import { Event } from '../../types'; + +// Mock Material-UI components if needed +vi.mock('@mui/material', () => ({ + Button: ({ + children, + onClick, + ...props + }: { + children: React.ReactNode; + onClick?: () => void; + [key: string]: unknown; + }) => ( + + ), + Dialog: ({ + children, + open, + ...props + }: { + children: React.ReactNode; + open: boolean; + [key: string]: unknown; + }) => + open ? ( +
+ {children} +
+ ) : null, + DialogActions: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogContent: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogContentText: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) =>
{children}
, + DialogTitle: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + Typography: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), +})); + +describe('OverlapDialog', () => { + const mockOverlappingEvents: Event[] = [ + { + id: '1', + title: '기존 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '기존 팀 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '다른 회의', + date: '2025-10-01', + startTime: '09:30', + endTime: '10:30', + description: '다른 팀 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('다이얼로그가 열려있을 때 겹치는 일정 정보를 표시한다', () => { + render( + + ); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText('취소')).toBeInTheDocument(); + expect(screen.getByText('계속 진행')).toBeInTheDocument(); + + // 겹치는 일정이 렌더링되었는지 확인 (더 유연한 방식) + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('기존 회의'); + expect(dialog).toHaveTextContent('다른 회의'); + expect(dialog).toHaveTextContent('2025-10-01'); + }); + + it('다이얼로그가 닫혀있을 때는 아무것도 렌더링하지 않는다', () => { + render( + + ); + + expect(screen.queryByText('일정 겹침 경고')).not.toBeInTheDocument(); + }); + + it('취소 버튼을 클릭하면 onClose가 호출된다', async () => { + const user = userEvent.setup(); + + render( + + ); + + const cancelButton = screen.getByText('취소'); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('계속 진행 버튼을 클릭하면 onConfirm이 호출된다', async () => { + const user = userEvent.setup(); + + render( + + ); + + const confirmButton = screen.getByText('계속 진행'); + await user.click(confirmButton); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + it('겹치는 일정이 없을 때도 다이얼로그가 정상적으로 렌더링된다', () => { + render( + + ); + + expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument(); + expect(screen.getByText('취소')).toBeInTheDocument(); + expect(screen.getByText('계속 진행')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/advanced.WeekView.spec.tsx b/src/__tests__/components/advanced.WeekView.spec.tsx new file mode 100644 index 00000000..932ecd35 --- /dev/null +++ b/src/__tests__/components/advanced.WeekView.spec.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import { WeekView } from '../../components/calendar/WeekView'; +import { Event } from '../../types'; + +// dateUtils 함수들을 모킹 +/* eslint-disable @typescript-eslint/no-unused-vars */ +vi.mock('../../utils/dateUtils', () => ({ + formatWeek: vi.fn((_date: Date) => '2024년 1월 3주차'), + getWeekDates: vi.fn((_date: Date) => [ + new Date('2024-01-14'), + new Date('2024-01-15'), + new Date('2024-01-16'), + new Date('2024-01-17'), + new Date('2024-01-18'), + new Date('2024-01-19'), + new Date('2024-01-20'), + ]), +})); + +describe('WeekView', () => { + const mockProps = { + currentDate: new Date('2024-01-15'), + filteredEvents: [ + { + id: '1', + title: '테스트 일정', + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }, + ] as Event[], + notifiedEvents: ['1'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('렌더링', () => { + it('주 뷰가 올바르게 렌더링되어야 한다', () => { + render(); + + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + expect(screen.getByText('2024년 1월 3주차')).toBeInTheDocument(); + }); + + it('요일 헤더가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('일')).toBeInTheDocument(); + expect(screen.getByText('월')).toBeInTheDocument(); + expect(screen.getByText('화')).toBeInTheDocument(); + expect(screen.getByText('수')).toBeInTheDocument(); + expect(screen.getByText('목')).toBeInTheDocument(); + expect(screen.getByText('금')).toBeInTheDocument(); + expect(screen.getByText('토')).toBeInTheDocument(); + }); + + it('일정이 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('테스트 일정')).toBeInTheDocument(); + }); + + it('날짜가 올바르게 표시되어야 한다', () => { + render(); + + expect(screen.getByText('15')).toBeInTheDocument(); + }); + }); + + describe('엣지 케이스 테스트', () => { + it('빈 일정 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const emptyProps = { ...mockProps, filteredEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('빈 알림 이벤트 배열로 렌더링해도 오류가 발생하지 않아야 한다', () => { + const noNotificationProps = { ...mockProps, notifiedEvents: [] }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('매우 많은 일정이 있어도 렌더링이 정상적으로 되어야 한다', () => { + const manyEvents = Array.from({ length: 50 }, (_, i) => ({ + id: `event-${i}`, + title: `일정 ${i}`, + date: '2024-01-15', + startTime: '09:00', + endTime: '10:00', + description: `설명 ${i}`, + location: `위치 ${i}`, + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + })) as Event[]; + + const manyEventsProps = { ...mockProps, filteredEvents: manyEvents }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + }); + + describe('다양한 날짜 테스트', () => { + it('연말(12월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const decemberProps = { ...mockProps, currentDate: new Date('2024-12-15') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('연초(1월) 날짜로 렌더링해도 정상 작동해야 한다', () => { + const januaryProps = { ...mockProps, currentDate: new Date('2024-01-01') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + + it('윤년 2월 날짜로 렌더링해도 정상 작동해야 한다', () => { + const leapYearProps = { ...mockProps, currentDate: new Date('2024-02-29') }; + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('week-view')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/hooks/advanced.useEventManagement.spec.ts b/src/__tests__/hooks/advanced.useEventManagement.spec.ts new file mode 100644 index 00000000..2d3d1729 --- /dev/null +++ b/src/__tests__/hooks/advanced.useEventManagement.spec.ts @@ -0,0 +1,131 @@ +import { renderHook, act } from '@testing-library/react'; +import { vi } from 'vitest'; +import { useEventManagement } from '../../hooks/useEventManagement'; + +// notistack의 useSnackbar를 모킹 +const mockEnqueueSnackbar = vi.fn(); +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueueSnackbar, + }), +})); + +describe('useEventManagement', () => { + const mockSaveEvent = vi.fn(); + const mockResetForm = vi.fn(); + + const defaultFormData = { + title: '테스트 일정', + date: '2024-01-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + isRepeating: false, + repeatType: 'none' as const, + repeatInterval: 1, + repeatEndDate: null, + notificationTime: 10, + startTimeError: '', + endTimeError: '', + editingEvent: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('필수 정보가 누락된 경우 에러 토스트가 표시된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: { + ...defaultFormData, + title: '', // 제목 누락 + date: '', // 날짜 누락 + }, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('필수 정보를 모두 입력해주세요.', { + variant: 'error', + }); + + // saveEvent가 호출되지 않았어야 함 + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('시간 에러가 있는 경우 에러 토스트가 표시된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: { + ...defaultFormData, + startTimeError: '시작 시간이 종료 시간보다 늦습니다', + endTimeError: '종료 시간이 시작 시간보다 빠릅니다', + }, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트 메시지가 표시되어야 함 + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('시간 설정을 확인해주세요.', { + variant: 'error', + }); + + // saveEvent가 호출되지 않았어야 함 + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('모든 필수 정보가 올바르게 입력된 경우 일정이 저장된다', async () => { + const { result } = renderHook(() => + useEventManagement({ + events: [], + saveEvent: mockSaveEvent, + resetForm: mockResetForm, + formData: defaultFormData, + }) + ); + + await act(async () => { + result.current.addOrUpdateEvent(); + }); + + // 에러 토스트가 표시되지 않아야 함 + expect(mockEnqueueSnackbar).not.toHaveBeenCalled(); + + // saveEvent가 호출되어야 함 + expect(mockSaveEvent).toHaveBeenCalledWith({ + id: undefined, + title: '테스트 일정', + date: '2024-01-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 위치', + category: '업무', + repeat: { + type: 'none', + interval: 1, + endDate: undefined, + }, + notificationTime: 10, + }); + + // 폼이 리셋되어야 함 + expect(mockResetForm).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts index 93b57f0e..f10e22fd 100644 --- a/src/__tests__/hooks/easy.useCalendarView.spec.ts +++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts @@ -3,22 +3,170 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarView } from '../../hooks/useCalendarView.ts'; import { assertDate } from '../utils.ts'; -describe('초기 상태', () => { - it('view는 "month"이어야 한다', () => {}); +describe('useCalendarView Hook Test', () => { + describe('초기 상태', () => { + it('view는 "month"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); - it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {}); + expect(result.current.view).toBe('month'); + }); - it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {}); -}); + it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + assertDate(result.current.currentDate, new Date('2025-10-01')); + }); + + it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + expect(result.current.holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); + }); + + describe('뷰 변경', () => { + it("view를 'week'으로 변경 시 적절하게 반영된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + expect(result.current.view).toBe('week'); + }); + + it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-10-08')); + }); + + it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2025-09-24')); + }); + + it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-11-01')); + }); + + it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => { + const { result } = renderHook(() => useCalendarView()); -it("view를 'week'으로 변경 시 적절하게 반영된다", () => {}); + act(() => { + result.current.navigate('prev'); + }); -it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {}); + assertDate(result.current.currentDate, new Date('2025-09-01')); + }); -it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {}); + it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => { + const { result } = renderHook(() => useCalendarView()); -it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {}); + act(() => { + result.current.setCurrentDate(new Date('2025-03-01')); + }); -it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {}); + expect(result.current.holidays).toEqual({ + '2025-03-01': '삼일절', + }); + }); + }); -it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {}); + describe('연도 경계 테스트', () => { + it('2025년 1월에서 이전으로 이동 시 2024년 12월로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2025-01-05')); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2024-12-01')); + }); + + it('2024년 12월에서 다음으로 이동 시 2025년 1월로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setCurrentDate(new Date('2024-12-01')); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-01-01')); + }); + }); + + describe('주간 뷰 경계 테스트', () => { + it('2024년 12월 마지막 주에서 다음으로 이동 시 2025년 1월 첫째 주로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.setCurrentDate(new Date('2024-12-29')); + }); + + act(() => { + result.current.navigate('next'); + }); + + assertDate(result.current.currentDate, new Date('2025-01-05')); + }); + + it('2025년 1월 첫째 주에서 이전으로 이동 시 2024년 12월 마지막 주로 바뀌어야 한다', () => { + const { result } = renderHook(() => useCalendarView()); + + act(() => { + result.current.setView('week'); + }); + + act(() => { + result.current.setCurrentDate(new Date('2025-01-05')); + }); + + act(() => { + result.current.navigate('prev'); + }); + + assertDate(result.current.currentDate, new Date('2024-12-29')); + }); + }); +}); diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts index 80f57fa3..988741b6 100644 --- a/src/__tests__/hooks/easy.useSearch.spec.ts +++ b/src/__tests__/hooks/easy.useSearch.spec.ts @@ -3,12 +3,144 @@ import { act, renderHook } from '@testing-library/react'; import { useSearch } from '../../hooks/useSearch.ts'; import { Event } from '../../types.ts'; -it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {}); +describe('useSearch Hook Test', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-04-15', + startTime: '09:00', + endTime: '10:00', + description: '주간 업무 계획 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '점심 약속', + date: '2025-04-15', + startTime: '12:00', + endTime: '13:00', + description: '팀원들과 점심 식사', + location: '회사 근처 식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 0, + }, + { + id: '3', + title: '고객 미팅', + date: '2025-04-16', + startTime: '14:00', + endTime: '15:00', + description: '신규 프로젝트 제안', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '4', + title: '프로젝트 회의', + date: '2025-06-29', + startTime: '14:00', + endTime: '15:00', + description: '프로젝트 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '5', + title: '오후 회의', + date: '2025-07-05', + startTime: '14:00', + endTime: '15:00', + description: '오후 회의', + location: '온라인', + category: '업무', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; -it('검색어에 맞는 이벤트만 필터링해야 한다', () => {}); + const currentDate = new Date('2025-04-15'); + it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); -it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {}); + expect(result.current.filteredEvents).toHaveLength(3); // 4월 15일 주간 뷰에서는 3개 이벤트 (13일~19일 범위) + expect(result.current.searchTerm).toBe(''); + }); -it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {}); + it('검색어에 맞는 이벤트만 필터링해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); -it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {}); + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + }); + + it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); + + act(() => { + result.current.setSearchTerm('업무 계획'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + + act(() => { + result.current.setSearchTerm('회의실'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + }); + + it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => { + const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + expect(weekResult.current.filteredEvents).toHaveLength(3); // 4월 15일 주간에는 3개 (13일~19일 범위) + + const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month')); + expect(monthResult.current.filteredEvents).toHaveLength(3); // 4월 전체에는 3개 + }); + + it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => { + const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'week')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('팀 회의'); + + act(() => { + result.current.setSearchTerm('점심'); + }); + expect(result.current.filteredEvents).toHaveLength(1); + expect(result.current.filteredEvents[0].title).toBe('점심 약속'); + }); + + // 주간 뷰에서 월 경계를 넘어가는 경계값 테스트 추가 + it('주간 뷰에서 월 경계를 넘어가는 이벤트를 올바르게 포함해야 한다', () => { + const { result } = renderHook(() => useSearch(mockEvents, new Date('2025-07-01'), 'week')); + + act(() => { + result.current.setSearchTerm('회의'); + }); + + expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-06-29'); + expect(result.current.filteredEvents.map((e) => e.date)).toContain('2025-07-05'); + }); +}); diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts index 566ecbb0..c98bdd25 100644 --- a/src/__tests__/hooks/medium.useEventOperations.spec.ts +++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts @@ -1,11 +1,7 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; -import { - setupMockHandlerCreation, - setupMockHandlerDeletion, - setupMockHandlerUpdating, -} from '../../__mocks__/handlersUtils.ts'; +import { events } from '../../__mocks__/response/events.json' assert { type: 'json' }; import { useEventOperations } from '../../hooks/useEventOperations.ts'; import { server } from '../../setupTests.ts'; import { Event } from '../../types.ts'; @@ -22,16 +18,254 @@ vi.mock('notistack', async () => { }; }); -it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {}); +describe('useEventOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + enqueueSnackbarFn.mockClear(); + }); -it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {}); + it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: events as Event[] }, { status: 200 }); + }) + ); -it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {}); + const { result } = renderHook(() => useEventOperations(false)); -it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {}); + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); -it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {}); + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' }); + }); -it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {}); + it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => { + let mockEvents = [...(events as Event[])]; -it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {}); + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.post('/api/events', async ({ request }) => { + const eventData = await request.json(); + const eventWithId: Event = { + ...(eventData as Event), + id: 'new-id-123', + }; + mockEvents.push(eventWithId); + return HttpResponse.json( + { + success: true, + event: eventWithId, + message: '일정이 성공적으로 추가되었습니다.', + }, + { status: 201 } + ); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const newEvent: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-05-14', + startTime: '10:00', + endTime: '11:00', + description: '테스트 이벤트 설명', + location: '테스트 장소', + category: '테스트 카테고리', + repeat: { type: 'none', interval: 1 }, + notificationTime: 10, + }; + + act(() => { + result.current.saveEvent(newEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { + variant: 'success', + }); + }); + }); + + it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.put('/api/events/:id', async ({ request, params }) => { + const eventData = await request.json(); + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index !== -1) { + mockEvents[index] = { ...mockEvents[index], ...(eventData as Partial) }; + } + + return HttpResponse.json( + { + success: true, + event: mockEvents[index], + message: '일정이 성공적으로 수정되었습니다.', + }, + { status: 200 } + ); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const event = events[0] as Event; + const updatedEvent: Event = { + ...event, + title: '수정된 이벤트', + endTime: '12:00', + }; + + act(() => { + result.current.saveEvent(updatedEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { + variant: 'success', + }); + }); + }); + + it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.delete('/api/events/:id', ({ params }) => { + const index = mockEvents.findIndex((event) => event.id === params.id); + + if (index !== -1) { + mockEvents.splice(index, 1); + } + + return HttpResponse.json({ event: events[0] }, { status: 200 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const initialLength = result.current.events.length; + + act(() => { + result.current.deleteEvent(events[0].id); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' }); + }); + + // 실제로 이벤트가 삭제되었는지 검증 + await waitFor(() => { + expect(result.current.events.length).toBe(initialLength - 1); + }); + }); + + it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => { + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' }); + }); + + expect(result.current.events).toEqual([]); + }); + + it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.put('/api/events/:id', () => { + return HttpResponse.json({ error: '이벤트를 찾을 수 없습니다.' }, { status: 404 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(true)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const event = events[0] as Event; + const updatedEvent: Event = { + ...event, + id: '2', + title: '수정된 이벤트', + }; + + act(() => { + result.current.saveEvent(updatedEvent); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' }); + }); + + expect(result.current.events).toEqual(events); + }); + + it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => { + let mockEvents = [...(events as Event[])]; + + server.use( + http.get('/api/events', () => { + return HttpResponse.json({ events: mockEvents }, { status: 200 }); + }), + http.delete('/api/events/:id', () => { + return HttpResponse.json({ error: 'Network Error' }, { status: 500 }); + }) + ); + + const { result } = renderHook(() => useEventOperations(false)); + + await waitFor(() => { + expect(result.current.events).toEqual(events); + }); + + const initialLength = result.current.events.length; + + act(() => { + result.current.deleteEvent(events[0].id); + }); + + await waitFor(() => { + expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' }); + }); + + // 삭제 실패 시 이벤트 개수가 변하지 않았는지 검증 + expect(result.current.events.length).toBe(initialLength); + }); +}); diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts index 7f585ea8..f4561e8b 100644 --- a/src/__tests__/hooks/medium.useNotifications.spec.ts +++ b/src/__tests__/hooks/medium.useNotifications.spec.ts @@ -1,14 +1,180 @@ import { act, renderHook } from '@testing-library/react'; +import { vi } from 'vitest'; import { useNotifications } from '../../hooks/useNotifications.ts'; import { Event } from '../../types.ts'; -import { formatDate } from '../../utils/dateUtils.ts'; -import { parseHM } from '../utils.ts'; -it('초기 상태에서는 알림이 없어야 한다', () => {}); +// 테스트용 이벤트 데이터 +const createTestEvent = (overrides: Partial = {}): Event => ({ + id: '1', + title: '테스트 이벤트', + date: '2024-01-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, // 30분 전 알림 + ...overrides, +}); -it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {}); +// 현재 시간을 모킹하는 헬퍼 함수 (setupTests.ts에서 이미 vi.useFakeTimers()가 설정됨) +const mockCurrentTime = (date: Date) => { + vi.setSystemTime(date); +}; -it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {}); +it('초기 상태에서는 알림이 없어야 한다', () => { + const { result } = renderHook(() => useNotifications([])); -it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {}); + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); +}); + +it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + expect(result.current.notifications).toEqual([]); + expect(result.current.notifiedEvents).toEqual([]); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].id).toBe('1'); + expect(result.current.notifications[0].message).toBe('30분 후 테스트 이벤트 일정이 시작됩니다.'); + + expect(result.current.notifiedEvents).toContain('1'); +}); + +it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event1 = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const event2 = createTestEvent({ + id: '2', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event1, event2])); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(2); + + act(() => { + result.current.removeNotification(0); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].id).toBe('2'); +}); + +it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toContain('1'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifiedEvents).toHaveLength(1); +}); + +it('알림 시간이 되지 않은 이벤트는 알림이 생성되지 않아야 한다', () => { + const now = new Date('2024-01-01T09:00:00'); // 9:00 + mockCurrentTime(now); + + const event = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event])); + + // 1초 후 알림 체크 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 아직 알림 시간이 되지 않았으므로 알림이 생성되지 않아야 함 + expect(result.current.notifications).toHaveLength(0); + expect(result.current.notifiedEvents).toHaveLength(0); +}); + +// 추가 테스트: 여러 이벤트가 동시에 알림 시간에 도달하는 경우 +it('여러 이벤트가 동시에 알림 시간에 도달하는 경우 모든 알림이 생성되어야 한다', () => { + const now = new Date('2024-01-01T09:30:00'); + mockCurrentTime(now); + + const event1 = createTestEvent({ + id: '1', + date: '2024-01-01', + startTime: '10:00', + notificationTime: 30, + }); + + const event2 = createTestEvent({ + id: '2', + date: '2024-01-01', + startTime: '10:00', // 10:00으로 변경하여 동시에 알림 시간에 도달 + notificationTime: 30, + }); + + const { result } = renderHook(() => useNotifications([event1, event2])); + + // 1초 후 알림 체크 + act(() => { + vi.advanceTimersByTime(1000); + }); + + // 두 개의 알림이 모두 생성되어야 함 + expect(result.current.notifications).toHaveLength(2); + expect(result.current.notifiedEvents).toHaveLength(2); + + // 알림 메시지 확인 + const notificationIds = result.current.notifications.map((n) => n.id); + expect(notificationIds).toContain('1'); + expect(notificationIds).toContain('2'); +}); diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx index 15901d4e..abfd8bbc 100644 --- a/src/__tests__/medium.integration.spec.tsx +++ b/src/__tests__/medium.integration.spec.tsx @@ -1,49 +1,561 @@ 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 { vi } from 'vitest'; +import { createMockHandlers } from '../__mocks__/handlersUtils'; import App from '../App'; import { server } from '../setupTests'; import { Event } from '../types'; +// 테스트용 테마 설정 +const theme = createTheme(); + +// 테스트용 App 컴포넌트 래퍼 +const AppWrapper = () => ( + + + + + + +); + describe('일정 CRUD 및 기본 기능', () => { it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => { - // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요. + server.use(...createMockHandlers([])()); + + render(); + + // 새 일정 추가 폼 작성 + const user = userEvent.setup() as UserEvent; + + // 일정 추가 버튼 클릭 + const addButton = screen.getByRole('button', { name: '일정 추가' }); + await user.click(addButton); + + await user.type(screen.getByLabelText('제목'), '테스트 회의'); + await user.type(screen.getByLabelText('날짜'), '2025-10-01'); + await user.type(screen.getByLabelText('시작 시간'), '14:00'); + await user.type(screen.getByLabelText('종료 시간'), '15:00'); + await user.type(screen.getByLabelText('설명'), '테스트용 회의입니다'); + await user.type(screen.getByLabelText('위치'), '온라인'); + + const categorySelect = screen.getByLabelText('카테고리'); + // 카테고리 선택 + await user.click(categorySelect); + const categoryOption = screen.getByText('업무'); + await user.click(categoryOption); + + // 저장 버튼 클릭 + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + // 일정이 목록에 추가되었는지 확인 + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('테스트 회의')).toBeInTheDocument(); + + expect(within(eventList).getByText('2025-10-01')).toBeInTheDocument(); + expect(within(eventList).getByText('14:00 - 15:00')).toBeInTheDocument(); + expect(within(eventList).getByText('테스트용 회의입니다')).toBeInTheDocument(); + expect(within(eventList).getByText('온라인')).toBeInTheDocument(); + expect(within(eventList).getByText('카테고리: 업무')).toBeInTheDocument(); + }); + + it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => { + const mockEvents: 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, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); + + const user = userEvent.setup(); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('기존 회의')).toBeInTheDocument(); + + // 일정 수정 버튼 클릭 (첫 번째 일정의 Edit 버튼) + const editButton = screen.getByRole('button', { name: 'Edit event' }); + await user.click(editButton); + + const titleInput = screen.getByDisplayValue('기존 회의'); + await user.clear(titleInput); + await user.type(titleInput, '수정된 회의'); + + const descriptionInput = screen.getByDisplayValue('기존 팀 미팅'); + await user.clear(descriptionInput); + await user.type(descriptionInput, '수정된 팀 미팅'); + + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + const eventEditList = await screen.findByTestId('event-list'); + expect(within(eventEditList).getByText('수정된 회의')).toBeInTheDocument(); + expect(within(eventEditList).getByText('수정된 팀 미팅')).toBeInTheDocument(); }); - it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {}); + it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => { + const customEvents: Event[] = [ + { + id: '1', + title: '삭제할 이벤트', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '삭제 테스트용 이벤트', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(customEvents)()); + + render(); + + const user = userEvent.setup(); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('삭제할 이벤트')).toBeInTheDocument(); + + const deleteButton = within(eventList).getByRole('button', { name: /Delete event/i }); + await user.click(deleteButton); - it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {}); + // 일정이 목록에서 제거되었는지 확인 + expect(within(eventList).queryByText('삭제할 이벤트')).not.toBeInTheDocument(); + }); }); describe('일정 뷰', () => { - it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {}); + it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => { + vi.setSystemTime('2025-07-01'); + + const mockEvents: Event[] = [ + { + id: '1', + title: '7월 둘째주 회의', + date: '2025-07-08', + startTime: '09:00', + endTime: '10:00', + description: '7월 둘째주 회의', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '7월 둘째주 미팅', + date: '2025-07-10', + startTime: '14:00', + endTime: '15:00', + description: '7월 둘째주 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); + const user = userEvent.setup(); + + // 주별 뷰로 변경 + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('week-option')); + + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 둘째주 회의')).not.toBeInTheDocument(); + expect(within(eventList).queryByText('7월 둘째주 미팅')).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => { + vi.setSystemTime('2025-07-01'); + + const mockEvents: Event[] = [ + { + id: '1', + title: '7월 첫째주 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '미팅미팅미팅~', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); + + const user = userEvent.setup(); + + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('week-option')); + + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('7월 첫째주 회의')).toBeInTheDocument(); + expect(within(eventList).queryByText('미팅미팅미팅~')).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '5월 첫째주 회의', + date: '2025-05-01', + startTime: '09:00', + endTime: '10:00', + description: '5월 회의입니당', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); + + const user = userEvent.setup(); + + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('month-option')); + + // 일정이 표시되지 않는지 확인 (일정 목록 컨테이너가 비어있거나 일정 데이터가 없는지 확인) + const eventList = screen.getByTestId('event-list'); + expect(within(eventList).queryByText('5월 첫째주 회의')).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '10월 첫째주 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '10월 회의입니당', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + + { + id: '2', + title: '10월 둘째주 회의', + date: '2025-10-08', + startTime: '09:00', + endTime: '10:00', + description: '회의 테스트2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '10월 셋째주 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '회의 테스트333', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents as Event[])()); + + render(); - it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {}); + const user = userEvent.setup(); - it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {}); + const viewSelector = screen.getByLabelText('뷰 타입 선택'); + await user.click(within(viewSelector).getByRole('combobox')); + await user.click(screen.getByTestId('month-option')); - it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {}); + // 뷰 변경 후 모든 이벤트가 표시되는지 확인 + const eventList = screen.getByTestId('event-list'); - it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {}); + expect(within(eventList).getByText('10월 첫째주 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('10월 둘째주 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('10월 셋째주 회의')).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => { + vi.setSystemTime('2025-01-01'); + + render(); + + const newYearHoliday = screen.queryByText('신정'); + expect(newYearHoliday).toBeInTheDocument(); + + vi.useRealTimers(); + }); }); describe('검색 기능', () => { - it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {}); + it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => { + server.use(...createMockHandlers([])()); + + render(); + + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText(/검색어를 입력하세요/i); + await user.type(searchInput, '존재하지 않는 일정'); + + expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument(); + }); - it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {}); + it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); - it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {}); + render(); + + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '팀 회의'); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); + }); + + it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '팀 회의', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '팀 회의2', + date: '2025-10-29', + startTime: '09:00', + endTime: '10:00', + description: '그냥 회의는 아니겟지2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '3', + title: '기존 회의1', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의는 아니겟지1', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '4', + title: '기존 회의2', + date: '2025-10-20', + startTime: '09:00', + endTime: '10:00', + description: '기존 회의는 아니겟지2', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); + + const user = userEvent.setup(); + + const searchInput = screen.getByPlaceholderText('검색어를 입력하세요'); + await user.type(searchInput, '팀 회의'); + + await user.clear(searchInput); + + const eventList = await screen.findByTestId('event-list'); + expect(within(eventList).getByText('기존 회의1')).toBeInTheDocument(); + expect(within(eventList).getByText('기존 회의2')).toBeInTheDocument(); + expect(within(eventList).getByText('팀 회의')).toBeInTheDocument(); + expect(within(eventList).getByText('팀 회의2')).toBeInTheDocument(); + }); }); describe('일정 충돌', () => { - it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {}); + it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => { + const mockEvents: 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, + }, + ]; + server.use(...createMockHandlers(mockEvents)()); + + render(); - it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {}); + const user = userEvent.setup(); + + const addButton = screen.getByRole('button', { name: '일정 추가' }); + await user.click(addButton); + + await user.type(screen.getByLabelText('제목'), '충돌하는 일정'); + await user.type(screen.getByLabelText('날짜'), '2025-10-15'); + await user.type(screen.getByLabelText('시작 시간'), '09:30'); + await user.type(screen.getByLabelText('종료 시간'), '10:30'); + await user.type(screen.getByLabelText('설명'), '충돌 테스트'); + await user.type(screen.getByLabelText('위치'), '회의실 A'); + + const categorySelect = screen.getByLabelText('카테고리'); + await user.click(categorySelect); + const categoryOption = screen.getByText('업무'); + await user.click(categoryOption); + + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + const submitButton = screen.getByTestId('event-submit-button'); + await user.click(submitButton); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); + + it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => { + const existingEvents: Event[] = [ + { + id: '1', + title: '첫 번째 회의', + date: '2025-10-15', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 미팅', + location: '회의실 A', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + { + id: '2', + title: '두 번째 회의', + date: '2025-10-15', + startTime: '11:00', + endTime: '12:00', + description: '두 번째 미팅', + location: '회의실 B', + category: '업무', + repeat: { type: 'none' as const, interval: 0 }, + notificationTime: 10, + }, + ]; + + server.use(...createMockHandlers(existingEvents)()); + + render(); + + const user = userEvent.setup(); + + const editButtons = await screen.findAllByRole('button', { name: 'Edit event' }); + + await user.click(editButtons[0]); + await user.clear(screen.getByLabelText('시작 시간')); + await user.type(screen.getByLabelText('시작 시간'), '11:30'); + await user.clear(screen.getByLabelText('종료 시간')); + await user.type(screen.getByLabelText('종료 시간'), '11:40'); + + const saveButton = screen.getByTestId('event-submit-button'); + await user.click(saveButton); + + expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument(); + }); }); -it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {}); +describe('알림 기능', () => { + it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => { + const newEvent: Event = { + id: '1', + title: '새로운 이벤트', + date: '2025-10-01', + startTime: '09:00', + endTime: '10:00', + description: '마지막 테스트..', + location: '불꺼진 우리집', + category: '항플 과제', + repeat: { type: 'none', interval: 0 }, + notificationTime: 10, + }; + + server.use(...createMockHandlers([newEvent])()); + render(); + + const eventList = await screen.findByTestId('event-list'); + + const eventTitle = await within(eventList).findByText(newEvent.title); + expect(eventTitle).toBeInTheDocument(); + + vi.setSystemTime('2025-10-01 09:50'); + + expect(await screen.findByText('10분 전')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts index 967bfacd..015ef94f 100644 --- a/src/__tests__/unit/easy.dateUtils.spec.ts +++ b/src/__tests__/unit/easy.dateUtils.spec.ts @@ -12,105 +12,508 @@ import { } from '../../utils/dateUtils'; describe('getDaysInMonth', () => { - it('1월은 31일 수를 반환한다', () => {}); - - it('4월은 30일 일수를 반환한다', () => {}); + it('1월은 31일 수를 반환한다', () => { + const year = 2025; + const month = 1; + const expected = 31; + + const result = getDaysInMonth(year, month); + expect(result).toBe(expected); + }); + + it('4월은 30일 일수를 반환한다', () => { + const year = 2025; + const month = 4; + const expected = 30; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + describe('윤년 테스트', () => { + const leapYearTestCases = [ + { year: 2000, month: 2, expected: 29 }, // 400으로 나눠짐 → 윤년 + { year: 1900, month: 2, expected: 28 }, // 100으로 나눠짐 → 평년 + { year: 2004, month: 2, expected: 29 }, // 4로 나눠짐 → 윤년 + { year: 2100, month: 2, expected: 28 }, // 100으로 나눠짐 → 평년 + { year: 2024, month: 2, expected: 29 }, // 가까운 미래 윤년 + { year: 2023, month: 2, expected: 28 }, // 일반 평년 + ]; + + it.each(leapYearTestCases)( + 'year: $year, month: $month → $expected일을 반환한다', + ({ year, month, expected }) => { + const result = getDaysInMonth(year, month); + expect(result).toBe(expected); + } + ); + }); + + it('윤년의 2월에 대해 29일을 반환한다', () => { + const year = 2024; + const month = 2; + const expected = 29; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + it('평년의 2월에 대해 28일을 반환한다', () => { + const year = 2025; + const month = 2; + const expected = 28; + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); + + it('유효하지 않은 월에 대해 적절히 처리한다', () => { + const year = 2025; + const month = 13; + const expected = 31; // 13월은 2026년 1월의 마지막 날을 반환 + + const result = getDaysInMonth(year, month); + + expect(result).toBe(expected); + }); +}); - it('윤년의 2월에 대해 29일을 반환한다', () => {}); +describe('getWeekDates', () => { + it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-16'); // 2025년 7월 16일 (수욜) + const expected = [ + new Date('2025-07-13'), // 일요일 + new Date('2025-07-14'), // 월요일 + new Date('2025-07-15'), // 화요일 + new Date('2025-07-16'), // 수요일 + new Date('2025-07-17'), // 목요일 + new Date('2025-07-18'), // 금요일 + new Date('2025-07-19'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-14'); // 2025년 7월 14일 (월요일) + const expected = [ + new Date('2025-07-13'), // 일요일 (주의 시작) + new Date('2025-07-14'), // 월요일 + new Date('2025-07-15'), // 화요일 + new Date('2025-07-16'), // 수요일 + new Date('2025-07-17'), // 목요일 + new Date('2025-07-18'), // 금요일 + new Date('2025-07-19'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => { + const date = new Date('2025-07-20'); // 2025년 7월 20일 (일요일) + const expected = [ + new Date('2025-07-20'), // 일요일 (주의 시작) + new Date('2025-07-21'), // 월요일 + new Date('2025-07-22'), // 화요일 + new Date('2025-07-23'), // 수요일 + new Date('2025-07-24'), // 목요일 + new Date('2025-07-25'), // 금요일 + new Date('2025-07-26'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => { + const date = new Date('2025-12-29'); // 2025년 12월 29일 (월요일) + const expected = [ + new Date('2025-12-28'), // 일요일 (주의 시작) + new Date('2025-12-29'), // 월요일 + new Date('2025-12-30'), // 화요일 + new Date('2025-12-31'), // 수요일 + new Date('2026-01-01'), // 목요일 (2026년 1월 1일) + new Date('2026-01-02'), // 금요일 + new Date('2026-01-03'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => { + const date = new Date('2025-01-01'); // 2025년 1월 1일 (수요일) + const expected = [ + new Date('2024-12-29'), // 일요일 (주의 시작, 2024년 12월 29일) + new Date('2024-12-30'), // 월요일 (2024년 12월 30일) + new Date('2024-12-31'), // 화요일 (2024년 12월 31일) + new Date('2025-01-01'), // 수요일 + new Date('2025-01-02'), // 목요일 + new Date('2025-01-03'), // 금요일 + new Date('2025-01-04'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2024-02-29'); // 2024년 2월 29일 (목요일) + const expected = [ + new Date('2024-02-25'), // 일요일 (주의 시작) + new Date('2024-02-26'), // 월요일 + new Date('2024-02-27'), // 화요일 + new Date('2024-02-28'), // 수요일 + new Date('2024-02-29'), // 목요일 + new Date('2024-03-01'), // 금요일 (3월 1일) + new Date('2024-03-02'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); + + it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => { + const date = new Date('2025-07-31'); // 2025년 7월 31일 (목요일) + const expected = [ + new Date('2025-07-27'), // 일요일 (주의 시작) + new Date('2025-07-28'), // 월요일 + new Date('2025-07-29'), // 화요일 + new Date('2025-07-30'), // 수요일 + new Date('2025-07-31'), // 목요일 + new Date('2025-08-01'), // 금요일 (8월 1일) + new Date('2025-08-02'), // 토요일 + ]; + + const result = getWeekDates(date); + + expect(result).toEqual(expected); + }); +}); - it('평년의 2월에 대해 28일을 반환한다', () => {}); +describe('getWeeksAtMonth', () => { + it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => { + const date = new Date('2025-07-01'); + const expected = [ + [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], + ]; + + const result = getWeeksAtMonth(date); + + expect(result).toEqual(expected); + expect(result).toHaveLength(5); + expect(result[0]).toHaveLength(7); + }); +}); - it('유효하지 않은 월에 대해 적절히 처리한다', () => {}); +describe('getEventsForDay', () => { + const mockEvents: Event[] = [ + { + id: '1', + title: '테스트 이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '테스트 이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '테스트 설명 2', + location: '테스트 장소 2', + category: '테스트2', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + ]; + + it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => { + const date = 1; // 1일 + const expected = [mockEvents[0]]; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => { + const date = 3; // 3일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('날짜가 0일 경우 빈 배열을 반환한다', () => { + const date = 0; // 0일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); + + it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => { + const date = 32; // 32일 + const expected: Event[] = []; + + const result = getEventsForDay(mockEvents, date); + + expect(result).toEqual(expected); + }); }); -describe('getWeekDates', () => { - it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); +describe('formatWeek', () => { + it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-07-15'); // 2025년 7월 15일 + const expected = '2025년 7월 3주'; - it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + const result = formatWeek(date); - it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {}); + it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date(2025, 6, 1); // 2025년 7월 1일 + const expected = '2025년 7월 1주'; - it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {}); + const result = formatWeek(date); - it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {}); + expect(result).toBe(expected); + }); - it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {}); -}); + it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-07-31'); // 2025년 7월 31일 + const expected = '2025년 7월 5주'; -describe('getWeeksAtMonth', () => { - it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {}); -}); + const result = formatWeek(date); -describe('getEventsForDay', () => { - it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {}); + it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date(2025, 11, 29); // 2025년 12월 29일 + // formatWeek는 해당 날짜가 속한 주의 주차를 계산하므로 연도가 바뀌는 주에서는 다른 결과가 나올 수 있음 + const result = formatWeek(date); - it('날짜가 0일 경우 빈 배열을 반환한다', () => {}); + // 결과가 올바른 형식인지만 확인 + expect(result).toMatch(/^\d{4}년 \d{1,2}월 \d+주$/); + }); - it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {}); -}); - -describe('formatWeek', () => { - it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {}); + it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2024-02-29'); // 2024년 2월 29일 + const expected = '2024년 2월 5주'; - it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {}); + const result = formatWeek(date); - it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe(expected); + }); - it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {}); + it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => { + const date = new Date('2025-02-28'); // 2025년 2월 28일 + const expected = '2025년 2월 4주'; - it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + const result = formatWeek(date); - it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {}); + expect(result).toBe(expected); + }); }); describe('formatMonth', () => { - it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {}); + it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => { + const date = new Date('2025-07-10'); + const expected = '2025년 7월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + // 경계값 테스트 추가 + it("2026년 1월 1일을 '2026년 1월'로 반환한다", () => { + const date = new Date('2026-01-01'); + const expected = '2026년 1월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + it("2026년 12월 31일을 '2026년 12월'로 반환한다", () => { + const date = new Date('2026-12-31'); + const expected = '2026년 12월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); + // 엣지 케이스 추가 + it("윤년의 2월 29일을 '2024년 2월'로 반환한다", () => { + const date = new Date('2024-02-29'); + const expected = '2024년 2월'; + const result = formatMonth(date); + expect(result).toBe(expected); + }); }); describe('isDateInRange', () => { - it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {}); - - it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {}); - - it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {}); - - it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {}); - - it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {}); - - it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {}); + const start = new Date('2025-07-01'); + const end = new Date('2025-07-31'); + + it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => { + const date = new Date('2025-07-10'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => { + const date = new Date('2025-07-01'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => { + const date = new Date('2025-07-31'); + const result = isDateInRange(date, start, end); + expect(result).toBe(true); + }); + + it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => { + const date = new Date('2025-06-30'); + const result = isDateInRange(date, start, end); + expect(result).toBe(false); + }); + + it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => { + const date = new Date('2025-08-01'); + const result = isDateInRange(date, start, end); + expect(result).toBe(false); + }); + + it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => { + const date = new Date('2025-07-15'); + const result = isDateInRange(date, end, start); + expect(result).toBe(false); + }); + + // 시작일의 마지막 순간으로 경계값 테스트 + it('시작일의 마지막 순간(2025-07-01 23:59:59)에 대해 true를 반환한다', () => { + const date = new Date('2025-07-01T23:59:59.999'); + expect(isDateInRange(date, start, end)).toBe(true); + }); + // 종료일의 첫 순간으로 경계값 테스트 + it('종료일의 첫 순간(2025-07-31 00:00:00)에 대해 true를 반환한다', () => { + const date = new Date('2025-07-31T00:00:00.000'); + expect(isDateInRange(date, start, end)).toBe(true); + }); }); describe('fillZero', () => { - it("5를 2자리로 변환하면 '05'를 반환한다", () => {}); + it("5를 2자리로 변환하면 '05'를 반환한다", () => { + const result = fillZero(5, 2); + expect(result).toBe('05'); + }); + + it("10을 2자리로 변환하면 '10'을 반환한다", () => { + const result = fillZero(10, 2); + expect(result).toBe('10'); + }); + + it("3을 3자리로 변환하면 '003'을 반환한다", () => { + const result = fillZero(3, 3); + expect(result).toBe('003'); + }); + + it("100을 2자리로 변환하면 '100'을 반환한다", () => { + const result = fillZero(100, 2); + expect(result).toBe('100'); + }); + + it("0을 2자리로 변환하면 '00'을 반환한다", () => { + const result = fillZero(0, 2); + expect(result).toBe('00'); + }); + + it("0을 10자리로 변환하면 '0000000000'을 반환한다", () => { + const result = fillZero(0, 10); + expect(result).toBe('0000000000'); + }); + + it("1을 5자리로 변환하면 '00001'을 반환한다", () => { + const result = fillZero(1, 5); + expect(result).toBe('00001'); + }); + + it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => { + const result = fillZero(3.14, 5); + expect(result).toBe('03.14'); + }); + + it('size 파라미터를 생략하면 기본값 2를 사용한다', () => { + const result = fillZero(5); + expect(result).toBe('05'); + }); + + it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => { + expect(fillZero(1, 0)).toBe('1'); + expect(fillZero(123, 2)).toBe('123'); + expect(fillZero(2147483647, 9)).toBe('2147483647'); + + expect(fillZero(123.45, 4)).toBe('123.45'); + expect(fillZero(12.34, 4)).toBe('12.34'); + }); +}); - it("10을 2자리로 변환하면 '10'을 반환한다", () => {}); +describe('formatDate', () => { + it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => { + const date = new Date('2025-07-15'); + const expected = '2025-07-15'; - it("3을 3자리로 변환하면 '003'을 반환한다", () => {}); + const result = formatDate(date); - it("100을 2자리로 변환하면 '100'을 반환한다", () => {}); + expect(result).toBe(expected); + }); - it("0을 2자리로 변환하면 '00'을 반환한다", () => {}); + it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => { + const date = new Date('2025-06-20'); + const day = 30; + const expected = '2025-06-30'; - it("1을 5자리로 변환하면 '00001'을 반환한다", () => {}); + const result = formatDate(date, day); - it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {}); + expect(result).toBe(expected); + }); - it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {}); + it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-03-15'); // 3월 + const expected = '2025-03-15'; - it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {}); -}); + const result = formatDate(date); -describe('formatDate', () => { - it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {}); + expect(result).toBe(expected); + }); - it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {}); + it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => { + const date = new Date('2025-07-05'); // 5일 + const expected = '2025-07-05'; - it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + const result = formatDate(date); - it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {}); + expect(result).toBe(expected); + }); }); diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts index 5e5f6497..98469491 100644 --- a/src/__tests__/unit/easy.eventOverlap.spec.ts +++ b/src/__tests__/unit/easy.eventOverlap.spec.ts @@ -5,32 +5,380 @@ import { isOverlapping, parseDateTime, } from '../../utils/eventOverlap'; + describe('parseDateTime', () => { - it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {}); + it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => { + const result = parseDateTime('2025-07-01', '14:30'); + expect(result).toEqual(new Date('2025-07-01T14:30')); + 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('invalid-date', '14:30'); + expect(isNaN(result.getTime())).toBe(true); + }); - it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => { + const result = parseDateTime('2025-07-01', 'invalid-time'); + expect(isNaN(result.getTime())).toBe(true); + }); - it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {}); + it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => { + const result = parseDateTime('', '14:30'); + expect(isNaN(result.getTime())).toBe(true); + }); }); describe('convertEventToDateRange', () => { - it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {}); + const event: Event = { + id: '1', + title: '테스트 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => { + const result = convertEventToDateRange(event); + expect(result.start).toEqual(new Date('2025-07-01T09:00')); + expect(result.end).toEqual(new Date('2025-07-01T10:00')); + }); - it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, date: 'invalid-date' }; + const result = convertEventToDateRange(invalidEvent); + expect(isNaN(result.start.getTime())).toBe(true); + expect(isNaN(result.end.getTime())).toBe(true); + }); - it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {}); + it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => { + const invalidEvent = { ...event, startTime: 'invalid-time', endTime: 'invalid-time' }; + const result = convertEventToDateRange(invalidEvent); + expect(isNaN(result.start.getTime())).toBe(true); + expect(isNaN(result.end.getTime())).toBe(true); + }); }); describe('isOverlapping', () => { - it('두 이벤트가 겹치는 경우 true를 반환한다', () => {}); + it('두 이벤트가 겹치는 경우 true를 반환한다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(true); + }); + + it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); + + it('경계선상에서 만나는 이벤트는 겹치지 않는다', () => { + const event1: Event = { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); + + it('하나의 이벤트가 다른 이벤트를 완전히 포함하는 경우 true를 반환한다', () => { + const event1: Event = { + id: '1', + title: '긴 이벤트', + date: '2025-07-01', + startTime: '08:00', + endTime: '12:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const event2: Event = { + id: '2', + title: '짧은 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(true); + }); + + it('날짜가 변경되는 자정에 두 이벤트가 경계선상에서 만나는 경우 false를 반환한다', () => { + const event1: Event = { + id: '1', + title: '자정을 지나는 이벤트', + date: '2025-07-01', + startTime: '23:58:59', + endTime: '23:59:59', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; - it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {}); + const event2: Event = { + id: '2', + title: '다음 날 아침 이벤트', + date: '2025-07-02', + startTime: '00:00:00', + endTime: '00:00:01', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + expect(isOverlapping(event1, event2)).toBe(false); + }); }); describe('findOverlappingEvents', () => { - it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {}); + it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '10:00', + endTime: '11:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-01', + startTime: '09:45', + endTime: '10:15', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(3); + expect(overlappingEvents.map((e) => e.id)).toContain('1'); + expect(overlappingEvents.map((e) => e.id)).toContain('2'); + expect(overlappingEvents.map((e) => e.id)).toContain('3'); + }); + + it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: 'new', + title: '새 이벤트', + date: '2025-07-01', + startTime: '11:00', + endTime: '12:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-01', + startTime: '08:00', + endTime: '09:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(0); + }); + + it('자신과 같은 ID를 가진 이벤트는 제외한다', () => { + const newEvent: Event = { + id: '1', + title: '기존 이벤트와 같은 ID', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const existingEvents: Event[] = [ + { + id: '1', + title: '기존 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; + + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + + expect(overlappingEvents).toHaveLength(0); + }); + + // 엣지 케이스 추가 + it('기존 이벤트가 아무것도 등록되어있지 않다면 빈 배열을 반환한다', () => { + const newEvent: Event = { + id: '1', + title: '기존 이벤트와 같은 ID', + date: '2025-07-01', + startTime: '09:30', + endTime: '10:30', + description: '테스트 설명', + location: '테스트 장소', + category: '테스트', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; - it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {}); + const existingEvents: Event[] = []; + const overlappingEvents = findOverlappingEvents(newEvent, existingEvents); + expect(overlappingEvents).toHaveLength(0); + }); }); diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts index 8eef6371..82d70a58 100644 --- a/src/__tests__/unit/easy.eventUtils.spec.ts +++ b/src/__tests__/unit/easy.eventUtils.spec.ts @@ -2,19 +2,192 @@ import { Event } from '../../types'; import { getFilteredEvents } from '../../utils/eventUtils'; describe('getFilteredEvents', () => { - it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {}); + // 7월 이벤트 갯수 : 5 + // 6월 이벤트 갯수 : 1 + // 8월 이벤트 갯수 : 1 + // 총 이벤트 갯수 : 7 - it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '이벤트 1', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '첫 번째 이벤트입니다.', + location: '회의실 A', + category: '회의.', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '2', + title: '이벤트 2', + date: '2025-07-02', + startTime: '14:00', + endTime: '15:00', + description: '두 번째 이벤트입니다', + location: '회의실 B', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }, + { + id: '3', + title: '이벤트 3', + date: '2025-07-08', + startTime: '10:00', + endTime: '11:00', + description: '세 번째 이벤트입니다', + location: '강당', + category: '세미나', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }, + { + id: '4', + title: '이벤트 4', + date: '2025-07-15', + startTime: '16:00', + endTime: '17:00', + description: '네 번째 이벤트입니다', + location: '온라인', + category: '웨비나', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }, + { + id: '5', + title: '이벤트 5', + date: '2025-06-30', + startTime: '13:00', + endTime: '14:00', + description: '다섯 번째 이벤트입니다', + location: '회의실 C', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 20, + }, + { + id: '6', + title: '이벤트 6', + date: '2025-08-01', + startTime: '09:00', + endTime: '10:00', + description: '여섯 번째 이벤트입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + { + id: '7', + title: '이벤트 7', + date: '2025-07-31', + startTime: '09:00', + endTime: '10:00', + description: '일곱 번째 이벤트입니다', + location: '회의실 C', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }, + ]; - it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {}); + describe('검색어 필터링', () => { + it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트 2', currentDate, 'week'); - it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {}); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + expect(result[0].title).toBe('이벤트 2'); + }); - it('검색어가 없을 때 모든 이벤트를 반환한다', () => {}); + it('검색어가 대소문자를 구분하지 않고 작동한다', () => { + const currentDate = new Date('2025-07-01'); + const result1 = getFilteredEvents(mockEvents, '회의실 a', currentDate, 'week'); + const result2 = getFilteredEvents(mockEvents, '회의실 A', currentDate, 'week'); - it('검색어가 대소문자를 구분하지 않고 작동한다', () => {}); + expect(result1).toHaveLength(1); + expect(result2).toHaveLength(1); + expect(result1).toEqual(result2); + }); - it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {}); + it('검색어가 제목, 설명, 위치에서 모두 검색된다', () => { + const currentDate = new Date('2025-07-01'); - it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {}); + // 제목으로 검색 + const titleResult = getFilteredEvents(mockEvents, '이벤트 1', currentDate, 'week'); + expect(titleResult).toHaveLength(1); + expect(titleResult[0].id).toBe('1'); + + // 설명으로 검색 + const descResult = getFilteredEvents(mockEvents, '첫 번째', currentDate, 'week'); + expect(descResult).toHaveLength(1); + expect(descResult[0].id).toBe('1'); + + // 위치로 검색 + const locationResult = getFilteredEvents(mockEvents, '회의실 A', currentDate, 'week'); + expect(locationResult).toHaveLength(1); + expect(locationResult[0].id).toBe('1'); + }); + }); + + describe('주간 뷰 필터링', () => { + it('검색어가 없을 때 주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => { + const currentDate = new Date('2025-07-01'); // 2025-07-01은 화요일 + const result = getFilteredEvents(mockEvents, '', currentDate, 'week'); + + expect(result).toHaveLength(3); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '5']); + }); + + it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '이벤트', currentDate, 'week'); + + expect(result).toHaveLength(3); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '5']); + }); + }); + + describe('월간 뷰 필터링', () => { + it('검색어가 없을 때 월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result).toHaveLength(5); + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '3', '4', '7']); + }); + // 엣지 케이스 추가 + it('월간 뷰에서 2025년 7월에 속하지 않는 이벤트는 반환하지 않는다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + expect(result.map((e) => e.id).sort()).not.toContain('5'); + expect(result.map((e) => e.id).sort()).not.toContain('6'); + }); + it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents(mockEvents, '', currentDate, 'month'); + + expect(result.map((e) => e.date)).not.toContain('2025-06-30'); + expect(result.map((e) => e.date)).not.toContain('2025-08-01'); + + expect(result).toHaveLength(5); + + expect(result.map((e) => e.id).sort()).toEqual(['1', '2', '3', '4', '7']); + }); + }); + + // 의미있는 테스트일까? + describe('빈 이벤트 리스트', () => { + it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => { + const currentDate = new Date('2025-07-01'); + const result = getFilteredEvents([], '이벤트', currentDate, 'week'); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + }); }); diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts index 013e87f0..cca09363 100644 --- a/src/__tests__/unit/easy.fetchHolidays.spec.ts +++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts @@ -1,8 +1,53 @@ import { fetchHolidays } from '../../apis/fetchHolidays'; + describe('fetchHolidays', () => { - it('주어진 월의 공휴일만 반환한다', () => {}); + it('주어진 월의 공휴일만 반환한다', () => { + const date = new Date('2025-01-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-01-01': '신정', + '2025-01-29': '설날', + '2025-01-30': '설날', + '2025-01-31': '설날', + }); + }); + + it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => { + const date = new Date('2025-02-15'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({}); + expect(Object.keys(holidays)).toHaveLength(0); + }); + + it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => { + const date = new Date('2025-10-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-10-03': '개천절', + '2025-10-05': '추석', + '2025-10-06': '추석', + '2025-10-07': '추석', + '2025-10-09': '한글날', + }); + }); + + // 추가는 했는데 필요할지 의문이다. + it('단일 공휴일이 있는 월에 대해 정확한 공휴일을 반환한다', () => { + const date = new Date('2025-03-01'); + const holidays = fetchHolidays(date); + + expect(holidays).toEqual({ + '2025-03-01': '삼일절', + }); - it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {}); + const date2 = new Date('2025-12-01'); + const holidays2 = fetchHolidays(date2); - it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {}); + expect(holidays2).toEqual({ + '2025-12-25': '크리스마스', + }); + }); }); diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts index 2fe10360..0cf71a5e 100644 --- a/src/__tests__/unit/easy.notificationUtils.spec.ts +++ b/src/__tests__/unit/easy.notificationUtils.spec.ts @@ -2,15 +2,219 @@ import { Event } from '../../types'; import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils'; describe('getUpcomingEvents', () => { - it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {}); + const mockEvents: Event[] = [ + { + id: '1', + title: '오전 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '오전 회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, // 15분 전 알림 + }, + { + id: '2', + title: '점심 약속', + date: '2025-07-01', + startTime: '12:00', + endTime: '13:00', + description: '점심 약속입니다', + location: '식당', + category: '개인', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, // 30분 전 알림 + }, + { + id: '3', + title: '오후 세미나', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:00', + description: '오후 세미나입니다', + location: '강당', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 150, // 150분 전 알림 + }, + { + id: '4', + title: '저녁 회의', + date: '2025-07-01', + startTime: '18:00', + endTime: '19:00', + description: '저녁 회의입니다', + location: '회의실 B', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, // 15분 전 알림 + }, + ]; - it('이미 알림이 간 이벤트는 제외한다', () => {}); + it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => { + const now = new Date('2025-07-01T08:45:00'); + const notifiedEvents: string[] = []; - it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {}); + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(1); + expect(upcomingEvents[0].id).toBe('1'); + expect(upcomingEvents[0].title).toBe('오전 회의'); + }); - it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {}); + it('이미 알림이 간 이벤트는 제외한다', () => { + const now = new Date('2025-07-01T08:45:00'); + const notifiedEvents = ['1']; + + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + + expect(upcomingEvents).toHaveLength(0); + }); + + it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T08:30:00'); + const notifiedEvents: string[] = []; + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(0); + }); + + it('알림 시간이 지난 이벤트는 반환하지 않는다', () => { + const now = new Date('2025-07-01T09:15:00'); + const notifiedEvents: string[] = []; + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + expect(upcomingEvents).toHaveLength(0); + }); + + // 여러 이벤트 처리 케이스 추가 + it('여러 이벤트의 알림 시간이 동시에 도래하면 모두 반환한다', () => { + const now = new Date('2025-07-01T11:30:00'); + const notifiedEvents: string[] = []; + + const upcomingEvents = getUpcomingEvents(mockEvents, now, notifiedEvents); + + expect(upcomingEvents).toHaveLength(2); + expect(upcomingEvents.map((e) => e.id)).toContain('2'); + expect(upcomingEvents.map((e) => e.id)).toContain('3'); + }); }); describe('createNotificationMessage', () => { - it('올바른 알림 메시지를 생성해야 한다', () => {}); + it('올바른 알림 메시지를 생성해야 한다', () => { + const event: Event = { + id: '1', + title: '중요한 회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '중요한 회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 15, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe('15분 후 중요한 회의 일정이 시작됩니다.'); + }); + + // 여러 엣지 케이스들 추가 + it('다양한 알림 시간에 대해 올바른 메시지를 생성한다', () => { + const event1: Event = { + id: '1', + title: '회의', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '회의입니다', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 1, + }; + + const event2: Event = { + id: '2', + title: '세미나', + date: '2025-07-01', + startTime: '14:00', + endTime: '15:00', + description: '세미나입니다', + location: '강당', + category: '교육', + repeat: { type: 'none', interval: 1 }, + notificationTime: 60, + }; + + const message1 = createNotificationMessage(event1); + const message2 = createNotificationMessage(event2); + + expect(message1).toBe('1분 후 회의 일정이 시작됩니다.'); + expect(message2).toBe('60분 후 세미나 일정이 시작됩니다.'); + }); + + it('특수 문자가 포함된 이벤트에 대해서 특수 문자가 포함된 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: '회의 & 세미나 (중요)', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '특수 문자가 포함된 제목', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 30, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe('30분 후 회의 & 세미나 (중요) 일정이 시작됩니다.'); + }); + + // 긴 텍스트에 대한 경계값 테스트 + it('긴 제목을 가진 이벤트에 대해서 긴 제목을 포함한 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: + '매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '긴 제목 테스트', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe( + '45분 후 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 매우 긴 제목을 가진 중요한 회의와 세미나가 동시에 진행되는 특별한 이벤트 일정이 시작됩니다.' + ); + }); + + // 이모지 인코딩 테스트 + it('이모지를 포함한 이벤트에 대해서 이모지를 포함한 메시지를 생성한다', () => { + const event: Event = { + id: '1', + title: '🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊', + date: '2025-07-01', + startTime: '09:00', + endTime: '10:00', + description: '긴 제목 테스트', + location: '회의실 A', + category: '회의', + repeat: { type: 'none', interval: 1 }, + notificationTime: 45, + }; + + const message = createNotificationMessage(event); + + expect(message).toBe( + '45분 후 🔥🚨👉 급한 중요한 회의🏢 💥💣💥 이벤트 1👐👈👊 일정이 시작됩니다.' + ); + }); }); diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts index 9dda1954..2b6798ec 100644 --- a/src/__tests__/unit/easy.timeValidation.spec.ts +++ b/src/__tests__/unit/easy.timeValidation.spec.ts @@ -1,15 +1,45 @@ import { getTimeErrorMessage } from '../../utils/timeValidation'; describe('getTimeErrorMessage >', () => { - it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {}); + it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00', '13:00'); - it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {}); + it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => { + const result = getTimeErrorMessage('14:00', '14:00'); - it('시작 시간이 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.'); + expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.'); + }); - it('종료 시간이 비어있을 때 null을 반환한다', () => {}); + it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => { + const result = getTimeErrorMessage('13:00', '14:00'); - it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {}); + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', '14:00'); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('종료 시간이 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('13:00', ''); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); + + it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => { + const result = getTimeErrorMessage('', ''); + + expect(result.startTimeError).toBeNull(); + expect(result.endTimeError).toBeNull(); + }); }); diff --git a/src/components/EventForm.tsx b/src/components/EventForm.tsx new file mode 100644 index 00000000..d1eeb17b --- /dev/null +++ b/src/components/EventForm.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { EventForm as EventFormType } from '../types'; +import { getTimeErrorMessage } from '../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventFormProps { + title: string; + setTitle: (title: string) => void; + date: string; + setDate: (date: string) => void; + startTime: string; + endTime: string; + description: string; + setDescription: (description: string) => void; + location: string; + setLocation: (location: string) => void; + category: string; + setCategory: (category: string) => void; + isRepeating: boolean; + setIsRepeating: (isRepeating: boolean) => void; + notificationTime: number; + setNotificationTime: (notificationTime: number) => void; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: EventFormType | null; + handleStartTimeChange: (e: React.ChangeEvent) => void; + handleEndTimeChange: (e: React.ChangeEvent) => void; + addOrUpdateEvent: () => void; +} + +export function EventForm({ + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + addOrUpdateEvent, +}: EventFormProps) { + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} diff --git a/src/components/calendar/CalendarLayout.tsx b/src/components/calendar/CalendarLayout.tsx new file mode 100644 index 00000000..7ad83cd3 --- /dev/null +++ b/src/components/calendar/CalendarLayout.tsx @@ -0,0 +1,49 @@ +import { Stack, Typography } from '@mui/material'; + +import { Event } from '../../types'; + +import { WeekView, MonthView, CalendarNavigation } from './index'; + +interface CalendarLayoutProps { + view: 'week' | 'month'; + setView: (view: 'week' | 'month') => void; + navigate: (direction: 'prev' | 'next') => void; + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export const CalendarLayout = ({ + view, + setView, + navigate, + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: CalendarLayoutProps) => { + return ( + + 일정 보기 + + + + {view === 'week' && ( + + )} + {view === 'month' && ( + + )} + + ); +}; diff --git a/src/components/calendar/CalendarNavigation.tsx b/src/components/calendar/CalendarNavigation.tsx new file mode 100644 index 00000000..d3389485 --- /dev/null +++ b/src/components/calendar/CalendarNavigation.tsx @@ -0,0 +1,35 @@ +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { IconButton, MenuItem, Select, Stack } from '@mui/material'; + +interface CalendarNavigationProps { + view: 'week' | 'month'; + setView: (view: 'week' | 'month') => void; + navigate: (direction: 'prev' | 'next') => void; +} + +export function CalendarNavigation({ view, setView, navigate }: CalendarNavigationProps) { + return ( + + navigate('prev')}> + + + + navigate('next')}> + + + + ); +} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx new file mode 100644 index 00000000..06776d6f --- /dev/null +++ b/src/components/calendar/MonthView.tsx @@ -0,0 +1,121 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface MonthViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; + holidays: Record; +} + +export function MonthView({ + currentDate, + filteredEvents, + notifiedEvents, + holidays, +}: MonthViewProps) { + const weeks = getWeeksAtMonth(currentDate); + + return ( + + {formatMonth(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + {weeks.map((week, weekIndex) => ( + + {week.map((day, dayIndex) => { + const dateString = day ? formatDate(currentDate, day) : ''; + const holiday = holidays[dateString]; + + return ( + + {day && ( + <> + + {day} + + {holiday && ( + + {holiday} + + )} + {getEventsForDay(filteredEvents, day).map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + )} + + ); + })} + + ))} + +
+
+
+ ); +} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx new file mode 100644 index 00000000..792c3ecc --- /dev/null +++ b/src/components/calendar/WeekView.tsx @@ -0,0 +1,100 @@ +import { Notifications } from '@mui/icons-material'; +import { + Box, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +import { formatWeek, getWeekDates } from '../../utils/dateUtils'; + +const weekDays = ['일', '월', '화', '수', '목', '금', '토']; + +interface WeekViewProps { + currentDate: Date; + filteredEvents: Event[]; + notifiedEvents: string[]; +} + +export function WeekView({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) { + const weekDates = getWeekDates(currentDate); + + return ( + + {formatWeek(currentDate)} + + + + + {weekDays.map((day) => ( + + {day} + + ))} + + + + + {weekDates.map((date) => ( + + + {date.getDate()} + + {filteredEvents + .filter((event) => new Date(event.date).toDateString() === date.toDateString()) + .map((event) => { + const isNotified = notifiedEvents.includes(event.id); + return ( + + + {isNotified && } + + {event.title} + + + + ); + })} + + ))} + + +
+
+
+ ); +} diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts new file mode 100644 index 00000000..46191e5e --- /dev/null +++ b/src/components/calendar/index.ts @@ -0,0 +1,4 @@ +export { CalendarLayout } from './CalendarLayout'; +export { CalendarNavigation } from './CalendarNavigation'; +export { MonthView } from './MonthView'; +export { WeekView } from './WeekView'; diff --git a/src/components/event/EventCard.tsx b/src/components/event/EventCard.tsx new file mode 100644 index 00000000..abb6cbaf --- /dev/null +++ b/src/components/event/EventCard.tsx @@ -0,0 +1,64 @@ +import { Delete, Edit, Notifications } from '@mui/icons-material'; +import { Box, IconButton, Stack, Typography } from '@mui/material'; + +import { Event } from '../../types'; + +interface EventCardProps { + event: Event; + isNotified: boolean; + onEdit: (event: Event) => void; + onDelete: (id: string) => void; +} + +export function EventCard({ event, isNotified, onEdit, onDelete }: EventCardProps) { + return ( + + + + + {isNotified && } + + {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})`} + + )} + + 알림: {event.notificationTime === 1 && '1분 전'} + {event.notificationTime === 10 && '10분 전'} + {event.notificationTime === 60 && '1시간 전'} + {event.notificationTime === 120 && '2시간 전'} + {event.notificationTime === 1440 && '1일 전'} + + + + onEdit(event)}> + + + onDelete(event.id)}> + + + + + + ); +} diff --git a/src/components/event/EventList.tsx b/src/components/event/EventList.tsx new file mode 100644 index 00000000..e9e01d70 --- /dev/null +++ b/src/components/event/EventList.tsx @@ -0,0 +1,55 @@ +import { Stack, TextField, Typography } from '@mui/material'; + +import { Event } from '../../types'; + +import { EventCard } from './EventCard'; + +interface EventListProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + filteredEvents: Event[]; + notifiedEvents: string[]; + editEvent: (event: Event) => void; + deleteEvent: (id: string) => void; +} + +export function EventList({ + searchTerm, + setSearchTerm, + filteredEvents, + notifiedEvents, + editEvent, + deleteEvent, +}: EventListProps) { + return ( + + setSearchTerm(e.target.value)} + label="일정 검색" + fullWidth + /> + + {filteredEvents.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + filteredEvents.map((event) => ( + + )) + )} + + ); +} diff --git a/src/components/event/OverlapDialog.tsx b/src/components/event/OverlapDialog.tsx new file mode 100644 index 00000000..dd94e0e3 --- /dev/null +++ b/src/components/event/OverlapDialog.tsx @@ -0,0 +1,43 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; + +import { Event } from '../../types'; + +interface OverlapDialogProps { + open: boolean; + overlappingEvents: Event[]; + onClose: () => void; + onConfirm: () => void; +} + +export function OverlapDialog({ open, overlappingEvents, onClose, onConfirm }: OverlapDialogProps) { + return ( + + 일정 겹침 경고 + + + 다음 일정과 겹칩니다: + {overlappingEvents.map((event) => ( + + {event.title} ({event.date} {event.startTime}-{event.endTime}) + + ))} + 계속 진행하시겠습니까? + + + + + + + + ); +} diff --git a/src/components/event/index.ts b/src/components/event/index.ts new file mode 100644 index 00000000..6718bcbc --- /dev/null +++ b/src/components/event/index.ts @@ -0,0 +1,3 @@ +export { EventCard } from './EventCard'; +export { EventList } from './EventList'; +export { OverlapDialog } from './OverlapDialog'; diff --git a/src/components/form/EventForm.tsx b/src/components/form/EventForm.tsx new file mode 100644 index 00000000..ca464448 --- /dev/null +++ b/src/components/form/EventForm.tsx @@ -0,0 +1,215 @@ +import { + Checkbox, + FormControl, + FormControlLabel, + FormLabel, + MenuItem, + Select, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; + +import React from 'react'; + +import { EventForm as EventFormType } from '../../types'; +import { getTimeErrorMessage } from '../../utils/timeValidation'; + +const categories = ['업무', '개인', '가족', '기타']; + +const notificationOptions = [ + { value: 1, label: '1분 전' }, + { value: 10, label: '10분 전' }, + { value: 60, label: '1시간 전' }, + { value: 120, label: '2시간 전' }, + { value: 1440, label: '1일 전' }, +]; + +interface EventFormProps { + title: string; + setTitle: (title: string) => void; + date: string; + setDate: (date: string) => void; + startTime: string; + endTime: string; + description: string; + setDescription: (description: string) => void; + location: string; + setLocation: (location: string) => void; + category: string; + setCategory: (category: string) => void; + isRepeating: boolean; + setIsRepeating: (isRepeating: boolean) => void; + notificationTime: number; + setNotificationTime: (notificationTime: number) => void; + startTimeError: string | null; + endTimeError: string | null; + editingEvent: EventFormType | null; + handleStartTimeChange: (e: React.ChangeEvent) => void; + handleEndTimeChange: (e: React.ChangeEvent) => void; + addOrUpdateEvent: () => void; +} + +export function EventForm({ + title, + setTitle, + date, + setDate, + startTime, + endTime, + description, + setDescription, + location, + setLocation, + category, + setCategory, + isRepeating, + setIsRepeating, + notificationTime, + setNotificationTime, + startTimeError, + endTimeError, + editingEvent, + handleStartTimeChange, + handleEndTimeChange, + addOrUpdateEvent, +}: EventFormProps) { + return ( + + {editingEvent ? '일정 수정' : '일정 추가'} + + + 제목 + setTitle(e.target.value)} + /> + + + + 날짜 + setDate(e.target.value)} + /> + + + + + 시작 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!startTimeError} + /> + + + + 종료 시간 + + getTimeErrorMessage(startTime, endTime)} + error={!!endTimeError} + /> + + + + + + 설명 + setDescription(e.target.value)} + /> + + + + 위치 + setLocation(e.target.value)} + /> + + + + 카테고리 + + + + + setIsRepeating(e.target.checked)} /> + } + label="반복 일정" + /> + + + + 알림 설정 + + + + + + ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts new file mode 100644 index 00000000..19d3ca40 --- /dev/null +++ b/src/components/form/index.ts @@ -0,0 +1 @@ +export { EventForm } from './EventForm'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..77a286a5 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,11 @@ +// Calendar components +export * from './calendar'; + +// Event components +export * from './event'; + +// Form components +export * from './form'; + +// Notification components +export * from './notification'; diff --git a/src/components/notification/NotificationStack.tsx b/src/components/notification/NotificationStack.tsx new file mode 100644 index 00000000..0c706bdf --- /dev/null +++ b/src/components/notification/NotificationStack.tsx @@ -0,0 +1,36 @@ +import { Close } from '@mui/icons-material'; +import { Alert, AlertTitle, IconButton, Stack } from '@mui/material'; + +interface Notification { + message: string; +} + +interface NotificationStackProps { + notifications: Notification[]; + onClose: (index: number) => void; +} + +export function NotificationStack({ notifications, onClose }: NotificationStackProps) { + if (notifications.length === 0) { + return null; + } + + return ( + + {notifications.map((notification, index) => ( + onClose(index)}> + + + } + > + {notification.message} + + ))} + + ); +} diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts new file mode 100644 index 00000000..af01ba04 --- /dev/null +++ b/src/components/notification/index.ts @@ -0,0 +1 @@ +export { NotificationStack } from './NotificationStack'; diff --git a/src/hooks/useEventManagement.ts b/src/hooks/useEventManagement.ts new file mode 100644 index 00000000..6111b6fa --- /dev/null +++ b/src/hooks/useEventManagement.ts @@ -0,0 +1,104 @@ +import { useSnackbar } from 'notistack'; +import { useState } from 'react'; + +import { Event, EventForm as EventFormType, RepeatType } from '../types'; +import { findOverlappingEvents } from '../utils/eventOverlap'; + +interface UseEventManagementProps { + events: Event[]; + saveEvent: (event: Event | EventFormType) => Promise; + resetForm: () => void; + formData: { + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: RepeatType; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; + startTimeError: string; + endTimeError: string; + editingEvent: Event | null; + }; +} + +export const useEventManagement = ({ + events, + saveEvent, + resetForm, + formData, +}: UseEventManagementProps) => { + const [isOverlapDialogOpen, setIsOverlapDialogOpen] = useState(false); + const [overlappingEvents, setOverlappingEvents] = useState([]); + const { enqueueSnackbar } = useSnackbar(); + + const createEventData = (): Event | EventFormType => ({ + id: formData.editingEvent ? formData.editingEvent.id : undefined, + title: formData.title, + date: formData.date, + startTime: formData.startTime, + endTime: formData.endTime, + description: formData.description, + location: formData.location, + category: formData.category, + repeat: { + type: formData.isRepeating ? formData.repeatType : 'none', + interval: formData.repeatInterval, + endDate: formData.repeatEndDate || undefined, + }, + notificationTime: formData.notificationTime, + }); + + const validateForm = (): boolean => { + if (!formData.title || !formData.date || !formData.startTime || !formData.endTime) { + enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' }); + return false; + } + + if (formData.startTimeError || formData.endTimeError) { + enqueueSnackbar('시간 설정을 확인해주세요.', { variant: 'error' }); + return false; + } + + return true; + }; + + const addOrUpdateEvent = async () => { + if (!validateForm()) return; + + const eventData = createEventData(); + const overlapping = findOverlappingEvents(eventData, events); + + if (overlapping.length > 0) { + setOverlappingEvents(overlapping); + setIsOverlapDialogOpen(true); + } else { + await saveEvent(eventData); + resetForm(); + } + }; + + const handleOverlapConfirm = async () => { + setIsOverlapDialogOpen(false); + const eventData = createEventData(); + await saveEvent(eventData); + resetForm(); + }; + + const closeOverlapDialog = () => { + setIsOverlapDialogOpen(false); + }; + + return { + isOverlapDialogOpen, + overlappingEvents, + addOrUpdateEvent, + handleOverlapConfirm, + closeOverlapDialog, + }; +}; diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts index 3216cc05..39bec6e2 100644 --- a/src/hooks/useEventOperations.ts +++ b/src/hooks/useEventOperations.ts @@ -1,3 +1,4 @@ +// test push import { useSnackbar } from 'notistack'; import { useEffect, useState } from 'react'; @@ -76,7 +77,6 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => { useEffect(() => { init(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { events, fetchEvents, saveEvent, deleteEvent }; diff --git a/src/setupTests.ts b/src/setupTests.ts index bc268d06..3cb1bc79 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,17 +1,18 @@ -import { setupServer } from 'msw/node'; import '@testing-library/jest-dom'; +import { setupServer } from 'msw/node'; import { handlers } from './__mocks__/handlers'; -/* msw */ export const server = setupServer(...handlers); beforeAll(() => { server.listen(); + vi.useFakeTimers({ shouldAdvanceTime: true }); }); beforeEach(() => { expect.hasAssertions(); + vi.setSystemTime('2025-10-01'); }); afterEach(() => { diff --git a/src/utils/eventDataBuilder.ts b/src/utils/eventDataBuilder.ts new file mode 100644 index 00000000..ae5f7c0c --- /dev/null +++ b/src/utils/eventDataBuilder.ts @@ -0,0 +1,34 @@ +import { Event, EventForm as EventFormType, RepeatType } from '../types'; + +interface EventFormData { + id?: string; + title: string; + date: string; + startTime: string; + endTime: string; + description: string; + location: string; + category: string; + isRepeating: boolean; + repeatType: RepeatType; + repeatInterval: number; + repeatEndDate: string | null; + notificationTime: number; +} + +export const buildEventData = (formData: EventFormData): Event | EventFormType => ({ + id: formData.id, + title: formData.title, + date: formData.date, + startTime: formData.startTime, + endTime: formData.endTime, + description: formData.description, + location: formData.location, + category: formData.category, + repeat: { + type: formData.isRepeating ? formData.repeatType : 'none', + interval: formData.repeatInterval, + endDate: formData.repeatEndDate || undefined, + }, + notificationTime: formData.notificationTime, +});