diff --git a/src/components/TimerProgressBar/TimerProgressBar.test.tsx b/src/components/TimerProgressBar/TimerProgressBar.test.tsx new file mode 100644 index 00000000..f1752da3 --- /dev/null +++ b/src/components/TimerProgressBar/TimerProgressBar.test.tsx @@ -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('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( + , + ); + + 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(); + + expect(screen.getByTestId('timer-progress-fill')).toHaveClass(colorClass); + }); + + it.each([ + [-10, 0], + [120, 100], + ])('진행률 %s를 %s 범위로 제한한다', (progress, expectedProgress) => { + render( + , + ); + + 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( + , + ); + + expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 30, { + duration: 0.7, + ease: 'easeOut', + }); + + rerender(); + + expect(animateMock).toHaveBeenLastCalledWith(expect.anything(), 60, { + duration: 0, + ease: 'easeOut', + }); + }); +}); diff --git a/src/components/TimerProgressBar/TimerProgressBar.tsx b/src/components/TimerProgressBar/TimerProgressBar.tsx new file mode 100644 index 00000000..09841cc7 --- /dev/null +++ b/src/components/TimerProgressBar/TimerProgressBar.tsx @@ -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 = { + 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 ( +
+ +
+ ); +} diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts index a41e4245..4dbb08db 100644 --- a/src/hooks/sockets/useAudienceSocket.test.ts +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -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', diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts index 5782fad3..b495a1f6 100644 --- a/src/hooks/sockets/useAudienceSocket.ts +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -8,12 +8,13 @@ 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 - 소켓 연결 상태입니다. @@ -21,7 +22,15 @@ import { isSocketMessage } from '../../apis/sockets/util'; * @returns {Function} returns.disconnect - `useSocket.disconnect`에 위임하기 전에 현재 메시지를 초기화합니다. * @returns {Error | null} returns.error - 가장 최근에 발생한 소켓 오류입니다. */ -export default function useAudienceSocket(roomId: number) { +interface UseAudienceSocketOptions { + enabled?: boolean; +} + +export default function useAudienceSocket( + roomId: number, + options: UseAudienceSocketOptions = {}, +) { + const { enabled = true } = options; const { connect, disconnect, @@ -72,6 +81,10 @@ export default function useAudienceSocket(roomId: number) { }, [addConnectionListener, resetMessage]); useEffect(() => { + if (!enabled) { + return; + } + 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, diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index ae7ac2c2..06d7935b 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -3,21 +3,91 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import userEvent from '@testing-library/user-event'; import { Component, ReactNode } from 'react'; -import AudienceSharePage, { - AudienceShareDisplayError, -} from './AudienceSharePage'; +import AudienceSharePage from './AudienceSharePage'; import { useAudienceShareState } from './hooks/useAudienceShareState'; -import { AudienceShareError, AudienceShareErrorCode } from './error'; +import { AudienceShareError } from './error'; +import { useGetDebateTableDataForShare } from '../../hooks/query/useGetDebateTableDataForShare'; +import { GetDebateTableDataForShareResponseType } from '../../apis/responses/live'; // 모의 (Mock) vi.mock('./hooks/useAudienceShareState'); +vi.mock('../../hooks/query/useGetDebateTableDataForShare'); +vi.mock('../../hooks/mutations/useLogout', () => ({ + default: () => ({ mutate: vi.fn() }), +})); vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (translated, [name, value]) => translated.replace(`{{${name}}}`, value), + key, + ), + i18n: { + language: 'ko', + resolvedLanguage: 'ko', + }, }), })); const mockUseAudienceShareState = vi.mocked(useAudienceShareState); +const mockUseGetDebateTableDataForShare = vi.mocked( + useGetDebateTableDataForShare, +); + +const mockDebateTableData: GetDebateTableDataForShareResponseType = { + id: 123, + info: { + name: '공유 토론 테이블', + agenda: '공유 토론 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'CONS', + speechType: '반론', + bell: null, + boxType: 'NORMAL', + time: 90, + timePerTeam: null, + timePerSpeaking: null, + speaker: '이토론', + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 180, + timePerSpeaking: 30, + speaker: null, + }, + ], +}; + +type ShareQueryResult = ReturnType; + +function setQueryState( + state: Partial & + Pick, +) { + mockUseGetDebateTableDataForShare.mockReturnValue({ + data: undefined, + error: null, + ...state, + } as ShareQueryResult); +} class TestErrorBoundary extends Component< { children: ReactNode; onError: (error: Error) => void }, @@ -62,6 +132,12 @@ describe('AudienceSharePage', () => { beforeEach(() => { vi.clearAllMocks(); + setQueryState({ + data: mockDebateTableData, + isError: false, + isLoading: false, + isSuccess: true, + }); mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, @@ -97,6 +173,25 @@ describe('AudienceSharePage', () => { }); describe('상태별 화면 렌더링', () => { + it('API 조회 중에는 소켓 연결을 비활성화하고 회색 스피너만 표시한다', () => { + setQueryState({ + isError: false, + isLoading: true, + isSuccess: false, + }); + + renderPage('/live/123'); + + expect(mockUseAudienceShareState).toHaveBeenCalledWith(123, { + enabled: false, + table: undefined, + }); + expect(screen.getByRole('status', { name: 'Loading' })).toHaveClass( + 'text-gray-500', + ); + expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); + }); + it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', () => { mockUseAudienceShareState.mockReturnValue({ status: 'connecting', @@ -104,9 +199,14 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); + expect(mockUseAudienceShareState).toHaveBeenCalledWith(123, { + enabled: true, + table: mockDebateTableData.table, + }); expect( screen.getByRole('status', { name: 'Loading' }), ).toBeInTheDocument(); + expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); // 타이머 요소 없는지 확인 expect(screen.queryByText('남은 시간')).not.toBeInTheDocument(); expect(screen.queryByText('찬성')).not.toBeInTheDocument(); @@ -122,6 +222,45 @@ describe('AudienceSharePage', () => { expect( screen.getByText('토론 시작을 대기 중입니다.'), ).toBeInTheDocument(); + expect(screen.getByText('공유 토론 테이블')).toBeInTheDocument(); + expect(screen.getByText('공유 토론 주제')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '언어 선택' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '홈으로 이동' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '로그인' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: '도움말' }), + ).not.toBeInTheDocument(); + }); + + it('테이블 이름과 주제가 공백이면 TimerPage와 같은 대체 문구를 표시한다', () => { + setQueryState({ + data: { + ...mockDebateTableData, + info: { + ...mockDebateTableData.info, + name: ' ', + agenda: '', + }, + }, + isError: false, + isLoading: false, + isSuccess: true, + }); + mockUseAudienceShareState.mockReturnValue({ + status: 'waiting', + error: null, + }); + + renderPage('/live/123'); + + expect(screen.getByText('테이블 이름 없음')).toBeInTheDocument(); + expect(screen.getByText('주제 없음')).toBeInTheDocument(); }); it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', () => { @@ -133,12 +272,45 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: true, singleTime: 120, + sequence: 0, + }, + }); + renderPage('/live/123'); + + expect(screen.getByText('입론')).toBeInTheDocument(); + expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(screen.getByText('토론자 없음')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '02 : 00', + ); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '33.33333333333333', + ); + }); + + it('NORMAL sequence의 CONS 항목에 반대 팀 정보를 연결한다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 45, + sequence: 1, }, }); + renderPage('/live/123'); - expect(screen.getByText('남은 시간')).toBeInTheDocument(); - expect(screen.getByText('02:00')).toBeInTheDocument(); + expect(screen.getByText('반론')).toBeInTheDocument(); + expect(screen.getByText('반대 팀')).toBeInTheDocument(); + expect(screen.getByText('이토론 토론자')).toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-camp-red', + ); }); it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', () => { @@ -151,17 +323,24 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: true, singleTime: 10, + sequence: 0, }, }); renderPage('/live/123'); - expect(screen.getByText('00:10')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); act(() => { vi.advanceTimersByTime(2000); }); - expect(screen.getByText('00:08')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 08', + ); }); it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', () => { @@ -174,6 +353,7 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: false, singleTime: 10, + sequence: 0, }, }); renderPage('/live/123'); @@ -182,7 +362,10 @@ describe('AudienceSharePage', () => { vi.advanceTimersByTime(2000); }); - expect(screen.getByText('00:10')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); }); it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', () => { @@ -193,16 +376,25 @@ describe('AudienceSharePage', () => { timerType: 'TIME_BASED', currentTeam: 'PROS', isRunning: true, - prosTime: 180, - consTime: 150, + prosTime: 20, + consTime: null, + sequence: 2, + eventType: 'PLAY', + revision: 1, }, }); renderPage('/live/123'); - expect(screen.getByText('찬성')).toBeInTheDocument(); - expect(screen.getByText('03:00')).toBeInTheDocument(); - expect(screen.getByText('반대')).toBeInTheDocument(); - expect(screen.getByText('02:30')).toBeInTheDocument(); + expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(screen.getByText('반대 팀')).toBeInTheDocument(); + expect(screen.getByTestId('pros-total-timer')).toHaveAttribute( + 'aria-label', + '03 : 00', + ); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 20', + ); }); it('PLAY 상태의 TIME_BASED 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', () => { @@ -216,19 +408,34 @@ describe('AudienceSharePage', () => { isRunning: true, prosTime: 10, consTime: 20, + sequence: 2, + eventType: 'PLAY', + revision: 1, }, }); renderPage('/live/123'); - expect(screen.getByText('00:10')).toBeInTheDocument(); - expect(screen.getByText('00:20')).toBeInTheDocument(); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); + expect(screen.getByTestId('cons-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 30', + ); act(() => { vi.advanceTimersByTime(2000); }); - expect(screen.getByText('00:08')).toBeInTheDocument(); - expect(screen.getByText('00:20')).toBeInTheDocument(); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 08', + ); + expect(screen.getByTestId('cons-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 30', + ); }); it('finished 상태에서는 종료 문구와 페이지 닫기 버튼이 표시되며 클릭 시 window.close를 호출한다', async () => { @@ -256,47 +463,151 @@ describe('AudienceSharePage', () => { // 상태 변경이나 홈 이동이 없어야 함 expect(screen.getByText('토론이 종료되었습니다.')).toBeInTheDocument(); }); - }); - describe('오류 처리 로직 (AudienceShareDisplayError)', () => { - const errorMap: Record = { - SOCKET_URL_UNAVAILABLE: '실시간 연결 주소를 확인할 수 없어요.', - SOCKET_SERVER_REJECTED: '토론방 연결이 거부되었어요.', - SOCKET_STOMP_ERROR: '실시간 연결에서 서버 오류가 발생했어요.', - SOCKET_RETRY_EXHAUSTED: '실시간 연결을 복구하지 못했어요.', - EVENT_TIMEOUT: '토론 상태를 오래 받지 못했어요.', - SERVER_ERROR: '서버에서 토론 진행 오류를 전달했어요.', - UNKNOWN: '알 수 없는 실시간 오류가 발생했어요.', - }; - - it.each(Object.entries(errorMap))( - '상태 훅에서 %s 오류가 발생하면 번역된 Error를 throw하고 원본 오류를 보존한다', - (code, expectedMessage) => { - const sourceError = new AudienceShareError( - code as AudienceShareErrorCode, - ); - mockUseAudienceShareState.mockReturnValue({ - status: 'connecting', - error: sourceError, - }); - - let caughtError: AudienceShareDisplayError | null = null; - - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); // Error boundary logging 숨김 - - renderPage('/live/123', (err) => { - caughtError = err as AudienceShareDisplayError; - }); - - expect(caughtError).toBeInstanceOf(Error); - expect(caughtError!.name).toBe('AudienceShareDisplayError'); - expect(caughtError!.message).toBe(expectedMessage); - expect(caughtError!.sourceError).toBe(sourceError); - - consoleErrorSpy.mockRestore(); + it.each([ + { + name: '배열 범위를 벗어난 sequence', + sequence: 10, + table: mockDebateTableData.table, }, - ); + { + name: 'TIME_BASED API 항목', + sequence: 0, + table: [ + { + ...mockDebateTableData.table[0], + boxType: 'TIME_BASED' as const, + time: null, + timePerTeam: 60, + }, + ], + }, + { + name: '0초 NORMAL API 항목', + sequence: 0, + table: [{ ...mockDebateTableData.table[0], time: 0 }], + }, + ])('$name이면 서버 연결 오류 UI를 표시한다', ({ sequence, table }) => { + setQueryState({ + data: { ...mockDebateTableData, table }, + isError: false, + isLoading: false, + isSuccess: true, + }); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence, + }, + }); + + renderPage('/live/123'); + + expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('NEUTRAL NORMAL 항목은 중립 진행 바로 타이머를 표시한다', () => { + setQueryState({ + data: { + ...mockDebateTableData, + table: [ + { + ...mockDebateTableData.table[0], + stance: 'NEUTRAL', + time: 60, + }, + ], + }, + isError: false, + isLoading: false, + isSuccess: true, + }); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence: 0, + }, + }); + + renderPage('/live/123'); + + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-default-neutral', + ); + }); + }); + + describe('오류 처리', () => { + it('API 조회에 실패하면 페이지 중앙에 데이터 오류와 새로고침 버튼을 표시한다', () => { + setQueryState({ + error: new Error('API failure'), + isError: true, + isLoading: false, + isSuccess: false, + }); + + renderPage('/live/123'); + + expect( + screen.getByText( + '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + ), + ).toBeInTheDocument(); + expect( + screen.getByTestId('audience-share-error-icon'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '새로고침' }), + ).toBeInTheDocument(); + expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); + }); + + it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: new AudienceShareError('SOCKET_STOMP_ERROR'), + }); + + renderPage('/live/123'); + + expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '새로고침' }), + ).toBeInTheDocument(); + }); + + it('API와 소켓 오류가 함께 있으면 소켓 오류를 우선 표시한다', () => { + setQueryState({ + error: new Error('API failure'), + isError: true, + isLoading: false, + isSuccess: false, + }); + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: new AudienceShareError('SOCKET_STOMP_ERROR'), + }); + + renderPage('/live/123'); + + expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); + expect( + screen.queryByText( + '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + ), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 2517b168..c783ede5 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -1,21 +1,101 @@ import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { MdErrorOutline } from 'react-icons/md'; import { useAudienceShareState } from './hooks/useAudienceShareState'; import { useAudienceCountdown } from './hooks/useAudienceCountdown'; -import { AudienceShareError, AudienceShareErrorCode } from './error'; +import { useAudienceTimeBasedCountdown } from './hooks/useAudienceTimeBasedCountdown'; import AudienceNormalTimer from './components/AudienceNormalTimer'; import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import LoadingSpinner from '../../components/LoadingSpinner'; +import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; +import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; +import { useGetDebateTableDataForShare } from '../../hooks/query/useGetDebateTableDataForShare'; +import { DebateInfo, TimeBoxInfo } from '../../type/type'; -export class AudienceShareDisplayError extends Error { - public readonly sourceError: AudienceShareError; +interface ErrorContentProps { + message: string; + onReload: () => void; +} + +function LoadingContent() { + return ( +
+ +
+ ); +} + +function ErrorContent({ message, onReload }: ErrorContentProps) { + const { t } = useTranslation(); + + return ( +
+
+ ); +} + +type ValidNormalTimeBox = TimeBoxInfo & { + boxType: 'NORMAL'; + time: number; +}; + +type ValidTimeBasedTimeBox = TimeBoxInfo & { + boxType: 'TIME_BASED'; + timePerTeam: number; + timePerSpeaking: number | null; +}; + +function isValidNormalTimeBox( + timeBox: TimeBoxInfo | undefined, +): timeBox is ValidNormalTimeBox { + return ( + timeBox?.boxType === 'NORMAL' && timeBox.time !== null && timeBox.time > 0 + ); +} + +function isValidTimeBasedTimeBox( + timeBox: TimeBoxInfo | undefined, +): timeBox is ValidTimeBasedTimeBox { + return ( + timeBox?.boxType === 'TIME_BASED' && + timeBox.timePerTeam !== null && + timeBox.timePerTeam > 0 && + (timeBox.timePerSpeaking === null || timeBox.timePerSpeaking > 0) + ); +} - constructor(message: string, sourceError: AudienceShareError) { - super(message); - this.name = 'AudienceShareDisplayError'; - this.sourceError = sourceError; +function getNormalTimerTeamName( + stance: TimeBoxInfo['stance'], + info: DebateInfo, +) { + if (stance === 'PROS') { + return info.prosTeamName; } + + if (stance === 'CONS') { + return info.consTeamName; + } + + return ''; } export default function AudienceSharePage() { @@ -23,14 +103,38 @@ export default function AudienceSharePage() { const { t } = useTranslation(); const tableId = Number(id); + const isValidTableId = + !!id && !isNaN(tableId) && tableId > 0 && Number.isInteger(tableId); + const debateTableQuery = useGetDebateTableDataForShare( + isValidTableId ? tableId : undefined, + ); + const state = useAudienceShareState(tableId, { + enabled: isValidTableId && debateTableQuery.isSuccess, + table: debateTableQuery.data?.table, + }); - if (!id || isNaN(tableId) || tableId <= 0 || !Number.isInteger(tableId)) { + if (!isValidTableId) { throw new Error(t('유효하지 않은 토론방 ID입니다.')); } - const state = useAudienceShareState(tableId); const audienceDisplayData = state.status === 'displaying' ? state.displayData : null; + const normalDisplayData = + audienceDisplayData?.timerType === 'NORMAL' ? audienceDisplayData : null; + const timeBasedDisplayData = + audienceDisplayData?.timerType === 'TIME_BASED' + ? audienceDisplayData + : null; + const normalTimeBox = normalDisplayData + ? debateTableQuery.data?.table[normalDisplayData.sequence] + : undefined; + const hasInvalidNormalTimeBox = + normalDisplayData !== null && !isValidNormalTimeBox(normalTimeBox); + const timeBasedTimeBox = timeBasedDisplayData + ? debateTableQuery.data?.table[timeBasedDisplayData.sequence] + : undefined; + const hasInvalidTimeBasedTimeBox = + timeBasedDisplayData !== null && !isValidTimeBasedTimeBox(timeBasedTimeBox); const normalCountdown = useAudienceCountdown({ receivedTime: @@ -42,25 +146,14 @@ export default function AudienceSharePage() { ? audienceDisplayData.isRunning : false, }); - const prosCountdown = useAudienceCountdown({ - receivedTime: - audienceDisplayData?.timerType === 'TIME_BASED' - ? audienceDisplayData.prosTime - : null, - isRunning: - audienceDisplayData?.timerType === 'TIME_BASED' && - audienceDisplayData.isRunning && - audienceDisplayData.currentTeam === 'PROS', - }); - const consCountdown = useAudienceCountdown({ - receivedTime: - audienceDisplayData?.timerType === 'TIME_BASED' - ? audienceDisplayData.consTime - : null, - isRunning: - audienceDisplayData?.timerType === 'TIME_BASED' && - audienceDisplayData.isRunning && - audienceDisplayData.currentTeam === 'CONS', + const timeBasedCountdown = useAudienceTimeBasedCountdown({ + displayData: timeBasedDisplayData, + timePerTeam: isValidTimeBasedTimeBox(timeBasedTimeBox) + ? timeBasedTimeBox.timePerTeam + : null, + timePerSpeaking: isValidTimeBasedTimeBox(timeBasedTimeBox) + ? timeBasedTimeBox.timePerSpeaking + : null, }); const handleClosePage = () => { @@ -73,34 +166,46 @@ export default function AudienceSharePage() { }, 100); }; - if (state.error) { - const errorMessages: Record = { - SOCKET_URL_UNAVAILABLE: '실시간 연결 주소를 확인할 수 없어요.', - SOCKET_SERVER_REJECTED: '토론방 연결이 거부되었어요.', - SOCKET_STOMP_ERROR: '실시간 연결에서 서버 오류가 발생했어요.', - SOCKET_RETRY_EXHAUSTED: '실시간 연결을 복구하지 못했어요.', - EVENT_TIMEOUT: '토론 상태를 오래 받지 못했어요.', - SERVER_ERROR: '서버에서 토론 진행 오류를 전달했어요.', - UNKNOWN: '알 수 없는 실시간 오류가 발생했어요.', - }; - - throw new AudienceShareDisplayError( - t(errorMessages[state.error.code]), - state.error, - ); - } + const handleReload = () => { + window.location.reload(); + }; const renderContent = () => { - console.log(`# Status = ${state.status}`); + if (state.error) { + return ( + + ); + } - if (state.status === 'connecting') { + if (debateTableQuery.isError) { return ( -
- -
+ + ); + } + + if (hasInvalidNormalTimeBox || hasInvalidTimeBasedTimeBox) { + return ( + ); } + if ( + debateTableQuery.isLoading || + !debateTableQuery.data || + state.status === 'connecting' + ) { + return ; + } + if (state.status === 'waiting') { return (
@@ -114,24 +219,73 @@ export default function AudienceSharePage() { if (state.status === 'displaying') { const { displayData } = state; if (displayData.timerType === 'NORMAL') { + if (!isValidNormalTimeBox(normalTimeBox)) { + return null; + } + return (
); } if (displayData.timerType === 'TIME_BASED') { + if (!isValidTimeBasedTimeBox(timeBasedTimeBox)) { + return null; + } + + const prosTotalRemainingTime = + timeBasedCountdown.pros.totalRemainingTime ?? + timeBasedTimeBox.timePerTeam; + const consTotalRemainingTime = + timeBasedCountdown.cons.totalRemainingTime ?? + timeBasedTimeBox.timePerTeam; + const prosCurrentSpeakingRemainingTime = + timeBasedTimeBox.timePerSpeaking === null + ? null + : (timeBasedCountdown.pros.currentSpeakingRemainingTime ?? + timeBasedTimeBox.timePerSpeaking); + const consCurrentSpeakingRemainingTime = + timeBasedTimeBox.timePerSpeaking === null + ? null + : (timeBasedCountdown.cons.currentSpeakingRemainingTime ?? + timeBasedTimeBox.timePerSpeaking); + return (
); @@ -158,8 +312,39 @@ export default function AudienceSharePage() { return null; }; + const isReady = + debateTableQuery.isSuccess && + !!debateTableQuery.data && + !state.error && + !hasInvalidNormalTimeBox && + !hasInvalidTimeBasedTimeBox && + state.status !== 'connecting'; + return ( + {isReady ? ( + + + + + + + + + + ) : null}
{renderContent()} diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx index 1933a79b..d9de53dd 100644 --- a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx @@ -1,19 +1,137 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import AudienceNormalTimer from './AudienceNormalTimer'; describe('AudienceNormalTimer', () => { - it('남은 시간 레이블과 포맷팅된 시간을 올바르게 렌더링한다', () => { - render(); + it('발언 유형, 팀명, 토론자명과 포맷팅된 시간을 렌더링한다', () => { + render( + , + ); - // @AC-037: "남은 시간" 레이블은 i18next 번역을 통해 렌더링된다. - expect(screen.getByText('남은 시간')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '입론' })).toBeInTheDocument(); + expect(screen.queryByText('남은 시간')).not.toBeInTheDocument(); + const debateIcon = screen.getByTestId('debate-icon'); + const participantRow = screen.getByTestId('participant-row'); + const timerValue = screen.getByTestId('timer-value'); - // @AC-036: remainingTime={65}일 때 화면에 01:05가 표시된다. - expect(screen.getByText('01:05')).toBeInTheDocument(); - - // @AC-038: 컴포넌트는 사용자 조작 요소 없이 props만으로 동일한 UI를 렌더링한다. - // 버튼 등 조작 요소가 없는지 확인한다. + expect(debateIcon).toHaveAttribute('aria-hidden', 'true'); + expect(debateIcon).toHaveClass('h-[34px]'); + expect(participantRow).toHaveClass('mt-[24px]', 'text-[34px]'); + expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(screen.getByText('김토론 토론자')).toBeInTheDocument(); + expect(timerValue).toHaveClass( + 'mt-[64px]', + 'grid-cols-[2ch_1ch_2ch]', + 'gap-x-[0.33ch]', + ); + expect(timerValue).toHaveAttribute('aria-label', '01 : 05'); + expect(screen.getByText('01')).toBeInTheDocument(); + expect(screen.getByText(':')).toHaveClass('text-center'); + expect(screen.getByText('05')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '50', + ); + expect(screen.getByRole('progressbar')).toHaveClass('mt-[108px]'); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + it('알 수 없는 발언 유형은 원문을 표시한다', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: '사용자 지정 발언' }), + ).toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-camp-red', + ); + }); + + it('NEUTRAL 진영은 팀명과 토론자 정보를 표시하지 않는다', () => { + render( + , + ); + + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); + expect(screen.queryByText('팀명 없음')).not.toBeInTheDocument(); + expect(screen.queryByText('토론자 없음')).not.toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-default-neutral', + ); + }); + + it.each([ + { remainingTime: 80, expectedProgress: 0 }, + { remainingTime: -20, expectedProgress: 100 }, + ])( + '경과 진행률을 0~100으로 제한한다: $remainingTime', + ({ remainingTime, expectedProgress }) => { + render( + , + ); + + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + String(expectedProgress), + ); + }, + ); + + it('남은 시간이 음수이면 마이너스를 표시하고 진행률을 100%로 고정한다', () => { + render( + , + ); + + expect(screen.getByTestId('negative-sign')).toHaveTextContent('-'); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '- 00 : 05', + ); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '100', + ); + }); }); diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx index 77b4dc85..46933397 100644 --- a/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx @@ -1,23 +1,103 @@ import { useTranslation } from 'react-i18next'; import { Formatting } from '../../../util/formatting'; +import { Stance } from '../../../type/type'; +import DTDebate from '../../../components/icons/Debate'; +import TimerProgressBar, { + TimerProgressBarTeam, +} from '../../../components/TimerProgressBar/TimerProgressBar'; +import { normalizeSpeechTypeKey } from '../../../util/speechType'; interface AudienceNormalTimerProps { remainingTime: number; + totalTime: number; + speechType: string; + stance: Stance; + teamName: string; + speaker: string | null; + isRunning: boolean; +} + +function getProgressBarTeam(stance: Stance): TimerProgressBarTeam { + return stance === 'NEUTRAL' ? 'DISABLED' : stance; } export default function AudienceNormalTimer({ remainingTime, + totalTime, + speechType, + stance, + teamName, + speaker, + isRunning, }: AudienceNormalTimerProps) { const { t } = useTranslation(); + const normalizedSpeechType = normalizeSpeechTypeKey(speechType); + const speechTypeLabel = normalizedSpeechType + ? t(normalizedSpeechType) + : speechType; + const teamLabel = + teamName.trim() === '' + ? t('팀명 없음') + : t('{{team}} 팀', { team: t(teamName) }); + const speakerLabel = + !speaker || speaker.trim() === '' + ? t('토론자 없음') + : t('{{speaker}} 토론자', { speaker: t(speaker) }); + const progress = ((totalTime - remainingTime) / totalTime) * 100; + const [minutes, seconds] = Formatting.formatSecondsToMMSS( + Math.abs(remainingTime), + ).split(':'); + const isOvertime = remainingTime < 0; return ( -
+

- {t('남은 시간')} + {speechTypeLabel}

- - {Formatting.formatSecondsToMMSS(remainingTime)} + + {stance !== 'NEUTRAL' && ( +
+
+ )} + + + {isOvertime ? ( + + ) : null} + {minutes} + + {seconds} + +
); } diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx index d3a5a52a..7f6530a7 100644 --- a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.test.tsx @@ -1,64 +1,71 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; import AudienceTimeBasedTimer from './AudienceTimeBasedTimer'; -// Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (translated, [name, value]) => translated.replace(`{{${name}}}`, value), + key, + ), }), })); describe('AudienceTimeBasedTimer', () => { - it('양 팀에 수신값이 있으면 각각 독립적으로 포맷되어 표시된다 (@AC-039)', () => { + it('PROS를 왼쪽, CONS를 오른쪽의 동일 너비 영역에 표시한다', () => { render( , ); - expect(screen.getByText('01:05')).toBeInTheDocument(); - expect(screen.getByText('00:40')).toBeInTheDocument(); + + const row = screen.getByTestId('time-based-timer-row'); + const [pros, cons] = Array.from(row.children); + + expect(pros).toHaveAttribute('data-testid', 'pros-timer-display'); + expect(cons).toHaveAttribute('data-testid', 'cons-timer-display'); + expect(pros).toHaveClass('flex-1'); + expect(cons).toHaveClass('flex-1'); + expect(screen.getByText('한빛 팀')).toBeInTheDocument(); + expect(screen.getByText('다온 팀')).toBeInTheDocument(); }); - it('수신 이력이 없는 팀의 null 값은 --:--로 표시된다 (@AC-040)', () => { + it('양 팀에 1회당 발언 시간이 있으면 각각 전체/현재 시간을 표시한다', () => { render( , ); - const nullDisplays = screen.getAllByText('--:--'); - expect(nullDisplays).toHaveLength(2); - }); - it('PROS인 현재 발언 팀은 접근성 상태와 텍스트로 식별된다 (@AC-041)', () => { - render( - , + expect(screen.getAllByText('전체 시간')).toHaveLength(2); + expect(screen.getAllByText('현재 시간')).toHaveLength(2); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 20', ); - const prosSpeakingStatus = screen.getByTestId('pros-speaking-status'); - expect(prosSpeakingStatus).toBeInTheDocument(); - expect( - screen.queryByTestId('cons-speaking-status'), - ).not.toBeInTheDocument(); - }); - - it('CONS인 현재 발언 팀은 접근성 상태와 텍스트로 식별된다 (@AC-041)', () => { - render( - , + expect(screen.getByTestId('cons-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 15', ); - const consSpeakingStatus = screen.getByTestId('cons-speaking-status'); - expect(consSpeakingStatus).toBeInTheDocument(); + expect(screen.getByTestId('cons-speaking-status')).toBeInTheDocument(); expect( screen.queryByTestId('pros-speaking-status'), ).not.toBeInTheDocument(); diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx index 54acfc06..61e37595 100644 --- a/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimer.tsx @@ -1,89 +1,56 @@ -import { useTranslation } from 'react-i18next'; import { TimeBasedStance } from '../../../type/type'; -import { Formatting } from '../../../util/formatting'; -import clsx from 'clsx'; +import AudienceTimeBasedTimerDisplay from './AudienceTimeBasedTimerDisplay'; export interface AudienceTimeBasedTimerProps { - prosRemainingTime: number | null; - consRemainingTime: number | null; + prosTeamName: string; + consTeamName: string; + timePerTeam: number; + timePerSpeaking: number | null; + prosTotalRemainingTime: number; + consTotalRemainingTime: number; + prosCurrentSpeakingRemainingTime: number | null; + consCurrentSpeakingRemainingTime: number | null; currentTeam: TimeBasedStance; + isRunning: boolean; } export default function AudienceTimeBasedTimer({ - prosRemainingTime, - consRemainingTime, + prosTeamName, + consTeamName, + timePerTeam, + timePerSpeaking, + prosTotalRemainingTime, + consTotalRemainingTime, + prosCurrentSpeakingRemainingTime, + consCurrentSpeakingRemainingTime, currentTeam, + isRunning, }: AudienceTimeBasedTimerProps) { - const { t } = useTranslation(); - - const formatTime = (time: number | null) => { - if (time === null) return '--:--'; - return Formatting.formatSecondsToMMSS(time); - }; - return ( -
- {/* 찬성 팀 영역 */} -
-
- {currentTeam === 'PROS' && ( - - {t('현재 발언 중')} - - )} - {currentTeam === 'PROS' && ( - - )} -
-

{t('찬성')}

-
- {formatTime(prosRemainingTime)} -
-
- - {/* 반대 팀 영역 */} -
-
- {currentTeam === 'CONS' && ( - - {t('현재 발언 중')} - - )} - {currentTeam === 'CONS' && ( - - )} -
-

{t('반대')}

-
- {formatTime(consRemainingTime)} -
-
+
+ +
); } diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx new file mode 100644 index 00000000..66daa610 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx @@ -0,0 +1,175 @@ +import { render, screen } from '@testing-library/react'; +import AudienceTimeBasedTimerDisplay from './AudienceTimeBasedTimerDisplay'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (translated, [name, value]) => translated.replace(`{{${name}}}`, value), + key, + ), + }), +})); + +describe('AudienceTimeBasedTimerDisplay', () => { + it('1회당 발언 시간이 없으면 제목, 전체 타이머, 프로그레스 바를 표시한다', () => { + render( + , + ); + + expect(screen.getByText('한빛 팀')).toBeInTheDocument(); + expect(screen.getByTestId('pros-total-timer')).toHaveAttribute( + 'aria-label', + '01 : 30', + ); + expect(screen.queryByText('전체 시간')).not.toBeInTheDocument(); + expect(screen.queryByText('현재 시간')).not.toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '25', + ); + }); + + it('1회당 발언 시간이 있으면 전체/현재 배지와 두 타이머를 표시한다', () => { + render( + , + ); + + expect(screen.getByText('전체 시간')).toHaveClass( + 'h-[48px]', + 'w-[144px]', + 'bg-default-black', + 'text-[24px]', + ); + expect(screen.getByText('현재 시간')).toHaveClass( + 'h-[64px]', + 'w-[200px]', + 'bg-camp-red', + 'text-[32px]', + ); + expect(screen.getByTestId('cons-total-timer')).toHaveAttribute( + 'aria-label', + '04 : 00', + ); + expect(screen.getByTestId('cons-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 45', + ); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '25', + ); + }); + + it('빈 팀 이름은 폴백을 표시하고 비활성 팀의 모든 요소를 무채색으로 표시한다', () => { + render( + , + ); + + expect(screen.getByText('팀명 없음')).toHaveClass( + 'text-default-disabled/hover', + ); + expect(screen.getByTestId('cons-timer-display')).not.toHaveClass( + 'bg-gray-100', + 'opacity-50', + 'grayscale', + ); + expect(screen.getByText('전체 시간')).toHaveClass( + 'bg-default-disabled/hover', + ); + expect(screen.getByText('현재 시간')).toHaveClass( + 'bg-default-disabled/hover', + ); + expect(screen.getByTestId('cons-total-timer')).toHaveClass( + 'text-default-disabled/hover', + ); + expect(screen.getByTestId('cons-current-timer')).toHaveClass( + 'text-default-disabled/hover', + ); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-default-neutral', + ); + expect( + screen.queryByTestId('cons-speaking-status'), + ).not.toBeInTheDocument(); + }); + + it('현재 팀은 접근성 상태와 팀 색상으로 식별된다', () => { + render( + , + ); + + expect(screen.getByTestId('pros-timer-display')).toHaveAttribute( + 'aria-current', + 'step', + ); + expect(screen.getByTestId('pros-speaking-status')).toHaveTextContent( + '현재 발언 중', + ); + expect(screen.getByText('현재 시간')).toHaveClass('bg-camp-blue'); + }); + + it('0초 아래의 값과 100%를 넘는 진행도를 각각 0초와 100%로 제한한다', () => { + render( + , + ); + + expect(screen.getByTestId('pros-total-timer')).toHaveAttribute( + 'aria-label', + '00 : 00', + ); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 00', + ); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '100', + ); + }); +}); diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx new file mode 100644 index 00000000..a924e0e6 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx @@ -0,0 +1,157 @@ +import clsx from 'clsx'; +import { useTranslation } from 'react-i18next'; +import TimerProgressBar from '../../../components/TimerProgressBar/TimerProgressBar'; +import { TimeBasedStance } from '../../../type/type'; +import { Formatting } from '../../../util/formatting'; + +interface AudienceTimeBasedTimerDisplayProps { + team: TimeBasedStance; + teamName: string; + timePerTeam: number; + timePerSpeaking: number | null; + totalRemainingTime: number; + currentSpeakingRemainingTime: number | null; + isCurrentTeam: boolean; + isRunning: boolean; +} + +interface TimerValueProps { + seconds: number; + testId: string; + isDisabled: boolean; + className?: string; +} + +function TimerValue({ + seconds, + testId, + isDisabled, + className, +}: TimerValueProps) { + const normalizedSeconds = Math.max(0, seconds); + const [minutes, remainingSeconds] = + Formatting.formatSecondsToMMSS(normalizedSeconds).split(':'); + + return ( + + {minutes} + + {remainingSeconds} + + ); +} + +export default function AudienceTimeBasedTimerDisplay({ + team, + teamName, + timePerTeam, + timePerSpeaking, + totalRemainingTime, + currentSpeakingRemainingTime, + isCurrentTeam, + isRunning, +}: AudienceTimeBasedTimerDisplayProps) { + const { t } = useTranslation(); + const teamId = team.toLowerCase(); + const teamLabel = + teamName.trim() === '' + ? t('팀명 없음') + : t('{{team}} 팀', { team: t(teamName) }); + const progressBase = timePerSpeaking === null ? timePerTeam : timePerSpeaking; + const progressRemaining = + timePerSpeaking === null + ? totalRemainingTime + : (currentSpeakingRemainingTime ?? timePerSpeaking); + const progress = ((progressBase - progressRemaining) / progressBase) * 100; + const activeCurrentBadgeClass = + team === 'PROS' ? 'bg-camp-blue' : 'bg-camp-red'; + + return ( +
+ {isCurrentTeam ? ( + + {t('현재 발언 중')} + + ) : null} + +

+ {teamLabel} +

+ + {timePerSpeaking === null ? ( + <> + + + + ) : ( + <> + + {t('전체 시간')} + + + + {t('현재 시간')} + + + + + )} +
+ ); +} diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts index 7d35420f..a9999795 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts @@ -66,7 +66,7 @@ describe('useAudienceCountdown', () => { expect(result.current.currentSeconds).toBe(14); }); - it('0 아래로 내려가지 않음', () => { + it('0 아래에서도 음수로 카운트다운을 계속한다', () => { const { result } = renderHook(() => useAudienceCountdown({ receivedTime: 1, isRunning: true }), ); @@ -75,10 +75,91 @@ describe('useAudienceCountdown', () => { vi.advanceTimersByTime(2000); }); + expect(result.current.currentSeconds).toBe(-1); + expect(result.current.formattedTime).toBe('-00:01'); + }); + + it('최소 시간이 설정되면 해당 값에서 멈춘다', () => { + const { result } = renderHook(() => + useAudienceCountdown({ + receivedTime: 1, + isRunning: true, + minimumTime: 0, + }), + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(0); expect(result.current.formattedTime).toBe('00:00'); }); + it('실행 상태 변경 시 초기화하지 않는 옵션은 정지한 값에서 재개한다', () => { + const { result, rerender } = renderHook( + (props) => useAudienceCountdown(props), + { + initialProps: { + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: false, + shouldResetOnRunStateChange: false, + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.currentSeconds).toBe(7); + }); + + it('동일한 수신값도 syncKey가 변경되면 다시 동기화한다', () => { + const { result, rerender } = renderHook( + (props) => useAudienceCountdown(props), + { + initialProps: { + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + syncKey: 1, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: false, + shouldResetOnRunStateChange: false, + syncKey: 2, + }); + + expect(result.current.currentSeconds).toBe(10); + }); + it('입력 변경과 언마운트 시 interval 정리', () => { const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts index 2ee1e248..a24b12ba 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts @@ -4,6 +4,9 @@ import { Formatting } from '../../../util/formatting'; export interface UseAudienceCountdownParams { receivedTime: number | null; isRunning: boolean; + minimumTime?: number; + shouldResetOnRunStateChange?: boolean; + syncKey?: number; } export interface UseAudienceCountdownReturn { @@ -14,34 +17,85 @@ export interface UseAudienceCountdownReturn { export function useAudienceCountdown({ receivedTime, isRunning, + minimumTime, + shouldResetOnRunStateChange = true, + syncKey, }: UseAudienceCountdownParams): UseAudienceCountdownReturn { const [currentSeconds, setCurrentSeconds] = useState( receivedTime, ); const intervalRef = useRef | null>(null); const targetTimeRef = useRef(null); + const currentSecondsRef = useRef(receivedTime); + const previousReceivedTimeRef = useRef(undefined); + const previousSyncKeyRef = useRef(undefined); + const isInitializedRef = useRef(false); useEffect(() => { - // cleanup previous interval if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } - setCurrentSeconds(receivedTime); + const hasReceivedTimeChanged = + previousReceivedTimeRef.current !== receivedTime; + const hasSyncKeyChanged = previousSyncKeyRef.current !== syncKey; + const shouldSynchronize = + !isInitializedRef.current || + hasReceivedTimeChanged || + hasSyncKeyChanged || + shouldResetOnRunStateChange; + const normalizedReceivedTime = + receivedTime === null || minimumTime === undefined + ? receivedTime + : Math.max(minimumTime, receivedTime); + const startingSeconds = shouldSynchronize + ? normalizedReceivedTime + : currentSecondsRef.current; - if (isRunning && receivedTime !== null) { - targetTimeRef.current = Date.now() + receivedTime * 1000; + if (shouldSynchronize) { + currentSecondsRef.current = normalizedReceivedTime; + setCurrentSeconds(normalizedReceivedTime); + } + + previousReceivedTimeRef.current = receivedTime; + previousSyncKeyRef.current = syncKey; + isInitializedRef.current = true; + + const hasReachedMinimum = + minimumTime !== undefined && + startingSeconds !== null && + startingSeconds <= minimumTime; + + if (isRunning && startingSeconds !== null && !hasReachedMinimum) { + targetTimeRef.current = Date.now() + startingSeconds * 1000; intervalRef.current = setInterval(() => { if (targetTimeRef.current === null) return; const now = Date.now(); - const remainingTime = Math.max( - 0, - Math.ceil((targetTimeRef.current - now) / 1000), + const rawRemainingTime = Math.ceil( + (targetTimeRef.current - now) / 1000, ); + const remainingTime = + minimumTime === undefined + ? rawRemainingTime + : Math.max(minimumTime, rawRemainingTime); + + currentSecondsRef.current = remainingTime; setCurrentSeconds(remainingTime); + + if ( + minimumTime !== undefined && + rawRemainingTime <= minimumTime && + intervalRef.current + ) { + clearInterval(intervalRef.current); + intervalRef.current = null; + targetTimeRef.current = null; + } }, 200); + } else { + targetTimeRef.current = null; } return () => { @@ -50,11 +104,19 @@ export function useAudienceCountdown({ intervalRef.current = null; } }; - }, [receivedTime, isRunning]); + }, [ + isRunning, + minimumTime, + receivedTime, + shouldResetOnRunStateChange, + syncKey, + ]); const formattedTime = currentSeconds !== null - ? Formatting.formatSecondsToMMSS(currentSeconds) + ? `${currentSeconds < 0 ? '-' : ''}${Formatting.formatSecondsToMMSS( + Math.abs(currentSeconds), + )}` : '00:00'; return { currentSeconds, formattedTime }; diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index 3b3398d0..0ec0a429 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -4,12 +4,35 @@ import { useAudienceCountdown } from './useAudienceCountdown'; import * as useAudienceSocketModule from '../../../hooks/sockets/useAudienceSocket'; import { SocketMessage } from '../../../apis/sockets/type'; import { SocketError } from '../../../apis/sockets/error'; +import { TimeBoxInfo } from '../../../type/type'; vi.mock('../../../hooks/sockets/useAudienceSocket'); describe('useAudienceShareState', () => { let mockConnect: ReturnType; let mockDisconnect: ReturnType; + const normalTable: TimeBoxInfo[] = [ + { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'CONS', + speechType: '반론', + bell: null, + boxType: 'NORMAL', + time: 90, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + ]; beforeEach(() => { vi.useFakeTimers(); @@ -50,6 +73,36 @@ describe('useAudienceShareState', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); + it('연결이 비활성화되면 소켓에 연결하거나 연결 해제를 시도하지 않는다.', () => { + const { result, unmount } = renderHook(() => + useAudienceShareState(1, { enabled: false }), + ); + + expect(result.current.status).toBe('connecting'); + expect(mockConnect).not.toHaveBeenCalled(); + + unmount(); + + expect(mockDisconnect).not.toHaveBeenCalled(); + }); + + it('연결이 활성화되는 시점에 소켓 연결을 한 번 시작하고 언마운트 시 정리한다.', () => { + const { rerender, unmount } = renderHook( + ({ enabled }) => useAudienceShareState(1, { enabled }), + { initialProps: { enabled: false } }, + ); + + expect(mockConnect).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + expect(mockConnect).toHaveBeenCalledTimes(1); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + it('연결 전에는 connecting, 연결 후 데이터 대기, 데이터 표시, 종료 상태가 입력에 따라 명확히 구분된다.', () => { // 연결 전 connecting const { result, rerender } = renderHook(() => useAudienceShareState(1)); @@ -98,10 +151,104 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.timerType).toBe('NORMAL'); if (result.current.displayData.timerType === 'NORMAL') { expect(result.current.displayData.singleTime).toBe(60); + expect(result.current.displayData.sequence).toBe(1); } } }); + it.each([ + { eventType: 'PLAY' as const, isRunning: true }, + { eventType: 'STOP' as const, isRunning: false }, + ])('$eventType 이벤트를 독립적으로 처리한다', ({ eventType, isRunning }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 75 }, + }, + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(isRunning); + expect(result.current.displayData.singleTime).toBe(75); + expect(result.current.displayData.sequence).toBe(0); + } + }); + + it('RESET 이벤트는 현재 순서의 전체 시간으로 되돌린다', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'RESET', + data: { timerType: 'NORMAL', sequence: 1, remainingTime: 12 }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: normalTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.singleTime).toBe(90); + } + }); + + it.each([ + { + eventType: 'BEFORE' as const, + receivedSequence: 1, + expectedSequence: 0, + expectedTime: 180, + }, + { + eventType: 'NEXT' as const, + receivedSequence: 0, + expectedSequence: 1, + expectedTime: 90, + }, + ])( + '$eventType 이벤트는 sequence를 이동하고 목표 순서의 전체 시간을 표시한다', + ({ eventType, receivedSequence, expectedSequence, expectedTime }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { + timerType: 'NORMAL', + sequence: receivedSequence, + remainingTime: 12, + }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: normalTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(expectedSequence); + expect(result.current.displayData.singleTime).toBe(expectedTime); + } + }, + ); + it('시간 기반 이벤트는 현재 팀 시간만 갱신하고 상대 팀의 마지막 수신 시간 또는 null을 유지한다.', () => { const { result, rerender } = renderHook(() => useAudienceShareState(1)); @@ -177,6 +324,9 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.prosTime).toBe(77); expect(result.current.displayData.consTime).toBeNull(); expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.eventType).toBe('TEAM_SWITCH'); + expect(result.current.displayData.revision).toBe(1); } } @@ -202,7 +352,121 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.prosTime).toBe(77); expect(result.current.displayData.consTime).toBe(55); expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(2); + expect(result.current.displayData.revision).toBe(2); + } + } + }); + + it('실행 중 TEAM_SWITCH는 새 팀에서도 실행 상태를 유지한다.', () => { + let latestMessage: SocketMessage = { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 30, + }, + }; + setSocketState({ isConnected: true, latestMessage }); + + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + latestMessage = { + eventType: 'TEAM_SWITCH', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 25, + }, + }; + setSocketState({ isConnected: true, latestMessage }); + rerender(); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.currentTeam).toBe('CONS'); + expect(result.current.displayData.isRunning).toBe(true); + expect(result.current.displayData.eventType).toBe('TEAM_SWITCH'); + expect(result.current.displayData.revision).toBe(2); + } + }); + + it.each([ + { eventType: 'BEFORE' as const, sequence: 2, expectedSequence: 1 }, + { eventType: 'NEXT' as const, sequence: 0, expectedSequence: 1 }, + ])( + 'TIME_BASED $eventType는 실제 이동 대상 sequence를 보존한다.', + ({ eventType, sequence, expectedSequence }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence, + remainingTime: 30, + }, + }, + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.sequence).toBe(expectedSequence); + expect(result.current.displayData.eventType).toBe(eventType); } + }, + ); + + it('NORMAL에서 TIME_BASED 순서로 이동하면 API 설정으로 시간 기반 표시 상태를 만든다.', () => { + const mixedTable: TimeBoxInfo[] = [ + normalTable[0], + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 120, + timePerSpeaking: 30, + speaker: null, + }, + ]; + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'NEXT', + data: { + timerType: 'NORMAL', + sequence: 0, + remainingTime: 20, + }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: mixedTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.currentTeam).toBe('PROS'); + expect(result.current.displayData.prosTime).toBe(30); + expect(result.current.displayData.eventType).toBe('NEXT'); } }); @@ -224,7 +488,7 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('waiting'); }); - it('sequence는 내부 최신 payload에는 유지되지만 반환 UI 상태와 렌더링 props에는 노출되지 않는다.', () => { + it('NORMAL 이벤트의 sequence를 표시 상태에 보존한다.', () => { setSocketState({ isConnected: true, latestMessage: { @@ -236,9 +500,10 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('displaying'); if (result.current.status === 'displaying') { - expect( - (result.current.displayData as Record).sequence, - ).toBeUndefined(); + expect(result.current.displayData.timerType).toBe('NORMAL'); + if (result.current.displayData.timerType === 'NORMAL') { + expect(result.current.displayData.sequence).toBe(5); + } } }); diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index db895c9e..4ff002f0 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -1,22 +1,27 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import useAudienceSocket from '../../../hooks/sockets/useAudienceSocket'; import { AudienceShareError, AudienceShareErrorCode } from '../error'; -import { TimeBasedStance } from '../../../type/type'; +import { TimeBasedStance, TimeBoxInfo } from '../../../type/type'; import { isSocketError } from '../../../apis/sockets/error'; +import { TimerDataPayload, TimerEventTypes } from '../../../apis/sockets/type'; -type AudienceNormalDisplayData = { +export type AudienceNormalDisplayData = { timerType: 'NORMAL'; currentTeam: null; isRunning: boolean; singleTime: number; + sequence: number; }; -type AudienceTimeBasedDisplayData = { +export type AudienceTimeBasedDisplayData = { timerType: 'TIME_BASED'; currentTeam: TimeBasedStance; isRunning: boolean; prosTime: number | null; consTime: number | null; + sequence: number; + eventType: TimerEventTypes; + revision: number; }; type AudienceDisplayData = @@ -35,18 +40,248 @@ export type AudienceShareState = const EVENT_TIMEOUT_MS = 600 * 1000; +interface UseAudienceShareStateOptions { + enabled?: boolean; + table?: TimeBoxInfo[]; +} + function getNextTeam(team: TimeBasedStance): TimeBasedStance { return team === 'PROS' ? 'CONS' : 'PROS'; } -export function useAudienceShareState(roomId: number): AudienceShareState { +function getNormalTotalTime( + table: TimeBoxInfo[] | undefined, + sequence: number, + fallbackTime: number, +) { + const timeBox = table?.[sequence]; + + if ( + timeBox?.boxType === 'NORMAL' && + timeBox.time !== null && + timeBox.time > 0 + ) { + return timeBox.time; + } + + return fallbackTime; +} + +function createDisplayData( + data: TimerDataPayload, + previousDisplayData: AudienceDisplayData | null, + options: { + eventType: TimerEventTypes; + isRunning: boolean; + sequence?: number; + normalTime?: number; + shouldSwitchTeam?: boolean; + }, +): AudienceDisplayData | null { + const { + eventType, + isRunning, + sequence = data.sequence, + normalTime = data.remainingTime, + shouldSwitchTeam = false, + } = options; + + if (data.timerType === 'NORMAL') { + return { + timerType: 'NORMAL', + currentTeam: null, + isRunning, + singleTime: normalTime, + sequence, + }; + } + + if (data.currentTeam !== 'PROS' && data.currentTeam !== 'CONS') { + return previousDisplayData; + } + + const previousTimeBasedData = + previousDisplayData?.timerType === 'TIME_BASED' + ? previousDisplayData + : null; + const receivedCurrentTeam = data.currentTeam; + + return { + timerType: 'TIME_BASED', + currentTeam: shouldSwitchTeam + ? getNextTeam(receivedCurrentTeam) + : receivedCurrentTeam, + isRunning, + prosTime: + receivedCurrentTeam === 'PROS' + ? data.remainingTime + : (previousTimeBasedData?.prosTime ?? null), + consTime: + receivedCurrentTeam === 'CONS' + ? data.remainingTime + : (previousTimeBasedData?.consTime ?? null), + sequence, + eventType, + revision: (previousTimeBasedData?.revision ?? 0) + 1, + }; +} + +function createNavigationDisplayData( + eventType: 'BEFORE' | 'NEXT', + data: TimerDataPayload, + previousDisplayData: AudienceDisplayData | null, + table: TimeBoxInfo[] | undefined, + sequence: number, +) { + const targetTimeBox = table?.[sequence]; + + if ( + targetTimeBox?.boxType === 'NORMAL' && + targetTimeBox.time !== null && + targetTimeBox.time > 0 + ) { + return { + timerType: 'NORMAL' as const, + currentTeam: null, + isRunning: false, + singleTime: targetTimeBox.time, + sequence, + }; + } + + if ( + targetTimeBox?.boxType === 'TIME_BASED' && + targetTimeBox.timePerTeam !== null && + targetTimeBox.timePerTeam > 0 + ) { + const previousTimeBasedData = + previousDisplayData?.timerType === 'TIME_BASED' + ? previousDisplayData + : null; + const currentTeam = + data.currentTeam === 'PROS' || data.currentTeam === 'CONS' + ? data.currentTeam + : (previousTimeBasedData?.currentTeam ?? 'PROS'); + const initialCurrentTime = + targetTimeBox.timePerSpeaking ?? targetTimeBox.timePerTeam; + + return { + timerType: 'TIME_BASED' as const, + currentTeam, + isRunning: false, + prosTime: currentTeam === 'PROS' ? initialCurrentTime : null, + consTime: currentTeam === 'CONS' ? initialCurrentTime : null, + sequence, + eventType, + revision: (previousTimeBasedData?.revision ?? 0) + 1, + }; + } + + return null; +} + +function getDisplayDataByEvent( + eventType: TimerEventTypes, + data: TimerDataPayload, + previousDisplayData: AudienceDisplayData | null, + table: TimeBoxInfo[] | undefined, +): AudienceDisplayData | null { + switch (eventType) { + case 'PLAY': + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: true, + }); + + case 'STOP': + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: false, + }); + + case 'RESET': + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: false, + normalTime: getNormalTotalTime( + table, + data.sequence, + data.remainingTime, + ), + }); + + case 'BEFORE': { + const previousSequence = data.sequence - 1; + const navigationDisplayData = createNavigationDisplayData( + eventType, + data, + previousDisplayData, + table, + previousSequence, + ); + + if (navigationDisplayData) { + return navigationDisplayData; + } + + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: false, + sequence: previousSequence, + normalTime: getNormalTotalTime( + table, + previousSequence, + data.remainingTime, + ), + }); + } + + case 'NEXT': { + const nextSequence = data.sequence + 1; + const navigationDisplayData = createNavigationDisplayData( + eventType, + data, + previousDisplayData, + table, + nextSequence, + ); + + if (navigationDisplayData) { + return navigationDisplayData; + } + + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: false, + sequence: nextSequence, + normalTime: getNormalTotalTime(table, nextSequence, data.remainingTime), + }); + } + + case 'TEAM_SWITCH': + return createDisplayData(data, previousDisplayData, { + eventType, + isRunning: + previousDisplayData?.timerType === 'TIME_BASED' + ? previousDisplayData.isRunning + : false, + shouldSwitchTeam: true, + }); + } +} + +export function useAudienceShareState( + roomId: number, + options: UseAudienceShareStateOptions = {}, +): AudienceShareState { + const { enabled = true, table } = options; const { connect, disconnect, latestMessage, isConnected, error: socketError, - } = useAudienceSocket(roomId); + } = useAudienceSocket(roomId, { enabled }); const [error, setError] = useState(null); const [isFinished, setIsFinished] = useState(false); @@ -67,15 +302,19 @@ export function useAudienceShareState(roomId: number): AudienceShareState { // Mount/Unmount useEffect(() => { + if (!enabled) { + return; + } + connect(); return () => { cleanup(); }; - }, [connect, cleanup]); + }, [connect, cleanup, enabled]); // Handle Socket Error useEffect(() => { - if (socketError) { + if (enabled && socketError) { cleanup(); let code: AudienceShareErrorCode = 'UNKNOWN'; if (isSocketError(socketError)) { @@ -90,11 +329,11 @@ export function useAudienceShareState(roomId: number): AudienceShareState { ), ); } - }, [socketError, cleanup]); + }, [enabled, socketError, cleanup]); // Handle Messages and Disconnects useEffect(() => { - if (error || isFinished) return; // Ignore if already failed or finished + if (!enabled || error || isFinished) return; // Ignore if inactive, already failed or finished if (!isConnected) { if (timeoutRef.current) { @@ -138,48 +377,15 @@ export function useAudienceShareState(roomId: number): AudienceShareState { const { eventType, data } = latestMessage; if (!data) return; - const isRunning = eventType === 'PLAY'; - - setDisplayData((prev) => { - if (data.timerType === 'NORMAL') { - return { - timerType: 'NORMAL', - currentTeam: null, - isRunning, - singleTime: data.remainingTime, - }; - } - - if (data.currentTeam !== 'PROS' && data.currentTeam !== 'CONS') { - return prev; - } - - const previousTimeBasedData = - prev?.timerType === 'TIME_BASED' ? prev : null; - const receivedCurrentTeam = data.currentTeam; - const displayCurrentTeam = - eventType === 'TEAM_SWITCH' - ? getNextTeam(receivedCurrentTeam) - : receivedCurrentTeam; - - return { - timerType: 'TIME_BASED', - currentTeam: displayCurrentTeam, - isRunning, - prosTime: - receivedCurrentTeam === 'PROS' - ? data.remainingTime - : (previousTimeBasedData?.prosTime ?? null), - consTime: - receivedCurrentTeam === 'CONS' - ? data.remainingTime - : (previousTimeBasedData?.consTime ?? null), - }; - }); - }, [isConnected, latestMessage, error, isFinished, cleanup]); + setDisplayData((previousDisplayData) => + getDisplayDataByEvent(eventType, data, previousDisplayData, table), + ); + }, [enabled, isConnected, latestMessage, error, isFinished, cleanup, table]); let status: AudienceShareState['status'] = 'connecting'; - if (isFinished) { + if (!enabled) { + status = 'connecting'; + } else if (isFinished) { status = 'finished'; } else if (!isConnected) { status = 'connecting'; diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts new file mode 100644 index 00000000..32043069 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -0,0 +1,262 @@ +import { act, renderHook } from '@testing-library/react'; +import { + getNextSpeakingTime, + useAudienceTimeBasedCountdown, +} from './useAudienceTimeBasedCountdown'; +import { AudienceTimeBasedDisplayData } from './useAudienceShareState'; + +function createDisplayData( + overrides: Partial = {}, +): AudienceTimeBasedDisplayData { + return { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: false, + prosTime: 30, + consTime: null, + sequence: 0, + eventType: 'STOP', + revision: 1, + ...overrides, + }; +} + +describe('getNextSpeakingTime', () => { + it('상대 팀 전체 시간이 남으면 전체 잔여 시간과 1회당 시간 중 작은 값을 사용한다', () => { + expect( + getNextSpeakingTime({ + totalRemainingTime: 20, + timePerSpeaking: 30, + isOpponentTotalDone: false, + }), + ).toBe(20); + }); + + it('상대 팀 전체 시간이 끝나면 자신의 전체 잔여 시간을 모두 사용한다', () => { + expect( + getNextSpeakingTime({ + totalRemainingTime: 80, + timePerSpeaking: 30, + isOpponentTotalDone: true, + }), + ).toBe(80); + }); +}); + +describe('useAudienceTimeBasedCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('API 설정값으로 양 팀을 초기화하고 현재 팀의 두 시간만 함께 감소시킨다', () => { + const { result } = renderHook(() => + useAudienceTimeBasedCountdown({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 30, + }), + timePerTeam: 120, + timePerSpeaking: 30, + }), + ); + + expect(result.current.pros.totalRemainingTime).toBe(120); + expect(result.current.pros.currentSpeakingRemainingTime).toBe(30); + expect(result.current.cons.totalRemainingTime).toBe(120); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.pros.totalRemainingTime).toBe(118); + expect(result.current.pros.currentSpeakingRemainingTime).toBe(28); + expect(result.current.cons.totalRemainingTime).toBe(120); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + }); + + it('정지 후 재생해도 전체 시간을 초기값으로 되돌리지 않는다', () => { + const initial = createDisplayData({ + isRunning: true, + eventType: 'PLAY', + revision: 1, + }); + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + }), + { initialProps: { displayData: initial } }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + rerender({ + displayData: createDisplayData({ + isRunning: false, + eventType: 'STOP', + prosTime: 28, + revision: 2, + }), + }); + expect(result.current.pros.totalRemainingTime).toBe(118); + + rerender({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 28, + revision: 3, + }), + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.pros.totalRemainingTime).toBe(117); + expect(result.current.pros.currentSpeakingRemainingTime).toBe(27); + }); + + it('TEAM_SWITCH는 새 팀 현재 시간을 초기화하고 실행 상태를 유지한다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + rerender({ + displayData: createDisplayData({ + currentTeam: 'CONS', + isRunning: true, + eventType: 'TEAM_SWITCH', + prosTime: 28, + revision: 2, + }), + }); + + expect(result.current.pros.totalRemainingTime).toBe(118); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(30); + expect(result.current.cons.isRunning).toBe(true); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.cons.totalRemainingTime).toBe(119); + expect(result.current.cons.currentSpeakingRemainingTime).toBe(29); + }); + + it('RESET은 현재 팀의 전체/현재 시간을 API 설정값으로 초기화한다', () => { + const { result, rerender } = renderHook( + ({ displayData }) => + useAudienceTimeBasedCountdown({ + displayData, + timePerTeam: 120, + timePerSpeaking: 30, + }), + { + initialProps: { + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + revision: 1, + }), + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + rerender({ + displayData: createDisplayData({ + isRunning: false, + eventType: 'RESET', + revision: 2, + }), + }); + + expect(result.current.pros.totalRemainingTime).toBe(120); + expect(result.current.pros.currentSpeakingRemainingTime).toBe(30); + expect(result.current.pros.isRunning).toBe(false); + }); + + it('전체 시간 또는 현재 시간이 끝나면 0초에서 함께 정지한다', () => { + const { result } = renderHook(() => + useAudienceTimeBasedCountdown({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 2, + }), + timePerTeam: 10, + timePerSpeaking: 2, + }), + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.pros.currentSpeakingRemainingTime).toBe(0); + expect(result.current.pros.totalRemainingTime).toBe(8); + expect(result.current.pros.isRunning).toBe(false); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.pros.totalRemainingTime).toBe(8); + }); + + it('1회당 발언 시간이 없으면 소켓의 전체 시간부터 0초까지 감소한다', () => { + const { result } = renderHook(() => + useAudienceTimeBasedCountdown({ + displayData: createDisplayData({ + isRunning: true, + eventType: 'PLAY', + prosTime: 2, + }), + timePerTeam: 10, + timePerSpeaking: null, + }), + ); + + expect(result.current.pros.totalRemainingTime).toBe(2); + expect(result.current.pros.currentSpeakingRemainingTime).toBeNull(); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(result.current.pros.totalRemainingTime).toBe(0); + expect(result.current.pros.isRunning).toBe(false); + }); +}); diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts new file mode 100644 index 00000000..90c3bfc5 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -0,0 +1,379 @@ +import { useEffect, useRef, useState } from 'react'; +import { TimeBasedStance } from '../../../type/type'; +import { useAudienceCountdown } from './useAudienceCountdown'; +import { AudienceTimeBasedDisplayData } from './useAudienceShareState'; + +interface TeamCountdownInput { + totalTime: number | null; + speakingTime: number | null; + totalSyncKey: number; + speakingSyncKey: number; +} + +interface CountdownInputs { + sequence: number | null; + timePerTeam: number | null; + timePerSpeaking: number | null; + pros: TeamCountdownInput; + cons: TeamCountdownInput; +} + +interface TeamCountdownResult { + totalRemainingTime: number | null; + currentSpeakingRemainingTime: number | null; + isRunning: boolean; +} + +export interface UseAudienceTimeBasedCountdownParams { + displayData: AudienceTimeBasedDisplayData | null; + timePerTeam: number | null; + timePerSpeaking: number | null; +} + +export interface UseAudienceTimeBasedCountdownReturn { + pros: TeamCountdownResult; + cons: TeamCountdownResult; +} + +interface GetNextSpeakingTimeParams { + totalRemainingTime: number; + timePerSpeaking: number | null; + isOpponentTotalDone: boolean; +} + +const EMPTY_TEAM_INPUT: TeamCountdownInput = { + totalTime: null, + speakingTime: null, + totalSyncKey: 0, + speakingSyncKey: 0, +}; + +function createInitialInputs(): CountdownInputs { + return { + sequence: null, + timePerTeam: null, + timePerSpeaking: null, + pros: EMPTY_TEAM_INPUT, + cons: EMPTY_TEAM_INPUT, + }; +} + +function createTeamInput( + totalTime: number, + speakingTime: number | null, + previousInput: TeamCountdownInput, +): TeamCountdownInput { + return { + totalTime, + speakingTime, + totalSyncKey: previousInput.totalSyncKey + 1, + speakingSyncKey: previousInput.speakingSyncKey + 1, + }; +} + +function getTeamValue(team: TimeBasedStance, prosValue: T, consValue: T) { + return team === 'PROS' ? prosValue : consValue; +} + +function updateTeamInput( + inputs: CountdownInputs, + team: TimeBasedStance, + update: (input: TeamCountdownInput) => TeamCountdownInput, +) { + if (team === 'PROS') { + return { ...inputs, pros: update(inputs.pros) }; + } + + return { ...inputs, cons: update(inputs.cons) }; +} + +export function getNextSpeakingTime({ + totalRemainingTime, + timePerSpeaking, + isOpponentTotalDone, +}: GetNextSpeakingTimeParams) { + if (timePerSpeaking === null) { + return null; + } + + if (isOpponentTotalDone) { + return totalRemainingTime; + } + + return Math.min(totalRemainingTime, timePerSpeaking); +} + +export function useAudienceTimeBasedCountdown({ + displayData, + timePerTeam, + timePerSpeaking, +}: UseAudienceTimeBasedCountdownParams): UseAudienceTimeBasedCountdownReturn { + const [inputs, setInputs] = useState(createInitialInputs); + const [isProsLocallyStopped, setIsProsLocallyStopped] = useState(false); + const [isConsLocallyStopped, setIsConsLocallyStopped] = useState(false); + const configurationKeyRef = useRef(''); + const latestValuesRef = useRef({ + prosTotal: null as number | null, + prosSpeaking: null as number | null, + consTotal: null as number | null, + consSpeaking: null as number | null, + }); + + const isProsRequestedToRun = + displayData?.isRunning === true && + displayData.currentTeam === 'PROS' && + !isProsLocallyStopped; + const isConsRequestedToRun = + displayData?.isRunning === true && + displayData.currentTeam === 'CONS' && + !isConsLocallyStopped; + + const prosTotalCountdown = useAudienceCountdown({ + receivedTime: inputs.pros.totalTime, + isRunning: isProsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.pros.totalSyncKey, + }); + const prosSpeakingCountdown = useAudienceCountdown({ + receivedTime: inputs.pros.speakingTime, + isRunning: isProsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.pros.speakingSyncKey, + }); + const consTotalCountdown = useAudienceCountdown({ + receivedTime: inputs.cons.totalTime, + isRunning: isConsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.cons.totalSyncKey, + }); + const consSpeakingCountdown = useAudienceCountdown({ + receivedTime: inputs.cons.speakingTime, + isRunning: isConsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.cons.speakingSyncKey, + }); + + const prosTotal = prosTotalCountdown.currentSeconds; + const prosSpeaking = prosSpeakingCountdown.currentSeconds; + const consTotal = consTotalCountdown.currentSeconds; + const consSpeaking = consSpeakingCountdown.currentSeconds; + const displaySequence = displayData?.sequence ?? null; + const displayRevision = displayData?.revision ?? 0; + const displayEventType = displayData?.eventType ?? null; + const displayCurrentTeam = displayData?.currentTeam ?? null; + const displayProsTime = displayData?.prosTime ?? null; + const displayConsTime = displayData?.consTime ?? null; + + latestValuesRef.current = { + prosTotal, + prosSpeaking, + consTotal, + consSpeaking, + }; + + useEffect(() => { + if (prosTotal === 0 || prosSpeaking === 0) { + setIsProsLocallyStopped(true); + } + }, [prosSpeaking, prosTotal]); + + useEffect(() => { + if (consTotal === 0 || consSpeaking === 0) { + setIsConsLocallyStopped(true); + } + }, [consSpeaking, consTotal]); + + useEffect(() => { + if ( + displaySequence === null || + displayEventType === null || + displayCurrentTeam === null || + timePerTeam === null || + timePerTeam <= 0 || + (timePerSpeaking !== null && timePerSpeaking <= 0) + ) { + configurationKeyRef.current = ''; + setInputs(createInitialInputs()); + setIsProsLocallyStopped(false); + setIsConsLocallyStopped(false); + return; + } + + const configurationKey = `${displaySequence}:${timePerTeam}:${timePerSpeaking}`; + const hasConfigurationChanged = + configurationKeyRef.current !== configurationKey; + configurationKeyRef.current = configurationKey; + + if (hasConfigurationChanged) { + setIsProsLocallyStopped(false); + setIsConsLocallyStopped(false); + } else if ( + displayEventType === 'PLAY' || + displayEventType === 'RESET' || + displayEventType === 'TEAM_SWITCH' + ) { + if (displayCurrentTeam === 'PROS') { + setIsProsLocallyStopped(false); + } else { + setIsConsLocallyStopped(false); + } + } + + setInputs((previousInputs) => { + const defaultSpeakingTime = timePerSpeaking; + let nextInputs = hasConfigurationChanged + ? { + sequence: displaySequence, + timePerTeam, + timePerSpeaking, + pros: createTeamInput( + timePerTeam, + defaultSpeakingTime, + previousInputs.pros, + ), + cons: createTeamInput( + timePerTeam, + defaultSpeakingTime, + previousInputs.cons, + ), + } + : previousInputs; + + const currentTeam = displayCurrentTeam; + const opponentTeam = currentTeam === 'PROS' ? 'CONS' : 'PROS'; + const latestCurrentTotal = getTeamValue( + currentTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const currentInput = getTeamValue( + currentTeam, + nextInputs.pros, + nextInputs.cons, + ); + const currentTotal = latestCurrentTotal ?? currentInput.totalTime ?? 0; + const receivedCurrentTime = getTeamValue( + currentTeam, + displayProsTime, + displayConsTime, + ); + + if (displayEventType === 'BEFORE' || displayEventType === 'NEXT') { + return nextInputs; + } + + if (displayEventType === 'RESET') { + const opponentTotal = getTeamValue( + opponentTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const resetSpeakingTime = getNextSpeakingTime({ + totalRemainingTime: timePerTeam, + timePerSpeaking, + isOpponentTotalDone: opponentTotal === 0, + }); + + return updateTeamInput(nextInputs, currentTeam, (input) => + createTeamInput(timePerTeam, resetSpeakingTime, input), + ); + } + + if (displayEventType === 'TEAM_SWITCH') { + const previousTeam = opponentTeam; + const receivedPreviousTime = getTeamValue( + previousTeam, + displayProsTime, + displayConsTime, + ); + const previousTeamTotal = getTeamValue( + previousTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const newSpeakingTime = getNextSpeakingTime({ + totalRemainingTime: currentTotal, + timePerSpeaking, + isOpponentTotalDone: + (timePerSpeaking === null + ? receivedPreviousTime + : previousTeamTotal) === 0, + }); + + nextInputs = updateTeamInput(nextInputs, previousTeam, (input) => ({ + ...input, + ...(timePerSpeaking === null + ? { totalTime: receivedPreviousTime ?? input.totalTime } + : { speakingTime: receivedPreviousTime ?? input.speakingTime }), + totalSyncKey: + timePerSpeaking === null + ? input.totalSyncKey + 1 + : input.totalSyncKey, + speakingSyncKey: + timePerSpeaking === null + ? input.speakingSyncKey + : input.speakingSyncKey + 1, + })); + nextInputs = updateTeamInput(nextInputs, currentTeam, (input) => ({ + ...input, + speakingTime: newSpeakingTime, + speakingSyncKey: input.speakingSyncKey + 1, + })); + + return nextInputs; + } + + return updateTeamInput(nextInputs, currentTeam, (input) => ({ + ...input, + ...(timePerSpeaking === null + ? { totalTime: receivedCurrentTime ?? input.totalTime } + : { speakingTime: receivedCurrentTime ?? input.speakingTime }), + totalSyncKey: + timePerSpeaking === null + ? input.totalSyncKey + 1 + : input.totalSyncKey, + speakingSyncKey: + timePerSpeaking === null + ? input.speakingSyncKey + : input.speakingSyncKey + 1, + })); + }); + }, [ + displayConsTime, + displayCurrentTeam, + displayEventType, + displayProsTime, + displayRevision, + displaySequence, + timePerSpeaking, + timePerTeam, + ]); + + const isProsRunning = + isProsRequestedToRun && + prosTotal !== null && + prosTotal > 0 && + (prosSpeaking === null || prosSpeaking > 0); + const isConsRunning = + isConsRequestedToRun && + consTotal !== null && + consTotal > 0 && + (consSpeaking === null || consSpeaking > 0); + + return { + pros: { + totalRemainingTime: prosTotal, + currentSpeakingRemainingTime: prosSpeaking, + isRunning: isProsRunning, + }, + cons: { + totalRemainingTime: consTotal, + currentSpeakingRemainingTime: consSpeaking, + isRunning: isConsRunning, + }, + }; +} diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index 9e69f60a..55d7fd65 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -34,6 +34,7 @@ import { useLiveShare } from './hooks/useLiveShare'; import { SocketEventType, TimerDataPayload } from '../../apis/sockets/type'; import AnswerTimeSetting from './components/AnswerTimeSetting'; import AnswerTimeGuideModal from './components/AnswerTimeGuideModal'; +import { getRemainingTimeForShare } from './getRemainingTimeForShare'; // 토론 타이머 실행, 라운드 이동, 종료 흐름을 관리하는 메인 페이지다. export default function TimerPage() { @@ -82,12 +83,13 @@ export default function TimerPage() { normalTimer, } = state; const timerType = data && data.table[index].boxType; - const remainingTime = - timerType === 'NORMAL' - ? normalTimer.timer - : prosConsSelected === 'PROS' - ? timer1.speakingTimer - : timer2.speakingTimer; + const remainingTime = getRemainingTimeForShare({ + timerType, + normalTimer: normalTimer.timer, + currentTeam: prosConsSelected, + prosTimer: timer1, + consTimer: timer2, + }); const { isLiveShareModalOpen, @@ -114,6 +116,15 @@ export default function TimerPage() { // 만약 소켓 열려 있으면, 발송 if (isSocketConnected) { + if (eventType === 'FINISHED') { + issueEvent(eventType, null); + return; + } + + if (remainingTime === null) { + return; + } + // 타입에 따른 페이로드 준비 let innerPayload: TimerDataPayload; @@ -122,24 +133,22 @@ export default function TimerPage() { timerType: timerType, remainingTime: remainingTime, sequence: index, - } as TimerDataPayload; + }; } else if (timerType === 'TIME_BASED') { innerPayload = { currentTeam: prosConsSelected, timerType: timerType, remainingTime: remainingTime, sequence: index, - } as TimerDataPayload; + }; } else { // 피드백 타이머 타입은 여기 올 수 없음 // 따라서 별도 작업 하지 않고 그냥 반환 return; } - const payload = eventType === 'FINISHED' ? null : innerPayload; - // 이벤트 발행 - issueEvent(eventType, payload); + issueEvent(eventType, innerPayload); } }; diff --git a/src/page/TimerPage/getRemainingTimeForShare.test.ts b/src/page/TimerPage/getRemainingTimeForShare.test.ts new file mode 100644 index 00000000..2f4e247d --- /dev/null +++ b/src/page/TimerPage/getRemainingTimeForShare.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { getRemainingTimeForShare } from './getRemainingTimeForShare'; + +describe('getRemainingTimeForShare', () => { + const normalTimer = 45; + const prosTimer = { + totalTimer: 120, + speakingTimer: 30, + isSpeakingTimerAvailable: true, + }; + const consTimer = { + totalTimer: 90, + speakingTimer: null, + isSpeakingTimerAvailable: false, + }; + + it('NORMAL은 일반 타이머의 잔여 시간을 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'NORMAL', + normalTimer, + currentTeam: 'PROS', + prosTimer, + consTimer, + }), + ).toBe(45); + }); + + it('1회당 발언 시간이 있는 TIME_BASED는 선택 팀의 speakingTimer를 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'TIME_BASED', + normalTimer, + currentTeam: 'PROS', + prosTimer, + consTimer, + }), + ).toBe(30); + }); + + it('1회당 발언 시간이 없는 TIME_BASED는 선택 팀의 totalTimer를 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'TIME_BASED', + normalTimer, + currentTeam: 'CONS', + prosTimer, + consTimer, + }), + ).toBe(90); + }); +}); diff --git a/src/page/TimerPage/getRemainingTimeForShare.ts b/src/page/TimerPage/getRemainingTimeForShare.ts new file mode 100644 index 00000000..36e4c008 --- /dev/null +++ b/src/page/TimerPage/getRemainingTimeForShare.ts @@ -0,0 +1,36 @@ +import { TimeBasedStance, TimeBoxType } from '../../type/type'; + +interface ShareTimerState { + totalTimer: number | null; + speakingTimer: number | null; + isSpeakingTimerAvailable: boolean; +} + +interface GetRemainingTimeForShareParams { + timerType: TimeBoxType | undefined; + normalTimer: number | null; + currentTeam: TimeBasedStance; + prosTimer: ShareTimerState; + consTimer: ShareTimerState; +} + +export function getRemainingTimeForShare({ + timerType, + normalTimer, + currentTeam, + prosTimer, + consTimer, +}: GetRemainingTimeForShareParams) { + if (timerType === 'NORMAL') { + return normalTimer; + } + + if (timerType !== 'TIME_BASED') { + return null; + } + + const currentTimer = currentTeam === 'PROS' ? prosTimer : consTimer; + return currentTimer.isSpeakingTimerAvailable + ? currentTimer.speakingTimer + : currentTimer.totalTimer; +}