Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest"
"test": "vitest",
"chromatic": "npx chromatic --project-token=chpt_a6dc39eba6488b2"
},
"dependencies": {
"react": "^18.3.1",
Expand All @@ -38,6 +39,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"chromatic": "^11.20.2",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-node": "^0.3.9",
Expand Down
14 changes: 0 additions & 14 deletions src/App.test.tsx

This file was deleted.

126 changes: 126 additions & 0 deletions src/hooks/useModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// useModal.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useModal } from './useModal';
import { GlobalPortal } from '../util/GlobalPortal';

function TestModal({
closeOnOverlayClick = true,
}: {
closeOnOverlayClick?: boolean;
}) {
const { openModal, closeModal, ModalWrapper } = useModal({
closeOnOverlayClick,
});

return (
<GlobalPortal.Provider>
<div>
<button data-testid="open-btn" onClick={openModal}>
모달 열기
</button>

{/* 모달 렌더링 */}
<ModalWrapper>
<div data-testid="modal-content">Hello Modal</div>
</ModalWrapper>

{/* 모달 닫기 (직접 호출 테스트용) */}
<button data-testid="close-btn" onClick={closeModal}>
모달 닫기
</button>
</div>
</GlobalPortal.Provider>
);
}

describe('useModal Hook 테스트', () => {
test('초기에는 모달이 닫혀 있다.', () => {
render(<TestModal />);

// 화면에 모달 내용이 없어야 함
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument();
});

test('openModal을 호출하면 모달이 열린다.', async () => {
const user = userEvent.setup();
render(<TestModal />);

// 열기 버튼 클릭
await user.click(screen.getByTestId('open-btn'));

// 모달이 열렸는지 확인
expect(screen.getByTestId('modal-content')).toBeInTheDocument();
});

test('closeModal을 호출하면 모달이 닫힌다.', async () => {
const user = userEvent.setup();
render(<TestModal />);

// 모달 열기
await user.click(screen.getByTestId('open-btn'));
expect(screen.getByTestId('modal-content')).toBeInTheDocument();

// 직접 closeModal 호출
await user.click(screen.getByTestId('close-btn'));

// 모달이 사라졌는지 확인
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument();
});

test('closeOnOverlayClick 옵션이 true이면, 오버레이 클릭 시 모달이 닫힌다.', async () => {
const user = userEvent.setup();
render(<TestModal closeOnOverlayClick={true} />);

// 모달 열기
await user.click(screen.getByTestId('open-btn'));
const modalContent = screen.getByTestId('modal-content');
expect(modalContent).toBeInTheDocument();

// 부모(오버레이) 요소 찾기
const overlay = modalContent.parentElement?.parentElement;
// 실제로 null이 아닌지 보장하기 위해 체크
expect(overlay).toBeTruthy();
if (!overlay) throw new Error('Overlay가 없습니다.');

await user.click(overlay);

// 모달이 닫혔는지 확인
expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument();
});

test('X 버튼을 클릭하면 모달이 닫힌다.', async () => {
const user = userEvent.setup();
render(<TestModal />);

// 모달 열기
await user.click(screen.getByTestId('open-btn'));
expect(screen.getByTestId('modal-content')).toBeInTheDocument();

// 'X' 버튼(모달 내부 right-4 top-4) 클릭
const closeButton = screen.getByRole('button', { name: /x/i });
await user.click(closeButton);

expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument();
});

test('closeOnOverlayClick 옵션이 false면, 오버레이를 클릭해도 모달이 닫히지 않는다.', async () => {
const user = userEvent.setup();
render(<TestModal closeOnOverlayClick={false} />);

// 모달 열기
await user.click(screen.getByTestId('open-btn'));
const modalContent = screen.getByTestId('modal-content');
expect(modalContent).toBeInTheDocument();

// 부모(오버레이) 요소 찾기
const overlay = modalContent.parentElement?.parentElement;
// 실제로 null이 아닌지 보장하기 위해 체크
expect(overlay).toBeTruthy();
if (!overlay) throw new Error('Overlay가 없습니다.');

await user.click(overlay);
// 여전히 모달이 열린 상태
expect(screen.getByTestId('modal-content')).toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions src/hooks/useModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ReactNode, useState, useCallback } from 'react';
import { GlobalPortal } from '../util/GlobalPortal';

interface UseModalOptions {
closeOnOverlayClick?: boolean;
}

/**
* 모달을 쉽게 열고 닫을 수 있는 훅.
* @param options 모달 표시 옵션
*/
export function useModal(options: UseModalOptions = {}) {
const [isOpen, setIsOpen] = useState(false);
const { closeOnOverlayClick = true } = options;

const openModal = useCallback(() => {
setIsOpen(true);
}, []);

const closeModal = useCallback(() => {
setIsOpen(false);
}, []);

const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget && closeOnOverlayClick) {
closeModal();
}
},
[closeModal, closeOnOverlayClick],
);

const ModalWrapper = ({ children }: { children: ReactNode }) => {
if (!isOpen) return null;

return (
<GlobalPortal.Consumer>
<div
className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
onClick={handleOverlayClick}
>
<div className="relative w-2/3 rounded-lg bg-white p-5 shadow-lg">
{children}
<button
type="button"
onClick={closeModal}
className="absolute right-4 top-4 text-gray-500 hover:text-gray-700"
>
X
</button>
</div>
</div>
</GlobalPortal.Consumer>
);
};

return { isOpen, openModal, closeModal, ModalWrapper };
}
22 changes: 22 additions & 0 deletions src/page/TableSetupPage/TableSetup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Meta, StoryObj } from '@storybook/react';
import TableSetup from './TableSetup';
import { GlobalPortal } from '../../util/GlobalPortal';

const meta: Meta<typeof TableSetup> = {
title: 'page/TableSetup',
component: TableSetup,
tags: ['autodocs'],
decorators: [
(Story) => (
<GlobalPortal.Provider>
<Story />
</GlobalPortal.Provider>
),
],
};

export default meta;

type Story = StoryObj<typeof TableSetup>;

export const Default: Story = {};
98 changes: 98 additions & 0 deletions src/page/TableSetupPage/TableSetup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// TableSetup.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TableSetup from './TableSetup';
import { GlobalPortal } from '../../util/GlobalPortal';
function TestTableSetupt() {
return (
<GlobalPortal.Provider>
<TableSetup />
</GlobalPortal.Provider>
);
}
describe('TableSetup', () => {
it('왼쪽 + 버튼 클릭 → 모달 → "타임박스 설정하기" → 찬성 박스 생성', async () => {
render(<TestTableSetupt />);
const user = userEvent.setup();

// "+" 버튼: [0] = left, [1] = right
const plusButtons = screen.getAllByRole('button', { name: '+' });
expect(plusButtons).toHaveLength(2);

// 왼쪽 + 버튼 클릭
await user.click(plusButtons[0]);

// 모달 열림 확인
expect(
screen.getByRole('heading', { name: '타임박스 설정' }),
).toBeInTheDocument();

// "타임박스 설정하기" 버튼 클릭
await user.click(screen.getByRole('button', { name: '타임박스 설정하기' }));

// 모달이 닫혔는지 확인
expect(
screen.queryByRole('heading', { name: '타임박스 설정' }),
).not.toBeInTheDocument();

//박스 생성 확인
expect(screen.getByText('토론자', { exact: false })).toBeInTheDocument();
});

it('오른쪽 + 버튼 클릭 → 반대 박스 생성', async () => {
render(<TestTableSetupt />);
const user = userEvent.setup();

const plusButtons = screen.getAllByRole('button', { name: '+' });
// 오른쪽 + 버튼 클릭
await user.click(plusButtons[1]);

// 모달 열림 확인
expect(
screen.getByRole('heading', { name: '타임박스 설정' }),
).toBeInTheDocument();

// "타임박스 설정하기" 버튼 클릭
await user.click(screen.getByRole('button', { name: '타임박스 설정하기' }));
// 모달 닫힘
expect(
screen.queryByRole('heading', { name: '타임박스 설정' }),
).not.toBeInTheDocument();

//박스 생성 확인
expect(screen.getByText('토론자', { exact: false })).toBeInTheDocument();
});

it('"유형"에 작전시간을 선택하면 입장 드롭박스가 비활성화되고, 설정 시 작전시간 박스가 생성된다.', async () => {
render(<TestTableSetupt />);
const user = userEvent.setup();

// 왼쪽 + 버튼 클릭
const plusButtons = screen.getAllByRole('button', { name: '+' });
await user.click(plusButtons[0]);

// 모달 열림 확인
expect(
screen.getByRole('heading', { name: '타임박스 설정' }),
).toBeInTheDocument();

const debateTypeSelect = screen.getByLabelText('유형') as HTMLSelectElement;
// "작전시간" 옵션 선택
await user.selectOptions(debateTypeSelect, 'TIME_OUT');

// "입장" 셀렉트 박스가 이제 disabled 상태인지 확인
const stanceSelect = screen.getByLabelText('입장') as HTMLSelectElement;
expect(stanceSelect).toBeDisabled();

// "타임박스 설정하기" 클릭
await user.click(screen.getByRole('button', { name: '타임박스 설정하기' }));

// 모달 닫힘
expect(
screen.queryByRole('heading', { name: '타임박스 설정' }),
).not.toBeInTheDocument();

// DebatePanel이 "작전시간" 인지 확인
expect(screen.getByText('작전 시간', { exact: false })).toBeInTheDocument();
});
});
Loading