Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@
"나의 토론 주제": "My Debate Topic",
"나의 시간표": "My Timetable",
"유효하지 않은 토론방 ID입니다.": "Invalid debate room ID.",
"시간표 설정에 오류가 발생했어요.": "An error occurred in timetable settings.",
"실시간 연결 주소를 확인할 수 없어요.": "Could not verify live connection address.",
"토론방 연결이 거부되었어요.": "Connection to the debate room was rejected.",
"실시간 연결에서 서버 오류가 발생했어요.": "A server error occurred during live connection.",
Expand Down
1 change: 1 addition & 0 deletions public/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@
"나의 토론 주제": "나의 토론 주제",
"나의 시간표": "나의 시간표",
"유효하지 않은 토론방 ID입니다.": "유효하지 않은 토론방 ID입니다.",
"시간표 설정에 오류가 발생했어요.": "시간표 설정에 오류가 발생했어요.",
"실시간 연결 주소를 확인할 수 없어요.": "실시간 연결 주소를 확인할 수 없어요.",
"토론방 연결이 거부되었어요.": "토론방 연결이 거부되었어요.",
"실시간 연결에서 서버 오류가 발생했어요.": "실시간 연결에서 서버 오류가 발생했어요.",
Expand Down
102 changes: 102 additions & 0 deletions src/components/TimerProgressBar/TimerProgressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TimerProgressBar from './TimerProgressBar';

const animateMock = vi.hoisted(() => vi.fn());

vi.mock('framer-motion', async () => {
const actual =
await vi.importActual<typeof import('framer-motion')>('framer-motion');

return {
...actual,
animate: animateMock,
};
});

describe('TimerProgressBar', () => {
beforeEach(() => {
animateMock.mockReset();
animateMock.mockImplementation(
(motionValue: { set: (value: number) => void }, target: number) => {
motionValue.set(target);
return { stop: vi.fn() };
},
);
});

it('기본 크기와 전달받은 className 및 접근성 진행률을 적용한다', () => {
render(
<TimerProgressBar
progress={35}
team="PROS"
isRunning={false}
className="max-w-[1280px]"
/>,
);

const progressBar = screen.getByRole('progressbar');

expect(progressBar).toHaveClass(
'h-[24px]',
'w-full',
'overflow-hidden',
'rounded-full',
'max-w-[1280px]',
);
expect(progressBar).toHaveAttribute('aria-valuemin', '0');
expect(progressBar).toHaveAttribute('aria-valuemax', '100');
expect(progressBar).toHaveAttribute('aria-valuenow', '35');
});

it.each([
['PROS', 'bg-camp-blue'],
['CONS', 'bg-camp-red'],
['DISABLED', 'bg-default-neutral'],
] as const)('%s 팀 색상을 진행 영역에 적용한다', (team, colorClass) => {
render(<TimerProgressBar progress={50} team={team} isRunning={false} />);

expect(screen.getByTestId('timer-progress-fill')).toHaveClass(colorClass);
});

it.each([
[-10, 0],
[120, 100],
])('진행률 %s를 %s 범위로 제한한다', (progress, expectedProgress) => {
render(
<TimerProgressBar
progress={progress}
team="DISABLED"
isRunning={false}
/>,
);

expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuenow',
String(expectedProgress),
);
expect(animateMock).toHaveBeenCalledWith(
expect.anything(),
expectedProgress,
expect.objectContaining({ duration: 0 }),
);
});

it('실행 중에는 0.7초 easeOut으로 애니메이션하고 정지 상태에서는 즉시 동기화한다', () => {
const { rerender } = render(
<TimerProgressBar progress={30} team="PROS" isRunning={true} />,
);

expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 30, {
duration: 0.7,
ease: 'easeOut',
});

rerender(<TimerProgressBar progress={60} team="PROS" isRunning={false} />);

expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 60, {
duration: 0,
ease: 'easeOut',
});
});
});
66 changes: 66 additions & 0 deletions src/components/TimerProgressBar/TimerProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import clsx from 'clsx';
import {
animate,
clamp,
motion,
useMotionValue,
useTransform,
} from 'framer-motion';
import { useEffect } from 'react';

export type TimerProgressBarTeam = 'PROS' | 'CONS' | 'DISABLED';

interface TimerProgressBarProps {
progress: number;
team: TimerProgressBarTeam;
isRunning: boolean;
className?: string;
}

const TEAM_COLOR_CLASS: Record<TimerProgressBarTeam, string> = {
PROS: 'bg-camp-blue',
CONS: 'bg-camp-red',
DISABLED: 'bg-default-neutral',
};

export default function TimerProgressBar({
progress,
team,
isRunning,
className,
}: TimerProgressBarProps) {
const normalizedProgress = clamp(0, 100, progress);
const progressMotionValue = useMotionValue(normalizedProgress);
const width = useTransform(
progressMotionValue,
(currentProgress) => `${currentProgress}%`,
);

useEffect(() => {
const controls = animate(progressMotionValue, normalizedProgress, {
duration: isRunning ? 0.7 : 0,
ease: 'easeOut',
});

return () => controls.stop();
}, [isRunning, normalizedProgress, progressMotionValue]);

return (
<div
className={clsx(
'h-[24px] w-full overflow-hidden rounded-full bg-default-disabled/hover',
className,
)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={normalizedProgress}
>
<motion.div
className={clsx('h-full rounded-full', TEAM_COLOR_CLASS[team])}
data-testid="timer-progress-fill"
style={{ width }}
/>
</div>
);
}
50 changes: 50 additions & 0 deletions src/hooks/sockets/useAudienceSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,56 @@ describe('useAudienceSocket', () => {
expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function));
});

it('비활성 상태에서는 roomId 기반 채널을 구독하지 않는다', () => {
renderHook(() => useAudienceSocket(123, { enabled: false }));

expect(subscribe).not.toHaveBeenCalled();
});

it('활성 상태로 전환되면 roomId 기반 채널을 구독한다', () => {
const { rerender } = renderHook(
({ enabled }) => useAudienceSocket(123, { enabled }),
{ initialProps: { enabled: false } },
);

expect(subscribe).not.toHaveBeenCalled();

rerender({ enabled: true });

expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function));
});

it('enabled가 true에서 false로 전환되면 latestMessage를 null로 초기화하고 unsubscribe를 호출해야 한다', () => {
const message: SocketMessage = {
eventType: 'FINISHED',
data: null,
};
let handleMessage: (message: IMessage) => void = () => undefined;
subscribe.mockImplementation(
(_destination: string, callback: (message: IMessage) => void) => {
handleMessage = callback;
},
);

const { result, rerender } = renderHook(
({ enabled }) => useAudienceSocket(123, { enabled }),
{ initialProps: { enabled: true } },
);

act(() => {
handleMessage({ body: JSON.stringify(message) } as IMessage);
});

expect(result.current.latestMessage).toEqual(message);

act(() => {
rerender({ enabled: false });
});

expect(result.current.latestMessage).toBeNull();
expect(unsubscribe).toHaveBeenCalledWith('/room/123');
});

it('유효한 메시지를 수신하면 latestMessage 상태를 업데이트해야 한다', () => {
const message: SocketMessage = {
eventType: 'FINISHED',
Expand Down
23 changes: 19 additions & 4 deletions src/hooks/sockets/useAudienceSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,29 @@ import { isSocketMessage } from '../../apis/sockets/util';
* 청중 전용 웹소켓 훅입니다.
* 토론 이벤트 채널을 구독하여 실시간 토론 정보를 수령합니다.
*
* * ⚠️ 주의: 이 훅을 컴포넌트에서 호출하여 페이지가 마운트(렌더링)되는 순간,
* * ⚠️ 주의: 기본적으로 이 훅을 컴포넌트에서 호출하여 페이지가 마운트(렌더링)되는 순간,
* 내부의 `useEffect`가 실행되어 즉시 `/room/{roomId}` 채널에 대한 구독(Subscribe)을 요청합니다.
* 컴포넌트가 언마운트되면 해당 채널의 구독은 자동으로 해제됩니다.
* `enabled`가 false이면 구독하지 않으며, 컴포넌트가 언마운트되면 활성 구독은 자동으로 해제됩니다.
* * 청중은 토론 데이터를 수신만 하며, 송신(Publish) 권한은 제공되지 않습니다.
*
* @param {number} roomId - 입장한 토론방의 고유 ID
* @param {UseAudienceSocketOptions} options - 소켓 채널 구독 활성화 옵션
* @returns {Object} 청중 소켓 상태와 제어 함수를 반환합니다.
* @returns {SocketMessage | null} returns.latestMessage - 검증된 가장 최근의 수신 메시지입니다.
* @returns {boolean} returns.isConnected - 소켓 연결 상태입니다.
* @returns {Function} returns.connect - `useSocket.connect`에 위임하기 전에 현재 메시지를 초기화합니다.
* @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다.
* @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다.
*/
export default function useAudienceSocket(roomId: number) {
interface UseAudienceSocketOptions {
Comment thread
i-meant-to-be marked this conversation as resolved.
enabled?: boolean;
}

export default function useAudienceSocket(
roomId: number,
options: UseAudienceSocketOptions = {},
) {
const { enabled = true } = options;
Comment thread
i-meant-to-be marked this conversation as resolved.
const {
connect,
disconnect,
Expand Down Expand Up @@ -72,6 +81,11 @@ export default function useAudienceSocket(roomId: number) {
}, [addConnectionListener, resetMessage]);

useEffect(() => {
if (!enabled) {
resetMessage();
return;
}
Comment thread
i-meant-to-be marked this conversation as resolved.

const destination = `/room/${roomId}`;

resetMessage();
Expand All @@ -92,9 +106,10 @@ export default function useAudienceSocket(roomId: number) {

// 컴포넌트 언마운트 또는 roomId 변경 시 해당 채널 구독 해제
return () => {
resetMessage();
unsubscribe(destination);
};
}, [roomId, resetMessage, subscribe, unsubscribe]);
}, [enabled, roomId, resetMessage, subscribe, unsubscribe]);

return {
latestMessage,
Expand Down
Loading
Loading