Skip to content
Merged
1 change: 1 addition & 0 deletions docs/live-share-timer-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

- 청중 코드에서 로컬 0초 도달을 근거로 순서 이동·종료 화면 전환 같은 **상태 변경을 하지 않습니다.** 이런 동작은 사회자 이벤트를 받았을 때만 합니다.
- 사회자 탭이 백그라운드로 가면 브라우저가 타이머를 늦춰 heartbeat가 늦어질 수 있습니다. 청중은 15초(`CHAIRMAN_ABSENT_TIMEOUT_MS`) 동안 메시지가 없으면 "사회자 연결 끊김"으로 보고 카운트다운을 멈춥니다.
- 청중이 사회자보다 먼저 들어와 사회자 메시지를 한 번도 받지 못했다면, 서버의 `CHAIRMAN_ABSENT` 알림이나 15초 경과와 관계없이 "토론 시작을 대기 중입니다." 화면을 유지합니다. 사회자가 공유를 시작해 첫 `SYNC`가 오면 타이머 화면으로 바뀝니다. "사회자 연결 끊김" 안내는 사회자 메시지를 한 번이라도 받은 뒤에만 표시합니다.

## 4. 빠른 연타 (Case 15)

Expand Down
190 changes: 190 additions & 0 deletions src/page/AudienceSharePage/AudienceSharePage.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useAudienceSocketModule.default>;

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<AudienceSocketState>) => {
socketState = { ...socketState, ...state };
};

const renderPage = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const createPage = () => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/live/123']}>
<Routes>
<Route path="/live/:id" element={<AudienceSharePage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
);
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();
});
});
37 changes: 37 additions & 0 deletions src/page/AudienceSharePage/AudienceSharePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`, () => {
Expand Down
9 changes: 2 additions & 7 deletions src/page/AudienceSharePage/AudienceSharePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -156,13 +157,7 @@ export default function AudienceSharePage() {
return <LoadingContent />;

case 'WAITING':
return (
<div className="flex h-full w-full flex-col items-center justify-center space-y-[20px]">
<h1 className="break-keep px-4 text-center text-2xl font-bold text-gray-800 xl:text-4xl">
{viewState.message}
</h1>
</div>
);
return <DebateWaitingNotice message={viewState.message} />;

case 'NORMAL_TIMER':
return (
Expand Down
Loading
Loading