-
Notifications
You must be signed in to change notification settings - Fork 2
[FEAT] 청중용 라이브 토론 공유 페이지 개선 #464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
c1f5cd7
6eb323e
84fca95
308cfb9
07c1ba0
fe14e05
aee5cbf
7ee6045
cac970f
944e338
69ade37
2c4e9a7
222086b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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', | ||
| }); | ||
| }); | ||
| }); |
| 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); | ||
| 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> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 인터페이스 이름이 use로 시작이 되어서 살짝 훅 같이 헷갈리는 것 같아요 ! |
||||||||||||||||
| enabled?: boolean; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export default function useAudienceSocket( | ||||||||||||||||
| roomId: number, | ||||||||||||||||
| options: UseAudienceSocketOptions = {}, | ||||||||||||||||
| ) { | ||||||||||||||||
| const { enabled = true } = options; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재는 AudienceSharePage에서 API 조회 성공 이후에만 소켓을 붙이기 위해 enabled를 넘기고 있는데, 훅 자체의 기본값은 true라서 옵션을 빼먹으면 바로 구독이 시작됩니다. 의도한 설계라면 괜찮지만, 청중 소켓처럼 roomId와 초기 데이터 준비 순서가 중요한 훅이라면 기본값을 명시적으로 둘지, 호출부에서 항상 enabled를 넘기게 할지 정해두면 더 예측 가능할 것 같습니다 !! |
||||||||||||||||
| const { | ||||||||||||||||
| connect, | ||||||||||||||||
| disconnect, | ||||||||||||||||
|
|
@@ -72,6 +81,10 @@ export default function useAudienceSocket(roomId: number) { | |||||||||||||||
| }, [addConnectionListener, resetMessage]); | ||||||||||||||||
|
|
||||||||||||||||
| useEffect(() => { | ||||||||||||||||
| if (!enabled) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+84
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 구독을 비활성화할 때 이전 메시지도 초기화하세요.
수정 예시 if (!enabled) {
+ resetMessage();
return;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
|
|
||||||||||||||||
| const destination = `/room/${roomId}`; | ||||||||||||||||
|
|
||||||||||||||||
| resetMessage(); | ||||||||||||||||
|
|
@@ -94,7 +107,7 @@ export default function useAudienceSocket(roomId: number) { | |||||||||||||||
| return () => { | ||||||||||||||||
| unsubscribe(destination); | ||||||||||||||||
| }; | ||||||||||||||||
| }, [roomId, resetMessage, subscribe, unsubscribe]); | ||||||||||||||||
| }, [enabled, roomId, resetMessage, subscribe, unsubscribe]); | ||||||||||||||||
|
|
||||||||||||||||
| return { | ||||||||||||||||
| latestMessage, | ||||||||||||||||
|
|
||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
페이지가 처음 렌더링되거나 새로고침될 때, 타이머가 이미 진행 중인 상태(예: 80%)라면 진행률 바가 0%부터 80%까지 애니메이션되는 시각적 어색함이 발생할 수 있습니다.
useMotionValue를normalizedProgress로 초기화하면 첫 렌더링 시에는 애니메이션 없이 현재 진행률을 즉시 표시하고, 이후 변경 사항에 대해서만 부드럽게 애니메이션되도록 개선할 수 있습니다.