From d9312371f65693db762556ed04370366d3e82228 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 19:30:50 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FIX]=20=EC=9E=91=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=AA=A8=EB=8B=AC=EC=9D=B4=20=ED=83=80?= =?UTF-8?q?=EC=9D=B4=EB=A8=B8=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EA=B9=94?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공유 버튼/모달 래퍼에 z-20을 주어 타이머와 라운드 버튼 위에 그려지고 클릭되도록 함 - 공유 모달에 불투명 배경과 그림자를 주어 뒤의 타이머가 비치지 않도록 함 Co-Authored-By: Claude Opus 5.5 --- .../TimerPage/TimerPage.liveShare.test.tsx | 106 ++++++++++++++++++ src/page/TimerPage/TimerPage.tsx | 4 +- .../TimerPage/components/LiveShareModal.tsx | 5 +- 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/page/TimerPage/TimerPage.liveShare.test.tsx diff --git a/src/page/TimerPage/TimerPage.liveShare.test.tsx b/src/page/TimerPage/TimerPage.liveShare.test.tsx new file mode 100644 index 00000000..5379461b --- /dev/null +++ b/src/page/TimerPage/TimerPage.liveShare.test.tsx @@ -0,0 +1,106 @@ +import { useCallback, useRef, useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import TimerPage from './TimerPage'; +import { GlobalPortal } from '../../util/GlobalPortal'; + +// 소켓 연결 없이 공유 모달 열고 닫기만 검증하기 위해 라이브 공유 훅을 대체한다 +vi.mock('./hooks/useLiveShare', () => ({ + useLiveShare: () => { + const [isLiveShareModalOpen, setIsLiveShareModalOpen] = useState(false); + const toggleLiveShareModal = useCallback(() => { + setIsLiveShareModalOpen((prev) => !prev); + }, []); + const liveShareModalRef = useRef(null); + + return { + isLiveShareModalOpen, + toggleLiveShareModal, + liveShareModalRef, + issueEvent: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + shareUrl: 'https://example.com/live/5', + isLoading: false, + isError: false, + errorType: 'else', + isSocketConnected: false, + restartLiveShare: vi.fn(), + }; + }, +})); + +vi.mock('qrcode.react', () => ({ + QRCodeSVG: () => , +})); + +function renderTimerPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + } /> + + + + , + ); +} + +describe('TimerPage - 라이브 공유 모달', () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem('isVisited', 'true'); + localStorage.setItem('accessToken', 'test-token'); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('공유 버튼과 모달 영역은 타이머보다 위 레이어에 놓인다', async () => { + renderTimerPage(); + + const shareButton = await screen.findByRole('button', { + name: '라이브 공유', + }); + + expect(shareButton.parentElement).toHaveClass('z-20'); + }); + + it('공유 모달은 뒤의 타이머가 비치지 않도록 불투명한 배경을 가진다', async () => { + const user = userEvent.setup(); + renderTimerPage(); + + await user.click( + await screen.findByRole('button', { name: '라이브 공유' }), + ); + + expect(screen.getByTestId('live-share-modal')).toHaveClass( + 'bg-default-white', + ); + }); + + it('공유 모달의 닫기 버튼을 누르면 모달이 닫히고 공유 버튼이 다시 보인다', async () => { + const user = userEvent.setup(); + renderTimerPage(); + + await user.click( + await screen.findByRole('button', { name: '라이브 공유' }), + ); + expect(screen.getByTestId('live-share-modal')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '모달 닫기' })); + + expect(screen.queryByTestId('live-share-modal')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '라이브 공유' }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index a286982e..72ecd0f6 100644 --- a/src/page/TimerPage/TimerPage.tsx +++ b/src/page/TimerPage/TimerPage.tsx @@ -389,10 +389,10 @@ export default function TimerPage() { bgColorMap[bg], )} > - {/* 라이브 공유 버튼 및 모달 */} + {/* 라이브 공유 버튼 및 모달 (화면이 작아져도 타이머와 라운드 버튼 위에 보이고 클릭되도록 레이어를 올린다) */} {IS_LIVE_SHARE_ENABLED && isLoggedIn() && (
{!isLiveShareModalOpen && ( diff --git a/src/page/TimerPage/components/LiveShareModal.tsx b/src/page/TimerPage/components/LiveShareModal.tsx index 5a64f59f..af508fa9 100644 --- a/src/page/TimerPage/components/LiveShareModal.tsx +++ b/src/page/TimerPage/components/LiveShareModal.tsx @@ -78,7 +78,10 @@ export default function LiveShareModal({ }[errorType]; return ( -
+
{isLoading ? (
Date: Sat, 26 Sep 2026 19:32:00 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FIX]=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=97=90=EB=8A=94=20=EC=B2=AD=EC=A4=91=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=ED=86=A0=EB=A1=A0=20=EB=8C=80=EA=B8=B0=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A1=9C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 청중이 사회자보다 먼저 들어오면 1초 뒤 첫 순서 타이머를 정지 상태로 띄우고, 이어서 CHAIRMAN_ABSENT를 받으면 "사회자 연결이 끊겼어요" 오버레이가 표시되었다. - 첫 메시지 전 초기 타이머 화면(createInitialDisplayData) fallback 제거 - 사회자 메시지를 받기 전까지는 CHAIRMAN_ABSENT를 받아도 waiting 상태를 유지 - 사회자 SYNC를 받으면 곧바로 타이머 화면으로 전환 Co-Authored-By: Claude Opus 5.5 --- .../hooks/EventInterpreter.ts | 24 --- .../hooks/useAudienceShareState.test.ts | 150 +++++------------- .../hooks/useAudienceShareState.ts | 28 +--- 3 files changed, 41 insertions(+), 161 deletions(-) diff --git a/src/page/AudienceSharePage/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts index b0fb4f5f..eb249b5d 100644 --- a/src/page/AudienceSharePage/hooks/EventInterpreter.ts +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -183,30 +183,6 @@ export function createNavigationDisplayData( return null; } -/** - * 첫 메시지를 받기 전 보여줄 초기 화면(첫 순서 타이머, 정지 상태)을 만듭니다. - * 표시할 수 없는 테이블이면 `null`을 반환합니다. - */ -export function createInitialDisplayData( - table: TimeBoxInfo[] | undefined, -): AudienceDisplayData | null { - const firstTimeBox = table?.[0]; - if ( - !firstTimeBox || - (firstTimeBox.boxType !== 'NORMAL' && firstTimeBox.boxType !== 'TIME_BASED') - ) { - return null; - } - - return createNavigationDisplayData( - 'NEXT', - { timerType: firstTimeBox.boxType, sequence: 0, remainingTime: 0 }, - null, - table, - 0, - ); -} - export function getDisplayDataByEvent( eventType: TimerEventTypes, data: TimerDataPayload, diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index 81ee2015..4f184c12 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -1,5 +1,8 @@ import { renderHook, act } from '@testing-library/react'; -import { useAudienceShareState } from './useAudienceShareState'; +import { + CHAIRMAN_ABSENT_TIMEOUT_MS, + useAudienceShareState, +} from './useAudienceShareState'; import { useAudienceCountdown } from './useAudienceCountdown'; import * as useAudienceSocketModule from '../../../hooks/sockets/useAudienceSocket'; import { SocketMessage } from '../../../apis/sockets/type'; @@ -629,7 +632,7 @@ describe('useAudienceShareState', () => { vi.advanceTimersByTime(600 * 1000); }); - expect(result.current.status).toBe('displaying'); + expect(result.current.status).toBe('waiting'); expect(result.current.error).toBeNull(); expect(mockDisconnect).not.toHaveBeenCalled(); }); @@ -1011,26 +1014,20 @@ describe('useAudienceShareState', () => { expect(result.current.syncedAt).toBe(RECEIVED_AT); } }); - - it('메시지 없이 표시한 초기 화면은 syncedAt이 null이다', () => { - setSocketState({ latestMessage: null }); - - const { result } = renderHook(() => - useAudienceShareState(1, { table: normalTable }), - ); - act(() => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.status).toBe('displaying'); - if (result.current.status === 'displaying') { - expect(result.current.syncedAt).toBeNull(); - } - }); }); - describe('첫 메시지 대기 중 초기 화면', () => { - it('연결 후 1초 동안 메시지가 없으면 첫 순서 타이머를 정지 상태로 표시한다', () => { + describe('사회자 첫 메시지 대기', () => { + const syncMessage: SocketMessage = { + eventType: 'SYNC', + data: { + timerType: 'NORMAL', + sequence: 1, + remainingTime: 42, + isRunning: true, + }, + }; + + it('연결 후 사회자 메시지가 없으면 시간이 지나도 대기 상태를 유지한다', () => { setSocketState({ isConnected: true, latestMessage: null }); const { result } = renderHook(() => @@ -1039,112 +1036,54 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('waiting'); act(() => { - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(CHAIRMAN_ABSENT_TIMEOUT_MS + 5000); }); - expect(result.current.status).toBe('displaying'); - if (result.current.status === 'displaying') { - expect(result.current.displayData).toEqual({ - timerType: 'NORMAL', - currentTeam: null, - isRunning: false, - singleTime: 180, - sequence: 0, - }); - } + expect(result.current.status).toBe('waiting'); }); - it('첫 순서가 자유토론이면 자유토론 타이머를 정지 상태로 표시한다', () => { - const timeBasedTable: TimeBoxInfo[] = [ - { - stance: 'NEUTRAL', - speechType: '자유토론', - bell: null, - boxType: 'TIME_BASED', - time: null, - timePerTeam: 90, - timePerSpeaking: 30, - speaker: null, - }, - ]; - setSocketState({ isConnected: true, latestMessage: null }); + it('사회자 메시지 전에 사회자 부재 알림을 받아도 대기 상태를 유지한다', () => { + setSocketState({ + isConnected: true, + latestMessage: null, + chairmanAbsentAt: Date.now(), + }); const { result } = renderHook(() => - useAudienceShareState(1, { table: timeBasedTable }), + useAudienceShareState(1, { table: normalTable }), ); - act(() => { - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(CHAIRMAN_ABSENT_TIMEOUT_MS + 5000); }); - expect(result.current.status).toBe('displaying'); - if (result.current.status === 'displaying') { - expect(result.current.displayData).toMatchObject({ - timerType: 'TIME_BASED', - currentTeam: 'PROS', - isRunning: false, - sequence: 0, - }); - } + expect(result.current.status).toBe('waiting'); }); - it('1초 안에 메시지를 받으면 초기 화면으로 덮어쓰지 않는다', () => { - setSocketState({ isConnected: true, latestMessage: null }); - const { result, rerender } = renderHook(() => - useAudienceShareState(1, { table: normalTable }), - ); - + it('대기 중 사회자 SYNC를 받으면 받은 상태로 표시한다', () => { setSocketState({ isConnected: true, - latestMessage: { - eventType: 'SYNC', - data: { - timerType: 'NORMAL', - sequence: 1, - remainingTime: 42, - isRunning: true, - }, - }, - }); - rerender(); - act(() => { - vi.advanceTimersByTime(1000); + latestMessage: null, + chairmanAbsentAt: Date.now(), }); - - expect(result.current.status).toBe('displaying'); - if (result.current.status === 'displaying') { - expect(result.current.displayData).toMatchObject({ - sequence: 1, - singleTime: 42, - isRunning: true, - }); - } - }); - - it('초기 화면 표시 후 SYNC를 받으면 받은 상태로 바뀐다', () => { - setSocketState({ isConnected: true, latestMessage: null }); const { result, rerender } = renderHook(() => useAudienceShareState(1, { table: normalTable }), ); + act(() => { - vi.advanceTimersByTime(1000); + vi.advanceTimersByTime(3000); }); - + const receivedAt = Date.now(); setSocketState({ isConnected: true, - latestMessage: { - eventType: 'SYNC', - data: { - timerType: 'NORMAL', - sequence: 1, - remainingTime: 42, - isRunning: true, - }, - }, + latestMessage: syncMessage, + latestMessageReceivedAt: receivedAt, + lastReceivedAt: receivedAt, + chairmanAbsentAt: receivedAt - 3000, }); rerender(); expect(result.current.status).toBe('displaying'); + expect(result.current.chairmanPresence).toBe('present'); if (result.current.status === 'displaying') { expect(result.current.displayData).toMatchObject({ sequence: 1, @@ -1153,17 +1092,6 @@ describe('useAudienceShareState', () => { }); } }); - - it('테이블 정보가 없으면 대기 상태를 유지한다', () => { - setSocketState({ isConnected: true, latestMessage: null }); - const { result } = renderHook(() => useAudienceShareState(1)); - - act(() => { - vi.advanceTimersByTime(1000); - }); - - expect(result.current.status).toBe('waiting'); - }); }); describe('재연결 소진', () => { diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index f1ca43e9..60223602 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -5,7 +5,6 @@ import { TimeBoxInfo } from '../../../type/type'; import { isSocketError } from '../../../apis/sockets/error'; import { getDisplayDataByEvent, - createInitialDisplayData, AudienceDisplayData, } from './EventInterpreter'; import { getNetworkDelayMs } from './getNetworkDelayMs'; @@ -27,7 +26,7 @@ export type AudienceShareState = /** * 표시 중인 남은 시간이 유효했던 시각(기기 시계 기준, epoch ms) * - 수신 시각에서 서버 중계 이후 흐른 네트워크 지연을 뺀 값 - * - 메시지 없이 만든 초기 화면이면 `null` + * - 수신 시각을 알 수 없으면 `null` */ syncedAt: number | null; } @@ -47,9 +46,6 @@ export type ChairmanPresence = 'waiting' | 'present' | 'absent'; // 사회자 heartbeat(5초) 3회를 놓치면 사회자 연결이 끊긴 것으로 판단 export const CHAIRMAN_ABSENT_TIMEOUT_MS = 15 * 1000; -// 연결 후 이 시간 동안 메시지가 없으면 첫 순서 타이머를 정지 상태로 먼저 표시 -const INITIAL_DISPLAY_DELAY_MS = 1000; - interface UseAudienceShareStateOptions { enabled?: boolean; table?: TimeBoxInfo[]; @@ -234,27 +230,6 @@ export function useAudienceShareState( chairmanPresence = 'absent'; } - // 사회자 응답을 기다리는 동안 빈 화면 대신 첫 순서 타이머를 정지 상태로 표시 - // 이후 SYNC 등 메시지를 받으면 받은 상태로 바뀐다 - const isWaitingFirstMessage = - enabled && isConnected && !error && !isFinished && displayData === null; - useEffect(() => { - if (!isWaitingFirstMessage) { - return; - } - - const initialDisplayTimeout = setTimeout(() => { - setDisplayData( - (previousDisplayData) => - previousDisplayData ?? createInitialDisplayData(table), - ); - }, INITIAL_DISPLAY_DELAY_MS); - - return () => { - clearTimeout(initialDisplayTimeout); - }; - }, [isWaitingFirstMessage, table]); - const connectionStatus: AudienceConnectionStatus = isConnectionLost ? 'lost' : 'connected'; @@ -268,6 +243,7 @@ export function useAudienceShareState( // 한 번이라도 화면을 띄운 뒤라면, 재연결하는 동안에도 마지막 화면을 계속 보여준다 status = 'connecting'; } else if (!displayData) { + // 사회자 메시지를 한 번도 받지 못했다면 사회자 부재 알림을 받아도 토론 시작을 기다린다 status = 'waiting'; } else { status = 'displaying'; From cea4fd1e8b4e3a01da42c8e5b4b0b8a08a8244d2 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 19:32:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FEAT]=20=EC=B2=AD=EC=A4=91=20=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=EB=8C=80=EA=B8=B0=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=A4=91=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토론 시작 대기 화면을 DebateWaitingNotice 컴포넌트로 분리 - 모래시계 아이콘을 주기적으로 뒤집어 화면이 멈춘 것이 아니라 기다리는 중임을 표시 Co-Authored-By: Claude Opus 5.5 --- .../AudienceSharePage.test.tsx | 37 ++++++++++++++++++ .../AudienceSharePage/AudienceSharePage.tsx | 9 +---- .../components/DebateWaitingNotice.tsx | 38 +++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 src/page/AudienceSharePage/components/DebateWaitingNotice.tsx diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index 5996251e..2b531b3e 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -253,6 +253,43 @@ describe('AudienceSharePage', () => { ).not.toBeInTheDocument(); }); + it('waiting 상태에서는 대기 중임을 알리는 아이콘을 함께 표시한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'waiting', + connectionStatus: 'connected', + status: 'waiting', + error: null, + }); + renderPage('/live/123'); + + expect( + await screen.findByTestId('audience-waiting-icon'), + ).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + '토론 시작을 대기 중입니다.', + ); + }); + + it('사회자 메시지 전에 사회자 부재 알림을 받아도 끊김 안내 대신 대기 화면을 표시한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + chairmanPresence: 'absent', + connectionStatus: 'connected', + status: 'waiting', + error: null, + }); + renderPage('/live/123'); + + expect( + await screen.findByText('토론 시작을 대기 중입니다.'), + ).toBeInTheDocument(); + expect(screen.getByTestId('audience-waiting-icon')).toBeInTheDocument(); + expect( + screen.queryByText( + '사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.', + ), + ).not.toBeInTheDocument(); + }); + it('테이블 이름과 주제가 공백이면 TimerPage와 같은 대체 문구를 표시한다', async () => { server.use( http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 0f468ed8..62dbf4c0 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -9,6 +9,7 @@ import AudienceNormalTimer from './components/AudienceNormalTimer'; import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; import ChairmanStatusNotice from './components/ChairmanStatusNotice'; import ConnectionLostNotice from './components/ConnectionLostNotice'; +import DebateWaitingNotice from './components/DebateWaitingNotice'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import LoadingSpinner from '../../components/LoadingSpinner'; import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; @@ -156,13 +157,7 @@ export default function AudienceSharePage() { return ; case 'WAITING': - return ( -
-

- {viewState.message} -

-
- ); + return ; case 'NORMAL_TIMER': return ( diff --git a/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx new file mode 100644 index 00000000..5a653b6b --- /dev/null +++ b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx @@ -0,0 +1,38 @@ +import { MdHourglassTop } from 'react-icons/md'; +import { motion } from 'framer-motion'; + +interface DebateWaitingNoticeProps { + message: string; +} + +/** + * 사회자가 토론을 공유하기 전까지 청중 화면에 토론 시작 대기 안내를 표시합니다. + * 모래시계를 천천히 뒤집어 화면이 멈춘 것이 아니라 기다리는 중임을 알립니다. + */ +export default function DebateWaitingNotice({ + message, +}: DebateWaitingNoticeProps) { + return ( +
+ +

+ {message} +

+
+ ); +} From cf19aac707407fecf02e1806a4895e77c2e13ae7 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 19:33:56 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[TEST]=20=EC=B2=AD=EC=A4=91=20=EC=84=A0?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=ED=9B=84=20=EC=82=AC=ED=9A=8C=EC=9E=90=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=9D=90=EB=A6=84=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소켓 훅만 대체하고 청중 상태 훅, 화면 판단 로직, 페이지를 실제 코드로 검증한다. - 사회자 부재 알림과 부재 판단 시간이 지나도 토론 대기 화면 유지 - 사회자 SYNC 수신 시 타이머 화면으로 전환 - 사회자가 한 번 연결된 뒤 부재 알림을 받으면 끊김 안내 표시 (회귀) Co-Authored-By: Claude Opus 5.5 --- .../AudienceSharePage.integration.test.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/page/AudienceSharePage/AudienceSharePage.integration.test.tsx diff --git a/src/page/AudienceSharePage/AudienceSharePage.integration.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.integration.test.tsx new file mode 100644 index 00000000..aa59c160 --- /dev/null +++ b/src/page/AudienceSharePage/AudienceSharePage.integration.test.tsx @@ -0,0 +1,190 @@ +import { act, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../../apis/endpoints'; +import AudienceSharePage from './AudienceSharePage'; +import * as useAudienceSocketModule from '../../hooks/sockets/useAudienceSocket'; +import { CHAIRMAN_ABSENT_TIMEOUT_MS } from './hooks/useAudienceShareState'; +import { GetDebateTableDataForShareResponseType } from '../../apis/responses/live'; + +// 소켓 연결만 대체하고, 청중 상태 훅과 화면 판단 로직은 실제 코드로 검증한다 +vi.mock('../../hooks/sockets/useAudienceSocket'); +vi.mock('../../hooks/mutations/useLogout', () => ({ + default: () => ({ mutate: vi.fn() }), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { + language: 'ko', + resolvedLanguage: 'ko', + }, + }), +})); + +type AudienceSocketState = ReturnType; + +const WAITING_MESSAGE = '토론 시작을 대기 중입니다.'; +const ABSENT_MESSAGE = '사회자 연결이 끊겼어요. 재접속을 기다리는 중이에요.'; + +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, + }, + ], +}; + +describe('AudienceSharePage 통합 - 청중이 사회자보다 먼저 입장', () => { + let socketState: AudienceSocketState; + + const setSocketState = (state: Partial) => { + socketState = { ...socketState, ...state }; + }; + + const renderPage = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const createPage = () => ( + + + + } /> + + + + ); + const result = render(createPage()); + + return { rerenderPage: () => result.rerender(createPage()) }; + }; + + const advance = async (ms: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + }; + + beforeEach(() => { + vi.useFakeTimers(); + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json(mockDebateTableData); + }), + ); + socketState = { + connect: vi.fn(), + disconnect: vi.fn(), + latestMessage: null, + latestMessageReceivedAt: null, + lastReceivedAt: null, + chairmanAbsentAt: null, + isConnected: true, + error: null, + }; + vi.spyOn(useAudienceSocketModule, 'default').mockImplementation( + () => socketState, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('사회자 부재 알림을 받아도 대기 화면을 유지하다가 사회자가 연결되면 타이머를 표시한다', async () => { + const { rerenderPage } = renderPage(); + // 테이블 조회(MSW) 응답을 기다린다 + await advance(100); + + // 입장 직후: 토론 대기 화면 + expect(screen.getByText(WAITING_MESSAGE)).toBeInTheDocument(); + expect(screen.getByTestId('audience-waiting-icon')).toBeInTheDocument(); + + // 서버가 사회자 부재를 알리고, 부재 판단 시간이 지나도 대기 화면 유지 + setSocketState({ chairmanAbsentAt: Date.now() }); + rerenderPage(); + await advance(CHAIRMAN_ABSENT_TIMEOUT_MS + 5000); + + expect(screen.getByText(WAITING_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(ABSENT_MESSAGE)).not.toBeInTheDocument(); + expect(screen.queryByTestId('timer-value')).not.toBeInTheDocument(); + + // 사회자가 연결되어 SYNC를 보내면 타이머 화면으로 전환 + const receivedAt = Date.now(); + setSocketState({ + latestMessage: { + eventType: 'SYNC', + data: { + timerType: 'NORMAL', + sequence: 0, + remainingTime: 120, + isRunning: false, + }, + version: 1, + }, + latestMessageReceivedAt: receivedAt, + lastReceivedAt: receivedAt, + }); + rerenderPage(); + await advance(0); + + expect(screen.queryByText(WAITING_MESSAGE)).not.toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '02 : 00', + ); + expect(screen.queryByText(ABSENT_MESSAGE)).not.toBeInTheDocument(); + }); + + it('사회자가 한 번 연결된 뒤 부재 알림을 받으면 끊김 안내를 표시한다', async () => { + const { rerenderPage } = renderPage(); + // 테이블 조회(MSW) 응답을 기다린다 + await advance(100); + expect(screen.getByText(WAITING_MESSAGE)).toBeInTheDocument(); + + const receivedAt = Date.now(); + setSocketState({ + latestMessage: { + eventType: 'SYNC', + data: { + timerType: 'NORMAL', + sequence: 0, + remainingTime: 120, + isRunning: false, + }, + version: 1, + }, + latestMessageReceivedAt: receivedAt, + lastReceivedAt: receivedAt, + }); + rerenderPage(); + await advance(0); + expect(screen.getByTestId('timer-value')).toBeInTheDocument(); + + await advance(1000); + setSocketState({ chairmanAbsentAt: Date.now() }); + rerenderPage(); + await advance(0); + + expect(screen.getByText(ABSENT_MESSAGE)).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toBeInTheDocument(); + }); +}); From cb42745dfed711e479b4a23ef65e48995b3e5493 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 21:17:45 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=ED=86=A0=EB=A1=A0=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A8=EB=9E=98=EC=8B=9C=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=9E=98=EA=B0=80=20=ED=95=9C=20=EC=95=8C=EC=94=A9=20?= =?UTF-8?q?=EB=96=A8=EC=96=B4=EC=A7=80=EB=8A=94=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아이콘을 통째로 뒤집는 방식이라 모래가 위로 올라가는 것처럼 보였다. 새 의존성 없이 SVG와 framer-motion으로 모래시계를 직접 그린다. - 위쪽 모래는 표면이 목 쪽으로 내려가며 줄어들고, 아래쪽 모래는 봉긋한 더미로 차오름 - 목에서 모래 알갱이가 시차를 두고 한 알씩 떨어지며 중력처럼 점점 빨라짐 - 모래가 다 떨어지면 모래시계를 뒤집고, 대칭 구조라 다음 주기와 끊김 없이 이어짐 - 동작 줄이기(prefers-reduced-motion) 설정이면 멈춘 모래시계만 표시 Co-Authored-By: Claude Opus 5.5 --- .../components/DebateWaitingNotice.tsx | 162 ++++++++++++++++-- 1 file changed, 149 insertions(+), 13 deletions(-) diff --git a/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx index 5a653b6b..80741cf1 100644 --- a/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx +++ b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx @@ -1,35 +1,171 @@ -import { MdHourglassTop } from 'react-icons/md'; -import { motion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; interface DebateWaitingNoticeProps { message: string; } +// 모래가 한 번 다 떨어지고 모래시계를 뒤집기까지 걸리는 시간 +const CYCLE_DURATION_S = 9; +// 한 주기 안에서 모래가 흐르는 구간과 뒤집는 구간의 비율 +const FLOW_END = 0.8; +const FLIP_START = 0.88; +// 목에서 바닥까지 떨어지는 모래 알갱이 (x 흔들림과 출발 지연을 달리해 한 알씩 떨어지는 느낌을 준다) +const SAND_GRAINS = [ + { x: 0, delay: 0 }, + { x: 0.5, delay: 0.18 }, + { x: -0.4, delay: 0.36 }, + { x: 0.2, delay: 0.54 }, + { x: -0.6, delay: 0.72 }, + { x: 0.4, delay: 0.9 }, +]; +const GRAIN_FALL_DURATION_S = 1.08; + +// 위, 아래 유리 안쪽 모양. 모래는 이 모양으로 잘라 유리 밖으로 넘치지 않게 한다 +// 두 모양이 중심(32, 40)에 대해 대칭이라, 뒤집은 직후 아래 모래가 곧바로 다음 주기의 위 모래와 겹친다 +const TOP_BULB_PATH = 'M17 10 C17 23 30 31 31 39 L33 39 C34 31 47 23 47 10 Z'; +const BOTTOM_BULB_PATH = + 'M17 70 C17 57 30 49 31 41 L33 41 C34 49 47 57 47 70 Z'; +// 떨어진 모래가 가운데로 쌓이도록 위가 봉긋한 더미 모양 +const BOTTOM_SAND_PILE_PATH = 'M14 70 L14 50 Q32 34 50 50 L50 70 Z'; + /** * 사회자가 토론을 공유하기 전까지 청중 화면에 토론 시작 대기 안내를 표시합니다. - * 모래시계를 천천히 뒤집어 화면이 멈춘 것이 아니라 기다리는 중임을 알립니다. + * 위쪽 모래가 한 알씩 아래로 떨어져 쌓이고, 다 떨어지면 모래시계를 뒤집어 + * 화면이 멈춘 것이 아니라 기다리는 중임을 알립니다. */ export default function DebateWaitingNotice({ message, }: DebateWaitingNoticeProps) { + const shouldReduceMotion = useReducedMotion(); + + const cycleTransition = { + duration: CYCLE_DURATION_S, + times: [0, FLOW_END, FLIP_START, 1], + repeat: Infinity, + ease: 'linear' as const, + }; + return (
- + + + + + + + + + + + {/* 위쪽 모래: 표면이 목 쪽으로 내려가며 줄어든다 */} + + + + + {/* 떨어지는 모래 알갱이: 목에서 출발해 중력처럼 점점 빨라지며 바닥에 닿는다 */} + {!shouldReduceMotion && ( + + {SAND_GRAINS.map(({ x, delay }) => ( + + ))} + + )} + + {/* 아래쪽 모래: 가운데가 봉긋한 더미로 바닥부터 차오른다 */} + + + + + {/* 유리와 받침 */} + + + + +

{message}

From 72dc6050bdfaaf4bf2893381d6657f7ee66ef636 Mon Sep 17 00:00:00 2001 From: coli Date: Sat, 26 Sep 2026 21:20:54 +0900 Subject: [PATCH 6/7] =?UTF-8?q?[FIX]=20=EB=8C=80=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=EA=B3=84=EB=A5=BC=20=EB=92=A4=EC=A7=91?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=96=91=EC=98=86=EC=9D=B4=20=EC=9E=98?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세로로 긴 viewBox(64x80) 안에서 모래시계를 돌리면 가로로 눕는 순간 폭이 영역을 넘어 잘렸다. 중심에서 가장 먼 모서리까지 담기는 정사각형 viewBox로 바꿔 어느 각도에서도 잘리지 않게 한다. Co-Authored-By: Claude Opus 5.5 --- .../AudienceSharePage/components/DebateWaitingNotice.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx index 80741cf1..ee945aa0 100644 --- a/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx +++ b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx @@ -20,6 +20,10 @@ const SAND_GRAINS = [ ]; const GRAIN_FALL_DURATION_S = 1.08; +// 모래시계(가로 10~54, 세로 4~76)를 중심(32, 40)에서 돌려도 모서리가 잘리지 않도록 +// 중심에서 가장 먼 모서리까지의 거리(약 42)보다 넉넉한 정사각형 영역을 잡는다 +const HOURGLASS_VIEW_BOX = '-12 -4 88 88'; + // 위, 아래 유리 안쪽 모양. 모래는 이 모양으로 잘라 유리 밖으로 넘치지 않게 한다 // 두 모양이 중심(32, 40)에 대해 대칭이라, 뒤집은 직후 아래 모래가 곧바로 다음 주기의 위 모래와 겹친다 const TOP_BULB_PATH = 'M17 10 C17 23 30 31 31 39 L33 39 C34 31 47 23 47 10 Z'; @@ -52,8 +56,8 @@ export default function DebateWaitingNotice({ >