Skip to content
Open
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
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(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

페이지가 처음 렌더링되거나 새로고침될 때, 타이머가 이미 진행 중인 상태(예: 80%)라면 진행률 바가 0%부터 80%까지 애니메이션되는 시각적 어색함이 발생할 수 있습니다. useMotionValuenormalizedProgress로 초기화하면 첫 렌더링 시에는 애니메이션 없이 현재 진행률을 즉시 표시하고, 이후 변경 사항에 대해서만 부드럽게 애니메이션되도록 개선할 수 있습니다.

Suggested change
const progressMotionValue = useMotionValue(0);
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>
);
}
19 changes: 19 additions & 0 deletions src/hooks/sockets/useAudienceSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ 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('유효한 메시지를 수신하면 latestMessage 상태를 업데이트해야 한다', () => {
const message: SocketMessage = {
eventType: 'FINISHED',
Expand Down
21 changes: 17 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인터페이스 이름이 use로 시작이 되어서 살짝 훅 같이 헷갈리는 것 같아요 !
AudienceSocketOptions 요런 이름은 어떨까요?

enabled?: boolean;
}

export default function useAudienceSocket(
roomId: number,
options: UseAudienceSocketOptions = {},
) {
const { enabled = true } = options;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 AudienceSharePage에서 API 조회 성공 이후에만 소켓을 붙이기 위해 enabled를 넘기고 있는데, 훅 자체의 기본값은 true라서 옵션을 빼먹으면 바로 구독이 시작됩니다. 의도한 설계라면 괜찮지만, 청중 소켓처럼 roomId와 초기 데이터 준비 순서가 중요한 훅이라면 기본값을 명시적으로 둘지, 호출부에서 항상 enabled를 넘기게 할지 정해두면 더 예측 가능할 것 같습니다 !!

const {
connect,
disconnect,
Expand Down Expand Up @@ -72,6 +81,10 @@ export default function useAudienceSocket(roomId: number) {
}, [addConnectionListener, resetMessage]);

useEffect(() => {
if (!enabled) {
return;
}
Comment on lines +84 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

구독을 비활성화할 때 이전 메시지도 초기화하세요.

enabledtrue에서 false로 바뀌면 구독은 해제되지만 latestMessage는 남아, 소비자가 비활성 기간에 직전 이벤트를 현재 메시지로 읽을 수 있습니다.

수정 예시
     if (!enabled) {
+      resetMessage();
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!enabled) {
return;
}
if (!enabled) {
resetMessage();
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/sockets/useAudienceSocket.ts` around lines 84 - 86, Update the
enabled-state handling in useAudienceSocket so transitioning enabled from true
to false also clears latestMessage when unsubscribing. Preserve the existing
subscription behavior for enabled states and ensure consumers cannot read the
previous event while the subscription is disabled.


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

resetMessage();
Expand All @@ -94,7 +107,7 @@ export default function useAudienceSocket(roomId: number) {
return () => {
unsubscribe(destination);
};
}, [roomId, resetMessage, subscribe, unsubscribe]);
}, [enabled, roomId, resetMessage, subscribe, unsubscribe]);

return {
latestMessage,
Expand Down
Loading
Loading