diff --git a/docs/live-share-timer-sync.md b/docs/live-share-timer-sync.md index f064980b..f65cfa85 100644 --- a/docs/live-share-timer-sync.md +++ b/docs/live-share-timer-sync.md @@ -51,6 +51,7 @@ - 청중 코드에서 로컬 0초 도달을 근거로 순서 이동·종료 화면 전환 같은 **상태 변경을 하지 않습니다.** 이런 동작은 사회자 이벤트를 받았을 때만 합니다. - 사회자 탭이 백그라운드로 가면 브라우저가 타이머를 늦춰 heartbeat가 늦어질 수 있습니다. 청중은 15초(`CHAIRMAN_ABSENT_TIMEOUT_MS`) 동안 메시지가 없으면 "사회자 연결 끊김"으로 보고 카운트다운을 멈춥니다. +- 청중이 사회자보다 먼저 들어와 사회자 메시지를 한 번도 받지 못했다면, 서버의 `CHAIRMAN_ABSENT` 알림이나 15초 경과와 관계없이 "토론 시작을 대기 중입니다." 화면을 유지합니다. 사회자가 공유를 시작해 첫 `SYNC`가 오면 타이머 화면으로 바뀝니다. "사회자 연결 끊김" 안내는 사회자 메시지를 한 번이라도 받은 뒤에만 표시합니다. ## 4. 빠른 연타 (Case 15) 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(); + }); +}); 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..ee945aa0 --- /dev/null +++ b/src/page/AudienceSharePage/components/DebateWaitingNotice.tsx @@ -0,0 +1,178 @@ +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; + +// 모래시계(가로 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'; +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 ( +
+ +

+ {message} +

+
+ ); +} 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'; 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 ? (