diff --git a/package.json b/package.json
index b01b2b4b..64079517 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"react-dom": "19.1.0"
},
"devDependencies": {
+ "@eslint/js": "^9.33.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.5.2",
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..a8255044 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 {
- 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 { Box, Stack } from '@mui/material';
import { useSnackbar } from 'notistack';
import { useState } from 'react';
+import { CalendarView } from './components/calendar';
+import { OverlapDialog } from './components/dialog';
+import { EventForm as EventFormComponent } from './components/eventForm';
+import { EventList } from './components/eventList';
+import { NotificationToast } from './components/notification';
import { useCalendarView } from './hooks/useCalendarView.ts';
import { useEventForm } from './hooks/useEventForm.ts';
import { useEventOperations } from './hooks/useEventOperations.ts';
import { useNotifications } from './hooks/useNotifications.ts';
import { useSearch } from './hooks/useSearch.ts';
-// import { Event, EventForm, RepeatType } from './types';
import { Event, EventForm } from './types';
-import {
- formatDate,
- formatMonth,
- formatWeek,
- getEventsForDay,
- getWeekDates,
- getWeeksAtMonth,
-} from './utils/dateUtils';
import { findOverlappingEvents } from './utils/eventOverlap';
-import { getTimeErrorMessage } from './utils/timeValidation';
-
-const categories = ['업무', '개인', '가족', '기타'];
-
-const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
-
-const notificationOptions = [
- { value: 1, label: '1분 전' },
- { value: 10, label: '10분 전' },
- { value: 60, label: '1시간 전' },
- { value: 120, label: '2시간 전' },
- { value: 1440, label: '1일 전' },
-];
function App() {
const {
@@ -145,514 +100,86 @@ function App() {
}
};
- const renderWeekView = () => {
- const weekDates = getWeekDates(currentDate);
- return (
-
- {formatWeek(currentDate)}
-
-
-
-
- {weekDays.map((day) => (
-
- {day}
-
- ))}
-
-
-
-
- {weekDates.map((date) => (
-
-
- {date.getDate()}
-
- {filteredEvents
- .filter(
- (event) => new Date(event.date).toDateString() === date.toDateString()
- )
- .map((event) => {
- const isNotified = notifiedEvents.includes(event.id);
- return (
-
-
- {isNotified && }
-
- {event.title}
-
-
-
- );
- })}
-
- ))}
-
-
-
-
-
- );
- };
-
- const renderMonthView = () => {
- const weeks = getWeeksAtMonth(currentDate);
-
- return (
-
- {formatMonth(currentDate)}
-
-
-
-
- {weekDays.map((day) => (
-
- {day}
-
- ))}
-
-
-
- {weeks.map((week, weekIndex) => (
-
- {week.map((day, dayIndex) => {
- const dateString = day ? formatDate(currentDate, day) : '';
- const holiday = holidays[dateString];
-
- return (
-
- {day && (
- <>
-
- {day}
-
- {holiday && (
-
- {holiday}
-
- )}
- {getEventsForDay(filteredEvents, day).map((event) => {
- const isNotified = notifiedEvents.includes(event.id);
- return (
-
-
- {isNotified && }
-
- {event.title}
-
-
-
- );
- })}
- >
- )}
-
- );
- })}
-
- ))}
-
-
-
-
- );
- };
-
return (
-
- {editingEvent ? '일정 수정' : '일정 추가'}
-
-
- 제목
- setTitle(e.target.value)}
- />
-
-
-
- 날짜
- setDate(e.target.value)}
- />
-
-
-
-
- 시작 시간
-
- getTimeErrorMessage(startTime, endTime)}
- error={!!startTimeError}
- />
-
-
-
- 종료 시간
-
- getTimeErrorMessage(startTime, endTime)}
- error={!!endTimeError}
- />
-
-
-
-
-
- 설명
- setDescription(e.target.value)}
- />
-
-
-
- 위치
- setLocation(e.target.value)}
- />
-
-
-
- 카테고리
-
-
-
-
- setIsRepeating(e.target.checked)}
- />
- }
- label="반복 일정"
- />
-
-
-
- 알림 설정
-
-
-
- {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */}
- {/* {isRepeating && (
-
-
- 반복 유형
-
-
-
-
- 반복 간격
- setRepeatInterval(Number(e.target.value))}
- slotProps={{ htmlInput: { min: 1 } }}
- />
-
-
- 반복 종료일
- setRepeatEndDate(e.target.value)}
- />
-
-
-
- )} */}
-
-
-
-
-
- 일정 보기
-
-
- 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)}>
-
-
-
-
-
- ))
- )}
-
+
+
+
+
+
-
-
- {notifications.length > 0 && (
-
- {notifications.map((notification, index) => (
- setNotifications((prev) => prev.filter((_, i) => i !== index))}
- >
-
-
- }
- >
- {notification.message}
-
- ))}
-
- )}
+ setIsOverlapDialogOpen(false)}
+ onConfirm={() => {
+ setIsOverlapDialogOpen(false);
+ saveEvent({
+ id: editingEvent ? editingEvent.id : undefined,
+ title,
+ date,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ repeat: {
+ type: isRepeating ? repeatType : 'none',
+ interval: repeatInterval,
+ endDate: repeatEndDate || undefined,
+ },
+ notificationTime,
+ });
+ }}
+ />
+
+ setNotifications([])}
+ />
);
}
diff --git a/src/__tests__/components/NotificationToast.spec.tsx b/src/__tests__/components/NotificationToast.spec.tsx
new file mode 100644
index 00000000..e1dcebe7
--- /dev/null
+++ b/src/__tests__/components/NotificationToast.spec.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+
+import { NotificationToast } from '../../components/notification/NotificationToast';
+
+describe('NotificationToast', () => {
+ const mockNotifications = [
+ { id: '1', message: '10분 후 회의 일정이 시작됩니다.' },
+ { id: '2', message: '15분 후 점심 약속 일정이 시작됩니다.' },
+ ];
+
+ const mockOnRemoveNotification = vi.fn();
+
+ beforeEach(() => {
+ mockOnRemoveNotification.mockClear();
+ });
+
+ it('알림이 없을 때 아무것도 렌더링하지 않는다', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('알림이 있을 때 모든 알림을 표시한다', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('10분 후 회의 일정이 시작됩니다.')).toBeInTheDocument();
+ expect(screen.getByText('15분 후 점심 약속 일정이 시작됩니다.')).toBeInTheDocument();
+ });
+
+ it('알림의 닫기 버튼을 클릭하면 onRemoveNotification이 올바른 인덱스로 호출된다', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const closeButtons = screen.getAllByRole('button');
+
+ // 첫 번째 알림의 닫기 버튼 클릭
+ await user.click(closeButtons[0]);
+ expect(mockOnRemoveNotification).toHaveBeenCalledWith(0);
+ });
+});
diff --git a/src/__tests__/components/OverlapDialog.spec.tsx b/src/__tests__/components/OverlapDialog.spec.tsx
new file mode 100644
index 00000000..52e8639c
--- /dev/null
+++ b/src/__tests__/components/OverlapDialog.spec.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+
+import { OverlapDialog } from '../../components/dialog/OverlapDialog';
+import { Event } from '../../types';
+
+describe('OverlapDialog', () => {
+ const mockOverlappingEvents: Event[] = [
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '중요한 미팅',
+ date: '2025-01-15',
+ startTime: '09:30',
+ endTime: '11:00',
+ description: '중요한 클라이언트 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ];
+
+ const mockOnClose = vi.fn();
+ const mockOnConfirm = vi.fn();
+
+ beforeEach(() => {
+ mockOnClose.mockClear();
+ mockOnConfirm.mockClear();
+ });
+
+ it('다이얼로그가 열려있지 않을 때 렌더링되지 않는다', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText('일정 겹침 경고')).not.toBeInTheDocument();
+ });
+
+ it('다이얼로그가 열려있을 때 제목과 내용이 표시된다', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument();
+ expect(screen.getByText(/다음 일정과 겹칩니다:/)).toBeInTheDocument();
+ expect(screen.getByText(/계속 진행하시겠습니까?/)).toBeInTheDocument();
+ });
+
+ it('겹치는 일정들의 정보가 모두 표시된다', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('기존 회의 (2025-01-15 09:00-10:00)')).toBeInTheDocument();
+ expect(screen.getByText('중요한 미팅 (2025-01-15 09:30-11:00)')).toBeInTheDocument();
+ });
+
+ it('취소 버튼을 클릭하면 onClose가 호출된다', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const cancelButton = screen.getByText('취소');
+ await user.click(cancelButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ expect(mockOnConfirm).not.toHaveBeenCalled();
+ });
+
+ it('계속 진행 버튼을 클릭하면 onConfirm이 호출된다', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const confirmButton = screen.getByText('계속 진행');
+ await user.click(confirmButton);
+
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1);
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts
index 93b57f0e..0f7388b0 100644
--- a/src/__tests__/hooks/easy.useCalendarView.spec.ts
+++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts
@@ -1,24 +1,105 @@
import { act, renderHook } from '@testing-library/react';
+import { vi } from 'vitest';
import { useCalendarView } from '../../hooks/useCalendarView.ts';
import { assertDate } from '../utils.ts';
describe('초기 상태', () => {
- it('view는 "month"이어야 한다', () => {});
+ beforeEach(() => {
+ vi.setSystemTime(new Date('2025-10-01T00:00:00'));
+ });
- it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {});
+ it('view는 "month"이어야 한다', () => {
+ const { result } = renderHook(() => useCalendarView());
- it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {});
+ expect(result.current.view).toBe('month');
+ });
+
+ 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': '한글날',
+ });
+ });
+});
+
+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("view를 'week'으로 변경 시 적절하게 반영된다", () => {});
+it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {
+ const { result } = renderHook(() => useCalendarView());
-it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {});
+ act(() => {
+ result.current.navigate('next');
+ });
-it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {});
+ assertDate(result.current.currentDate, new Date('2025-11-01'));
+});
+
+it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {
+ const { result } = renderHook(() => useCalendarView());
+
+ act(() => {
+ result.current.navigate('prev');
+ });
-it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {});
+ assertDate(result.current.currentDate, new Date('2025-09-01'));
+});
+
+it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {
+ const { result } = renderHook(() => useCalendarView());
-it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {});
+ act(() => {
+ result.current.setCurrentDate(new Date('2025-03-01'));
+ });
-it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {});
+ expect(result.current.holidays).toEqual({
+ '2025-03-01': '삼일절',
+ });
+});
diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts
index 80f57fa3..83d467dd 100644
--- a/src/__tests__/hooks/easy.useSearch.spec.ts
+++ b/src/__tests__/hooks/easy.useSearch.spec.ts
@@ -3,12 +3,249 @@ import { act, renderHook } from '@testing-library/react';
import { useSearch } from '../../hooks/useSearch.ts';
import { Event } from '../../types.ts';
-it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {});
+const mockEvents: Event[] = [
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 프로젝트 진행상황 공유',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-01-15',
+ startTime: '12:00',
+ endTime: '13:00',
+ description: '팀원들과 점심 식사',
+ location: '회사 근처 식당',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ {
+ id: '3',
+ title: '고객 미팅',
+ date: '2025-01-16',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '신규 프로젝트 제안',
+ location: '온라인',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 20,
+ },
+ {
+ id: '4',
+ title: '운동',
+ date: '2025-01-22',
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '헬스장에서 운동',
+ location: '피트니스 센터',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ {
+ id: '5',
+ title: '노래방',
+ date: '2025-01-17',
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '노래방에서 노래하기',
+ location: '팀즈 노래방',
+ category: '팀활동',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+];
-it('검색어에 맞는 이벤트만 필터링해야 한다', () => {});
+const currentDate = new Date('2025-01-15');
-it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {});
+it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {
+ const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month'));
-it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {});
+ expect(result.current.searchTerm).toBe('');
+ expect(result.current.filteredEvents).toEqual(mockEvents);
+});
-it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {});
+it('검색어에 맞는 이벤트만 필터링해야 한다', () => {
+ const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month'));
+
+ act(() => {
+ result.current.setSearchTerm('회의');
+ });
+
+ expect(result.current.searchTerm).toBe('회의');
+ expect(result.current.filteredEvents).toEqual([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 프로젝트 진행상황 공유',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+});
+
+it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {
+ const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month'));
+
+ act(() => {
+ result.current.setSearchTerm('팀');
+ });
+
+ expect(result.current.searchTerm).toBe('팀');
+ expect(result.current.filteredEvents).toEqual([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 프로젝트 진행상황 공유',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-01-15',
+ startTime: '12:00',
+ endTime: '13:00',
+ description: '팀원들과 점심 식사',
+ location: '회사 근처 식당',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ {
+ id: '5',
+ title: '노래방',
+ date: '2025-01-17',
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '노래방에서 노래하기',
+ location: '팀즈 노래방',
+ category: '팀활동',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ ]);
+});
+
+it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {
+ const { result: weekResult } = renderHook(() => useSearch(mockEvents, currentDate, 'week'));
+
+ expect(weekResult.current.filteredEvents).toEqual([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 프로젝트 진행상황 공유',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-01-15',
+ startTime: '12:00',
+ endTime: '13:00',
+ description: '팀원들과 점심 식사',
+ location: '회사 근처 식당',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ {
+ id: '3',
+ title: '고객 미팅',
+ date: '2025-01-16',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '신규 프로젝트 제안',
+ location: '온라인',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 20,
+ },
+ {
+ id: '5',
+ title: '노래방',
+ date: '2025-01-17',
+ startTime: '18:00',
+ endTime: '19:00',
+ description: '노래방에서 노래하기',
+ location: '팀즈 노래방',
+ category: '팀활동',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ ]);
+
+ const { result: monthResult } = renderHook(() => useSearch(mockEvents, currentDate, 'month'));
+
+ expect(monthResult.current.filteredEvents).toEqual(mockEvents);
+});
+
+it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {
+ const { result } = renderHook(() => useSearch(mockEvents, currentDate, 'month'));
+
+ act(() => {
+ result.current.setSearchTerm('회의');
+ });
+
+ expect(result.current.searchTerm).toBe('회의');
+ expect(result.current.filteredEvents).toEqual([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 프로젝트 진행상황 공유',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ act(() => {
+ result.current.setSearchTerm('점심');
+ });
+
+ expect(result.current.searchTerm).toBe('점심');
+ expect(result.current.filteredEvents).toEqual([
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-01-15',
+ startTime: '12:00',
+ endTime: '13:00',
+ description: '팀원들과 점심 식사',
+ location: '회사 근처 식당',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ]);
+});
diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts
index 566ecbb0..f22c4910 100644
--- a/src/__tests__/hooks/medium.useEventOperations.spec.ts
+++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts
@@ -1,4 +1,4 @@
-import { act, renderHook } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import {
@@ -22,16 +22,172 @@ vi.mock('notistack', async () => {
};
});
-it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {});
+beforeEach(() => {
+ enqueueSnackbarFn.mockClear();
+});
+
+afterEach(() => {
+ server.resetHandlers();
+});
+
+it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {
+ const mockEvents: Event[] = [
+ {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ];
+
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ events: mockEvents });
+ })
+ );
+
+ const { result } = renderHook(() => useEventOperations(false));
+
+ await waitFor(() => {
+ expect(result.current.events).toHaveLength(1);
+ expect(result.current.events[0]).toEqual(mockEvents[0]);
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' });
+ });
+});
+
+it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {
+ const newEvent: Omit = {
+ title: '새 이벤트',
+ date: '2025-01-16',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '새로운 이벤트',
+ location: '새 장소',
+ category: '새 카테고리',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ };
-it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {});
+ setupMockHandlerCreation();
-it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {});
+ const { result } = renderHook(() => useEventOperations(false));
-it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {});
+ await act(async () => {
+ await result.current.saveEvent(newEvent);
+ });
-it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {});
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', {
+ variant: 'success',
+ });
+});
+
+it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {
+ const existingEvent: Event = {
+ id: '1',
+ title: '기존 이벤트',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 설명',
+ location: '기존 장소',
+ category: '기존',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const updatedEvent: Event = {
+ ...existingEvent,
+ title: '수정된 이벤트',
+ endTime: '11:00',
+ };
+
+ setupMockHandlerUpdating();
+
+ const { result } = renderHook(() => useEventOperations(true));
+
+ await act(async () => {
+ await result.current.saveEvent(updatedEvent);
+ });
+
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', {
+ variant: 'success',
+ });
+});
-it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {});
+it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {
+ setupMockHandlerDeletion();
-it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {});
+ const { result } = renderHook(() => useEventOperations(false));
+
+ await act(async () => {
+ await result.current.deleteEvent('1');
+ });
+
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' });
+});
+
+it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.error();
+ })
+ );
+
+ const { result } = renderHook(() => useEventOperations(false));
+
+ await waitFor(() => {
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' });
+ expect(result.current.events).toHaveLength(0);
+ });
+});
+
+it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {
+ const nonExistentEvent: Event = {
+ id: '999',
+ title: '존재하지 않는 이벤트',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '존재하지 않는 설명',
+ location: '존재하지 않는 장소',
+ category: '존재하지 않는',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ server.use(
+ http.put(`/api/events/${nonExistentEvent.id}`, () => {
+ return HttpResponse.error();
+ })
+ );
+
+ const { result } = renderHook(() => useEventOperations(true));
+
+ await act(async () => {
+ await result.current.saveEvent(nonExistentEvent);
+ });
+
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' });
+});
+
+it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {
+ server.use(
+ http.delete('/api/events/1', () => {
+ return HttpResponse.error();
+ })
+ );
+
+ const { result } = renderHook(() => useEventOperations(false));
+
+ await act(async () => {
+ await result.current.deleteEvent('1');
+ });
+
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' });
+});
diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts
index 7f585ea8..acc8ac48 100644
--- a/src/__tests__/hooks/medium.useNotifications.spec.ts
+++ b/src/__tests__/hooks/medium.useNotifications.spec.ts
@@ -2,13 +2,123 @@ import { act, renderHook } from '@testing-library/react';
import { useNotifications } from '../../hooks/useNotifications.ts';
import { Event } from '../../types.ts';
-import { formatDate } from '../../utils/dateUtils.ts';
-import { parseHM } from '../utils.ts';
-it('초기 상태에서는 알림이 없어야 한다', () => {});
+describe('useNotifications', () => {
+ it('초기 상태에서는 알림이 없어야 한다', () => {
+ const mockEvents: Event[] = [];
+ const { result } = renderHook(() => useNotifications(mockEvents));
-it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {});
+ expect(result.current.notifications).toHaveLength(0);
+ expect(result.current.notifiedEvents).toHaveLength(0);
+ });
-it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {});
+ it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {
+ const mockEvent: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-01-15',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
-it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {});
+ const { result } = renderHook(() => useNotifications([mockEvent]));
+
+ const eventDate = new Date('2025-01-15T10:00:00');
+
+ const notificationTime = new Date(eventDate);
+ notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime);
+
+ vi.setSystemTime(notificationTime);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' },
+ ]);
+ });
+
+ it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {
+ const mockEvent: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-01-15',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const { result } = renderHook(() => useNotifications([mockEvent]));
+
+ const eventDate = new Date('2025-01-15T10:00:00');
+
+ const notificationTime = new Date(eventDate);
+ notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime);
+
+ vi.setSystemTime(notificationTime);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' },
+ ]);
+
+ act(() => {
+ result.current.removeNotification(0);
+ });
+
+ expect(result.current.notifications).toEqual([]);
+ });
+
+ it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {
+ const mockEvent: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-01-15',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const { result } = renderHook(() => useNotifications([mockEvent]));
+
+ const eventDate = new Date('2025-01-15T10:00:00');
+
+ const notificationTime = new Date(eventDate);
+ notificationTime.setMinutes(notificationTime.getMinutes() - mockEvent.notificationTime);
+
+ vi.setSystemTime(notificationTime);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' },
+ ]);
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 테스트 이벤트 일정이 시작됩니다.' },
+ ]);
+ });
+});
diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx
index 0b559b44..24666da6 100644
--- a/src/__tests__/medium.integration.spec.tsx
+++ b/src/__tests__/medium.integration.spec.tsx
@@ -1,8 +1,7 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
-import { render, screen, within, act } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import { UserEvent, userEvent } from '@testing-library/user-event';
-import { http, HttpResponse } from 'msw';
import { SnackbarProvider } from 'notistack';
import { ReactElement } from 'react';
@@ -12,16 +11,12 @@ import {
setupMockHandlerUpdating,
} from '../__mocks__/handlersUtils';
import App from '../App';
-import { server } from '../setupTests';
-import { Event } from '../types';
+import { EventForm } from '../types';
-const theme = createTheme();
-
-// ! HINT. 이 유틸을 사용해 리액트 컴포넌트를 렌더링해보세요.
const setup = (element: ReactElement) => {
+ const theme = createTheme();
const user = userEvent.setup();
- // ? Medium: 여기서 Provider로 묶어주는 동작은 의미있을까요? 있다면 어떤 의미일까요?
return {
...render(
@@ -33,63 +28,305 @@ const setup = (element: ReactElement) => {
};
};
-// ! HINT. 이 유틸을 사용해 일정을 저장해보세요.
-const saveSchedule = async (
- user: UserEvent,
- form: Omit
-) => {
- const { title, date, startTime, endTime, location, description, category } = form;
-
- await user.click(screen.getAllByText('일정 추가')[0]);
-
- await user.type(screen.getByLabelText('제목'), title);
- await user.type(screen.getByLabelText('날짜'), date);
- await user.type(screen.getByLabelText('시작 시간'), startTime);
- await user.type(screen.getByLabelText('종료 시간'), endTime);
- await user.type(screen.getByLabelText('설명'), description);
- await user.type(screen.getByLabelText('위치'), location);
- await user.click(screen.getByLabelText('카테고리'));
- await user.click(within(screen.getByLabelText('카테고리')).getByRole('combobox'));
- await user.click(screen.getByRole('option', { name: `${category}-option` }));
-
- await user.click(screen.getByTestId('event-submit-button'));
+const fillEventForm = async (user: UserEvent, eventData: Partial) => {
+ if (eventData.title) await user.type(screen.getByLabelText('제목'), eventData.title);
+ if (eventData.date) await user.type(screen.getByLabelText('날짜'), eventData.date);
+ if (eventData.startTime) await user.type(screen.getByLabelText('시작 시간'), eventData.startTime);
+ if (eventData.endTime) await user.type(screen.getByLabelText('종료 시간'), eventData.endTime);
+ if (eventData.description) await user.type(screen.getByLabelText('설명'), eventData.description);
+ if (eventData.location) await user.type(screen.getByLabelText('위치'), eventData.location);
+
+ if (eventData.category) {
+ const selectCategory = screen.getByLabelText('카테고리');
+ await user.click(within(selectCategory).getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: `${eventData.category}-option` }));
+ }
};
-// ! HINT. "검색 결과가 없습니다"는 초기에 노출되는데요. 그럼 검증하고자 하는 액션이 실행되기 전에 검증해버리지 않을까요? 이 테스트를 신뢰성있게 만드려면 어떻게 할까요?
describe('일정 CRUD 및 기본 기능', () => {
- it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => {
- // ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요.
+ it('폼 데이터를 입력하고 일정 추가 버튼을 클릭하면 새로운 일정이 리스트에 추가된다.', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-10-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ await fillEventForm(user, {
+ title: '크리스마스 준비',
+ date: '2025-10-25',
+ startTime: '12:00',
+ endTime: '23:00',
+ description: '크리스마스 파티 준비하기',
+ location: '회의실 A',
+ category: '기타',
+ });
+
+ await user.click(screen.getByRole('button', { name: '일정 추가' }));
+
+ const editButtons = await screen.findAllByRole('button', { name: 'Edit event' });
+ expect(editButtons).toHaveLength(2);
+ expect(screen.getByText('크리스마스 파티 준비하기')).toBeInTheDocument();
});
- it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {});
+ it('일정을 수정하면 수정된 정보가 이벤트 리스트에 반영된다.', async () => {
+ setupMockHandlerUpdating();
+
+ const { user } = setup();
+
+ const editButtons = await screen.findAllByRole('button', { name: 'Edit event' });
+ const locationInput = screen.getByLabelText('위치');
+
+ await user.click(editButtons[0]);
+
+ expect(screen.getByRole('button', { name: '일정 수정' })).toBeInTheDocument();
+ expect(screen.getByDisplayValue('기존 회의')).toBeInTheDocument();
+
+ await user.clear(locationInput);
+ await user.type(locationInput, '회의실 D');
+ await user.click(screen.getByRole('button', { name: '일정 수정' }));
+
+ expect(screen.getByText('회의실 D')).toBeInTheDocument();
+ });
+
+ it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {
+ setupMockHandlerDeletion();
+
+ const { user } = setup();
- it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {});
+ const deleteButton = await screen.findByRole('button', { name: 'Delete event' });
+
+ await user.click(deleteButton);
+
+ expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
});
describe('일정 뷰', () => {
- it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {});
+ it("view를 'week'로 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.", async () => {
+ const { user } = setup();
+
+ const viewSelect = screen.getByLabelText('뷰 타입 선택');
+
+ await user.click(within(viewSelect).getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'week-option' }));
+
+ expect(screen.queryByText('기존 회의')).not.toBeInTheDocument();
+ expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+
+ it("view를 'week'로 선택 후 해당 주에 일정이 있다면 리스트, 달력에 표시된다", async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-10-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ const viewSelect = screen.getByLabelText('뷰 타입 선택');
+ await user.click(within(viewSelect).getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'week-option' }));
+
+ expect(within(screen.getByTestId('week-view')).getByText('기존 회의')).toBeInTheDocument();
+ expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument();
+ });
+
+ it("view가 'month'일 때 해당 월에 일정이 없으면, 일정이 표시되지 않는다.", async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-11-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ setup();
+
+ expect(screen.queryByText('기존 회의')).not.toBeInTheDocument();
+ expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+
+ it("view가 'month'일 때 해당 월에 일정이 있다면 리스트, 달력에 표시된다", async () => {
+ setup();
- it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {});
+ expect(
+ await within(screen.getByTestId('month-view')).findByText('기존 회의')
+ ).toBeInTheDocument();
+ expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument();
+ });
- it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {});
+ it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {
+ const { user } = setup();
- it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {});
+ for (let i = 0; i < 9; i++) {
+ await user.click(screen.getByLabelText('Previous'));
+ }
- it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {});
+ expect(screen.getByText('신정')).toBeInTheDocument();
+ expect(screen.getByText('신정')).toHaveStyle('color: rgb(211, 47, 47)');
+ });
});
describe('검색 기능', () => {
- it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {});
+ it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {
+ const { user } = setup();
+
+ await user.type(screen.getByLabelText('일정 검색'), '식사');
+
+ expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+
+ it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-10-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '팀 회의',
+ date: '2025-10-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ await user.type(screen.getByLabelText('일정 검색'), '팀 회의');
+
+ expect(
+ within(screen.getByTestId('event-list')).queryByText('기존 회의')
+ ).not.toBeInTheDocument();
+ expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument();
+ });
+
+ it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '기존 회의',
+ date: '2025-10-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '팀 회의',
+ date: '2025-10-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ await user.type(screen.getByLabelText('일정 검색'), '팀 회의');
- it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {});
+ expect(
+ within(screen.getByTestId('event-list')).queryByText('기존 회의')
+ ).not.toBeInTheDocument();
+ expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument();
- it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {});
+ await user.clear(screen.getByLabelText('일정 검색'));
+
+ expect(within(screen.getByTestId('event-list')).getByText('기존 회의')).toBeInTheDocument();
+ expect(within(screen.getByTestId('event-list')).getByText('팀 회의')).toBeInTheDocument();
+ });
});
describe('일정 충돌', () => {
- it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {});
+ it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {
+ const { user } = setup();
+
+ await fillEventForm(user, {
+ title: '새로운 회의',
+ date: '2025-10-15',
+ startTime: '09:30',
+ endTime: '10:30',
+ description: '새로운 팀 미팅',
+ location: '회의실 K',
+ });
+
+ await user.click(screen.getByRole('button', { name: '일정 추가' }));
+
+ expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument();
+ });
+
+ it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {
+ setupMockHandlerUpdating();
+
+ const { user } = setup();
- it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {});
+ 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('종료 시간'), '12:30');
+ await user.click(screen.getByRole('button', { name: '일정 수정' }));
+
+ expect(screen.getByText('일정 겹침 경고')).toBeInTheDocument();
+ });
});
-it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {});
+it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {
+ vi.setSystemTime(new Date('2025-10-15T08:50:00'));
+
+ setup();
+
+ await screen.findByText('기존 팀 미팅');
+
+ 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..1e65b17c 100644
--- a/src/__tests__/unit/easy.dateUtils.spec.ts
+++ b/src/__tests__/unit/easy.dateUtils.spec.ts
@@ -12,105 +12,351 @@ import {
} from '../../utils/dateUtils';
describe('getDaysInMonth', () => {
- it('1월은 31일 수를 반환한다', () => {});
-
- it('4월은 30일 일수를 반환한다', () => {});
-
- it('윤년의 2월에 대해 29일을 반환한다', () => {});
-
- it('평년의 2월에 대해 28일을 반환한다', () => {});
-
- it('유효하지 않은 월에 대해 적절히 처리한다', () => {});
+ it('1월은 31일 수를 반환한다', () => {
+ expect(getDaysInMonth(2025, 1)).toBe(31);
+ });
+
+ it('4월은 30일 일수를 반환한다', () => {
+ expect(getDaysInMonth(2025, 4)).toBe(30);
+ });
+
+ it('윤년의 2월에 대해 29일을 반환한다', () => {
+ // 윤년 = 1년이 366일이 되는 해
+ // 윤년은 4년마다 한 번씩 온다.
+ expect(getDaysInMonth(2024, 2)).toBe(29);
+ });
+
+ it('평년의 2월에 대해 28일을 반환한다', () => {
+ expect(getDaysInMonth(2025, 2)).toBe(28);
+ });
+
+ /**
+ * 캘린더에서 선택하는 날짜가 주입되는 방식이기 때문에,
+ * 유효하지 않은 입력이 매개변수로 주입되는 경우는 없다고 판단
+ *
+ * 그리고 해당 테스트를 통해서 유의미한 결과값을 도출해낼 수 있는지 잘 모르겠음
+ */
+ it('유효하지 않은 월을 입력할 경우 자동으로 이월하여 올바른 월의 일수를 반환한다', () => {
+ expect(getDaysInMonth(2025, 13)).toBe(31); // 2026년 1월의 일수
+ expect(getDaysInMonth(2025, 25)).toBe(31); // 2027년 1월의 일수
+ expect(getDaysInMonth(2025, -2)).toBe(31); // 2024년 10월의 일수
+ });
});
describe('getWeekDates', () => {
- it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {});
-
- it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {});
-
- it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {});
-
- it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {});
+ /**
+ * 아래의 테스트 케이스들은 모두
+ * 1. 주가 7일로 구성되는지
+ * 2. 주의 시작일과 종료일이 올바른지
+ *
+ * 를 검증하는 테스트들이다. 하지만,
+ *
+ * 1. 주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다
+ * 2. 주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다
+ * 3. 주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다
+ *
+ * 다른 테스트 케이스들은 반례를 다루고 있는 반면, 위 3개의 테스트는 중복되는 테스트로 불필요한 테스트이다.
+ *
+ */
+ it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const result = getWeekDates(new Date('2025-01-15')); // 수요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2025-01-12').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2025-01-18').toDateString()); // 토요일
+ });
+
+ it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const result = getWeekDates(new Date('2025-01-13')); // 월요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2025-01-12').toDateString()); // 일요일
+ expect(result[1].toDateString()).toBe(new Date('2025-01-13').toDateString()); // 월요일
+ expect(result[6].toDateString()).toBe(new Date('2025-01-18').toDateString()); // 토요일
+ });
+
+ it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const result = getWeekDates(new Date('2025-01-19')); // 일요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2025-01-19').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2025-01-25').toDateString()); // 토요일
+ });
+
+ /**
+ * 아래의 두 개의 테스트는 목적은 동일하지만, 위 주석친 사례와 달리 반례를 다루고 있음
+ */
+ it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {
+ const result = getWeekDates(new Date('2025-12-30')); // 화요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2025-12-28').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2026-01-03').toDateString()); // 토요일
+ });
+
+ it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {
+ const result = getWeekDates(new Date('2025-01-01')); // 수요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2024-12-29').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2025-01-04').toDateString()); // 토요일
+ });
+
+ it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {
+ const result = getWeekDates(new Date('2024-02-29')); // 목요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2024-02-25').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2024-03-02').toDateString()); // 토요일
+ });
+
+ it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {
+ const result = getWeekDates(new Date('2025-01-31')); // 금요일
+ expect(result).toHaveLength(7);
+ expect(result[0].toDateString()).toBe(new Date('2025-01-26').toDateString()); // 일요일
+ expect(result[6].toDateString()).toBe(new Date('2025-02-01').toDateString()); // 토요일
+ });
});
describe('getWeeksAtMonth', () => {
- it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {});
+ it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {
+ const result = getWeeksAtMonth(new Date('2025-07-01'));
+
+ expect(result).toEqual([
+ [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],
+ ]);
+ });
});
describe('getEventsForDay', () => {
- it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {});
-
- it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {});
-
- it('날짜가 0일 경우 빈 배열을 반환한다', () => {});
-
- it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {});
+ const mockEvents: Event[] = [
+ {
+ id: '1',
+ title: '1일 이벤트',
+ date: '2025-01-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '1일 이벤트',
+ location: '장소',
+ category: '카테고리',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '15일 이벤트',
+ date: '2025-01-15',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '15일 이벤트',
+ location: '장소',
+ category: '카테고리',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ];
+
+ it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {
+ const result = getEventsForDay(mockEvents, 1);
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: '1일 이벤트',
+ date: '2025-01-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '1일 이벤트',
+ location: '장소',
+ category: '카테고리',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+ });
+
+ it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {
+ const result = getEventsForDay(mockEvents, 10);
+ expect(result).toEqual([]);
+ });
+
+ /**
+ * 날짜가 0 혹은 32 이상이 되는 경우는 존재하지 않음.
+ * 아래 두 개의 테스트는 불필요한 테스트
+ */
+ it('날짜가 0일 경우 빈 배열을 반환한다', () => {
+ const result = getEventsForDay(mockEvents, 0);
+ expect(result).toEqual([]);
+ });
+
+ it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {
+ const result = getEventsForDay(mockEvents, 32);
+ expect(result).toEqual([]);
+ });
});
describe('formatWeek', () => {
- it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
+ /**
+ * 아래 3개의 테스트들은 모두 동일한 목적을 가지는 테스트로 불필요한 테스트이다.
+ */
+ it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2025-07-15'));
+ expect(result).toBe('2025년 7월 3주');
+ });
+
+ it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2025-07-01'));
+ expect(result).toBe('2025년 7월 1주');
+ });
+
+ it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2025-07-31'));
+ expect(result).toBe('2025년 7월 5주');
+ });
+
+ it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2025-12-31'));
+ expect(result).toBe('2026년 1월 1주');
+ });
+
+ it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2024-02-29'));
+ expect(result).toBe('2024년 2월 5주');
+ });
+
+ it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const result = formatWeek(new Date('2025-02-28'));
+ expect(result).toBe('2025년 2월 4주');
+ });
});
describe('formatMonth', () => {
- it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {});
+ it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {
+ const result = formatMonth(new Date('2025-07-10'));
+ expect(result).toBe('2025년 7월');
+ });
});
describe('isDateInRange', () => {
- it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {});
-
- it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {});
-
- it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {});
-
- it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {});
-
- it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {});
-
- it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {});
+ /**
+ * 1. 범위 내의 날짜 2025-07-10에 대해 true를 반환한다
+ * 2. 범위의 시작일 2025-07-01에 대해 true를 반환한다
+ * 3. 범위의 종료일 2025-07-31에 대해 true를 반환한다
+ *
+ * 위 3개의 테스트는 모두 경계값 내부를 다루는 테스트로 불필요한 테스트이다.
+ */
+ it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {
+ const start = new Date('2025-07-01');
+ const end = new Date('2025-07-31');
+ const testDate = new Date('2025-07-10');
+ expect(isDateInRange(testDate, start, end)).toBe(true);
+ });
+
+ it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {
+ const start = new Date('2025-07-01');
+ const end = new Date('2025-07-31');
+ const testDate = new Date('2025-07-01');
+ expect(isDateInRange(testDate, start, end)).toBe(true);
+ });
+
+ it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {
+ const start = new Date('2025-07-01');
+ const end = new Date('2025-07-31');
+ const testDate = new Date('2025-07-31');
+ expect(isDateInRange(testDate, start, end)).toBe(true);
+ });
+
+ it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {
+ const start = new Date('2025-07-01');
+ const end = new Date('2025-07-31');
+ const testDate = new Date('2025-06-30');
+ expect(isDateInRange(testDate, start, end)).toBe(false);
+ });
+
+ it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {
+ const start = new Date('2025-07-01');
+ const end = new Date('2025-07-31');
+ const testDate = new Date('2025-08-01');
+ expect(isDateInRange(testDate, start, end)).toBe(false);
+ });
+
+ /**
+ * 범위 이전 날짜 검증에 대한 테스트와 동일한 목적을 가지는 테스트
+ */
+ it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {
+ const start = new Date('2025-07-31');
+ const end = new Date('2025-07-01');
+ const testDate = new Date('2025-07-15');
+ expect(isDateInRange(testDate, start, end)).toBe(false);
+ });
});
describe('fillZero', () => {
- it("5를 2자리로 변환하면 '05'를 반환한다", () => {});
-
- it("10을 2자리로 변환하면 '10'을 반환한다", () => {});
-
- it("3을 3자리로 변환하면 '003'을 반환한다", () => {});
-
- it("100을 2자리로 변환하면 '100'을 반환한다", () => {});
-
- it("0을 2자리로 변환하면 '00'을 반환한다", () => {});
-
- it("1을 5자리로 변환하면 '00001'을 반환한다", () => {});
-
- it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {});
-
- it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {});
-
- it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {});
+ // 기본 동작
+ it("5를 2자리로 변환하면 '05'를 반환한다", () => {
+ expect(fillZero(5, 2)).toBe('05');
+ });
+
+ // 기본 동작
+ it("10을 2자리로 변환하면 '10'을 반환한다", () => {
+ expect(fillZero(10, 2)).toBe('10');
+ });
+
+ // 다른 자릿수
+ it("3을 3자리로 변환하면 '003'을 반환한다", () => {
+ expect(fillZero(3, 3)).toBe('003');
+ });
+
+ // 경계값
+ it("100을 2자리로 변환하면 '100'을 반환한다", () => {
+ expect(fillZero(100, 2)).toBe('100');
+ });
+
+ // 경계값
+ it("0을 2자리로 변환하면 '00'을 반환한다", () => {
+ expect(fillZero(0, 2)).toBe('00');
+ });
+
+ // 필요하지 않은 테스트
+ it("1을 5자리로 변환하면 '00001'을 반환한다", () => {
+ expect(fillZero(1, 5)).toBe('00001');
+ });
+
+ // 필요하지 않은 테스트
+ it('소수점이 있는 3.14를 5자리로 변환하면 "03.14"를 반환한다', () => {
+ expect(fillZero(3.14, 5)).toBe('03.14');
+ });
+
+ // 반례
+ it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {
+ expect(fillZero(5)).toBe('05');
+ });
+
+ // 필요하지 않은 테스트
+ it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {
+ expect(fillZero(100, 2)).toBe('100');
+ });
});
describe('formatDate', () => {
- it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {});
-
- it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {});
-
- it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {});
-
- it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {});
+ it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {
+ const result = formatDate(new Date('2025-07-15'));
+ expect(result).toBe('2025-07-15');
+ });
+
+ it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {
+ const result = formatDate(new Date('2025-07-15'), 20);
+ expect(result).toBe('2025-07-20');
+ });
+
+ /**
+ * "날짜를 YYYY-MM-DD 형식으로 포맷팅한다" 테스트에서 충분히 검증이 가능한 테스트이므로,
+ * 아래 2개의 테스트는 불필요한 테스트
+ */
+ it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {
+ const result = formatDate(new Date('2025-03-15'));
+ expect(result).toBe('2025-03-15');
+ });
+
+ it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {
+ const result = formatDate(new Date('2025-07-05'));
+ expect(result).toBe('2025-07-05');
+ });
});
diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts
index 5e5f6497..80e4fdea 100644
--- a/src/__tests__/unit/easy.eventOverlap.spec.ts
+++ b/src/__tests__/unit/easy.eventOverlap.spec.ts
@@ -5,32 +5,298 @@ 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).toBeInstanceOf(Date);
+ 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를 반환한다', () => {
+ const result = parseDateTime('invalid-date', '14:30');
- it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {});
+ expect(isNaN(result.getTime())).toBe(true);
+ });
- it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {});
+ it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {
+ const result = parseDateTime('2025-07-01', 'invalid-time');
- it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {});
+ expect(isNaN(result.getTime())).toBe(true);
+ });
+
+ it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {
+ const result = parseDateTime('', '14:30');
+
+ expect(isNaN(result.getTime())).toBe(true);
+ });
});
describe('convertEventToDateRange', () => {
- it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {});
+ it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {
+ const event: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const { start, end } = convertEventToDateRange(event);
+
+ expect(start).toBeInstanceOf(Date);
+ expect(end).toBeInstanceOf(Date);
+ expect(start.getFullYear()).toBe(2025);
+ expect(start.getMonth()).toBe(6);
+ expect(start.getDate()).toBe(1);
+ expect(start.getHours()).toBe(9);
+ expect(start.getMinutes()).toBe(0);
+ expect(end.getHours()).toBe(10);
+ expect(end.getMinutes()).toBe(0);
+ });
+
+ it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {
+ const event: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: 'invalid-date',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const { start, end } = convertEventToDateRange(event);
+
+ expect(isNaN(start.getTime())).toBe(true);
+ expect(isNaN(end.getTime())).toBe(true);
+ });
+
+ it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {
+ const event: Event = {
+ id: '1',
+ title: '테스트 이벤트',
+ date: '2025-07-01',
+ startTime: 'invalid-time',
+ endTime: 'invalid-time',
+ description: '테스트 설명',
+ location: '테스트 장소',
+ category: '테스트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
- it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {});
+ const { start, end } = convertEventToDateRange(event);
- it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {});
+ expect(isNaN(start.getTime())).toBe(true);
+ expect(isNaN(end.getTime())).toBe(true);
+ });
});
describe('isOverlapping', () => {
- it('두 이벤트가 겹치는 경우 true를 반환한다', () => {});
+ it('두 이벤트가 겹치는 경우 true를 반환한다', () => {
+ const event1: Event = {
+ id: '1',
+ title: '첫 번째 이벤트',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트',
+ location: '장소1',
+ category: '카테고리1',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
- it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {});
+ const event2: Event = {
+ id: '2',
+ title: '두 번째 이벤트',
+ date: '2025-07-01',
+ startTime: '09:30',
+ endTime: '10:30',
+ description: '두 번째 이벤트',
+ location: '장소2',
+ category: '카테고리2',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const isOverlapped = isOverlapping(event1, event2);
+ expect(isOverlapped).toBe(true);
+ });
+
+ it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {
+ const event1: Event = {
+ id: '1',
+ title: '첫 번째 이벤트',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트',
+ location: '장소1',
+ category: '카테고리1',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const event2: Event = {
+ id: '2',
+ title: '두 번째 이벤트',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '두 번째 이벤트',
+ location: '장소2',
+ category: '카테고리2',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const isOverlapped = isOverlapping(event1, event2);
+ expect(isOverlapped).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: 0 },
+ notificationTime: 10,
+ };
+
+ const existingEvents: Event[] = [
+ {
+ id: '1',
+ title: '겹치는 이벤트1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '겹치는 이벤트1',
+ location: '장소1',
+ category: '카테고리1',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '겹치지 않는 이벤트',
+ date: '2025-07-01',
+ startTime: '11:00',
+ endTime: '12:00',
+ description: '겹치지 않는 이벤트',
+ location: '장소2',
+ category: '카테고리2',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ title: '겹치는 이벤트2',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '겹치는 이벤트2',
+ location: '장소3',
+ category: '카테고리3',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ];
+
+ const result = findOverlappingEvents(newEvent, existingEvents);
+
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: '겹치는 이벤트1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '겹치는 이벤트1',
+ location: '장소1',
+ category: '카테고리1',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ title: '겹치는 이벤트2',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '겹치는 이벤트2',
+ location: '장소3',
+ category: '카테고리3',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+ });
+
+ it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {
+ const newEvent: Event = {
+ id: 'new',
+ title: '새 이벤트',
+ date: '2025-07-01',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '새 이벤트',
+ location: '새 장소',
+ category: '새 카테고리',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ };
+
+ const existingEvents: Event[] = [
+ {
+ id: '1',
+ title: '기존 이벤트1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '기존 이벤트1',
+ location: '장소1',
+ category: '카테고리1',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '기존 이벤트2',
+ date: '2025-07-01',
+ startTime: '16:00',
+ endTime: '17:00',
+ description: '기존 이벤트2',
+ location: '장소2',
+ category: '카테고리2',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ];
+
+ const result = findOverlappingEvents(newEvent, existingEvents);
- it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {});
+ expect(result).toEqual([]);
+ });
});
diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts
index 8eef6371..be324a6d 100644
--- a/src/__tests__/unit/easy.eventUtils.spec.ts
+++ b/src/__tests__/unit/easy.eventUtils.spec.ts
@@ -2,19 +2,262 @@ import { Event } from '../../types';
import { getFilteredEvents } from '../../utils/eventUtils';
describe('getFilteredEvents', () => {
- it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {});
+ const mockEvents: Event[] = [
+ {
+ id: '1',
+ title: 'Event 1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트입니다',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '이벤트 2',
+ date: '2025-07-02',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '두 번째 이벤트입니다',
+ location: '회의실 B',
+ category: '미팅',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ {
+ id: '3',
+ title: '이벤트 3',
+ date: '2025-07-08',
+ startTime: '11:00',
+ endTime: '12:00',
+ description: '세 번째 이벤트입니다',
+ location: '온라인',
+ category: '교육',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 20,
+ },
+ {
+ id: '4',
+ title: '이벤트 4',
+ date: '2025-07-15',
+ startTime: '16:00',
+ endTime: '17:00',
+ description: '네 번째 이벤트입니다',
+ location: '회의실 C',
+ category: '프로젝트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ {
+ id: '5',
+ title: '이벤트 5',
+ date: '2025-07-31',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '다섯 번째 이벤트입니다',
+ location: '회의실 D',
+ category: '리뷰',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 25,
+ },
+ ];
- it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {});
+ it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, '이벤트 2', currentDate, 'month');
- it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {});
+ expect(result).toEqual([
+ {
+ id: '2',
+ title: '이벤트 2',
+ date: '2025-07-02',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '두 번째 이벤트입니다',
+ location: '회의실 B',
+ category: '미팅',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ]);
+ });
- it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {});
+ it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {
+ const currentDate = new Date('2025-07-01'); // 2025년 7월 1일 (화요일)
+ const result = getFilteredEvents(mockEvents, '', currentDate, 'week');
- it('검색어가 없을 때 모든 이벤트를 반환한다', () => {});
+ // 2025-07-01 주는 6월 29일(일) ~ 7월 5일(토)이므로 이벤트 1, 2만 포함
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: 'Event 1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트입니다',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '이벤트 2',
+ date: '2025-07-02',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '두 번째 이벤트입니다',
+ location: '회의실 B',
+ category: '미팅',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ]);
+ });
- it('검색어가 대소문자를 구분하지 않고 작동한다', () => {});
+ it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, '', currentDate, 'month');
- it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {});
+ expect(result).toEqual(mockEvents);
+ });
- it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {});
+ it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, '이벤트', currentDate, 'week');
+
+ // 검색어 '이벤트'로 필터링 후 주간 뷰 적용
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: 'Event 1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트입니다',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '이벤트 2',
+ date: '2025-07-02',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '두 번째 이벤트입니다',
+ location: '회의실 B',
+ category: '미팅',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ ]);
+ });
+
+ it('검색어가 없을 때 모든 이벤트를 반환한다', () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, '', currentDate, 'month');
+
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: 'Event 1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트입니다',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '이벤트 2',
+ date: '2025-07-02',
+ startTime: '14:00',
+ endTime: '15:00',
+ description: '두 번째 이벤트입니다',
+ location: '회의실 B',
+ category: '미팅',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 15,
+ },
+ {
+ id: '3',
+ title: '이벤트 3',
+ date: '2025-07-08',
+ startTime: '11:00',
+ endTime: '12:00',
+ description: '세 번째 이벤트입니다',
+ location: '온라인',
+ category: '교육',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 20,
+ },
+ {
+ id: '4',
+ title: '이벤트 4',
+ date: '2025-07-15',
+ startTime: '16:00',
+ endTime: '17:00',
+ description: '네 번째 이벤트입니다',
+ location: '회의실 C',
+ category: '프로젝트',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ {
+ id: '5',
+ title: '이벤트 5',
+ date: '2025-07-31',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '다섯 번째 이벤트입니다',
+ location: '회의실 D',
+ category: '리뷰',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 25,
+ },
+ ]);
+ });
+
+ it('검색어가 대소문자를 구분하지 않고 작동한다', () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, 'event', currentDate, 'month');
+
+ expect(result).toEqual([
+ {
+ id: '1',
+ title: 'Event 1',
+ date: '2025-07-01',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '첫 번째 이벤트입니다',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+ });
+
+ it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents(mockEvents, '', currentDate, 'month');
+
+ // 7월 1일과 7월 31일 이벤트가 모두 포함되어야 함
+ expect(result).toEqual(mockEvents);
+ });
+
+ it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {
+ const currentDate = new Date('2025-07-01');
+ const result = getFilteredEvents([], '이벤트', currentDate, 'month');
+
+ expect(result).toEqual([]);
+ });
});
diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts
index 013e87f0..f00f8886 100644
--- a/src/__tests__/unit/easy.fetchHolidays.spec.ts
+++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts
@@ -1,8 +1,46 @@
import { fetchHolidays } from '../../apis/fetchHolidays';
-describe('fetchHolidays', () => {
- it('주어진 월의 공휴일만 반환한다', () => {});
- it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {});
+describe('fetchHolidays', () => {
+ it.each([
+ {
+ month: 1,
+ expected: {
+ '2025-01-01': '신정',
+ '2025-01-29': '설날',
+ '2025-01-30': '설날',
+ '2025-01-31': '설날',
+ },
+ holidayNames: '신정, 설날',
+ },
+ { month: 3, expected: { '2025-03-01': '삼일절' }, holidayNames: '삼일절' },
+ { month: 5, expected: { '2025-05-05': '어린이날' }, holidayNames: '어린이날' },
+ { month: 6, expected: { '2025-06-06': '현충일' }, holidayNames: '현충일' },
+ { month: 8, expected: { '2025-08-15': '광복절' }, holidayNames: '광복절' },
+ {
+ month: 10,
+ expected: {
+ '2025-10-03': '개천절',
+ '2025-10-05': '추석',
+ '2025-10-06': '추석',
+ '2025-10-07': '추석',
+ '2025-10-09': '한글날',
+ },
+ holidayNames: '개천절, 추석, 한글날',
+ },
+ { month: 12, expected: { '2025-12-25': '크리스마스' }, holidayNames: '크리스마스' },
+ ])('$month월에는 $holidayNames 공휴일이 있다', ({ month, expected }) => {
+ const result = fetchHolidays(new Date(`2025-${month}`));
+ expect(result).toEqual(expected);
+ });
- it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {});
+ it.each([
+ { month: 2, expected: {} },
+ { month: 4, expected: {} },
+ { month: 7, expected: {} },
+ { month: 9, expected: {} },
+ { month: 11, expected: {} },
+ ])('$month월에는 공휴일이 없다', ({ month, expected }) => {
+ const result = fetchHolidays(new Date(`2025-${month}`));
+ expect(result).toEqual(expected);
+ });
});
diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts
index 2fe10360..f8bf785b 100644
--- a/src/__tests__/unit/easy.notificationUtils.spec.ts
+++ b/src/__tests__/unit/easy.notificationUtils.spec.ts
@@ -1,16 +1,49 @@
import { Event } from '../../types';
import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils';
+const event: Event = {
+ id: '1',
+ date: '2025-10-01',
+ startTime: '10:00:00',
+ endTime: '11:00:00',
+ title: 'test',
+ notificationTime: 10,
+ description: 'test',
+ location: 'test',
+ category: 'test',
+ repeat: { type: 'none', interval: 0 },
+};
+
describe('getUpcomingEvents', () => {
- it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {});
+ it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {
+ const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T09:50:00'), []);
+
+ expect(result).toEqual([event]);
+ });
+
+ it('이미 알림이 간 이벤트는 제외한다', () => {
+ const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T10:00:00'), ['1']);
- it('이미 알림이 간 이벤트는 제외한다', () => {});
+ expect(result).toEqual([]);
+ });
- it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {});
+ it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {
+ const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T09:40:00'), []);
- it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {});
+ expect(result).toEqual([]);
+ });
+
+ it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {
+ const result = getUpcomingEvents([{ ...event }], new Date('2025-10-01T10:00:00'), []);
+
+ expect(result).toEqual([]);
+ });
});
describe('createNotificationMessage', () => {
- it('올바른 알림 메시지를 생성해야 한다', () => {});
+ it('올바른 알림 메시지를 생성해야 한다', () => {
+ const result = createNotificationMessage(event);
+
+ expect(result).toBe('10분 후 test 일정이 시작됩니다.');
+ });
});
diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts
index 9dda1954..4a1e9499 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:00', '13:00:00');
- it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {});
+ expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.');
+ expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.');
+ });
- it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {});
+ it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {
+ const result = getTimeErrorMessage('14:00:00', '14:00:00');
- it('시작 시간이 비어있을 때 null을 반환한다', () => {});
+ expect(result.startTimeError).toBe('시작 시간은 종료 시간보다 빨라야 합니다.');
+ expect(result.endTimeError).toBe('종료 시간은 시작 시간보다 늦어야 합니다.');
+ });
- it('종료 시간이 비어있을 때 null을 반환한다', () => {});
+ it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('13:00:00', '14:00:00');
- it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {});
+ expect(result.startTimeError).toBe(null);
+ expect(result.endTimeError).toBe(null);
+ });
+
+ it('시작 시간이 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('', '14:00:00');
+
+ expect(result.startTimeError).toBe(null);
+ expect(result.endTimeError).toBe(null);
+ });
+
+ it('종료 시간이 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('14:00:00', '');
+
+ expect(result.startTimeError).toBe(null);
+ expect(result.endTimeError).toBe(null);
+ });
+
+ it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('', '');
+
+ expect(result.startTimeError).toBe(null);
+ expect(result.endTimeError).toBe(null);
+ });
});
diff --git a/src/components/calendar/CalendarView.tsx b/src/components/calendar/CalendarView.tsx
new file mode 100644
index 00000000..724679b5
--- /dev/null
+++ b/src/components/calendar/CalendarView.tsx
@@ -0,0 +1,70 @@
+import { ChevronLeft, ChevronRight } from '@mui/icons-material';
+import { IconButton, MenuItem, Select, Stack, Typography } from '@mui/material';
+
+import { MonthView } from './MonthView';
+import { WeekView } from './WeekView';
+import { Event } from '../../types';
+
+interface CalendarViewProps {
+ view: 'week' | 'month';
+ currentDate: Date;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ holidays: Record;
+ onViewChange: (view: 'week' | 'month') => void;
+ onNavigate: (direction: 'prev' | 'next') => void;
+}
+
+export const CalendarView = ({
+ view,
+ currentDate,
+ filteredEvents,
+ notifiedEvents,
+ holidays,
+ onViewChange,
+ onNavigate,
+}: CalendarViewProps) => {
+ return (
+
+ 일정 보기
+
+
+ onNavigate('prev')}>
+
+
+
+ onNavigate('next')}>
+
+
+
+
+ {view === 'week' && (
+
+ )}
+ {view === 'month' && (
+
+ )}
+
+ );
+};
diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx
new file mode 100644
index 00000000..d7f5cf9d
--- /dev/null
+++ b/src/components/calendar/MonthView.tsx
@@ -0,0 +1,120 @@
+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';
+
+interface MonthViewProps {
+ currentDate: Date;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ holidays: Record;
+}
+
+const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
+
+export const 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..bd55d3cd
--- /dev/null
+++ b/src/components/calendar/WeekView.tsx
@@ -0,0 +1,99 @@
+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';
+
+interface WeekViewProps {
+ currentDate: Date;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+}
+
+const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
+
+export const 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..49f9fc0f
--- /dev/null
+++ b/src/components/calendar/index.ts
@@ -0,0 +1 @@
+export { CalendarView } from './CalendarView';
diff --git a/src/components/dialog/OverlapDialog.tsx b/src/components/dialog/OverlapDialog.tsx
new file mode 100644
index 00000000..0a79b87e
--- /dev/null
+++ b/src/components/dialog/OverlapDialog.tsx
@@ -0,0 +1,48 @@
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+
+interface OverlapDialogProps {
+ isOpen: boolean;
+ overlappingEvents: Event[];
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export const OverlapDialog = ({
+ isOpen,
+ overlappingEvents,
+ onClose,
+ onConfirm,
+}: OverlapDialogProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts
new file mode 100644
index 00000000..09bed373
--- /dev/null
+++ b/src/components/dialog/index.ts
@@ -0,0 +1 @@
+export { OverlapDialog } from './OverlapDialog';
diff --git a/src/components/eventForm/EventForm.tsx b/src/components/eventForm/EventForm.tsx
new file mode 100644
index 00000000..9f788a69
--- /dev/null
+++ b/src/components/eventForm/EventForm.tsx
@@ -0,0 +1,210 @@
+import {
+ Checkbox,
+ FormControl,
+ FormControlLabel,
+ FormLabel,
+ MenuItem,
+ Select,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import { Button } from '@mui/material';
+import { ChangeEvent } from 'react';
+
+import { Event } from '../../types';
+import { getTimeErrorMessage } from '../../utils/timeValidation';
+
+const categories = ['업무', '개인', '가족', '기타'];
+
+const notificationOptions = [
+ { value: 1, label: '1분 전' },
+ { value: 10, label: '10분 전' },
+ { value: 60, label: '1시간 전' },
+ { value: 120, label: '2시간 전' },
+ { value: 1440, label: '1일 전' },
+];
+
+interface EventFormProps {
+ title: string;
+ setTitle: (title: string) => void;
+ date: string;
+ setDate: (date: string) => void;
+ startTime: string;
+ endTime: string;
+ description: string;
+ setDescription: (description: string) => void;
+ location: string;
+ setLocation: (location: string) => void;
+ category: string;
+ setCategory: (category: string) => void;
+ isRepeating: boolean;
+ setIsRepeating: (isRepeating: boolean) => void;
+ repeatType: string;
+ repeatInterval: number;
+ repeatEndDate: string | null;
+ notificationTime: number;
+ setNotificationTime: (notificationTime: number) => void;
+ startTimeError: string | null;
+ endTimeError: string | null;
+ editingEvent: Event | null;
+ handleStartTimeChange: (e: ChangeEvent) => void;
+ handleEndTimeChange: (e: ChangeEvent) => void;
+ onSubmit: () => void;
+}
+
+export const EventForm = ({
+ title,
+ setTitle,
+ date,
+ setDate,
+ startTime,
+ endTime,
+ description,
+ setDescription,
+ location,
+ setLocation,
+ category,
+ setCategory,
+ isRepeating,
+ setIsRepeating,
+ notificationTime,
+ setNotificationTime,
+ startTimeError,
+ endTimeError,
+ editingEvent,
+ handleStartTimeChange,
+ handleEndTimeChange,
+ onSubmit,
+}: EventFormProps) => {
+ return (
+
+ {editingEvent ? '일정 수정' : '일정 추가'}
+
+
+ 제목
+ setTitle(e.target.value)}
+ />
+
+
+
+ 날짜
+ setDate(e.target.value)}
+ />
+
+
+
+
+ 시작 시간
+
+ getTimeErrorMessage(startTime, endTime)}
+ error={!!startTimeError}
+ />
+
+
+
+ 종료 시간
+
+ getTimeErrorMessage(startTime, endTime)}
+ error={!!endTimeError}
+ />
+
+
+
+
+
+ 설명
+ setDescription(e.target.value)}
+ />
+
+
+
+ 위치
+ setLocation(e.target.value)}
+ />
+
+
+
+ 카테고리
+
+
+
+
+ setIsRepeating(e.target.checked)} />
+ }
+ label="반복 일정"
+ />
+
+
+
+ 알림 설정
+
+
+
+
+
+ );
+};
diff --git a/src/components/eventForm/index.ts b/src/components/eventForm/index.ts
new file mode 100644
index 00000000..19d3ca40
--- /dev/null
+++ b/src/components/eventForm/index.ts
@@ -0,0 +1 @@
+export { EventForm } from './EventForm';
diff --git a/src/components/eventList/EventList.tsx b/src/components/eventList/EventList.tsx
new file mode 100644
index 00000000..aa406bf5
--- /dev/null
+++ b/src/components/eventList/EventList.tsx
@@ -0,0 +1,91 @@
+import { Notifications, Delete, Edit } from '@mui/icons-material';
+import {
+ Box,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+import { getNotificationText, getRepeatText } from '../../utils/eventDisplayUtils';
+
+interface EventListProps {
+ searchTerm: string;
+ setSearchTerm: (searchTerm: string) => void;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ onEditEvent: (event: Event) => void;
+ onDeleteEvent: (eventId: string) => void;
+}
+
+export const EventList = ({
+ searchTerm,
+ setSearchTerm,
+ filteredEvents,
+ notifiedEvents,
+ onEditEvent,
+ onDeleteEvent,
+}: EventListProps) => {
+ return (
+
+
+ 일정 검색
+ setSearchTerm(e.target.value)}
+ />
+
+
+ {filteredEvents.length === 0 ? (
+ 검색 결과가 없습니다.
+ ) : (
+ filteredEvents.map((event) => (
+
+
+
+
+ {notifiedEvents.includes(event.id) && }
+
+ {event.title}
+
+
+ {event.date}
+
+ {event.startTime} - {event.endTime}
+
+ {event.description}
+ {event.location}
+ 카테고리: {event.category}
+ {event.repeat.type !== 'none' && (
+ {getRepeatText(event.repeat)}
+ )}
+ 알림: {getNotificationText(event.notificationTime)}
+
+
+ onEditEvent(event)}>
+
+
+ onDeleteEvent(event.id)}>
+
+
+
+
+
+ ))
+ )}
+
+ );
+};
diff --git a/src/components/eventList/index.ts b/src/components/eventList/index.ts
new file mode 100644
index 00000000..c535a542
--- /dev/null
+++ b/src/components/eventList/index.ts
@@ -0,0 +1 @@
+export { EventList } from './EventList';
diff --git a/src/components/notification/NotificationToast.tsx b/src/components/notification/NotificationToast.tsx
new file mode 100644
index 00000000..d40c04e2
--- /dev/null
+++ b/src/components/notification/NotificationToast.tsx
@@ -0,0 +1,38 @@
+import { Close } from '@mui/icons-material';
+import { Alert, AlertTitle, IconButton, Stack } from '@mui/material';
+
+interface Notification {
+ id: string;
+ message: string;
+}
+
+interface NotificationToastProps {
+ notifications: Notification[];
+ onRemoveNotification: (index: number) => void;
+}
+
+export const NotificationToast = ({
+ notifications,
+ onRemoveNotification,
+}: NotificationToastProps) => {
+ if (notifications.length === 0) return null;
+
+ return (
+
+ {notifications.map((notification, index) => (
+ onRemoveNotification(index)}>
+
+
+ }
+ >
+ {notification.message}
+
+ ))}
+
+ );
+};
diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts
new file mode 100644
index 00000000..04ee234e
--- /dev/null
+++ b/src/components/notification/index.ts
@@ -0,0 +1 @@
+export { NotificationToast } from './NotificationToast';
diff --git a/src/utils/eventDisplayUtils.ts b/src/utils/eventDisplayUtils.ts
new file mode 100644
index 00000000..b421ccac
--- /dev/null
+++ b/src/utils/eventDisplayUtils.ts
@@ -0,0 +1,35 @@
+import { Event } from '../types';
+
+export const getNotificationText = (notificationTime: number): string => {
+ switch (notificationTime) {
+ case 1:
+ return '1분 전';
+ case 10:
+ return '10분 전';
+ case 60:
+ return '1시간 전';
+ case 120:
+ return '2시간 전';
+ case 1440:
+ return '1일 전';
+ default:
+ return '';
+ }
+};
+
+export const getRepeatText = (repeat: Event['repeat']): string => {
+ if (repeat.type === 'none') return '';
+
+ const typeText = {
+ daily: '일',
+ weekly: '주',
+ monthly: '월',
+ yearly: '년',
+ }[repeat.type];
+
+ let text = `반복: ${repeat.interval}${typeText}마다`;
+ if (repeat.endDate) {
+ text += ` (종료: ${repeat.endDate})`;
+ }
+ return text;
+};