From c1f5cd7154b30300709d4e8ef24658494c50a3c4 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 01:30:24 +0900 Subject: [PATCH 01/19] =?UTF-8?q?design:=20=ED=94=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=8A=A4=20=EB=B0=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TimerProgressBar.test.tsx | 102 ++++++++++++++++++ .../TimerProgressBar/TimerProgressBar.tsx | 66 ++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/components/TimerProgressBar/TimerProgressBar.test.tsx create mode 100644 src/components/TimerProgressBar/TimerProgressBar.tsx 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 ( +
+ +
+ ); +} From 6eb323efbfd84b25ec24fd28a4bf745813ab6f86 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 01:33:01 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/sockets/useAudienceSocket.test.ts | 19 ++ src/hooks/sockets/useAudienceSocket.ts | 21 +- .../hooks/useAudienceCountdown.test.ts | 6 +- .../hooks/useAudienceCountdown.ts | 9 +- .../hooks/useAudienceShareState.test.ts | 156 ++++++++++++- .../hooks/useAudienceShareState.ts | 205 ++++++++++++++---- 6 files changed, 353 insertions(+), 63 deletions(-) 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/hooks/useAudienceCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts index 7d35420f..9c5d48e5 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,8 +75,8 @@ describe('useAudienceCountdown', () => { vi.advanceTimersByTime(2000); }); - expect(result.current.currentSeconds).toBe(0); - expect(result.current.formattedTime).toBe('00:00'); + expect(result.current.currentSeconds).toBe(-1); + expect(result.current.formattedTime).toBe('-00:01'); }); it('입력 변경과 언마운트 시 interval 정리', () => { diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts index 2ee1e248..f491c5fd 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts @@ -36,10 +36,7 @@ export function useAudienceCountdown({ intervalRef.current = setInterval(() => { if (targetTimeRef.current === null) return; const now = Date.now(); - const remainingTime = Math.max( - 0, - Math.ceil((targetTimeRef.current - now) / 1000), - ); + const remainingTime = Math.ceil((targetTimeRef.current - now) / 1000); setCurrentSeconds(remainingTime); }, 200); } @@ -54,7 +51,9 @@ export function useAudienceCountdown({ 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..acb0b90d 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)); @@ -224,7 +371,7 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('waiting'); }); - it('sequence는 내부 최신 payload에는 유지되지만 반환 UI 상태와 렌더링 props에는 노출되지 않는다.', () => { + it('NORMAL 이벤트의 sequence를 표시 상태에 보존한다.', () => { setSocketState({ isConnected: true, latestMessage: { @@ -236,9 +383,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..41fdffc6 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -1,14 +1,16 @@ 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 = { timerType: 'NORMAL'; currentTeam: null; isRunning: boolean; singleTime: number; + sequence: number; }; type AudienceTimeBasedDisplayData = { @@ -35,18 +37,156 @@ 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: { + isRunning: boolean; + normalSequence?: number; + normalTime?: number; + shouldSwitchTeam?: boolean; + }, +): AudienceDisplayData | null { + const { + isRunning, + normalSequence = data.sequence, + normalTime = data.remainingTime, + shouldSwitchTeam = false, + } = options; + + if (data.timerType === 'NORMAL') { + return { + timerType: 'NORMAL', + currentTeam: null, + isRunning, + singleTime: normalTime, + sequence: normalSequence, + }; + } + + 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), + }; +} + +function getDisplayDataByEvent( + eventType: TimerEventTypes, + data: TimerDataPayload, + previousDisplayData: AudienceDisplayData | null, + table: TimeBoxInfo[] | undefined, +): AudienceDisplayData | null { + switch (eventType) { + case 'PLAY': + return createDisplayData(data, previousDisplayData, { + isRunning: true, + }); + + case 'STOP': + return createDisplayData(data, previousDisplayData, { + isRunning: false, + }); + + case 'RESET': + return createDisplayData(data, previousDisplayData, { + isRunning: false, + normalTime: getNormalTotalTime( + table, + data.sequence, + data.remainingTime, + ), + }); + + case 'BEFORE': { + const previousSequence = data.sequence - 1; + return createDisplayData(data, previousDisplayData, { + isRunning: false, + normalSequence: previousSequence, + normalTime: getNormalTotalTime( + table, + previousSequence, + data.remainingTime, + ), + }); + } + + case 'NEXT': { + const nextSequence = data.sequence + 1; + return createDisplayData(data, previousDisplayData, { + isRunning: false, + normalSequence: nextSequence, + normalTime: getNormalTotalTime(table, nextSequence, data.remainingTime), + }); + } + + case 'TEAM_SWITCH': + return createDisplayData(data, 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 +207,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 +234,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 +282,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'; From 84fca95d4ea335e2e8c304936e080d0ee55af5c0 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 01:33:22 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EB=90=9C=20API=20=ED=99=9C=EC=9A=A9=ED=95=98=EC=97=AC?= =?UTF-8?q?=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceSharePage.test.tsx | 377 +++++++++++++++--- .../AudienceSharePage/AudienceSharePage.tsx | 192 +++++++-- .../components/AudienceNormalTimer.test.tsx | 139 ++++++- .../components/AudienceNormalTimer.tsx | 88 +++- 4 files changed, 702 insertions(+), 94 deletions(-) diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index ae7ac2c2..b53e22a5 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -3,21 +3,81 @@ 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: '이토론', + }, + ], +}; + +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 +122,12 @@ describe('AudienceSharePage', () => { beforeEach(() => { vi.clearAllMocks(); + setQueryState({ + data: mockDebateTableData, + isError: false, + isLoading: false, + isSuccess: true, + }); mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, @@ -97,6 +163,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 +189,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 +212,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 +262,45 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: true, singleTime: 120, + sequence: 0, }, }); 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-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('반대 팀')).toBeInTheDocument(); + expect(screen.getByText('이토론 토론자')).toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-camp-red', + ); }); it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', () => { @@ -151,17 +313,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 +343,7 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: false, singleTime: 10, + sequence: 0, }, }); renderPage('/live/123'); @@ -182,7 +352,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가 전달된다', () => { @@ -256,47 +429,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.getByText('팀명 없음')).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..37584518 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -1,21 +1,83 @@ 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 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; +}; + +function isValidNormalTimeBox( + timeBox: TimeBoxInfo | undefined, +): timeBox is ValidNormalTimeBox { + return ( + timeBox?.boxType === 'NORMAL' && timeBox.time !== null && timeBox.time > 0 + ); +} + +function getNormalTimerTeamName( + stance: TimeBoxInfo['stance'], + info: DebateInfo, +) { + if (stance === 'PROS') { + return info.prosTeamName; + } - constructor(message: string, sourceError: AudienceShareError) { - super(message); - this.name = 'AudienceShareDisplayError'; - this.sourceError = sourceError; + if (stance === 'CONS') { + return info.consTeamName; } + + return ''; } export default function AudienceSharePage() { @@ -23,14 +85,29 @@ 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 normalTimeBox = normalDisplayData + ? debateTableQuery.data?.table[normalDisplayData.sequence] + : undefined; + const hasInvalidNormalTimeBox = + normalDisplayData !== null && !isValidNormalTimeBox(normalTimeBox); const normalCountdown = useAudienceCountdown({ receivedTime: @@ -73,34 +150,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) { + return ( + + ); + } + + if ( + debateTableQuery.isLoading || + !debateTableQuery.data || + state.status === 'connecting' + ) { + return ; + } + if (state.status === 'waiting') { return (
@@ -114,12 +203,25 @@ export default function AudienceSharePage() { if (state.status === 'displaying') { const { displayData } = state; if (displayData.timerType === 'NORMAL') { + if (!isValidNormalTimeBox(normalTimeBox)) { + return null; + } + return (
); @@ -158,8 +260,38 @@ export default function AudienceSharePage() { return null; }; + const isReady = + debateTableQuery.isSuccess && + !!debateTableQuery.data && + !state.error && + !hasInvalidNormalTimeBox && + 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..0138cd99 100644 --- a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx @@ -1,19 +1,138 @@ 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-[1ch]', + ); + 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('빈 팀명과 토론자명에는 접미사 없는 대체 문구를 표시한다', () => { + render( + , + ); + + expect(screen.getByText('팀명 없음')).toBeInTheDocument(); + expect(screen.getByText('토론자 없음')).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} + +
); } From 308cfb9352f9dbf0bb7264cff5a32bb788808202 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:12:17 +0900 Subject: [PATCH 04/19] =?UTF-8?q?fix:=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=B4=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EC=9D=84=20=EC=A0=84=EC=86=A1=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/TimerPage/TimerPage.tsx | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) 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); } }; From 07c1ba0c699639f6e4eb25c175e51d6db4145d6f Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:12:44 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=EB=82=A8=EC=9D=80=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B3=84=EC=82=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan-0.md | 105 +++++++++++++++ plan-1.md | 124 ++++++++++++++++++ .../getRemainingTimeForShare.test.ts | 52 ++++++++ .../TimerPage/getRemainingTimeForShare.ts | 36 +++++ 4 files changed, 317 insertions(+) create mode 100644 plan-0.md create mode 100644 plan-1.md create mode 100644 src/page/TimerPage/getRemainingTimeForShare.test.ts create mode 100644 src/page/TimerPage/getRemainingTimeForShare.ts diff --git a/plan-0.md b/plan-0.md new file mode 100644 index 00000000..3efeec8f --- /dev/null +++ b/plan-0.md @@ -0,0 +1,105 @@ +# AudienceSharePage 토론 시간표 조회·초기 상태 처리 구현 계획 + +## 목표 + +`AudienceSharePage` 진입 시 공유용 토론 시간표 API를 먼저 호출하고, 조회 성공 후에만 기존 웹 소켓 연결을 시작한다. API 조회와 웹 소켓 연결이 모두 성공하기 전에는 헤더를 숨기고 콘텐츠 중앙에 회색 `LoadingSpinner`를 표시한다. 둘 중 하나라도 실패하면 페이지 중앙에 오류 아이콘, 오류 문구, 새로고침 버튼을 표시한다. 두 작업이 모두 성공하면 조회한 전체 응답을 페이지의 단일 데이터 원천으로 유지하고, `info`는 `TimerPage`와 같은 `DefaultLayout.Header` 구조에 사용한다. `table`은 이번 단계에서 소켓 상태 계산에 적용하지 않고 다음 수정 사항에서 사용할 수 있도록 페이지에 보관한다. + +## 확정된 동작 + +- 초기화 순서는 `토론 시간표 API 조회 → 웹 소켓 연결`이다. +- API 조회 전이나 조회 중에는 웹 소켓 연결을 시도하지 않는다. +- API 조회와 웹 소켓 연결이 모두 성공해야 헤더 및 기존 관객용 콘텐츠를 표시한다. +- 초기화 중에는 헤더 없이 콘텐츠 중앙에 회색 `LoadingSpinner`를 표시한다. +- 오류는 Error Boundary로 던지지 않고 페이지 내부 오류 UI로 표시한다. +- 오류 UI는 `MdErrorOutline` 아이콘, 오류 문구, 새로고침 버튼을 세로·가로 중앙 정렬한다. +- API 조회 실패 문구는 `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.`로 한다. +- 웹 소켓 오류 문구는 오류 코드와 관계없이 `서버 연결에 실패했어요.`로 통일한다. +- API와 웹 소켓 오류가 동시에 존재하면 웹 소켓 오류 문구를 우선 표시한다. +- 새로고침 버튼은 브라우저 페이지 새로고침으로 전체 초기화 과정을 다시 시작한다. +- 헤더 왼쪽에는 테이블 이름, 중앙에는 토론 주제를 표시한다. +- 빈 문자열 또는 공백뿐인 이름/주제에는 `TimerPage`와 동일하게 각각 `테이블 이름 없음`, `주제 없음`을 표시한다. +- 헤더 오른쪽에는 공통 헤더가 제공하는 언어 선택기, 홈 버튼, 로그인/로그아웃 버튼만 유지한다. 도움말 버튼은 표시하지 않는다. +- 사용자에게 보이는 새 문구는 모두 `useTranslation()`을 통과시킨다. +- `LoadingSpinner` 내부의 기존 `aria-label="Loading"`은 이번 범위에서 변경하지 않는다. +- API 응답의 `table`은 페이지가 조회 결과의 일부로 보유하되, 이번 구현에서는 `useAudienceShareState`에 전달하거나 소켓 이벤트의 `sequence`와 결합하지 않는다. + +## 구현 순서 (TDD) + +1. `useAudienceShareState` 테스트에 연결 활성화 조건을 먼저 추가해, API 성공 전에는 연결하지 않고 활성화된 뒤 한 번 연결하는 동작을 명세한다. +2. `AudienceSharePage` 테스트에 API 조회와 소켓 연결의 순차 초기화, 통합 로딩, 오류 UI, 헤더 표시 조건을 먼저 추가한다. +3. `useAudienceShareState`가 연결 활성화 조건을 받을 수 있도록 최소 변경한다. +4. `AudienceSharePage`에서 기존 `useGetDebateTableDataForShare` 결과를 소켓 연결 활성화 조건과 렌더링에 연결하고, 전체 조회 결과를 이후 관객 표시 로직에서 재사용할 수 있게 페이지 스코프에 유지한다. +5. 소켓 오류 우선순위를 포함한 통합 로딩·오류·정상 UI 분기를 정리하고 헤더 정보를 추가한다. +6. 관련 Vitest를 실행한 뒤 프로젝트 lint/type check를 수행해 기존 카운트다운과 종료 흐름에 회귀가 없는지 검증한다. + +## 추가 예정 파일 및 내용 + +- 없음. + - 공유용 API 함수, 응답 타입, TanStack Query 훅, MSW 핸들러와 관련 테스트 파일이 이미 존재한다. + - 오류 UI는 현재 페이지 전용이고 구조가 작으므로 별도 공용 컴포넌트를 만들지 않고 `AudienceSharePage` 안에 응집한다. + +## 수정 대상 파일 및 내용 + +### `src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts` + +- 연결 비활성 상태에서는 `connect()`를 호출하지 않는 테스트를 추가한다. +- 비활성 상태에서 활성 상태로 바뀌면 웹 소켓 연결을 시작하는 테스트를 추가한다. +- 활성화 이후 unmount 시 기존 타이머와 연결을 정상적으로 정리하는지 검증한다. +- 기존 메시지 처리, 타임아웃, 소켓 오류, 종료 상태 테스트가 그대로 통과하는지 확인한다. + +### `src/page/AudienceSharePage/hooks/useAudienceShareState.ts` + +- API 조회 성공 전 웹 소켓 연결을 막을 수 있도록 `isEnabled` 인자 또는 동등한 명시적 옵션을 추가한다. +- 비활성 상태에서는 `connect()`를 호출하지 않고 `connecting` 상태를 유지한다. +- 활성 상태로 전환된 경우에만 연결하며, cleanup과 재렌더링으로 중복 연결이 발생하지 않도록 기존 effect 의존성을 유지한다. +- 이번 구현에서는 API 응답의 `table`을 훅에 전달하지 않으며 기존 소켓 메시지 처리 로직은 변경하지 않는다. + +### `src/page/AudienceSharePage/AudienceSharePage.test.tsx` + +- `useGetDebateTableDataForShare`와 `useAudienceShareState`의 반환 상태 및 호출 인자를 제어하도록 테스트 구성을 보강한다. +- 다음 동작을 TDD의 Red 단계에서 명세한다. + - API 조회 중에는 소켓 연결이 비활성화되고 회색 로딩 스피너만 표시된다. + - API 조회 성공 이후 소켓 연결이 활성화된다. + - API 성공 후 소켓 연결 중에도 헤더를 숨기고 같은 로딩 스피너를 표시한다. + - API 조회와 소켓 연결이 모두 성공한 뒤에만 헤더와 기존 `waiting`/`displaying`/`finished` 콘텐츠가 표시된다. + - 조회 데이터의 테이블 이름과 토론 주제가 헤더 좌측과 중앙에 표시된다. + - 이름과 주제가 공백뿐이면 `테이블 이름 없음`, `주제 없음`이 표시된다. + - 헤더 우측에 언어 선택기, 홈, 현재 인증 상태에 맞는 로그인/로그아웃 버튼이 표시되고 도움말 버튼은 표시되지 않는다. + - API 조회 실패 시 중앙 오류 UI와 `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.` 문구가 표시된다. + - 웹 소켓 실패 시 중앙 오류 UI와 `서버 연결에 실패했어요.` 문구가 표시된다. + - API 오류와 웹 소켓 오류가 함께 존재하면 웹 소켓 오류 문구가 우선 표시된다. + - 새로고침 버튼을 누르면 브라우저 새로고침 함수가 호출된다. +- 기존 ID 검증, 타이머 카운트다운, 종료 화면 테스트의 회귀 여부를 확인한다. + +### `src/page/AudienceSharePage/AudienceSharePage.tsx` + +- 검증된 `tableId`로 기존 `useGetDebateTableDataForShare`를 호출한다. +- TanStack Query의 조회 성공 데이터(`GetDebateTableDataForShareResponseType`)를 페이지 변수로 유지해 이후 수정에서도 재사용 가능한 단일 데이터 원천으로 삼고, 동일 데이터를 로컬 state에 복제하지 않는다. `info`는 헤더에 사용하고 `table`은 다음 수정 사항을 위해 조회 결과 안에 그대로 보관한다. +- API 조회 성공 여부를 `useAudienceShareState`의 연결 활성화 조건으로 전달한다. +- API 조회와 소켓 상태를 다음 우선순위로 조합한다. + 1. 웹 소켓 오류: `서버 연결에 실패했어요.` 페이지 내부 오류 UI + 2. API 오류: `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.` 페이지 내부 오류 UI + 3. API 조회 중 또는 소켓 연결 중: 헤더 없는 로딩 UI + 4. 두 작업 성공: 헤더와 기존 관객용 콘텐츠 +- 로딩 UI의 `LoadingSpinner`에 `color="text-gray-..."`를 지정한다. +- 오류 UI에 `MdErrorOutline`, 번역된 오류 문구, 번역된 새로고침 버튼을 세로 배치하고 전체를 화면 중앙 정렬한다. +- 새로고침 버튼 클릭 핸들러에서 브라우저 페이지를 다시 불러온다. +- 기존 소켓 오류를 Error Boundary로 던지는 `AudienceShareDisplayError` 흐름은 페이지 내부 오류 렌더링으로 대체한다. 외부 사용처가 없는 것이 확인되면 클래스와 관련 테스트를 제거한다. +- 두 초기 작업이 성공한 경우에만 다음 헤더를 렌더링한다. + - `DefaultLayout.Header.Left`의 `HeaderTableInfo`: trim 기준 빈 값 대체 정책 적용 + - `DefaultLayout.Header.Center`의 `HeaderTitle`: trim 기준 빈 값 대체 정책 적용 + - `DefaultLayout.Header.Right`: 별도 children 없이 공통 헤더의 언어 선택기, 홈, 인증 버튼을 사용 +- 디버깅용 `console.log`를 제거한다. + +## 수정하지 않을 것으로 예상되는 기존 파일 + +- `src/apis/apis/live.ts`: `getDebateTableDataForShare`가 이미 구현되어 있다. +- `src/hooks/query/useGetDebateTableDataForShare.ts`: 기존 조회 훅을 그대로 사용하며, 페이지에서 성공 여부를 소켓 활성화 조건으로 조합한다. +- `src/hooks/query/useGetDebateTableDataForShare.test.tsx`: API 호출 및 캐시 저장 성공 경로가 이미 검증되어 있다. +- `src/mocks/handlers/live.ts`: 공유용 조회 성공 응답 핸들러가 이미 있다. 페이지 테스트는 기존 방식대로 훅을 mock한다. +- `src/components/LoadingSpinner.tsx`: 기존 `color` prop으로 회색을 지정하며, 하드코딩된 접근성 문구는 현재 PR 범위상 변경하지 않는다. +- `src/layout/components/header/StickyTriSectionHeader.tsx`: 언어 선택기, 홈, 로그인/로그아웃 버튼은 `DefaultLayout.Header.Right`가 이미 기본 제공하므로 변경하지 않는다. + +## 모호한 부분 (Q&A) + +- 현재 확인된 추가 모호점 없음. diff --git a/plan-1.md b/plan-1.md new file mode 100644 index 00000000..7164e967 --- /dev/null +++ b/plan-1.md @@ -0,0 +1,124 @@ +# AudienceNormalTimer 발언 정보·프로그레스 바 구현 계획 + +## 목표 + +웹 소켓 NORMAL 타이머 메시지의 `sequence`를 관객 표시 상태에 보존하고, `AudienceSharePage`가 `debateTableQuery.data.table[sequence]`에서 현재 발언 순서의 정보를 선택한다. 선택한 정보로 `AudienceNormalTimer`에 발언 유형, 진영별 팀명, 토론자명, 원래 제한 시간을 전달해 현재 남은 시간과 함께 표시하며, 경과 시간 비율을 나타내는 진영 색상의 가로 프로그레스 바를 추가한다. + +## 구현 원칙 + +- API 조회 결과는 기존처럼 `AudienceSharePage`의 단일 데이터 원천으로 유지하고 별도 state로 복제하지 않는다. +- 웹 소켓에서 받은 `sequence`만 관객 표시 상태에 추가하며 API의 `table` 전체를 소켓 상태 훅에 전달하지 않는다. +- `sequence`로 선택한 `TimeBoxInfo`와 팀명은 페이지에서 파생한 뒤 `AudienceNormalTimer`에 명시적인 props로 전달한다. +- 진행률은 `AudienceNormalTimer`에서 `(totalTime - remainingTime) / totalTime * 100`으로 파생하고 별도 React state로 중복 저장하지 않는다. +- 공용 프로그레스 바는 API 응답이나 시간 계산 방식을 알지 않으며, 계산된 `progress`, `team`, `className`, 애니메이션 여부만 입력받는다. +- 진행률은 0~100 범위로 제한해 음수 남은 시간이나 서버 동기화 오차가 레이아웃을 깨뜨리지 않게 한다. +- NORMAL 항목의 `stance`가 `NEUTRAL`이면 공용 바의 `DISABLED` 팀으로 매핑해 `bg-default-neutral` 색상을 적용하고 나머지 타이머는 정상 표시한다. +- `sequence`가 배열 범위를 벗어나거나 소켓의 NORMAL 타입과 API 항목의 `boxType`이 불일치하거나 NORMAL 항목의 `time`이 null/0 이하이면 서버 연결 오류 UI로 전환한다. +- 사용자에게 보이는 발언 유형·팀명·토론자명은 기존 i18n 정책을 따른다. +- 테스트는 Constitution에 따라 훅 → 컴포넌트 → 페이지 순서로 먼저 작성한다. + +## 구현 순서 (TDD) + +1. `useAudienceShareState` 테스트에 NORMAL 메시지의 `sequence` 보존 및 새 메시지 수신 시 갱신 동작을 먼저 명세한다. +2. 공용 프로그레스 바의 색상·className·애니메이션과 `AudienceNormalTimer`의 경과 진행률 계산 및 발언 정보 Row를 컴포넌트 테스트로 먼저 명세한다. +3. `AudienceSharePage` 테스트에서 `sequence`에 해당하는 API 테이블 항목과 팀명이 NORMAL 타이머에 연결되는지 명세한다. +4. 소켓 표시 상태에 `sequence`를 추가하고 페이지에서 현재 `TimeBoxInfo` 및 팀명을 파생한다. +5. `AudienceNormalTimer`의 제목·발언자 Row·타이머·프로그레스 바 UI와 Framer Motion 애니메이션을 구현한다. +6. 관련 Vitest를 실행하고, Prettier 적용 후 전체 lint와 build로 회귀를 검증한다. + +## 추가 예정 파일 및 내용 + +### `src/components/TimerProgressBar/TimerProgressBar.tsx` + +- 재사용 가능한 가로 프로그레스 바로서 계산된 `progress`, `team`, 레이아웃 확장용 `className`, 애니메이션 판단용 `isRunning`을 props로 받는다. +- `team`은 컴포넌트 전용 타입인 `PROS | CONS | DISABLED`를 지원한다. +- 바 외곽의 공통 스타일은 `h-[24px]`, `w-full`, `overflow-hidden`, `rounded-full`로 구성하고, 호출부가 전달한 `className`을 병합해 최대 너비·여백 등 배치 정책을 확장한다. +- 관객 NORMAL 타이머에서는 `w-full max-w-[1280px]`과 필요한 좌우 여백 class를 전달해 최대 1280px, 작은 화면에서는 반응형 축소를 적용한다. +- 내부 진행 바는 PROS일 때 `bg-camp-blue`, CONS일 때 `bg-camp-red`, DISABLED일 때 `bg-default-neutral`을 적용한다. +- 전달받은 `progress`를 0~100으로 제한한다. +- `CircularTimer`가 사용하는 방식처럼 Framer Motion의 `useMotionValue`, `animate`, cleanup을 이용한다. +- PLAY 중에는 직전 값에서 새 경과 진행률까지 0.7초 `easeOut`으로 왼쪽에서 오른쪽으로 채우고, STOP·RESET 및 그 밖의 비실행 상태에서는 즉시 동기화한다. +- 시각 정보만으로 상태를 전달하지 않도록 `role="progressbar"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`를 제공한다. + +### `src/components/TimerProgressBar/TimerProgressBar.test.tsx` + +- 전달한 `className`이 외곽 바의 기본 class와 함께 적용되는지 검증한다. +- PROS/CONS/DISABLED 팀별 색상 클래스를 검증한다. +- 0 미만 및 100 초과 `progress`의 제한 동작을 검증한다. +- 프로그레스 바의 접근성 role/value, 24px 높이 및 둥근 끝 스타일을 검증한다. +- fake timer 또는 Framer Motion 제어 mock을 최소한으로 사용해 PLAY의 0.7초 `easeOut` 애니메이션과 STOP·RESET 등 비실행 상태의 즉시 동기화를 검증한다. + +## 수정 대상 파일 및 내용 + +### `src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts` + +- NORMAL 이벤트의 `data.sequence`가 `displayData.sequence`로 보존되는 테스트를 추가한다. +- PLAY/STOP/RESET/NEXT/BEFORE 등 유효한 새 NORMAL 메시지 수신 시 남은 시간과 `sequence`가 함께 최신 값으로 갱신되는지 검증한다. +- 기존 TIME_BASED 상태, 연결 수명주기, 오류 및 타임아웃 테스트가 그대로 통과하는지 확인한다. + +### `src/page/AudienceSharePage/hooks/useAudienceShareState.ts` + +- `AudienceNormalDisplayData`에 `sequence: number`를 추가한다. +- NORMAL 메시지를 처리할 때 `data.sequence`를 `singleTime`, `isRunning`과 함께 저장한다. +- 이번 작업에서는 TIME_BASED 표시 상태에 `sequence`를 추가하지 않는다. + +### `src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx` + +- 기존 임시 문구 `남은 시간` 대신 전달받은 `speechType`이 제목으로 표시되는지 검증한다. +- `DTDebate` 아이콘, 현재 진영의 팀명, 토론자명이 한 Row에 렌더링되는지 검증한다. +- PROS 및 CONS 입력 각각에서 올바른 팀 정보와 프로그레스 바 색상이 연결되는지 검증한다. +- `remainingTime`이 기존과 동일하게 `MM:SS` 형식으로 표시되는지 검증한다. +- `(totalTime - remainingTime) / totalTime * 100`으로 계산한 경과 진행률을 공용 `TimerProgressBar`에 전달하는지 검증한다. +- `stance` PROS/CONS를 같은 이름의 바 `team` prop으로 전달하고, NEUTRAL은 DISABLED로 전달하는지 검증한다. +- 프로그레스 바에 최대 1280px과 반응형 전체 너비 class를 전달하는지 검증한다. +- 남은 시간이 전체 시간 범위를 벗어나도 최종 진행률이 0~100으로 제한되는지 검증한다. +- 팀명 또는 토론자명이 비어 있으면 접미사 없이 각각 `팀명 없음`, `토론자 없음` 대체 문구가 표시되는지 검증한다. +- 사용자 조작 요소가 추가되지 않는 기존 계약을 유지한다. + +### `src/page/AudienceSharePage/components/AudienceNormalTimer.tsx` + +- props를 현재의 `remainingTime` 단일 값에서 다음 정보를 받을 수 있는 구조로 확장한다. + - 현재 `TimeBoxInfo` 또는 이에 상응하는 `speechType`, `stance`, `speaker`, `totalTime` + - 현재 진영으로 결정된 `teamName` + - 로컬 카운트다운이 반영된 `remainingTime` + - 프로그레스 바 애니메이션 판단용 `isRunning` +- `

`의 `남은 시간`을 현재 순서의 `speechType`으로 교체한다. +- 제목 아래에 `DTDebate`, 팀명, 토론자명을 가로로 배치한 Row를 추가한다. +- `DTDebate`는 정보 전달 텍스트가 함께 있으므로 장식 아이콘으로 처리해 스크린 리더 중복 낭독을 방지한다. +- `TimerPage/NormalTimer`와 동일하게 `normalizeSpeechTypeKey`로 알려진 발언 유형을 정규화해 번역하고, 알 수 없는 사용자 입력은 원문을 표시한다. +- 값이 있는 팀명과 토론자명은 `t('{{team}} 팀', { team: t(teamName) })`, `t('{{speaker}} 토론자', { speaker: t(speaker) })` 형식을 사용한다. +- 팀명 또는 토론자명이 빈 문자열/null이면 템플릿에 넣지 않고 각각 번역된 `팀명 없음`, `토론자 없음`을 최종 문구로 표시한다. +- `(totalTime - remainingTime) / totalTime * 100` 경과 진행률을 렌더링 중 파생해 기존 포맷된 타이머 아래의 공용 `TimerProgressBar`에 전달한다. +- 공용 바에는 PROS/CONS 또는 NEUTRAL에서 변환한 DISABLED 팀, `w-full max-w-[1280px]` className, `isRunning`을 함께 전달한다. +- API 원본 객체에 직접 의존하지 않고 페이지에서 선택된 현재 항목만 props로 받아 컴포넌트 책임을 표시 UI로 제한한다. + +### `src/page/AudienceSharePage/AudienceSharePage.test.tsx` + +- NORMAL 표시 mock에 `sequence`를 추가한다. +- 여러 테이블 항목을 가진 API mock을 사용해 `sequence`에 해당하는 항목의 `speechType`, `stance`, `speaker`, `time`이 선택되는지 검증한다. +- PROS면 `info.prosTeamName`, CONS면 `info.consTeamName`이 표시되는지 각각 검증한다. +- 웹 소켓에서 새 `sequence`를 수신한 상태로 바뀌면 다른 발언 정보와 프로그레스 바 기준 시간이 표시되는지 검증한다. +- 유효하지 않은 `sequence`, `boxType !== 'NORMAL'`, `time === null`, `time <= 0`인 NORMAL 소켓 상태에서 서버 연결 오류 UI가 표시되는지 검증한다. +- NEUTRAL NORMAL 항목은 오류 없이 타이머를 유지하고 중립 색상의 프로그레스 바를 표시하는지 검증한다. +- 기존 API/소켓 로딩·오류, 헤더, TIME_BASED 타이머, 종료 화면 테스트가 그대로 통과하는지 확인한다. + +### `src/page/AudienceSharePage/AudienceSharePage.tsx` + +- NORMAL `displayData.sequence`로 `debateTableQuery.data.table[sequence]`를 선택한다. +- `sequence`가 배열 범위를 벗어나거나 선택된 항목의 `boxType !== 'NORMAL'` 또는 `time === null`/`time <= 0`이면 서버 데이터 불일치로 판단해 기존 `서버 연결에 실패했어요.` 오류 UI를 재사용한다. +- 선택된 항목의 `stance`에 따라 PROS는 `info.prosTeamName`, CONS는 `info.consTeamName`, NEUTRAL은 빈 팀명으로 파생하고 `AudienceNormalTimer`가 대체 문구를 표시하게 한다. +- `AudienceNormalTimer`에 현재 항목, 팀명, 로컬 카운트다운이 반영된 남은 시간, 실행 여부를 전달한다. +- API 데이터와 소켓 `sequence`의 결합 및 경계 검증은 페이지에 두고, `AudienceNormalTimer`에는 유효하고 표시 가능한 데이터만 전달한다. + +## 수정하지 않을 것으로 예상되는 기존 파일 + +- `src/apis/sockets/type.ts`: 웹 소켓 `TimerDataPayload`에 이미 `sequence`가 정의되어 있다. +- `src/apis/responses/live.ts` 및 `src/type/type.ts`: API 테이블 항목에 `stance`, `speechType`, `speaker`, `time`이 이미 정의되어 있다. +- `src/page/TimerPage/components/CircularTimer.tsx`: 참고만 하며 원형 타이머 동작은 변경하지 않는다. +- `src/page/TimerPage/hooks/useCircularTimerAnimation.ts`: 원형 타이머 전용 이름과 의미를 유지하고, 관객용 가로 바가 이를 직접 import해 페이지 간 결합을 만들지 않는다. +- `src/components/icons/Debate.tsx`: 기존 `DTDebate` 아이콘을 그대로 사용한다. +- `tailwind.config.js`: `camp-blue`, `camp-red` 색상 토큰이 이미 존재한다. + +## 모호한 부분 (Q&A) + +- 현재 확인된 추가 모호점 없음. 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; +} From fe14e058d64f6862f71395b1fb6aba6f964149f3 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:14:14 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=EC=9E=90=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=B9=B4=EC=9A=B4?= =?UTF-8?q?=ED=8A=B8=EB=8B=A4=EC=9A=B4=20=EB=A1=9C=EC=A7=81=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useAudienceTimeBasedCountdown.test.ts | 262 ++++++++++++ .../hooks/useAudienceTimeBasedCountdown.ts | 379 ++++++++++++++++++ 2 files changed, 641 insertions(+) create mode 100644 src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts create mode 100644 src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts 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, + }, + }; +} From aee5cbff7ae14700bc974f15809b8570abcc7689 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:14:23 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=9E=90=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceTimeBasedTimerDisplay.test.tsx | 157 ++++++++++++++++++ .../AudienceTimeBasedTimerDisplay.tsx | 136 +++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx create mode 100644 src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx new file mode 100644 index 00000000..54fd0ed9 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx @@ -0,0 +1,157 @@ +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('팀명 없음')).toBeInTheDocument(); + expect(screen.getByTestId('cons-timer-display')).toHaveClass( + 'bg-gray-100', + 'opacity-50', + ); + 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..ad987bd0 --- /dev/null +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx @@ -0,0 +1,136 @@ +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; + className?: string; +} + +function TimerValue({ seconds, testId, 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; + + return ( +
+ {isCurrentTeam ? ( + + {t('현재 발언 중')} + + ) : null} + +

+ {teamLabel} +

+ + {timePerSpeaking === null ? ( + <> + + + + ) : ( + <> + + {t('전체 시간')} + + + + {t('현재 시간')} + + + + + )} +
+ ); +} From 7ee60458daacbdaa73944fba5fd25e2430e62141 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:14:39 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useAudienceCountdown.test.ts | 81 ++++++++++++ .../hooks/useAudienceCountdown.ts | 75 ++++++++++- .../hooks/useAudienceShareState.test.ts | 117 ++++++++++++++++++ .../hooks/useAudienceShareState.ts | 111 +++++++++++++++-- 4 files changed, 370 insertions(+), 14 deletions(-) diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts index 9c5d48e5..a9999795 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts @@ -79,6 +79,87 @@ describe('useAudienceCountdown', () => { 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 f491c5fd..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,31 +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.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 () => { @@ -47,7 +104,13 @@ export function useAudienceCountdown({ intervalRef.current = null; } }; - }, [receivedTime, isRunning]); + }, [ + isRunning, + minimumTime, + receivedTime, + shouldResetOnRunStateChange, + syncKey, + ]); const formattedTime = currentSeconds !== null diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index acb0b90d..0ec0a429 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -324,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); } } @@ -349,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'); } }); diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index 41fdffc6..4ff002f0 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -5,7 +5,7 @@ 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; @@ -13,12 +13,15 @@ type AudienceNormalDisplayData = { 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 = @@ -68,15 +71,17 @@ function createDisplayData( data: TimerDataPayload, previousDisplayData: AudienceDisplayData | null, options: { + eventType: TimerEventTypes; isRunning: boolean; - normalSequence?: number; + sequence?: number; normalTime?: number; shouldSwitchTeam?: boolean; }, ): AudienceDisplayData | null { const { + eventType, isRunning, - normalSequence = data.sequence, + sequence = data.sequence, normalTime = data.remainingTime, shouldSwitchTeam = false, } = options; @@ -87,7 +92,7 @@ function createDisplayData( currentTeam: null, isRunning, singleTime: normalTime, - sequence: normalSequence, + sequence, }; } @@ -115,9 +120,66 @@ function createDisplayData( 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, @@ -127,16 +189,19 @@ function getDisplayDataByEvent( 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, @@ -147,9 +212,22 @@ function getDisplayDataByEvent( 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, - normalSequence: previousSequence, + sequence: previousSequence, normalTime: getNormalTotalTime( table, previousSequence, @@ -160,16 +238,33 @@ function getDisplayDataByEvent( 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, - normalSequence: nextSequence, + sequence: nextSequence, normalTime: getNormalTotalTime(table, nextSequence, data.remainingTime), }); } case 'TEAM_SWITCH': return createDisplayData(data, previousDisplayData, { - isRunning: false, + eventType, + isRunning: + previousDisplayData?.timerType === 'TIME_BASED' + ? previousDisplayData.isRunning + : false, shouldSwitchTeam: true, }); } From cac970f493b758bcae6182371cea742fb1b2dbc7 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:15:01 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=EC=9E=90=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=9D=84=20=EC=83=88=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=20=EB=94=94=EC=8A=A4=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceTimeBasedTimer.test.tsx | 81 ++++++------ .../components/AudienceTimeBasedTimer.tsx | 119 +++++++----------- 2 files changed, 87 insertions(+), 113 deletions(-) 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)} -
-
+
+ +
); } From 944e3383537860b0fe9d89e3ed4204077938c7cd Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:15:09 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceSharePage.test.tsx | 56 ++++++++--- .../AudienceSharePage/AudienceSharePage.tsx | 97 ++++++++++++++----- 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index b53e22a5..06d7935b 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -63,6 +63,16 @@ const mockDebateTableData: GetDebateTableDataForShareResponseType = { timePerSpeaking: null, speaker: '이토론', }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 180, + timePerSpeaking: 30, + speaker: null, + }, ], }; @@ -366,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 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', () => { @@ -389,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 () => { @@ -508,7 +542,7 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('팀명 없음')).toBeInTheDocument(); + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); expect(screen.getByTestId('timer-progress-fill')).toHaveClass( 'bg-default-neutral', ); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 37584518..c783ede5 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { MdErrorOutline } from 'react-icons/md'; import { useAudienceShareState } from './hooks/useAudienceShareState'; import { useAudienceCountdown } from './hooks/useAudienceCountdown'; +import { useAudienceTimeBasedCountdown } from './hooks/useAudienceTimeBasedCountdown'; import AudienceNormalTimer from './components/AudienceNormalTimer'; import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; @@ -57,6 +58,12 @@ type ValidNormalTimeBox = TimeBoxInfo & { time: number; }; +type ValidTimeBasedTimeBox = TimeBoxInfo & { + boxType: 'TIME_BASED'; + timePerTeam: number; + timePerSpeaking: number | null; +}; + function isValidNormalTimeBox( timeBox: TimeBoxInfo | undefined, ): timeBox is ValidNormalTimeBox { @@ -65,6 +72,17 @@ function isValidNormalTimeBox( ); } +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) + ); +} + function getNormalTimerTeamName( stance: TimeBoxInfo['stance'], info: DebateInfo, @@ -103,11 +121,20 @@ export default function AudienceSharePage() { 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: @@ -119,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 = () => { @@ -173,7 +189,7 @@ export default function AudienceSharePage() { ); } - if (hasInvalidNormalTimeBox) { + if (hasInvalidNormalTimeBox || hasInvalidTimeBasedTimeBox) { return (
); @@ -265,6 +317,7 @@ export default function AudienceSharePage() { !!debateTableQuery.data && !state.error && !hasInvalidNormalTimeBox && + !hasInvalidTimeBasedTimeBox && state.status !== 'connecting'; return ( From 69ade37a28e5ab004eee2fa34766617474b00479 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:15:21 +0900 Subject: [PATCH 11/19] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AudienceNormalTimer.test.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx index 0138cd99..d9de53dd 100644 --- a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx @@ -30,7 +30,7 @@ describe('AudienceNormalTimer', () => { expect(timerValue).toHaveClass( 'mt-[64px]', 'grid-cols-[2ch_1ch_2ch]', - 'gap-x-[1ch]', + 'gap-x-[0.33ch]', ); expect(timerValue).toHaveAttribute('aria-label', '01 : 05'); expect(screen.getByText('01')).toBeInTheDocument(); @@ -65,7 +65,7 @@ describe('AudienceNormalTimer', () => { ); }); - it('빈 팀명과 토론자명에는 접미사 없는 대체 문구를 표시한다', () => { + it('NEUTRAL 진영은 팀명과 토론자 정보를 표시하지 않는다', () => { render( { />, ); - expect(screen.getByText('팀명 없음')).toBeInTheDocument(); - expect(screen.getByText('토론자 없음')).toBeInTheDocument(); - expect(screen.queryByText('팀명 없음 팀')).not.toBeInTheDocument(); - expect(screen.queryByText('토론자 없음 토론자')).not.toBeInTheDocument(); + 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', ); From 2c4e9a7fbe67ab4057ceb05edd73dd262d6de732 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:15:53 +0900 Subject: [PATCH 12/19] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plan-0.md | 105 --------------------------------------------- plan-1.md | 124 ------------------------------------------------------ 2 files changed, 229 deletions(-) delete mode 100644 plan-0.md delete mode 100644 plan-1.md diff --git a/plan-0.md b/plan-0.md deleted file mode 100644 index 3efeec8f..00000000 --- a/plan-0.md +++ /dev/null @@ -1,105 +0,0 @@ -# AudienceSharePage 토론 시간표 조회·초기 상태 처리 구현 계획 - -## 목표 - -`AudienceSharePage` 진입 시 공유용 토론 시간표 API를 먼저 호출하고, 조회 성공 후에만 기존 웹 소켓 연결을 시작한다. API 조회와 웹 소켓 연결이 모두 성공하기 전에는 헤더를 숨기고 콘텐츠 중앙에 회색 `LoadingSpinner`를 표시한다. 둘 중 하나라도 실패하면 페이지 중앙에 오류 아이콘, 오류 문구, 새로고침 버튼을 표시한다. 두 작업이 모두 성공하면 조회한 전체 응답을 페이지의 단일 데이터 원천으로 유지하고, `info`는 `TimerPage`와 같은 `DefaultLayout.Header` 구조에 사용한다. `table`은 이번 단계에서 소켓 상태 계산에 적용하지 않고 다음 수정 사항에서 사용할 수 있도록 페이지에 보관한다. - -## 확정된 동작 - -- 초기화 순서는 `토론 시간표 API 조회 → 웹 소켓 연결`이다. -- API 조회 전이나 조회 중에는 웹 소켓 연결을 시도하지 않는다. -- API 조회와 웹 소켓 연결이 모두 성공해야 헤더 및 기존 관객용 콘텐츠를 표시한다. -- 초기화 중에는 헤더 없이 콘텐츠 중앙에 회색 `LoadingSpinner`를 표시한다. -- 오류는 Error Boundary로 던지지 않고 페이지 내부 오류 UI로 표시한다. -- 오류 UI는 `MdErrorOutline` 아이콘, 오류 문구, 새로고침 버튼을 세로·가로 중앙 정렬한다. -- API 조회 실패 문구는 `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.`로 한다. -- 웹 소켓 오류 문구는 오류 코드와 관계없이 `서버 연결에 실패했어요.`로 통일한다. -- API와 웹 소켓 오류가 동시에 존재하면 웹 소켓 오류 문구를 우선 표시한다. -- 새로고침 버튼은 브라우저 페이지 새로고침으로 전체 초기화 과정을 다시 시작한다. -- 헤더 왼쪽에는 테이블 이름, 중앙에는 토론 주제를 표시한다. -- 빈 문자열 또는 공백뿐인 이름/주제에는 `TimerPage`와 동일하게 각각 `테이블 이름 없음`, `주제 없음`을 표시한다. -- 헤더 오른쪽에는 공통 헤더가 제공하는 언어 선택기, 홈 버튼, 로그인/로그아웃 버튼만 유지한다. 도움말 버튼은 표시하지 않는다. -- 사용자에게 보이는 새 문구는 모두 `useTranslation()`을 통과시킨다. -- `LoadingSpinner` 내부의 기존 `aria-label="Loading"`은 이번 범위에서 변경하지 않는다. -- API 응답의 `table`은 페이지가 조회 결과의 일부로 보유하되, 이번 구현에서는 `useAudienceShareState`에 전달하거나 소켓 이벤트의 `sequence`와 결합하지 않는다. - -## 구현 순서 (TDD) - -1. `useAudienceShareState` 테스트에 연결 활성화 조건을 먼저 추가해, API 성공 전에는 연결하지 않고 활성화된 뒤 한 번 연결하는 동작을 명세한다. -2. `AudienceSharePage` 테스트에 API 조회와 소켓 연결의 순차 초기화, 통합 로딩, 오류 UI, 헤더 표시 조건을 먼저 추가한다. -3. `useAudienceShareState`가 연결 활성화 조건을 받을 수 있도록 최소 변경한다. -4. `AudienceSharePage`에서 기존 `useGetDebateTableDataForShare` 결과를 소켓 연결 활성화 조건과 렌더링에 연결하고, 전체 조회 결과를 이후 관객 표시 로직에서 재사용할 수 있게 페이지 스코프에 유지한다. -5. 소켓 오류 우선순위를 포함한 통합 로딩·오류·정상 UI 분기를 정리하고 헤더 정보를 추가한다. -6. 관련 Vitest를 실행한 뒤 프로젝트 lint/type check를 수행해 기존 카운트다운과 종료 흐름에 회귀가 없는지 검증한다. - -## 추가 예정 파일 및 내용 - -- 없음. - - 공유용 API 함수, 응답 타입, TanStack Query 훅, MSW 핸들러와 관련 테스트 파일이 이미 존재한다. - - 오류 UI는 현재 페이지 전용이고 구조가 작으므로 별도 공용 컴포넌트를 만들지 않고 `AudienceSharePage` 안에 응집한다. - -## 수정 대상 파일 및 내용 - -### `src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts` - -- 연결 비활성 상태에서는 `connect()`를 호출하지 않는 테스트를 추가한다. -- 비활성 상태에서 활성 상태로 바뀌면 웹 소켓 연결을 시작하는 테스트를 추가한다. -- 활성화 이후 unmount 시 기존 타이머와 연결을 정상적으로 정리하는지 검증한다. -- 기존 메시지 처리, 타임아웃, 소켓 오류, 종료 상태 테스트가 그대로 통과하는지 확인한다. - -### `src/page/AudienceSharePage/hooks/useAudienceShareState.ts` - -- API 조회 성공 전 웹 소켓 연결을 막을 수 있도록 `isEnabled` 인자 또는 동등한 명시적 옵션을 추가한다. -- 비활성 상태에서는 `connect()`를 호출하지 않고 `connecting` 상태를 유지한다. -- 활성 상태로 전환된 경우에만 연결하며, cleanup과 재렌더링으로 중복 연결이 발생하지 않도록 기존 effect 의존성을 유지한다. -- 이번 구현에서는 API 응답의 `table`을 훅에 전달하지 않으며 기존 소켓 메시지 처리 로직은 변경하지 않는다. - -### `src/page/AudienceSharePage/AudienceSharePage.test.tsx` - -- `useGetDebateTableDataForShare`와 `useAudienceShareState`의 반환 상태 및 호출 인자를 제어하도록 테스트 구성을 보강한다. -- 다음 동작을 TDD의 Red 단계에서 명세한다. - - API 조회 중에는 소켓 연결이 비활성화되고 회색 로딩 스피너만 표시된다. - - API 조회 성공 이후 소켓 연결이 활성화된다. - - API 성공 후 소켓 연결 중에도 헤더를 숨기고 같은 로딩 스피너를 표시한다. - - API 조회와 소켓 연결이 모두 성공한 뒤에만 헤더와 기존 `waiting`/`displaying`/`finished` 콘텐츠가 표시된다. - - 조회 데이터의 테이블 이름과 토론 주제가 헤더 좌측과 중앙에 표시된다. - - 이름과 주제가 공백뿐이면 `테이블 이름 없음`, `주제 없음`이 표시된다. - - 헤더 우측에 언어 선택기, 홈, 현재 인증 상태에 맞는 로그인/로그아웃 버튼이 표시되고 도움말 버튼은 표시되지 않는다. - - API 조회 실패 시 중앙 오류 UI와 `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.` 문구가 표시된다. - - 웹 소켓 실패 시 중앙 오류 UI와 `서버 연결에 실패했어요.` 문구가 표시된다. - - API 오류와 웹 소켓 오류가 함께 존재하면 웹 소켓 오류 문구가 우선 표시된다. - - 새로고침 버튼을 누르면 브라우저 새로고침 함수가 호출된다. -- 기존 ID 검증, 타이머 카운트다운, 종료 화면 테스트의 회귀 여부를 확인한다. - -### `src/page/AudienceSharePage/AudienceSharePage.tsx` - -- 검증된 `tableId`로 기존 `useGetDebateTableDataForShare`를 호출한다. -- TanStack Query의 조회 성공 데이터(`GetDebateTableDataForShareResponseType`)를 페이지 변수로 유지해 이후 수정에서도 재사용 가능한 단일 데이터 원천으로 삼고, 동일 데이터를 로컬 state에 복제하지 않는다. `info`는 헤더에 사용하고 `table`은 다음 수정 사항을 위해 조회 결과 안에 그대로 보관한다. -- API 조회 성공 여부를 `useAudienceShareState`의 연결 활성화 조건으로 전달한다. -- API 조회와 소켓 상태를 다음 우선순위로 조합한다. - 1. 웹 소켓 오류: `서버 연결에 실패했어요.` 페이지 내부 오류 UI - 2. API 오류: `필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.` 페이지 내부 오류 UI - 3. API 조회 중 또는 소켓 연결 중: 헤더 없는 로딩 UI - 4. 두 작업 성공: 헤더와 기존 관객용 콘텐츠 -- 로딩 UI의 `LoadingSpinner`에 `color="text-gray-..."`를 지정한다. -- 오류 UI에 `MdErrorOutline`, 번역된 오류 문구, 번역된 새로고침 버튼을 세로 배치하고 전체를 화면 중앙 정렬한다. -- 새로고침 버튼 클릭 핸들러에서 브라우저 페이지를 다시 불러온다. -- 기존 소켓 오류를 Error Boundary로 던지는 `AudienceShareDisplayError` 흐름은 페이지 내부 오류 렌더링으로 대체한다. 외부 사용처가 없는 것이 확인되면 클래스와 관련 테스트를 제거한다. -- 두 초기 작업이 성공한 경우에만 다음 헤더를 렌더링한다. - - `DefaultLayout.Header.Left`의 `HeaderTableInfo`: trim 기준 빈 값 대체 정책 적용 - - `DefaultLayout.Header.Center`의 `HeaderTitle`: trim 기준 빈 값 대체 정책 적용 - - `DefaultLayout.Header.Right`: 별도 children 없이 공통 헤더의 언어 선택기, 홈, 인증 버튼을 사용 -- 디버깅용 `console.log`를 제거한다. - -## 수정하지 않을 것으로 예상되는 기존 파일 - -- `src/apis/apis/live.ts`: `getDebateTableDataForShare`가 이미 구현되어 있다. -- `src/hooks/query/useGetDebateTableDataForShare.ts`: 기존 조회 훅을 그대로 사용하며, 페이지에서 성공 여부를 소켓 활성화 조건으로 조합한다. -- `src/hooks/query/useGetDebateTableDataForShare.test.tsx`: API 호출 및 캐시 저장 성공 경로가 이미 검증되어 있다. -- `src/mocks/handlers/live.ts`: 공유용 조회 성공 응답 핸들러가 이미 있다. 페이지 테스트는 기존 방식대로 훅을 mock한다. -- `src/components/LoadingSpinner.tsx`: 기존 `color` prop으로 회색을 지정하며, 하드코딩된 접근성 문구는 현재 PR 범위상 변경하지 않는다. -- `src/layout/components/header/StickyTriSectionHeader.tsx`: 언어 선택기, 홈, 로그인/로그아웃 버튼은 `DefaultLayout.Header.Right`가 이미 기본 제공하므로 변경하지 않는다. - -## 모호한 부분 (Q&A) - -- 현재 확인된 추가 모호점 없음. diff --git a/plan-1.md b/plan-1.md deleted file mode 100644 index 7164e967..00000000 --- a/plan-1.md +++ /dev/null @@ -1,124 +0,0 @@ -# AudienceNormalTimer 발언 정보·프로그레스 바 구현 계획 - -## 목표 - -웹 소켓 NORMAL 타이머 메시지의 `sequence`를 관객 표시 상태에 보존하고, `AudienceSharePage`가 `debateTableQuery.data.table[sequence]`에서 현재 발언 순서의 정보를 선택한다. 선택한 정보로 `AudienceNormalTimer`에 발언 유형, 진영별 팀명, 토론자명, 원래 제한 시간을 전달해 현재 남은 시간과 함께 표시하며, 경과 시간 비율을 나타내는 진영 색상의 가로 프로그레스 바를 추가한다. - -## 구현 원칙 - -- API 조회 결과는 기존처럼 `AudienceSharePage`의 단일 데이터 원천으로 유지하고 별도 state로 복제하지 않는다. -- 웹 소켓에서 받은 `sequence`만 관객 표시 상태에 추가하며 API의 `table` 전체를 소켓 상태 훅에 전달하지 않는다. -- `sequence`로 선택한 `TimeBoxInfo`와 팀명은 페이지에서 파생한 뒤 `AudienceNormalTimer`에 명시적인 props로 전달한다. -- 진행률은 `AudienceNormalTimer`에서 `(totalTime - remainingTime) / totalTime * 100`으로 파생하고 별도 React state로 중복 저장하지 않는다. -- 공용 프로그레스 바는 API 응답이나 시간 계산 방식을 알지 않으며, 계산된 `progress`, `team`, `className`, 애니메이션 여부만 입력받는다. -- 진행률은 0~100 범위로 제한해 음수 남은 시간이나 서버 동기화 오차가 레이아웃을 깨뜨리지 않게 한다. -- NORMAL 항목의 `stance`가 `NEUTRAL`이면 공용 바의 `DISABLED` 팀으로 매핑해 `bg-default-neutral` 색상을 적용하고 나머지 타이머는 정상 표시한다. -- `sequence`가 배열 범위를 벗어나거나 소켓의 NORMAL 타입과 API 항목의 `boxType`이 불일치하거나 NORMAL 항목의 `time`이 null/0 이하이면 서버 연결 오류 UI로 전환한다. -- 사용자에게 보이는 발언 유형·팀명·토론자명은 기존 i18n 정책을 따른다. -- 테스트는 Constitution에 따라 훅 → 컴포넌트 → 페이지 순서로 먼저 작성한다. - -## 구현 순서 (TDD) - -1. `useAudienceShareState` 테스트에 NORMAL 메시지의 `sequence` 보존 및 새 메시지 수신 시 갱신 동작을 먼저 명세한다. -2. 공용 프로그레스 바의 색상·className·애니메이션과 `AudienceNormalTimer`의 경과 진행률 계산 및 발언 정보 Row를 컴포넌트 테스트로 먼저 명세한다. -3. `AudienceSharePage` 테스트에서 `sequence`에 해당하는 API 테이블 항목과 팀명이 NORMAL 타이머에 연결되는지 명세한다. -4. 소켓 표시 상태에 `sequence`를 추가하고 페이지에서 현재 `TimeBoxInfo` 및 팀명을 파생한다. -5. `AudienceNormalTimer`의 제목·발언자 Row·타이머·프로그레스 바 UI와 Framer Motion 애니메이션을 구현한다. -6. 관련 Vitest를 실행하고, Prettier 적용 후 전체 lint와 build로 회귀를 검증한다. - -## 추가 예정 파일 및 내용 - -### `src/components/TimerProgressBar/TimerProgressBar.tsx` - -- 재사용 가능한 가로 프로그레스 바로서 계산된 `progress`, `team`, 레이아웃 확장용 `className`, 애니메이션 판단용 `isRunning`을 props로 받는다. -- `team`은 컴포넌트 전용 타입인 `PROS | CONS | DISABLED`를 지원한다. -- 바 외곽의 공통 스타일은 `h-[24px]`, `w-full`, `overflow-hidden`, `rounded-full`로 구성하고, 호출부가 전달한 `className`을 병합해 최대 너비·여백 등 배치 정책을 확장한다. -- 관객 NORMAL 타이머에서는 `w-full max-w-[1280px]`과 필요한 좌우 여백 class를 전달해 최대 1280px, 작은 화면에서는 반응형 축소를 적용한다. -- 내부 진행 바는 PROS일 때 `bg-camp-blue`, CONS일 때 `bg-camp-red`, DISABLED일 때 `bg-default-neutral`을 적용한다. -- 전달받은 `progress`를 0~100으로 제한한다. -- `CircularTimer`가 사용하는 방식처럼 Framer Motion의 `useMotionValue`, `animate`, cleanup을 이용한다. -- PLAY 중에는 직전 값에서 새 경과 진행률까지 0.7초 `easeOut`으로 왼쪽에서 오른쪽으로 채우고, STOP·RESET 및 그 밖의 비실행 상태에서는 즉시 동기화한다. -- 시각 정보만으로 상태를 전달하지 않도록 `role="progressbar"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`를 제공한다. - -### `src/components/TimerProgressBar/TimerProgressBar.test.tsx` - -- 전달한 `className`이 외곽 바의 기본 class와 함께 적용되는지 검증한다. -- PROS/CONS/DISABLED 팀별 색상 클래스를 검증한다. -- 0 미만 및 100 초과 `progress`의 제한 동작을 검증한다. -- 프로그레스 바의 접근성 role/value, 24px 높이 및 둥근 끝 스타일을 검증한다. -- fake timer 또는 Framer Motion 제어 mock을 최소한으로 사용해 PLAY의 0.7초 `easeOut` 애니메이션과 STOP·RESET 등 비실행 상태의 즉시 동기화를 검증한다. - -## 수정 대상 파일 및 내용 - -### `src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts` - -- NORMAL 이벤트의 `data.sequence`가 `displayData.sequence`로 보존되는 테스트를 추가한다. -- PLAY/STOP/RESET/NEXT/BEFORE 등 유효한 새 NORMAL 메시지 수신 시 남은 시간과 `sequence`가 함께 최신 값으로 갱신되는지 검증한다. -- 기존 TIME_BASED 상태, 연결 수명주기, 오류 및 타임아웃 테스트가 그대로 통과하는지 확인한다. - -### `src/page/AudienceSharePage/hooks/useAudienceShareState.ts` - -- `AudienceNormalDisplayData`에 `sequence: number`를 추가한다. -- NORMAL 메시지를 처리할 때 `data.sequence`를 `singleTime`, `isRunning`과 함께 저장한다. -- 이번 작업에서는 TIME_BASED 표시 상태에 `sequence`를 추가하지 않는다. - -### `src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx` - -- 기존 임시 문구 `남은 시간` 대신 전달받은 `speechType`이 제목으로 표시되는지 검증한다. -- `DTDebate` 아이콘, 현재 진영의 팀명, 토론자명이 한 Row에 렌더링되는지 검증한다. -- PROS 및 CONS 입력 각각에서 올바른 팀 정보와 프로그레스 바 색상이 연결되는지 검증한다. -- `remainingTime`이 기존과 동일하게 `MM:SS` 형식으로 표시되는지 검증한다. -- `(totalTime - remainingTime) / totalTime * 100`으로 계산한 경과 진행률을 공용 `TimerProgressBar`에 전달하는지 검증한다. -- `stance` PROS/CONS를 같은 이름의 바 `team` prop으로 전달하고, NEUTRAL은 DISABLED로 전달하는지 검증한다. -- 프로그레스 바에 최대 1280px과 반응형 전체 너비 class를 전달하는지 검증한다. -- 남은 시간이 전체 시간 범위를 벗어나도 최종 진행률이 0~100으로 제한되는지 검증한다. -- 팀명 또는 토론자명이 비어 있으면 접미사 없이 각각 `팀명 없음`, `토론자 없음` 대체 문구가 표시되는지 검증한다. -- 사용자 조작 요소가 추가되지 않는 기존 계약을 유지한다. - -### `src/page/AudienceSharePage/components/AudienceNormalTimer.tsx` - -- props를 현재의 `remainingTime` 단일 값에서 다음 정보를 받을 수 있는 구조로 확장한다. - - 현재 `TimeBoxInfo` 또는 이에 상응하는 `speechType`, `stance`, `speaker`, `totalTime` - - 현재 진영으로 결정된 `teamName` - - 로컬 카운트다운이 반영된 `remainingTime` - - 프로그레스 바 애니메이션 판단용 `isRunning` -- `

`의 `남은 시간`을 현재 순서의 `speechType`으로 교체한다. -- 제목 아래에 `DTDebate`, 팀명, 토론자명을 가로로 배치한 Row를 추가한다. -- `DTDebate`는 정보 전달 텍스트가 함께 있으므로 장식 아이콘으로 처리해 스크린 리더 중복 낭독을 방지한다. -- `TimerPage/NormalTimer`와 동일하게 `normalizeSpeechTypeKey`로 알려진 발언 유형을 정규화해 번역하고, 알 수 없는 사용자 입력은 원문을 표시한다. -- 값이 있는 팀명과 토론자명은 `t('{{team}} 팀', { team: t(teamName) })`, `t('{{speaker}} 토론자', { speaker: t(speaker) })` 형식을 사용한다. -- 팀명 또는 토론자명이 빈 문자열/null이면 템플릿에 넣지 않고 각각 번역된 `팀명 없음`, `토론자 없음`을 최종 문구로 표시한다. -- `(totalTime - remainingTime) / totalTime * 100` 경과 진행률을 렌더링 중 파생해 기존 포맷된 타이머 아래의 공용 `TimerProgressBar`에 전달한다. -- 공용 바에는 PROS/CONS 또는 NEUTRAL에서 변환한 DISABLED 팀, `w-full max-w-[1280px]` className, `isRunning`을 함께 전달한다. -- API 원본 객체에 직접 의존하지 않고 페이지에서 선택된 현재 항목만 props로 받아 컴포넌트 책임을 표시 UI로 제한한다. - -### `src/page/AudienceSharePage/AudienceSharePage.test.tsx` - -- NORMAL 표시 mock에 `sequence`를 추가한다. -- 여러 테이블 항목을 가진 API mock을 사용해 `sequence`에 해당하는 항목의 `speechType`, `stance`, `speaker`, `time`이 선택되는지 검증한다. -- PROS면 `info.prosTeamName`, CONS면 `info.consTeamName`이 표시되는지 각각 검증한다. -- 웹 소켓에서 새 `sequence`를 수신한 상태로 바뀌면 다른 발언 정보와 프로그레스 바 기준 시간이 표시되는지 검증한다. -- 유효하지 않은 `sequence`, `boxType !== 'NORMAL'`, `time === null`, `time <= 0`인 NORMAL 소켓 상태에서 서버 연결 오류 UI가 표시되는지 검증한다. -- NEUTRAL NORMAL 항목은 오류 없이 타이머를 유지하고 중립 색상의 프로그레스 바를 표시하는지 검증한다. -- 기존 API/소켓 로딩·오류, 헤더, TIME_BASED 타이머, 종료 화면 테스트가 그대로 통과하는지 확인한다. - -### `src/page/AudienceSharePage/AudienceSharePage.tsx` - -- NORMAL `displayData.sequence`로 `debateTableQuery.data.table[sequence]`를 선택한다. -- `sequence`가 배열 범위를 벗어나거나 선택된 항목의 `boxType !== 'NORMAL'` 또는 `time === null`/`time <= 0`이면 서버 데이터 불일치로 판단해 기존 `서버 연결에 실패했어요.` 오류 UI를 재사용한다. -- 선택된 항목의 `stance`에 따라 PROS는 `info.prosTeamName`, CONS는 `info.consTeamName`, NEUTRAL은 빈 팀명으로 파생하고 `AudienceNormalTimer`가 대체 문구를 표시하게 한다. -- `AudienceNormalTimer`에 현재 항목, 팀명, 로컬 카운트다운이 반영된 남은 시간, 실행 여부를 전달한다. -- API 데이터와 소켓 `sequence`의 결합 및 경계 검증은 페이지에 두고, `AudienceNormalTimer`에는 유효하고 표시 가능한 데이터만 전달한다. - -## 수정하지 않을 것으로 예상되는 기존 파일 - -- `src/apis/sockets/type.ts`: 웹 소켓 `TimerDataPayload`에 이미 `sequence`가 정의되어 있다. -- `src/apis/responses/live.ts` 및 `src/type/type.ts`: API 테이블 항목에 `stance`, `speechType`, `speaker`, `time`이 이미 정의되어 있다. -- `src/page/TimerPage/components/CircularTimer.tsx`: 참고만 하며 원형 타이머 동작은 변경하지 않는다. -- `src/page/TimerPage/hooks/useCircularTimerAnimation.ts`: 원형 타이머 전용 이름과 의미를 유지하고, 관객용 가로 바가 이를 직접 import해 페이지 간 결합을 만들지 않는다. -- `src/components/icons/Debate.tsx`: 기존 `DTDebate` 아이콘을 그대로 사용한다. -- `tailwind.config.js`: `camp-blue`, `camp-red` 색상 토큰이 이미 존재한다. - -## 모호한 부분 (Q&A) - -- 현재 확인된 추가 모호점 없음. From 222086b5484a508c52aeebf92eafcda3876fb737 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Mon, 13 Jul 2026 16:52:32 +0900 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=EC=95=88=EC=97=90=20=EB=A7=9E=EA=B2=8C=20UI=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceTimeBasedTimerDisplay.test.tsx | 28 +++++++++--- .../AudienceTimeBasedTimerDisplay.tsx | 43 ++++++++++++++----- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx index 54fd0ed9..66daa610 100644 --- a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx @@ -79,24 +79,42 @@ describe('AudienceTimeBasedTimerDisplay', () => { ); }); - it('빈 팀 이름은 폴백을 표시하고 비활성 팀은 흐리게 표시한다', () => { + it('빈 팀 이름은 폴백을 표시하고 비활성 팀의 모든 요소를 무채색으로 표시한다', () => { render( , ); - expect(screen.getByText('팀명 없음')).toBeInTheDocument(); - expect(screen.getByTestId('cons-timer-display')).toHaveClass( + 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'), diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx index ad987bd0..a924e0e6 100644 --- a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.tsx @@ -18,10 +18,16 @@ interface AudienceTimeBasedTimerDisplayProps { interface TimerValueProps { seconds: number; testId: string; + isDisabled: boolean; className?: string; } -function TimerValue({ seconds, testId, className }: TimerValueProps) { +function TimerValue({ + seconds, + testId, + isDisabled, + className, +}: TimerValueProps) { const normalizedSeconds = Math.max(0, seconds); const [minutes, remainingSeconds] = Formatting.formatSecondsToMMSS(normalizedSeconds).split(':'); @@ -29,7 +35,8 @@ function TimerValue({ seconds, testId, className }: TimerValueProps) { return ( @@ -82,7 +88,12 @@ export default function AudienceTimeBasedTimerDisplay({ ) : null} -

+

{teamLabel}

@@ -91,29 +102,38 @@ export default function AudienceTimeBasedTimerDisplay({ ) : ( <> - + {t('전체 시간')} {t('현재 시간')} @@ -121,11 +141,12 @@ export default function AudienceTimeBasedTimerDisplay({ From e8ad5fa08296574ce3d2f7d5bfd1f3847700112c Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 28 Jul 2026 19:28:45 +0900 Subject: [PATCH 14/19] =?UTF-8?q?fix:=20Gemini=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TimerProgressBar/TimerProgressBar.tsx | 2 +- src/page/AudienceSharePage/AudienceSharePage.tsx | 2 ++ src/page/AudienceSharePage/components/AudienceNormalTimer.tsx | 2 +- .../components/AudienceTimeBasedTimerDisplay.tsx | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/TimerProgressBar/TimerProgressBar.tsx b/src/components/TimerProgressBar/TimerProgressBar.tsx index 09841cc7..fb720bb5 100644 --- a/src/components/TimerProgressBar/TimerProgressBar.tsx +++ b/src/components/TimerProgressBar/TimerProgressBar.tsx @@ -30,7 +30,7 @@ export default function TimerProgressBar({ className, }: TimerProgressBarProps) { const normalizedProgress = clamp(0, 100, progress); - const progressMotionValue = useMotionValue(0); + const progressMotionValue = useMotionValue(normalizedProgress); const width = useTransform( progressMotionValue, (currentProgress) => `${currentProgress}%`, diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index c783ede5..fdac035d 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -327,6 +327,7 @@ export default function AudienceSharePage() { Date: Tue, 28 Jul 2026 20:35:27 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/en/translation.json | 1 + public/locales/ko/translation.json | 1 + src/hooks/sockets/useAudienceSocket.test.ts | 31 ++ src/hooks/sockets/useAudienceSocket.ts | 2 + .../AudienceSharePage.test.tsx | 297 ++++++++++-------- .../AudienceSharePage/AudienceSharePage.tsx | 2 +- 6 files changed, 195 insertions(+), 139 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 237065b4..f23db843 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -270,6 +270,7 @@ "나의 토론 주제": "My Debate Topic", "나의 시간표": "My Timetable", "유효하지 않은 토론방 ID입니다.": "Invalid debate room ID.", + "시간표 설정에 오류가 발생했어요.": "An error occurred in timetable settings.", "실시간 연결 주소를 확인할 수 없어요.": "Could not verify live connection address.", "토론방 연결이 거부되었어요.": "Connection to the debate room was rejected.", "실시간 연결에서 서버 오류가 발생했어요.": "A server error occurred during live connection.", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 98dcb290..2cab02d3 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -270,6 +270,7 @@ "나의 토론 주제": "나의 토론 주제", "나의 시간표": "나의 시간표", "유효하지 않은 토론방 ID입니다.": "유효하지 않은 토론방 ID입니다.", + "시간표 설정에 오류가 발생했어요.": "시간표 설정에 오류가 발생했어요.", "실시간 연결 주소를 확인할 수 없어요.": "실시간 연결 주소를 확인할 수 없어요.", "토론방 연결이 거부되었어요.": "토론방 연결이 거부되었어요.", "실시간 연결에서 서버 오류가 발생했어요.": "실시간 연결에서 서버 오류가 발생했어요.", diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts index 4dbb08db..2b7844c9 100644 --- a/src/hooks/sockets/useAudienceSocket.test.ts +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -73,6 +73,37 @@ describe('useAudienceSocket', () => { expect(subscribe).toHaveBeenCalledWith('/room/123', expect.any(Function)); }); + it('enabled가 true에서 false로 전환되면 latestMessage를 null로 초기화하고 unsubscribe를 호출해야 한다', () => { + const message: SocketMessage = { + eventType: 'FINISHED', + data: null, + }; + let handleMessage: (message: IMessage) => void = () => undefined; + subscribe.mockImplementation( + (_destination: string, callback: (message: IMessage) => void) => { + handleMessage = callback; + }, + ); + + const { result, rerender } = renderHook( + ({ enabled }) => useAudienceSocket(123, { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => { + handleMessage({ body: JSON.stringify(message) } as IMessage); + }); + + expect(result.current.latestMessage).toEqual(message); + + act(() => { + rerender({ enabled: false }); + }); + + expect(result.current.latestMessage).toBeNull(); + expect(unsubscribe).toHaveBeenCalledWith('/room/123'); + }); + it('유효한 메시지를 수신하면 latestMessage 상태를 업데이트해야 한다', () => { const message: SocketMessage = { eventType: 'FINISHED', diff --git a/src/hooks/sockets/useAudienceSocket.ts b/src/hooks/sockets/useAudienceSocket.ts index b495a1f6..c58193ae 100644 --- a/src/hooks/sockets/useAudienceSocket.ts +++ b/src/hooks/sockets/useAudienceSocket.ts @@ -82,6 +82,7 @@ export default function useAudienceSocket( useEffect(() => { if (!enabled) { + resetMessage(); return; } @@ -105,6 +106,7 @@ export default function useAudienceSocket( // 컴포넌트 언마운트 또는 roomId 변경 시 해당 채널 구독 해제 return () => { + resetMessage(); unsubscribe(destination); }; }, [enabled, roomId, resetMessage, subscribe, unsubscribe]); diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index 06d7935b..2dd16947 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -1,17 +1,19 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse, delay } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../../apis/endpoints'; import AudienceSharePage from './AudienceSharePage'; import { useAudienceShareState } from './hooks/useAudienceShareState'; 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() }), })); @@ -30,9 +32,6 @@ vi.mock('react-i18next', () => ({ })); const mockUseAudienceShareState = vi.mocked(useAudienceShareState); -const mockUseGetDebateTableDataForShare = vi.mocked( - useGetDebateTableDataForShare, -); const mockDebateTableData: GetDebateTableDataForShareResponseType = { id: 123, @@ -76,19 +75,6 @@ const mockDebateTableData: GetDebateTableDataForShareResponseType = { ], }; -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 }, { hasError: boolean } @@ -119,25 +105,34 @@ describe('AudienceSharePage', () => { initialRoute: string, onError: (error: Error) => void = () => {}, ) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return render( - - - - } /> - - - , + + + + + } /> + + + + , ); }; beforeEach(() => { vi.clearAllMocks(); - setQueryState({ - data: mockDebateTableData, - isError: false, - isLoading: false, - isSuccess: true, - }); + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json(mockDebateTableData); + }), + ); mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, @@ -174,11 +169,12 @@ describe('AudienceSharePage', () => { describe('상태별 화면 렌더링', () => { it('API 조회 중에는 소켓 연결을 비활성화하고 회색 스피너만 표시한다', () => { - setQueryState({ - isError: false, - isLoading: true, - isSuccess: false, - }); + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, async () => { + await delay('infinite'); + return HttpResponse.json(mockDebateTableData); + }), + ); renderPage('/live/123'); @@ -192,27 +188,28 @@ describe('AudienceSharePage', () => { expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); }); - it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', () => { + it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, }); renderPage('/live/123'); - expect(mockUseAudienceShareState).toHaveBeenCalledWith(123, { - enabled: true, - table: mockDebateTableData.table, + await waitFor(() => { + 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(); }); - it('waiting 상태에서는 서버 데이터 대기 번역 문구가 표시된다', () => { + it('waiting 상태에서는 서버 데이터 대기 번역 문구가 표시된다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'waiting', error: null, @@ -220,7 +217,7 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); expect( - screen.getByText('토론 시작을 대기 중입니다.'), + await screen.findByText('토론 시작을 대기 중입니다.'), ).toBeInTheDocument(); expect(screen.getByText('공유 토론 테이블')).toBeInTheDocument(); expect(screen.getByText('공유 토론 주제')).toBeInTheDocument(); @@ -238,20 +235,19 @@ describe('AudienceSharePage', () => { ).not.toBeInTheDocument(); }); - it('테이블 이름과 주제가 공백이면 TimerPage와 같은 대체 문구를 표시한다', () => { - setQueryState({ - data: { - ...mockDebateTableData, - info: { - ...mockDebateTableData.info, - name: ' ', - agenda: '', - }, - }, - isError: false, - isLoading: false, - isSuccess: true, - }); + it('테이블 이름과 주제가 공백이면 TimerPage와 같은 대체 문구를 표시한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json({ + ...mockDebateTableData, + info: { + ...mockDebateTableData.info, + name: ' ', + agenda: '', + }, + }); + }), + ); mockUseAudienceShareState.mockReturnValue({ status: 'waiting', error: null, @@ -259,11 +255,11 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('테이블 이름 없음')).toBeInTheDocument(); + expect(await screen.findByText('테이블 이름 없음')).toBeInTheDocument(); expect(screen.getByText('주제 없음')).toBeInTheDocument(); }); - it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', () => { + it('displaying 상태 (NORMAL)에서는 AudienceNormalTimer에 올바른 props가 전달된다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'displaying', error: null, @@ -277,7 +273,7 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); - expect(screen.getByText('입론')).toBeInTheDocument(); + expect(await screen.findByText('입론')).toBeInTheDocument(); expect(screen.getByText('찬성 팀')).toBeInTheDocument(); expect(screen.getByText('토론자 없음')).toBeInTheDocument(); expect(screen.getByTestId('timer-value')).toHaveAttribute( @@ -290,7 +286,7 @@ describe('AudienceSharePage', () => { ); }); - it('NORMAL sequence의 CONS 항목에 반대 팀 정보를 연결한다', () => { + it('NORMAL sequence의 CONS 항목에 반대 팀 정보를 연결한다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'displaying', error: null, @@ -305,7 +301,7 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('반론')).toBeInTheDocument(); + expect(await screen.findByText('반론')).toBeInTheDocument(); expect(screen.getByText('반대 팀')).toBeInTheDocument(); expect(screen.getByText('이토론 토론자')).toBeInTheDocument(); expect(screen.getByTestId('timer-progress-fill')).toHaveClass( @@ -313,7 +309,7 @@ describe('AudienceSharePage', () => { ); }); - it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', () => { + it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', async () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', @@ -328,13 +324,17 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByTestId('timer-value')).toHaveAttribute( 'aria-label', '00 : 10', ); - act(() => { - vi.advanceTimersByTime(2000); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByTestId('timer-value')).toHaveAttribute( @@ -343,7 +343,7 @@ describe('AudienceSharePage', () => { ); }); - it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', () => { + it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', async () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', @@ -358,8 +358,17 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); - act(() => { - vi.advanceTimersByTime(2000); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByTestId('timer-value')).toHaveAttribute( @@ -368,7 +377,7 @@ describe('AudienceSharePage', () => { ); }); - it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', () => { + it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'displaying', error: null, @@ -385,19 +394,21 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); - expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(await screen.findByText('찬성 팀')).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', - ); + await waitFor(() => { + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 20', + ); + }); }); - it('PLAY 상태의 TIME_BASED 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', () => { + it('PLAY 상태의 TIME_BASED 타이머는 현재 발언 팀만 로컬 카운트다운을 진행한다', async () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', @@ -415,6 +426,10 @@ describe('AudienceSharePage', () => { }); renderPage('/live/123'); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( 'aria-label', '00 : 10', @@ -424,8 +439,8 @@ describe('AudienceSharePage', () => { '00 : 30', ); - act(() => { - vi.advanceTimersByTime(2000); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( @@ -451,7 +466,9 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('토론이 종료되었습니다.')).toBeInTheDocument(); + expect( + await screen.findByText('토론이 종료되었습니다.'), + ).toBeInTheDocument(); const closeButton = screen.getByRole('button', { name: '페이지 닫기' }); expect(closeButton).toBeInTheDocument(); @@ -460,7 +477,6 @@ describe('AudienceSharePage', () => { expect(windowCloseSpy).toHaveBeenCalled(); - // 상태 변경이나 홈 이동이 없어야 함 expect(screen.getByText('토론이 종료되었습니다.')).toBeInTheDocument(); }); @@ -487,47 +503,50 @@ describe('AudienceSharePage', () => { 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, - }, - }); + ])( + '$name이면 시간표 설정 오류 UI를 표시한다', + async ({ sequence, table }) => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json({ ...mockDebateTableData, table }); + }), + ); + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence, + }, + }); - renderPage('/live/123'); + renderPage('/live/123'); - expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - }); + expect( + await screen.findByText('시간표 설정에 오류가 발생했어요.'), + ).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, - }); + it('NEUTRAL NORMAL 항목은 중립 진행 바로 타이머를 표시한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json({ + ...mockDebateTableData, + table: [ + { + ...mockDebateTableData.table[0], + stance: 'NEUTRAL', + time: 60, + }, + ], + }); + }), + ); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', error: null, @@ -542,26 +561,25 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); - expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + expect(await screen.findByTestId('timer-progress-fill')).toHaveClass( 'bg-default-neutral', ); + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); }); }); describe('오류 처리', () => { - it('API 조회에 실패하면 페이지 중앙에 데이터 오류와 새로고침 버튼을 표시한다', () => { - setQueryState({ - error: new Error('API failure'), - isError: true, - isLoading: false, - isSuccess: false, - }); + it('API 조회에 실패하면 페이지 중앙에 데이터 오류와 새로고침 버튼을 표시한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return new HttpResponse(null, { status: 500 }); + }), + ); renderPage('/live/123'); expect( - screen.getByText( + await screen.findByText( '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', ), ).toBeInTheDocument(); @@ -574,7 +592,7 @@ describe('AudienceSharePage', () => { expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); }); - it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', () => { + it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: new AudienceShareError('SOCKET_STOMP_ERROR'), @@ -582,19 +600,20 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); + expect( + await screen.findByText('서버 연결에 실패했어요.'), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: '새로고침' }), ).toBeInTheDocument(); }); - it('API와 소켓 오류가 함께 있으면 소켓 오류를 우선 표시한다', () => { - setQueryState({ - error: new Error('API failure'), - isError: true, - isLoading: false, - isSuccess: false, - }); + it('API와 소켓 오류가 함께 있으면 소켓 오류를 우선 표시한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return new HttpResponse(null, { status: 500 }); + }), + ); mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: new AudienceShareError('SOCKET_STOMP_ERROR'), @@ -602,7 +621,9 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); - expect(screen.getByText('서버 연결에 실패했어요.')).toBeInTheDocument(); + expect( + await screen.findByText('서버 연결에 실패했어요.'), + ).toBeInTheDocument(); expect( screen.queryByText( '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index fdac035d..8ecee8f1 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -192,7 +192,7 @@ export default function AudienceSharePage() { if (hasInvalidNormalTimeBox || hasInvalidTimeBasedTimeBox) { return ( ); From 8ba26106a7cf945dc2e20049e99e6ea81005c162 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Tue, 28 Jul 2026 21:24:22 +0900 Subject: [PATCH 16/19] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceSharePage/AudienceSharePage.tsx | 263 ++++------ .../domain/AudienceScreenState.test.ts | 341 +++++++++++++ .../domain/AudienceScreenState.ts | 183 +++++++ .../domain/EventInterpreter.test.ts | 451 ++++++++++++++++++ .../domain/EventInterpreter.ts | 250 ++++++++++ .../hooks/useAudienceShareState.ts | 261 +--------- 6 files changed, 1321 insertions(+), 428 deletions(-) create mode 100644 src/page/AudienceSharePage/domain/AudienceScreenState.test.ts create mode 100644 src/page/AudienceSharePage/domain/AudienceScreenState.ts create mode 100644 src/page/AudienceSharePage/domain/EventInterpreter.test.ts create mode 100644 src/page/AudienceSharePage/domain/EventInterpreter.ts diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 8ecee8f1..e8bf8cc5 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -11,7 +11,7 @@ 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'; +import { resolveAudienceScreenState } from './domain/AudienceScreenState'; interface ErrorContentProps { message: string; @@ -53,51 +53,6 @@ function ErrorContent({ message, onReload }: ErrorContentProps) { ); } -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) - ); -} - -function getNormalTimerTeamName( - stance: TimeBoxInfo['stance'], - info: DebateInfo, -) { - if (stance === 'PROS') { - return info.prosTeamName; - } - - if (stance === 'CONS') { - return info.consTeamName; - } - - return ''; -} - export default function AudienceSharePage() { const { id } = useParams(); const { t } = useTranslation(); @@ -117,43 +72,37 @@ export default function AudienceSharePage() { throw new Error(t('유효하지 않은 토론방 ID입니다.')); } - 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 viewState = resolveAudienceScreenState( + state, + { + data: debateTableQuery.data, + isLoading: debateTableQuery.isLoading, + isError: debateTableQuery.isError, + }, + t, + ); const normalCountdown = useAudienceCountdown({ receivedTime: - audienceDisplayData?.timerType === 'NORMAL' - ? audienceDisplayData.singleTime + viewState.type === 'NORMAL_TIMER' + ? viewState.displayData.singleTime : null, isRunning: - audienceDisplayData?.timerType === 'NORMAL' - ? audienceDisplayData.isRunning + viewState.type === 'NORMAL_TIMER' + ? viewState.displayData.isRunning : false, }); const timeBasedCountdown = useAudienceTimeBasedCountdown({ - displayData: timeBasedDisplayData, - timePerTeam: isValidTimeBasedTimeBox(timeBasedTimeBox) - ? timeBasedTimeBox.timePerTeam - : null, - timePerSpeaking: isValidTimeBasedTimeBox(timeBasedTimeBox) - ? timeBasedTimeBox.timePerSpeaking - : null, + displayData: + viewState.type === 'TIME_BASED_TIMER' ? viewState.displayData : null, + timePerTeam: + viewState.type === 'TIME_BASED_TIMER' + ? viewState.timeBox.timePerTeam + : null, + timePerSpeaking: + viewState.type === 'TIME_BASED_TIMER' + ? viewState.timeBox.timePerSpeaking + : null, }); const handleClosePage = () => { @@ -171,107 +120,62 @@ export default function AudienceSharePage() { }; const renderContent = () => { - if (state.error) { - return ( - - ); - } - - if (debateTableQuery.isError) { - return ( - - ); - } + switch (viewState.type) { + case 'LOADING': + return ; - if (hasInvalidNormalTimeBox || hasInvalidTimeBasedTimeBox) { - return ( - - ); - } - - if ( - debateTableQuery.isLoading || - !debateTableQuery.data || - state.status === 'connecting' - ) { - return ; - } - - if (state.status === 'waiting') { - return ( -
-

- {t('토론 시작을 대기 중입니다.')} -

-
- ); - } - - if (state.status === 'displaying') { - const { displayData } = state; - if (displayData.timerType === 'NORMAL') { - if (!isValidNormalTimeBox(normalTimeBox)) { - return null; - } + case 'WAITING': + return ( +
+

+ {viewState.message} +

+
+ ); + case 'NORMAL_TIMER': return (
); - } - - if (displayData.timerType === 'TIME_BASED') { - if (!isValidTimeBasedTimeBox(timeBasedTimeBox)) { - return null; - } + case 'TIME_BASED_TIMER': { const prosTotalRemainingTime = timeBasedCountdown.pros.totalRemainingTime ?? - timeBasedTimeBox.timePerTeam; + viewState.timeBox.timePerTeam; const consTotalRemainingTime = timeBasedCountdown.cons.totalRemainingTime ?? - timeBasedTimeBox.timePerTeam; + viewState.timeBox.timePerTeam; const prosCurrentSpeakingRemainingTime = - timeBasedTimeBox.timePerSpeaking === null + viewState.timeBox.timePerSpeaking === null ? null : (timeBasedCountdown.pros.currentSpeakingRemainingTime ?? - timeBasedTimeBox.timePerSpeaking); + viewState.timeBox.timePerSpeaking); const consCurrentSpeakingRemainingTime = - timeBasedTimeBox.timePerSpeaking === null + viewState.timeBox.timePerSpeaking === null ? null : (timeBasedCountdown.cons.currentSpeakingRemainingTime ?? - timeBasedTimeBox.timePerSpeaking); + viewState.timeBox.timePerSpeaking); return (
); } - } - if (state.status === 'finished') { - return ( -
-

- {t('토론이 종료되었습니다.')} -

- -
- ); - } + case 'FINISHED': + return ( +
+

+ {viewState.message} +

+ +
+ ); - return null; + case 'ERROR': + case 'CONFIG_ERROR': + return ( + + ); + } }; - const isReady = - debateTableQuery.isSuccess && - !!debateTableQuery.data && - !state.error && - !hasInvalidNormalTimeBox && - !hasInvalidTimeBasedTimeBox && - state.status !== 'connecting'; + const isHeaderVisible = + viewState.type === 'WAITING' || + viewState.type === 'NORMAL_TIMER' || + viewState.type === 'TIME_BASED_TIMER' || + viewState.type === 'FINISHED'; return ( - {isReady ? ( + {isHeaderVisible && debateTableQuery.data ? ( key; + +const mockDebateInfo: DebateInfo = { + name: '테스트 토론방', + agenda: '테스트 주제', + prosTeamName: '찬성팀', + consTeamName: '반대팀', +}; + +const mockNormalTimeBox: TimeBoxInfo = { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: '찬성 1', +}; + +const mockTimeBasedTimeBox: TimeBoxInfo = { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 600, + timePerSpeaking: 120, + speaker: null, +}; + +const mockTableData: DebateTableData = { + info: mockDebateInfo, + table: [mockNormalTimeBox, mockTimeBasedTimeBox], +}; + +describe('audienceScreenState', () => { + describe('isValidNormalTimeBox', () => { + it('유효한 일반 타이머 timeBox인 경우 true를 반환한다', () => { + expect(isValidNormalTimeBox(mockNormalTimeBox)).toBe(true); + }); + + it('timeBox가 undefined인 경우 false를 반환한다', () => { + expect(isValidNormalTimeBox(undefined)).toBe(false); + }); + + it('boxType이 NORMAL이 아닌 경우 false를 반환한다', () => { + expect(isValidNormalTimeBox(mockTimeBasedTimeBox)).toBe(false); + }); + + it('time이 null인 경우 false를 반환한다', () => { + const invalidTimeBox: TimeBoxInfo = { + ...mockNormalTimeBox, + time: null, + }; + expect(isValidNormalTimeBox(invalidTimeBox)).toBe(false); + }); + + it('time이 0 이하인 경우 false를 반환한다', () => { + const invalidTimeBox: TimeBoxInfo = { + ...mockNormalTimeBox, + time: 0, + }; + expect(isValidNormalTimeBox(invalidTimeBox)).toBe(false); + }); + }); + + describe('isValidTimeBasedTimeBox', () => { + it('유효한 자유토론 타이머 timeBox인 경우 true를 반환한다', () => { + expect(isValidTimeBasedTimeBox(mockTimeBasedTimeBox)).toBe(true); + }); + + it('timePerSpeaking이 null인 경우에도 유효하면 true를 반환한다', () => { + const validTimeBox: TimeBoxInfo = { + ...mockTimeBasedTimeBox, + timePerSpeaking: null, + }; + expect(isValidTimeBasedTimeBox(validTimeBox)).toBe(true); + }); + + it('timeBox가 undefined인 경우 false를 반환한다', () => { + expect(isValidTimeBasedTimeBox(undefined)).toBe(false); + }); + + it('boxType이 TIME_BASED가 아닌 경우 false를 반환한다', () => { + expect(isValidTimeBasedTimeBox(mockNormalTimeBox)).toBe(false); + }); + + it('timePerTeam이 null이거나 0 이하인 경우 false를 반환한다', () => { + const invalidTimeBox1: TimeBoxInfo = { + ...mockTimeBasedTimeBox, + timePerTeam: null, + }; + const invalidTimeBox2: TimeBoxInfo = { + ...mockTimeBasedTimeBox, + timePerTeam: 0, + }; + expect(isValidTimeBasedTimeBox(invalidTimeBox1)).toBe(false); + expect(isValidTimeBasedTimeBox(invalidTimeBox2)).toBe(false); + }); + + it('timePerSpeaking이 0 이하인 경우 false를 반환한다', () => { + const invalidTimeBox: TimeBoxInfo = { + ...mockTimeBasedTimeBox, + timePerSpeaking: 0, + }; + expect(isValidTimeBasedTimeBox(invalidTimeBox)).toBe(false); + }); + }); + + describe('getNormalTimerTeamName', () => { + it('stance가 PROS일 경우 찬성 팀명을 반환한다', () => { + expect(getNormalTimerTeamName('PROS', mockDebateInfo)).toBe('찬성팀'); + }); + + it('stance가 CONS일 경우 반대 팀명을 반환한다', () => { + expect(getNormalTimerTeamName('CONS', mockDebateInfo)).toBe('반대팀'); + }); + + it('stance가 NEUTRAL일 경우 빈 문자열을 반환한다', () => { + expect(getNormalTimerTeamName('NEUTRAL', mockDebateInfo)).toBe(''); + }); + }); + + describe('resolveAudienceScreenState', () => { + const successQueryState: AudienceQueryState = { + data: mockTableData, + isLoading: false, + isError: false, + }; + + it('소켓 에러가 발생하면 ERROR 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'connecting', + error: new AudienceShareError('UNKNOWN'), + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'ERROR', + message: '서버 연결에 실패했어요.', + }); + }); + + it('쿼리 에러가 발생하면 ERROR 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'connecting', + error: null, + }; + const queryState: AudienceQueryState = { + data: undefined, + isLoading: false, + isError: true, + }; + const result = resolveAudienceScreenState(socketState, queryState, t); + expect(result).toEqual({ + type: 'ERROR', + message: '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + }); + }); + + it('쿼리가 로딩 중이면 LOADING 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'connecting', + error: null, + }; + const queryState: AudienceQueryState = { + data: undefined, + isLoading: true, + isError: false, + }; + const result = resolveAudienceScreenState(socketState, queryState, t); + expect(result).toEqual({ type: 'LOADING' }); + }); + + it('소켓이 connecting 상태이면 LOADING 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'connecting', + error: null, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ type: 'LOADING' }); + }); + + it('소켓이 waiting 상태이면 WAITING 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'waiting', + error: null, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'WAITING', + message: '토론 시작을 대기 중입니다.', + }); + }); + + it('소켓이 finished 상태이면 FINISHED 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'finished', + error: null, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'FINISHED', + message: '토론이 종료되었습니다.', + }); + }); + + it('displaying 상태에서 일반 타이머 유효시 NORMAL_TIMER 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 120, + sequence: 0, + }, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'NORMAL_TIMER', + timeBox: mockNormalTimeBox, + displayData: socketState.displayData, + teamName: '찬성팀', + debateInfo: mockDebateInfo, + }); + }); + + it('displaying 상태에서 일반 타이머가 유효하지 않으면 CONFIG_ERROR 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 120, + sequence: 1, // 1번은 TIME_BASED timeBox라 NORMAL에 유효하지 않음 + }, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'CONFIG_ERROR', + message: '시간표 설정에 오류가 발생했어요.', + }); + }); + + it('displaying 상태에서 자유토론 타이머 유효시 TIME_BASED_TIMER 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'displaying', + error: null, + displayData: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 500, + consTime: 600, + sequence: 1, + eventType: 'PLAY', + revision: 1, + }, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'TIME_BASED_TIMER', + timeBox: mockTimeBasedTimeBox, + displayData: socketState.displayData, + prosTeamName: '찬성팀', + consTeamName: '반대팀', + debateInfo: mockDebateInfo, + }); + }); + + it('displaying 상태에서 자유토론 타이머가 유효하지 않으면 CONFIG_ERROR 상태를 반환한다', () => { + const socketState: AudienceShareState = { + status: 'displaying', + error: null, + displayData: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 500, + consTime: 600, + sequence: 0, // 0번은 NORMAL timeBox라 TIME_BASED에 유효하지 않음 + eventType: 'PLAY', + revision: 1, + }, + }; + const result = resolveAudienceScreenState( + socketState, + successQueryState, + t, + ); + expect(result).toEqual({ + type: 'CONFIG_ERROR', + message: '시간표 설정에 오류가 발생했어요.', + }); + }); + }); +}); diff --git a/src/page/AudienceSharePage/domain/AudienceScreenState.ts b/src/page/AudienceSharePage/domain/AudienceScreenState.ts new file mode 100644 index 00000000..4780c944 --- /dev/null +++ b/src/page/AudienceSharePage/domain/AudienceScreenState.ts @@ -0,0 +1,183 @@ +import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; +import { + AudienceNormalDisplayData, + AudienceShareState, + AudienceTimeBasedDisplayData, +} from '../hooks/useAudienceShareState'; + +export type ValidNormalTimeBox = TimeBoxInfo & { + boxType: 'NORMAL'; + time: number; +}; + +export type ValidTimeBasedTimeBox = TimeBoxInfo & { + boxType: 'TIME_BASED'; + timePerTeam: number; + timePerSpeaking: number | null; +}; + +export function isValidNormalTimeBox( + timeBox: TimeBoxInfo | undefined, +): timeBox is ValidNormalTimeBox { + return ( + timeBox?.boxType === 'NORMAL' && timeBox.time !== null && timeBox.time > 0 + ); +} + +export 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) + ); +} + +export function getNormalTimerTeamName( + stance: TimeBoxInfo['stance'], + info: DebateInfo, +): string { + if (stance === 'PROS') { + return info.prosTeamName; + } + + if (stance === 'CONS') { + return info.consTeamName; + } + + return ''; +} + +export type AudienceViewState = + | { + type: 'LOADING'; + } + | { + type: 'WAITING'; + message: string; + } + | { + type: 'NORMAL_TIMER'; + timeBox: ValidNormalTimeBox; + displayData: AudienceNormalDisplayData; + teamName: string; + debateInfo: DebateInfo; + } + | { + type: 'TIME_BASED_TIMER'; + timeBox: ValidTimeBasedTimeBox; + displayData: AudienceTimeBasedDisplayData; + prosTeamName: string; + consTeamName: string; + debateInfo: DebateInfo; + } + | { + type: 'FINISHED'; + message: string; + } + | { + type: 'ERROR'; + message: string; + } + | { + type: 'CONFIG_ERROR'; + message: string; + }; + +export interface AudienceQueryState { + data?: DebateTableData; + isLoading: boolean; + isError: boolean; +} + +export type TranslateFn = (key: string) => string; + +export function resolveAudienceScreenState( + state: AudienceShareState, + queryState: AudienceQueryState, + t: TranslateFn, +): AudienceViewState { + if (state.error) { + return { + type: 'ERROR', + message: t('서버 연결에 실패했어요.'), + }; + } + + if (queryState.isError) { + return { + type: 'ERROR', + message: t('필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.'), + }; + } + + if ( + queryState.isLoading || + !queryState.data || + state.status === 'connecting' + ) { + return { + type: 'LOADING', + }; + } + + if (state.status === 'waiting') { + return { + type: 'WAITING', + message: t('토론 시작을 대기 중입니다.'), + }; + } + + if (state.status === 'finished') { + return { + type: 'FINISHED', + message: t('토론이 종료되었습니다.'), + }; + } + + if (state.status === 'displaying') { + const { displayData } = state; + const timeBox = queryState.data.table[displayData.sequence]; + + if (displayData.timerType === 'NORMAL') { + if (!isValidNormalTimeBox(timeBox)) { + return { + type: 'CONFIG_ERROR', + message: t('시간표 설정에 오류가 발생했어요.'), + }; + } + + return { + type: 'NORMAL_TIMER', + timeBox, + displayData, + teamName: getNormalTimerTeamName(timeBox.stance, queryState.data.info), + debateInfo: queryState.data.info, + }; + } + + if (displayData.timerType === 'TIME_BASED') { + if (!isValidTimeBasedTimeBox(timeBox)) { + return { + type: 'CONFIG_ERROR', + message: t('시간표 설정에 오류가 발생했어요.'), + }; + } + + return { + type: 'TIME_BASED_TIMER', + timeBox, + displayData, + prosTeamName: queryState.data.info.prosTeamName, + consTeamName: queryState.data.info.consTeamName, + debateInfo: queryState.data.info, + }; + } + } + + return { + type: 'LOADING', + }; +} diff --git a/src/page/AudienceSharePage/domain/EventInterpreter.test.ts b/src/page/AudienceSharePage/domain/EventInterpreter.test.ts new file mode 100644 index 00000000..60b5756e --- /dev/null +++ b/src/page/AudienceSharePage/domain/EventInterpreter.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect } from 'vitest'; +import { + getNextTeam, + getNormalTotalTime, + createDisplayData, + createNavigationDisplayData, + getDisplayDataByEvent, + AudienceNormalDisplayData, + AudienceTimeBasedDisplayData, +} from './EventInterpreter'; +import { TimeBoxInfo } from '../../../type/type'; +import { TimerDataPayload } from '../../../apis/sockets/type'; + +describe('eventInterpreter 순수 함수', () => { + describe('getNextTeam', () => { + it('PROS 입력 시 CONS를 반환한다', () => { + expect(getNextTeam('PROS')).toBe('CONS'); + }); + + it('CONS 입력 시 PROS를 반환한다', () => { + expect(getNextTeam('CONS')).toBe('PROS'); + }); + }); + + describe('getNormalTotalTime', () => { + const mockTable: TimeBoxInfo[] = [ + { + stance: 'NEUTRAL', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 300, + timePerSpeaking: 60, + speaker: null, + }, + ]; + + it('table에 NORMAL 타입의 유효한 time이 있으면 그 값을 반환한다', () => { + expect(getNormalTotalTime(mockTable, 0, 100)).toBe(180); + }); + + it('table이 undefined이면 fallbackTime을 반환한다', () => { + expect(getNormalTotalTime(undefined, 0, 100)).toBe(100); + }); + + it('해당 index의 boxType이 TIME_BASED이면 fallbackTime을 반환한다', () => { + expect(getNormalTotalTime(mockTable, 1, 100)).toBe(100); + }); + + it('time이 null 또는 0 이하이면 fallbackTime을 반환한다', () => { + const invalidTable: TimeBoxInfo[] = [ + { + stance: 'NEUTRAL', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 0, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + ]; + expect(getNormalTotalTime(invalidTable, 0, 100)).toBe(100); + }); + }); + + describe('createDisplayData', () => { + it('NORMAL 타이머 데이터인 경우 AudienceNormalDisplayData를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 120, + sequence: 0, + }; + const result = createDisplayData(payload, null, { + eventType: 'PLAY', + isRunning: true, + }); + + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 120, + sequence: 0, + }); + }); + + it('TIME_BASED 타이머 데이터에서 currentTeam이 유효하지 않으면 이전 displayData를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 120, + sequence: 1, + currentTeam: undefined, + }; + const prevData: AudienceNormalDisplayData = { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 100, + sequence: 0, + }; + + const result = createDisplayData(payload, prevData, { + eventType: 'PLAY', + isRunning: true, + }); + + expect(result).toBe(prevData); + }); + + it('TIME_BASED 타이머 데이터 수신 시 currentTeam 시간에 맞게 갱신한다 (PROS)', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 150, + sequence: 1, + currentTeam: 'PROS', + }; + const prevData: AudienceTimeBasedDisplayData = { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: false, + prosTime: 180, + consTime: 180, + sequence: 1, + eventType: 'STOP', + revision: 1, + }; + + const result = createDisplayData(payload, prevData, { + eventType: 'PLAY', + isRunning: true, + }); + + expect(result).toEqual({ + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 150, + consTime: 180, + sequence: 1, + eventType: 'PLAY', + revision: 2, + }); + }); + + it('shouldSwitchTeam 옵션이 true이면 발언 팀을 변경한다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 150, + sequence: 1, + currentTeam: 'PROS', + }; + const prevData: AudienceTimeBasedDisplayData = { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 180, + consTime: 180, + sequence: 1, + eventType: 'PLAY', + revision: 1, + }; + + const result = createDisplayData(payload, prevData, { + eventType: 'TEAM_SWITCH', + isRunning: true, + shouldSwitchTeam: true, + }); + + expect(result?.timerType === 'TIME_BASED' && result.currentTeam).toBe( + 'CONS', + ); + }); + }); + + describe('createNavigationDisplayData', () => { + const mockTable: TimeBoxInfo[] = [ + { + stance: 'NEUTRAL', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 300, + timePerSpeaking: 60, + speaker: null, + }, + ]; + + it('목표 sequence가 NORMAL 타입인 경우 전달된 table 정보로 NORMAL 데이터를 생성한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 180, + sequence: 1, + }; + + const result = createNavigationDisplayData( + 'BEFORE', + payload, + null, + mockTable, + 0, + ); + + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 180, + sequence: 0, + }); + }); + + it('목표 sequence가 TIME_BASED 타입인 경우 전달된 table 정보로 TIME_BASED 데이터를 생성한다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 300, + sequence: 0, + currentTeam: 'CONS', + }; + + const result = createNavigationDisplayData( + 'NEXT', + payload, + null, + mockTable, + 1, + ); + + expect(result).toEqual({ + timerType: 'TIME_BASED', + currentTeam: 'CONS', + isRunning: false, + prosTime: null, + consTime: 60, + sequence: 1, + eventType: 'NEXT', + revision: 1, + }); + }); + + it('목표 sequence가 table 범위를 벗어나거나 조건에 안 맞으면 null을 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 180, + sequence: 0, + }; + + const result = createNavigationDisplayData( + 'NEXT', + payload, + null, + mockTable, + 99, + ); + + expect(result).toBeNull(); + }); + }); + + describe('getDisplayDataByEvent', () => { + const mockTable: TimeBoxInfo[] = [ + { + stance: 'NEUTRAL', + speechType: '입론 1', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 300, + timePerSpeaking: 60, + speaker: null, + }, + ]; + + describe('PLAY 이벤트', () => { + it('타이머 실행 상태(isRunning: true)로 데이터를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 175, + sequence: 0, + }; + + const result = getDisplayDataByEvent('PLAY', payload, null, mockTable); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: true, + singleTime: 175, + sequence: 0, + }); + }); + }); + + describe('STOP 이벤트', () => { + it('타이머 정지 상태(isRunning: false)로 데이터를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 120, + sequence: 0, + }; + + const result = getDisplayDataByEvent('STOP', payload, null, mockTable); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 120, + sequence: 0, + }); + }); + }); + + describe('RESET 이벤트', () => { + it('table에서 해당 sequence의 총 시간을 가져와 초기화한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 50, + sequence: 0, + }; + + const result = getDisplayDataByEvent('RESET', payload, null, mockTable); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 180, + sequence: 0, + }); + }); + }); + + describe('BEFORE 이벤트', () => { + it('이전 sequence(data.sequence - 1)로 이동한 데이터를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 300, + sequence: 1, + currentTeam: 'PROS', + }; + + const result = getDisplayDataByEvent( + 'BEFORE', + payload, + null, + mockTable, + ); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 180, + sequence: 0, + }); + }); + }); + + describe('NEXT 이벤트', () => { + it('다음 sequence(data.sequence + 1)로 이동한 데이터를 반환한다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 0, + sequence: 0, + currentTeam: 'PROS', + }; + + const result = getDisplayDataByEvent('NEXT', payload, null, mockTable); + expect(result).toEqual({ + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: false, + prosTime: 60, + consTime: null, + sequence: 1, + eventType: 'NEXT', + revision: 1, + }); + }); + }); + + describe('TEAM_SWITCH 이벤트', () => { + it('팀을 교체하고 이전 isRunning 상태를 유지한다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 50, + sequence: 1, + currentTeam: 'PROS', + }; + + const prevData: AudienceTimeBasedDisplayData = { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: true, + prosTime: 50, + consTime: 60, + sequence: 1, + eventType: 'PLAY', + revision: 2, + }; + + const result = getDisplayDataByEvent( + 'TEAM_SWITCH', + payload, + prevData, + mockTable, + ); + + expect(result).toEqual({ + timerType: 'TIME_BASED', + currentTeam: 'CONS', + isRunning: true, + prosTime: 50, + consTime: 60, + sequence: 1, + eventType: 'TEAM_SWITCH', + revision: 3, + }); + }); + }); + }); +}); diff --git a/src/page/AudienceSharePage/domain/EventInterpreter.ts b/src/page/AudienceSharePage/domain/EventInterpreter.ts new file mode 100644 index 00000000..a6d3464b --- /dev/null +++ b/src/page/AudienceSharePage/domain/EventInterpreter.ts @@ -0,0 +1,250 @@ +import { TimeBasedStance, TimeBoxInfo } from '../../../type/type'; +import { TimerDataPayload, TimerEventTypes } from '../../../apis/sockets/type'; + +export type AudienceNormalDisplayData = { + timerType: 'NORMAL'; + currentTeam: null; + isRunning: boolean; + singleTime: number; + sequence: number; +}; + +export type AudienceTimeBasedDisplayData = { + timerType: 'TIME_BASED'; + currentTeam: TimeBasedStance; + isRunning: boolean; + prosTime: number | null; + consTime: number | null; + sequence: number; + eventType: TimerEventTypes; + revision: number; +}; + +export type AudienceDisplayData = + | AudienceNormalDisplayData + | AudienceTimeBasedDisplayData; + +export function getNextTeam(team: TimeBasedStance): TimeBasedStance { + return team === 'PROS' ? 'CONS' : 'PROS'; +} + +export function getNormalTotalTime( + table: TimeBoxInfo[] | undefined, + sequence: number, + fallbackTime: number, +): number { + const timeBox = table?.[sequence]; + + if ( + timeBox?.boxType === 'NORMAL' && + timeBox.time !== null && + timeBox.time > 0 + ) { + return timeBox.time; + } + + return fallbackTime; +} + +export 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, + }; +} + +export function createNavigationDisplayData( + eventType: 'BEFORE' | 'NEXT', + data: TimerDataPayload, + previousDisplayData: AudienceDisplayData | null, + table: TimeBoxInfo[] | undefined, + sequence: number, +): AudienceDisplayData | null { + 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; +} + +export 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, + }); + } +} diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index 4ff002f0..0107cbc5 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -1,33 +1,21 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import useAudienceSocket from '../../../hooks/sockets/useAudienceSocket'; import { AudienceShareError, AudienceShareErrorCode } from '../error'; -import { TimeBasedStance, TimeBoxInfo } from '../../../type/type'; +import { TimeBoxInfo } from '../../../type/type'; import { isSocketError } from '../../../apis/sockets/error'; -import { TimerDataPayload, TimerEventTypes } from '../../../apis/sockets/type'; - -export type AudienceNormalDisplayData = { - timerType: 'NORMAL'; - currentTeam: null; - isRunning: boolean; - singleTime: number; - sequence: number; -}; - -export type AudienceTimeBasedDisplayData = { - timerType: 'TIME_BASED'; - currentTeam: TimeBasedStance; - isRunning: boolean; - prosTime: number | null; - consTime: number | null; - sequence: number; - eventType: TimerEventTypes; - revision: number; +import { + getDisplayDataByEvent, + AudienceNormalDisplayData, + AudienceTimeBasedDisplayData, + AudienceDisplayData, +} from '../domain/EventInterpreter'; + +export type { + AudienceNormalDisplayData, + AudienceTimeBasedDisplayData, + AudienceDisplayData, }; -type AudienceDisplayData = - | AudienceNormalDisplayData - | AudienceTimeBasedDisplayData; - export type AudienceShareState = | { status: 'connecting'; error: AudienceShareError | null } | { status: 'waiting'; error: AudienceShareError | null } @@ -45,231 +33,6 @@ interface UseAudienceShareStateOptions { table?: TimeBoxInfo[]; } -function getNextTeam(team: TimeBasedStance): TimeBasedStance { - return team === 'PROS' ? 'CONS' : 'PROS'; -} - -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 = {}, From 124870a9a459d549cda43e3a1dd2d2361d974df9 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 13 Aug 2026 11:11:33 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudienceSharePage/AudienceSharePage.tsx | 10 ++--- .../AudienceTimeBasedTimerDisplay.test.tsx | 4 ++ .../AudienceScreenState.test.ts | 2 +- .../{domain => hooks}/AudienceScreenState.ts | 4 +- .../EventInterpreter.test.ts | 43 +++++++++++++++++++ .../{domain => hooks}/EventInterpreter.ts | 7 ++- .../hooks/useAudienceShareState.ts | 10 +---- .../useAudienceTimeBasedCountdown.test.ts | 2 +- .../hooks/useAudienceTimeBasedCountdown.ts | 2 +- 9 files changed, 63 insertions(+), 21 deletions(-) rename src/page/AudienceSharePage/{domain => hooks}/AudienceScreenState.test.ts (99%) rename src/page/AudienceSharePage/{domain => hooks}/AudienceScreenState.ts (97%) rename src/page/AudienceSharePage/{domain => hooks}/EventInterpreter.test.ts (90%) rename src/page/AudienceSharePage/{domain => hooks}/EventInterpreter.ts (97%) diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index e8bf8cc5..b9c4e492 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -11,7 +11,7 @@ import LoadingSpinner from '../../components/LoadingSpinner'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; import { useGetDebateTableDataForShare } from '../../hooks/query/useGetDebateTableDataForShare'; -import { resolveAudienceScreenState } from './domain/AudienceScreenState'; +import { resolveAudienceScreenState } from './hooks/AudienceScreenState'; interface ErrorContentProps { message: string; @@ -68,10 +68,6 @@ export default function AudienceSharePage() { table: debateTableQuery.data?.table, }); - if (!isValidTableId) { - throw new Error(t('유효하지 않은 토론방 ID입니다.')); - } - const viewState = resolveAudienceScreenState( state, { @@ -105,6 +101,10 @@ export default function AudienceSharePage() { : null, }); + if (!isValidTableId) { + throw new Error(t('유효하지 않은 토론방 ID입니다.')); + } + const handleClosePage = () => { // 일단 페이지 닫기 window.close(); diff --git a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx index 66daa610..2db9a479 100644 --- a/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceTimeBasedTimerDisplay.test.tsx @@ -98,7 +98,11 @@ describe('AudienceTimeBasedTimerDisplay', () => { ); expect(screen.getByTestId('cons-timer-display')).not.toHaveClass( 'bg-gray-100', + ); + expect(screen.getByTestId('cons-timer-display')).not.toHaveClass( 'opacity-50', + ); + expect(screen.getByTestId('cons-timer-display')).not.toHaveClass( 'grayscale', ); expect(screen.getByText('전체 시간')).toHaveClass( diff --git a/src/page/AudienceSharePage/domain/AudienceScreenState.test.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts similarity index 99% rename from src/page/AudienceSharePage/domain/AudienceScreenState.test.ts rename to src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts index fb51a683..d27b4d72 100644 --- a/src/page/AudienceSharePage/domain/AudienceScreenState.test.ts +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts @@ -8,7 +8,7 @@ import { } from './AudienceScreenState'; import { AudienceShareError } from '../error'; import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; -import { AudienceShareState } from '../hooks/useAudienceShareState'; +import { AudienceShareState } from './useAudienceShareState'; const t = (key: string) => key; diff --git a/src/page/AudienceSharePage/domain/AudienceScreenState.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts similarity index 97% rename from src/page/AudienceSharePage/domain/AudienceScreenState.ts rename to src/page/AudienceSharePage/hooks/AudienceScreenState.ts index 4780c944..606d0f03 100644 --- a/src/page/AudienceSharePage/domain/AudienceScreenState.ts +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts @@ -1,9 +1,9 @@ import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; import { AudienceNormalDisplayData, - AudienceShareState, AudienceTimeBasedDisplayData, -} from '../hooks/useAudienceShareState'; +} from './EventInterpreter'; +import { AudienceShareState } from './useAudienceShareState'; export type ValidNormalTimeBox = TimeBoxInfo & { boxType: 'NORMAL'; diff --git a/src/page/AudienceSharePage/domain/EventInterpreter.test.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts similarity index 90% rename from src/page/AudienceSharePage/domain/EventInterpreter.test.ts rename to src/page/AudienceSharePage/hooks/EventInterpreter.test.ts index 60b5756e..c79b9e9d 100644 --- a/src/page/AudienceSharePage/domain/EventInterpreter.test.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts @@ -383,6 +383,28 @@ describe('eventInterpreter 순수 함수', () => { sequence: 0, }); }); + + it('첫 sequence(0)에서는 0으로 고정되어 범위를 벗어나지 않는다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 100, + sequence: 0, + }; + + const result = getDisplayDataByEvent( + 'BEFORE', + payload, + null, + mockTable, + ); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 180, + sequence: 0, + }); + }); }); describe('NEXT 이벤트', () => { @@ -406,6 +428,27 @@ describe('eventInterpreter 순수 함수', () => { revision: 1, }); }); + + it('마지막 sequence(table.length - 1)에서는 더 넘어가지 않고 고정된다', () => { + const payload: TimerDataPayload = { + timerType: 'TIME_BASED', + remainingTime: 60, + sequence: mockTable.length - 1, + currentTeam: 'PROS', + }; + + const result = getDisplayDataByEvent('NEXT', payload, null, mockTable); + expect(result).toEqual({ + timerType: 'TIME_BASED', + currentTeam: 'PROS', + isRunning: false, + prosTime: 60, + consTime: null, + sequence: mockTable.length - 1, + eventType: 'NEXT', + revision: 1, + }); + }); }); describe('TEAM_SWITCH 이벤트', () => { diff --git a/src/page/AudienceSharePage/domain/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts similarity index 97% rename from src/page/AudienceSharePage/domain/EventInterpreter.ts rename to src/page/AudienceSharePage/hooks/EventInterpreter.ts index a6d3464b..57c037a7 100644 --- a/src/page/AudienceSharePage/domain/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -190,7 +190,7 @@ export function getDisplayDataByEvent( }); case 'BEFORE': { - const previousSequence = data.sequence - 1; + const previousSequence = Math.max(data.sequence - 1, 0); const navigationDisplayData = createNavigationDisplayData( eventType, data, @@ -216,7 +216,10 @@ export function getDisplayDataByEvent( } case 'NEXT': { - const nextSequence = data.sequence + 1; + const nextSequence = Math.min( + data.sequence + 1, + (table?.length ?? 1) - 1, + ); const navigationDisplayData = createNavigationDisplayData( eventType, data, diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index 0107cbc5..cd0b63d5 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -5,16 +5,8 @@ import { TimeBoxInfo } from '../../../type/type'; import { isSocketError } from '../../../apis/sockets/error'; import { getDisplayDataByEvent, - AudienceNormalDisplayData, - AudienceTimeBasedDisplayData, AudienceDisplayData, -} from '../domain/EventInterpreter'; - -export type { - AudienceNormalDisplayData, - AudienceTimeBasedDisplayData, - AudienceDisplayData, -}; +} from './EventInterpreter'; export type AudienceShareState = | { status: 'connecting'; error: AudienceShareError | null } diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts index 32043069..0d7d275e 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts @@ -3,7 +3,7 @@ import { getNextSpeakingTime, useAudienceTimeBasedCountdown, } from './useAudienceTimeBasedCountdown'; -import { AudienceTimeBasedDisplayData } from './useAudienceShareState'; +import { AudienceTimeBasedDisplayData } from './EventInterpreter'; function createDisplayData( overrides: Partial = {}, diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts index 90c3bfc5..9061ba27 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { TimeBasedStance } from '../../../type/type'; import { useAudienceCountdown } from './useAudienceCountdown'; -import { AudienceTimeBasedDisplayData } from './useAudienceShareState'; +import { AudienceTimeBasedDisplayData } from './EventInterpreter'; interface TeamCountdownInput { totalTime: number | null; From a11f6ec18e6665212bdda5f3578ad8b4c265045a Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 13 Aug 2026 11:19:01 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20=EC=B6=94=EA=B0=80=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/EventInterpreter.test.ts | 20 +++++++++++++++++++ .../hooks/EventInterpreter.ts | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts index c79b9e9d..ff9b0ec1 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts @@ -449,6 +449,26 @@ describe('eventInterpreter 순수 함수', () => { revision: 1, }); }); + + it('빈 table에서는 sequence가 음수가 되지 않는다', () => { + const payload: TimerDataPayload = { + timerType: 'NORMAL', + remainingTime: 30, + sequence: 0, + currentTeam: 'PROS', + }; + + const result = getDisplayDataByEvent('NEXT', payload, null, []); + + expect(result?.sequence).toBeGreaterThanOrEqual(0); + expect(result).toEqual({ + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence: 0, + }); + }); }); describe('TEAM_SWITCH 이벤트', () => { diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts index 57c037a7..ea373f47 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -216,9 +216,9 @@ export function getDisplayDataByEvent( } case 'NEXT': { - const nextSequence = Math.min( - data.sequence + 1, - (table?.length ?? 1) - 1, + const nextSequence = Math.max( + 0, + Math.min(data.sequence + 1, (table?.length ?? 1) - 1), ); const navigationDisplayData = createNavigationDisplayData( eventType, From 8b191b255f41fadfe2c3d28c5edabc4fca7507b7 Mon Sep 17 00:00:00 2001 From: Shawn Kang Date: Thu, 13 Aug 2026 11:23:07 +0900 Subject: [PATCH 19/19] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/page/AudienceSharePage/hooks/EventInterpreter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts index ea373f47..742be618 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -218,7 +218,7 @@ export function getDisplayDataByEvent( case 'NEXT': { const nextSequence = Math.max( 0, - Math.min(data.sequence + 1, (table?.length ?? 1) - 1), + Math.min(data.sequence + 1, (table?.length ?? Infinity) - 1), ); const navigationDisplayData = createNavigationDisplayData( eventType,