diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index fe4bab77..6ddd6863 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -248,6 +248,10 @@ "토론 주제를 입력해주세요": "Please enter a debate topic", "팀명": "Team name", "팀명은 최대 15자까지 입력할 수 있습니다.": "Team name can be up to 15 characters.", + "팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "Team name contains characters that are not allowed.", + "시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "Table name can be up to {{val0}} characters.", + "시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "Table name contains characters that are not allowed.", + "토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "Debate topic can be up to {{val0}} characters.", "다음": "Next", "볼륨 조절": "Volume control", "투표 종료에 실패했습니다.": "Failed to end the vote.", @@ -270,6 +274,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 328ad3f0..5029a204 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -248,6 +248,10 @@ "토론 주제를 입력해주세요": "토론 주제를 입력해주세요", "팀명": "팀명", "팀명은 최대 15자까지 입력할 수 있습니다.": "팀명은 최대 15자까지 입력할 수 있습니다.", + "팀명에 사용할 수 없는 문자가 포함되어 있습니다.": "팀명에 사용할 수 없는 문자가 포함되어 있습니다.", + "시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.": "시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.", + "시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.": "시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.", + "토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.": "토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.", "다음": "다음", "볼륨 조절": "볼륨 조절", "투표 종료에 실패했습니다.": "투표 종료에 실패했습니다.", @@ -270,6 +274,7 @@ "나의 토론 주제": "나의 토론 주제", "나의 시간표": "나의 시간표", "유효하지 않은 토론방 ID입니다.": "유효하지 않은 토론방 ID입니다.", + "시간표 설정에 오류가 발생했어요.": "시간표 설정에 오류가 발생했어요.", "실시간 연결 주소를 확인할 수 없어요.": "실시간 연결 주소를 확인할 수 없어요.", "토론방 연결이 거부되었어요.": "토론방 연결이 거부되었어요.", "실시간 연결에서 서버 오류가 발생했어요.": "실시간 연결에서 서버 오류가 발생했어요.", diff --git a/src/apis/apis/organization.test.ts b/src/apis/apis/organization.test.ts new file mode 100644 index 00000000..b5272285 --- /dev/null +++ b/src/apis/apis/organization.test.ts @@ -0,0 +1,52 @@ +import { http, HttpResponse } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../endpoints'; +import { GetOrganizationTemplatesResponseType } from '../responses/organization'; +import { getOrganizationTemplates } from './organization'; + +const mockTemplatesResponse: GetOrganizationTemplatesResponseType = { + organizations: [ + { + organization: '테스트 기관', + affiliation: '테스트 대학', + iconPath: '/icon/test.png', + templates: [], + }, + ], +}; + +describe('조직 템플릿 조회 API', () => { + test('language=KO_KR 쿼리 파라미터와 함께 템플릿을 조회한다', async () => { + let capturedLanguage: string | null = null; + + server.use( + http.get(ApiUrl.organization + '/templates', ({ request }) => { + const url = new URL(request.url); + capturedLanguage = url.searchParams.get('language'); + return HttpResponse.json(mockTemplatesResponse); + }), + ); + + const data = await getOrganizationTemplates('KO_KR'); + + expect(capturedLanguage).toBe('KO_KR'); + expect(data).toEqual(mockTemplatesResponse); + }); + + test('language=US_EN 쿼리 파라미터와 함께 템플릿을 조회한다', async () => { + let capturedLanguage: string | null = null; + + server.use( + http.get(ApiUrl.organization + '/templates', ({ request }) => { + const url = new URL(request.url); + capturedLanguage = url.searchParams.get('language'); + return HttpResponse.json(mockTemplatesResponse); + }), + ); + + const data = await getOrganizationTemplates('US_EN'); + + expect(capturedLanguage).toBe('US_EN'); + expect(data).toEqual(mockTemplatesResponse); + }); +}); diff --git a/src/apis/apis/organization.ts b/src/apis/apis/organization.ts index dc12cdb9..063dc388 100644 --- a/src/apis/apis/organization.ts +++ b/src/apis/apis/organization.ts @@ -2,14 +2,18 @@ import { ApiUrl } from '../endpoints'; import { request } from '../primitives'; import { GetOrganizationTemplatesResponseType } from '../responses/organization'; +export type ApiLanguageCode = 'KO_KR' | 'US_EN'; + // GET /api/organizations/templates -export async function getOrganizationTemplates(): Promise { +export async function getOrganizationTemplates( + language: ApiLanguageCode, +): Promise { const requestUrl: string = ApiUrl.organization + '/templates'; const response = await request( 'GET', requestUrl, null, - null, + { language }, ); return response.data; diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index 42ee305b..2535100c 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { AxiosResponse } from 'axios'; import axiosInstance from './axiosInstance'; -import { isSentryCaptured } from '../util/sentry'; +import { isSentryCaptured, markSentryCaptured } from '../util/sentry'; // HTTP request methods export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -10,7 +10,6 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; export class APIError extends Error { public readonly status: number; public readonly data: unknown; - public __sentry_captured__?: boolean; constructor(message: string, status: number, data: unknown) { super(message); @@ -56,7 +55,11 @@ export async function request( error.response?.status || 500, responseData, ); - apiError.__sentry_captured__ = isSentryCaptured(error); + // Axios 인터셉터에서 이미 보낸 에러는 APIError로 감싼 뒤에도 + // ErrorBoundary/전역 핸들러에서 중복 수집되지 않도록 캡처 상태를 전달한다. + if (isSentryCaptured(error)) { + markSentryCaptured(apiError); + } throw apiError; } diff --git a/src/components/ClearableInput/ClearableInput.tsx b/src/components/ClearableInput/ClearableInput.tsx index b7ed6244..7b9c49f7 100644 --- a/src/components/ClearableInput/ClearableInput.tsx +++ b/src/components/ClearableInput/ClearableInput.tsx @@ -1,27 +1,52 @@ import clsx from 'clsx'; import { InputHTMLAttributes } from 'react'; import { IoMdCloseCircle } from 'react-icons/io'; +import { useTranslation } from 'react-i18next'; interface ClearableInputProps extends InputHTMLAttributes { value: string; disabled?: boolean; onClear?: () => void; + /** 검증 실패 시 붉은 테두리로 표시 */ + isError?: boolean; + /** 설정 시 입력창 경계 바로 아래에 `현재/최대` 글자 수 카운터를 노출한다. */ + maxCount?: number; + /** 설정 시 입력창 경계 바로 아래(왼쪽)에 붉은 에러 메시지를 노출한다. */ + errorMessage?: string; } export default function ClearableInput({ value, onClear, disabled = false, + isError = false, + maxCount, + errorMessage, className, + id, ...rest }: ClearableInputProps) { + const { t } = useTranslation(); + const hasCounter = typeof maxCount === 'number'; + const isOverLimit = hasCounter && value.length > maxCount; + const errorId = errorMessage && id ? `${id}-error` : undefined; + return (
{value && !disabled && onClear && ( )} + {hasCounter && ( + // 박스 경계 바로 아래에 절대배치 → 레이아웃을 밀지 않아 행 간격이 균일하게 유지된다. + + {t('{{current}}/{{max}}', { + current: value.length, + max: maxCount, + })} + + )} + {errorMessage && ( + // 카운터(우하단)와 겹치지 않도록 우측 여백을 두고 좌하단에 절대배치한다. + + {errorMessage} + + )}
); } diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index 73c6659e..d3269e1d 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -38,7 +38,8 @@ class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - // 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송 + // API 인터셉터에서 이미 커스텀 이벤트로 전송한 에러는 전역 beforeSend에서도 drop된다. + // ErrorBoundary에서는 아직 수집되지 않은 렌더링 에러만 render-error로 전송한다. if (!isSentryCaptured(error)) { const feature = resolveFeatureFromPathname(window.location.pathname); const sentryError = createSentryRenderError(error, feature); 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..fb720bb5 --- /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(normalizedProgress); + const width = useTransform( + progressMotionValue, + (currentProgress) => `${currentProgress}%`, + ); + + useEffect(() => { + const controls = animate(progressMotionValue, normalizedProgress, { + duration: isRunning ? 0.7 : 0, + ease: 'easeOut', + }); + + return () => controls.stop(); + }, [isRunning, normalizedProgress, progressMotionValue]); + + return ( +
+ +
+ ); +} diff --git a/src/hooks/query/useGetOrganizationTemplates.test.tsx b/src/hooks/query/useGetOrganizationTemplates.test.tsx new file mode 100644 index 00000000..2b6066ca --- /dev/null +++ b/src/hooks/query/useGetOrganizationTemplates.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../mocks/server'; +import { ApiUrl } from '../../apis/endpoints'; +import { GetOrganizationTemplatesResponseType } from '../../apis/responses/organization'; +import { useGetOrganizationTemplates } from './useGetOrganizationTemplates'; +import i18n from '../../i18n'; + +const mockTemplatesResponse: GetOrganizationTemplatesResponseType = { + organizations: [ + { + organization: '테스트 기관', + affiliation: '테스트 대학', + iconPath: '/icon/test.png', + templates: [], + }, + ], +}; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe('useGetOrganizationTemplates', () => { + afterEach(() => { + i18n.changeLanguage('ko'); + }); + + test('한국어(ko) 설정 시 language=KO_KR 파라미터로 요청한다', async () => { + i18n.changeLanguage('ko'); + const queryClient = createQueryClient(); + let capturedLanguage: string | null = null; + + server.use( + http.get(ApiUrl.organization + '/templates', ({ request }) => { + const url = new URL(request.url); + capturedLanguage = url.searchParams.get('language'); + return HttpResponse.json(mockTemplatesResponse); + }), + ); + + const { result } = renderHook(() => useGetOrganizationTemplates(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(capturedLanguage).toBe('KO_KR'); + expect(result.current.data).toEqual(mockTemplatesResponse); + expect( + queryClient.getQueryData(['OrganizationTemplates', 'KO_KR']), + ).toEqual(mockTemplatesResponse); + }); + + test('영어(en) 설정 시 language=US_EN 파라미터로 요청한다', async () => { + await i18n.changeLanguage('en'); + const queryClient = createQueryClient(); + let capturedLanguage: string | null = null; + + server.use( + http.get(ApiUrl.organization + '/templates', ({ request }) => { + const url = new URL(request.url); + capturedLanguage = url.searchParams.get('language'); + return HttpResponse.json(mockTemplatesResponse); + }), + ); + + const { result } = renderHook(() => useGetOrganizationTemplates(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(capturedLanguage).toBe('US_EN'); + expect(result.current.data).toEqual(mockTemplatesResponse); + expect( + queryClient.getQueryData(['OrganizationTemplates', 'US_EN']), + ).toEqual(mockTemplatesResponse); + }); + + test('enabled=false 이면 API를 호출하지 않는다', () => { + const queryClient = createQueryClient(); + + const { result } = renderHook(() => useGetOrganizationTemplates(false), { + wrapper: createWrapper(queryClient), + }); + + expect(result.current.fetchStatus).toBe('idle'); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/src/hooks/query/useGetOrganizationTemplates.ts b/src/hooks/query/useGetOrganizationTemplates.ts index 523bcbff..dfdfa6ed 100644 --- a/src/hooks/query/useGetOrganizationTemplates.ts +++ b/src/hooks/query/useGetOrganizationTemplates.ts @@ -1,11 +1,25 @@ import { useQuery } from '@tanstack/react-query'; import { GetOrganizationTemplatesResponseType } from '../../apis/responses/organization'; -import { getOrganizationTemplates } from '../../apis/apis/organization'; +import { + getOrganizationTemplates, + ApiLanguageCode, +} from '../../apis/apis/organization'; +import i18n from '../../i18n'; + +const LANG_MAP: Record = { + ko: 'KO_KR', + en: 'US_EN', +}; + +function getApiLanguage(): ApiLanguageCode { + return LANG_MAP[i18n.language] ?? 'KO_KR'; +} export function useGetOrganizationTemplates(enabled?: boolean) { + const language = getApiLanguage(); return useQuery({ - queryKey: ['OrganizationTemplates'], - queryFn: () => getOrganizationTemplates(), + queryKey: ['OrganizationTemplates', language], + queryFn: () => getOrganizationTemplates(language), enabled, throwOnError: false, }); diff --git a/src/hooks/sockets/useAudienceSocket.test.ts b/src/hooks/sockets/useAudienceSocket.test.ts index a41e4245..2b7844c9 100644 --- a/src/hooks/sockets/useAudienceSocket.test.ts +++ b/src/hooks/sockets/useAudienceSocket.test.ts @@ -54,6 +54,56 @@ 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('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 5782fad3..c58193ae 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,11 @@ export default function useAudienceSocket(roomId: number) { }, [addConnectionListener, resetMessage]); useEffect(() => { + if (!enabled) { + resetMessage(); + return; + } + const destination = `/room/${roomId}`; resetMessage(); @@ -92,9 +106,10 @@ export default function useAudienceSocket(roomId: number) { // 컴포넌트 언마운트 또는 roomId 변경 시 해당 채널 구독 해제 return () => { + resetMessage(); unsubscribe(destination); }; - }, [roomId, resetMessage, subscribe, unsubscribe]); + }, [enabled, roomId, resetMessage, subscribe, unsubscribe]); return { latestMessage, diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..fbf59fd6 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +/** + * 값의 변경이 delay(ms) 동안 멈추면(= 입력이 끝났다고 판단되면) 갱신되는 디바운스 값을 반환한다. + * 입력 중에는 이전 값을 유지하므로, 타이핑 도중이 아니라 입력이 마쳤을 때 검증을 트리거하는 데 사용한다. + */ +const useDebounce = (value: T, delay = 400): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +}; + +export default useDebounce; diff --git a/src/instrument.ts b/src/instrument.ts index 72655524..f130628b 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -1,8 +1,11 @@ import * as Sentry from '@sentry/react'; +import { shouldSkipSentryEvent } from './util/sentry'; const dsn = import.meta.env.VITE_SENTRY_DSN; +const isSentryEnabled = + import.meta.env.PROD || import.meta.env.VITE_ENABLE_SENTRY === 'true'; -if (import.meta.env.PROD && dsn) { +if (isSentryEnabled && dsn) { Sentry.init({ dsn, environment: import.meta.env.MODE, @@ -24,22 +27,13 @@ if (import.meta.env.PROD && dsn) { beforeSend(event, hint) { const originalException = hint?.originalException; - // 정상 사용자 흐름(이동/언마운트)에서 자주 생기는 취소성 에러는 노이즈로 간주 - if (originalException instanceof Error) { - const normalizedMessage = originalException.message.toLowerCase(); - const isCanceledRequest = - normalizedMessage.includes('cancel') || - normalizedMessage.includes('aborted') || - originalException.name === 'AbortError' || - originalException.name === 'CanceledError'; - - if (isCanceledRequest) { - return null; - } - } - - // 크로스 오리진 스크립트 에러는 재현 단서가 부족해 운영 액션 가능성이 낮음 - if (event.exception?.values?.[0]?.value === 'Script error.') { + // 이미 수집한 API 에러, 취소성 에러, 원인 추적이 어려운 Script error는 전송하지 않음 + if ( + shouldSkipSentryEvent( + originalException, + event.exception?.values?.[0]?.value, + ) + ) { return null; } diff --git a/src/page/AudienceSharePage/AudienceSharePage.test.tsx b/src/page/AudienceSharePage/AudienceSharePage.test.tsx index ae7ac2c2..2dd16947 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.test.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.test.tsx @@ -1,24 +1,80 @@ -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 AudienceSharePage, { - AudienceShareDisplayError, -} from './AudienceSharePage'; +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, AudienceShareErrorCode } from './error'; +import { AudienceShareError } from './error'; +import { GetDebateTableDataForShareResponseType } from '../../apis/responses/live'; // 모의 (Mock) vi.mock('./hooks/useAudienceShareState'); +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 mockDebateTableData: GetDebateTableDataForShareResponseType = { + id: 123, + info: { + name: '공유 토론 테이블', + agenda: '공유 토론 주제', + prosTeamName: '찬성', + consTeamName: '반대', + }, + table: [ + { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'CONS', + speechType: '반론', + bell: null, + boxType: 'NORMAL', + time: 90, + timePerTeam: null, + timePerSpeaking: null, + speaker: '이토론', + }, + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 180, + timePerSpeaking: 30, + speaker: null, + }, + ], +}; + class TestErrorBoundary extends Component< { children: ReactNode; onError: (error: Error) => void }, { hasError: boolean } @@ -49,19 +105,34 @@ describe('AudienceSharePage', () => { initialRoute: string, onError: (error: Error) => void = () => {}, ) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return render( - - - - } /> - - - , + + + + + } /> + + + + , ); }; beforeEach(() => { vi.clearAllMocks(); + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json(mockDebateTableData); + }), + ); mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, @@ -97,22 +168,48 @@ describe('AudienceSharePage', () => { }); describe('상태별 화면 렌더링', () => { - it('connecting 상태에서는 LoadingSpinner만 표시되고 타이머 조작 요소가 없다', () => { + it('API 조회 중에는 소켓 연결을 비활성화하고 회색 스피너만 표시한다', () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, async () => { + await delay('infinite'); + return HttpResponse.json(mockDebateTableData); + }), + ); + + 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만 표시되고 타이머 조작 요소가 없다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'connecting', error: null, }); renderPage('/live/123'); + 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, @@ -120,11 +217,49 @@ describe('AudienceSharePage', () => { renderPage('/live/123'); expect( - screen.getByText('토론 시작을 대기 중입니다.'), + await screen.findByText('토론 시작을 대기 중입니다.'), + ).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와 같은 대체 문구를 표시한다', 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, + }); + + renderPage('/live/123'); + + 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, @@ -133,15 +268,48 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: true, singleTime: 120, + sequence: 0, + }, + }); + renderPage('/live/123'); + + expect(await screen.findByText('입론')).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 항목에 반대 팀 정보를 연결한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 45, + sequence: 1, }, }); + renderPage('/live/123'); - expect(screen.getByText('남은 시간')).toBeInTheDocument(); - expect(screen.getByText('02:00')).toBeInTheDocument(); + expect(await screen.findByText('반론')).toBeInTheDocument(); + expect(screen.getByText('반대 팀')).toBeInTheDocument(); + expect(screen.getByText('이토론 토론자')).toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-camp-red', + ); }); - it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', () => { + it('PLAY 상태의 NORMAL 타이머는 화면에서 로컬 카운트다운을 진행한다', async () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', @@ -151,20 +319,31 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: true, singleTime: 10, + sequence: 0, }, }); renderPage('/live/123'); - expect(screen.getByText('00:10')).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); - act(() => { - vi.advanceTimersByTime(2000); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); }); - expect(screen.getByText('00:08')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 08', + ); }); - it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', () => { + it('정지 상태의 NORMAL 타이머는 수신된 시간을 유지한다', async () => { vi.useFakeTimers(); mockUseAudienceShareState.mockReturnValue({ status: 'displaying', @@ -174,18 +353,31 @@ describe('AudienceSharePage', () => { currentTeam: null, isRunning: false, singleTime: 10, + sequence: 0, }, }); 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.getByText('00:10')).toBeInTheDocument(); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); }); - it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', () => { + it('displaying 상태 (TIME_BASED)에서는 AudienceTimeBasedTimer에 올바른 props가 전달된다', async () => { mockUseAudienceShareState.mockReturnValue({ status: 'displaying', error: null, @@ -193,19 +385,30 @@ 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(await screen.findByText('찬성 팀')).toBeInTheDocument(); + expect(screen.getByText('반대 팀')).toBeInTheDocument(); + expect(screen.getByTestId('pros-total-timer')).toHaveAttribute( + 'aria-label', + '03 : 00', + ); + 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', @@ -216,19 +419,38 @@ 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(); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); - act(() => { - vi.advanceTimersByTime(2000); + expect(screen.getByTestId('pros-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 10', + ); + expect(screen.getByTestId('cons-current-timer')).toHaveAttribute( + 'aria-label', + '00 : 30', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(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 () => { @@ -244,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(); @@ -253,50 +477,158 @@ describe('AudienceSharePage', () => { expect(windowCloseSpy).toHaveBeenCalled(); - // 상태 변경이나 홈 이동이 없어야 함 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, + 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를 표시한다', + async ({ sequence, table }) => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return HttpResponse.json({ ...mockDebateTableData, table }); + }), ); mockUseAudienceShareState.mockReturnValue({ - status: 'connecting', - error: sourceError, + status: 'displaying', + error: null, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence, + }, }); - let caughtError: AudienceShareDisplayError | null = null; + renderPage('/live/123'); - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); // Error boundary logging 숨김 + expect( + await screen.findByText('시간표 설정에 오류가 발생했어요.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }, + ); - renderPage('/live/123', (err) => { - caughtError = err as AudienceShareDisplayError; - }); + 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, + displayData: { + timerType: 'NORMAL', + currentTeam: null, + isRunning: false, + singleTime: 30, + sequence: 0, + }, + }); - expect(caughtError).toBeInstanceOf(Error); - expect(caughtError!.name).toBe('AudienceShareDisplayError'); - expect(caughtError!.message).toBe(expectedMessage); - expect(caughtError!.sourceError).toBe(sourceError); + renderPage('/live/123'); - consoleErrorSpy.mockRestore(); - }, - ); + expect(await screen.findByTestId('timer-progress-fill')).toHaveClass( + 'bg-default-neutral', + ); + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); + }); + }); + + describe('오류 처리', () => { + it('API 조회에 실패하면 페이지 중앙에 데이터 오류와 새로고침 버튼을 표시한다', async () => { + server.use( + http.get(`${ApiUrl.live}/table/customize/:tableId`, () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + renderPage('/live/123'); + + expect( + await screen.findByText( + '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + ), + ).toBeInTheDocument(); + expect( + screen.getByTestId('audience-share-error-icon'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '새로고침' }), + ).toBeInTheDocument(); + expect(screen.queryByText('공유 토론 테이블')).not.toBeInTheDocument(); + }); + + it('소켓 연결에 실패하면 페이지 중앙에 서버 연결 오류를 표시한다', async () => { + mockUseAudienceShareState.mockReturnValue({ + status: 'connecting', + error: new AudienceShareError('SOCKET_STOMP_ERROR'), + }); + + renderPage('/live/123'); + + expect( + await screen.findByText('서버 연결에 실패했어요.'), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: '새로고침' }), + ).toBeInTheDocument(); + }); + + 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'), + }); + + renderPage('/live/123'); + + expect( + await screen.findByText('서버 연결에 실패했어요.'), + ).toBeInTheDocument(); + expect( + screen.queryByText( + '필요한 데이터를 불러오지 못했어요. 다시 시도해보세요.', + ), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/page/AudienceSharePage/AudienceSharePage.tsx b/src/page/AudienceSharePage/AudienceSharePage.tsx index 2517b168..b9c4e492 100644 --- a/src/page/AudienceSharePage/AudienceSharePage.tsx +++ b/src/page/AudienceSharePage/AudienceSharePage.tsx @@ -1,21 +1,56 @@ import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { MdErrorOutline } from 'react-icons/md'; import { useAudienceShareState } from './hooks/useAudienceShareState'; import { useAudienceCountdown } from './hooks/useAudienceCountdown'; -import { AudienceShareError, AudienceShareErrorCode } from './error'; +import { useAudienceTimeBasedCountdown } from './hooks/useAudienceTimeBasedCountdown'; import AudienceNormalTimer from './components/AudienceNormalTimer'; import AudienceTimeBasedTimer from './components/AudienceTimeBasedTimer'; import DefaultLayout from '../../layout/defaultLayout/DefaultLayout'; import LoadingSpinner from '../../components/LoadingSpinner'; +import HeaderTableInfo from '../../components/HeaderTableInfo/HeaderTableInfo'; +import HeaderTitle from '../../components/HeaderTitle/HeaderTitle'; +import { useGetDebateTableDataForShare } from '../../hooks/query/useGetDebateTableDataForShare'; +import { resolveAudienceScreenState } from './hooks/AudienceScreenState'; -export class AudienceShareDisplayError extends Error { - public readonly sourceError: AudienceShareError; +interface ErrorContentProps { + message: string; + onReload: () => void; +} - constructor(message: string, sourceError: AudienceShareError) { - super(message); - this.name = 'AudienceShareDisplayError'; - this.sourceError = sourceError; - } +function LoadingContent() { + return ( +
+ +
+ ); +} + +function ErrorContent({ message, onReload }: ErrorContentProps) { + const { t } = useTranslation(); + + return ( +
+
+ ); } export default function AudienceSharePage() { @@ -23,46 +58,53 @@ 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)) { - throw new Error(t('유효하지 않은 토론방 ID입니다.')); - } - - const state = useAudienceShareState(tableId); - const audienceDisplayData = - state.status === 'displaying' ? state.displayData : null; + 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 prosCountdown = useAudienceCountdown({ - receivedTime: - audienceDisplayData?.timerType === 'TIME_BASED' - ? audienceDisplayData.prosTime + const timeBasedCountdown = useAudienceTimeBasedCountdown({ + displayData: + viewState.type === 'TIME_BASED_TIMER' ? viewState.displayData : null, + timePerTeam: + viewState.type === 'TIME_BASED_TIMER' + ? viewState.timeBox.timePerTeam : null, - isRunning: - audienceDisplayData?.timerType === 'TIME_BASED' && - audienceDisplayData.isRunning && - audienceDisplayData.currentTeam === 'PROS', - }); - const consCountdown = useAudienceCountdown({ - receivedTime: - audienceDisplayData?.timerType === 'TIME_BASED' - ? audienceDisplayData.consTime + timePerSpeaking: + viewState.type === 'TIME_BASED_TIMER' + ? viewState.timeBox.timePerSpeaking : null, - isRunning: - audienceDisplayData?.timerType === 'TIME_BASED' && - audienceDisplayData.isRunning && - audienceDisplayData.currentTeam === 'CONS', }); + if (!isValidTableId) { + throw new Error(t('유효하지 않은 토론방 ID입니다.')); + } + const handleClosePage = () => { // 일단 페이지 닫기 window.close(); @@ -73,93 +115,143 @@ 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}`); + switch (viewState.type) { + case 'LOADING': + return ; - if (state.status === 'connecting') { - return ( -
- -
- ); - } - - if (state.status === 'waiting') { - return ( -
-

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

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

+ {viewState.message} +

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

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

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

+ {viewState.message} +

+ +
+ ); - return null; + case 'ERROR': + case 'CONFIG_ERROR': + return ( + + ); + } }; + const isHeaderVisible = + viewState.type === 'WAITING' || + viewState.type === 'NORMAL_TIMER' || + viewState.type === 'TIME_BASED_TIMER' || + viewState.type === 'FINISHED'; + return ( + {isHeaderVisible && debateTableQuery.data ? ( + + + + + + + + + + ) : null}
{renderContent()} diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx index 1933a79b..d9de53dd 100644 --- a/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx +++ b/src/page/AudienceSharePage/components/AudienceNormalTimer.test.tsx @@ -1,19 +1,137 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import AudienceNormalTimer from './AudienceNormalTimer'; describe('AudienceNormalTimer', () => { - it('남은 시간 레이블과 포맷팅된 시간을 올바르게 렌더링한다', () => { - render(); + it('발언 유형, 팀명, 토론자명과 포맷팅된 시간을 렌더링한다', () => { + render( + , + ); - // @AC-037: "남은 시간" 레이블은 i18next 번역을 통해 렌더링된다. - expect(screen.getByText('남은 시간')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '입론' })).toBeInTheDocument(); + expect(screen.queryByText('남은 시간')).not.toBeInTheDocument(); + const debateIcon = screen.getByTestId('debate-icon'); + const participantRow = screen.getByTestId('participant-row'); + const timerValue = screen.getByTestId('timer-value'); - // @AC-036: remainingTime={65}일 때 화면에 01:05가 표시된다. - expect(screen.getByText('01:05')).toBeInTheDocument(); - - // @AC-038: 컴포넌트는 사용자 조작 요소 없이 props만으로 동일한 UI를 렌더링한다. - // 버튼 등 조작 요소가 없는지 확인한다. + expect(debateIcon).toHaveAttribute('aria-hidden', 'true'); + expect(debateIcon).toHaveClass('h-[34px]'); + expect(participantRow).toHaveClass('mt-[24px]', 'text-[34px]'); + expect(screen.getByText('찬성 팀')).toBeInTheDocument(); + expect(screen.getByText('김토론 토론자')).toBeInTheDocument(); + expect(timerValue).toHaveClass( + 'mt-[64px]', + 'grid-cols-[2ch_1ch_2ch]', + 'gap-x-[0.33ch]', + ); + expect(timerValue).toHaveAttribute('aria-label', '01 : 05'); + expect(screen.getByText('01')).toBeInTheDocument(); + expect(screen.getByText(':')).toHaveClass('text-center'); + expect(screen.getByText('05')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '50', + ); + expect(screen.getByRole('progressbar')).toHaveClass('mt-[108px]'); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + it('알 수 없는 발언 유형은 원문을 표시한다', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: '사용자 지정 발언' }), + ).toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-camp-red', + ); + }); + + it('NEUTRAL 진영은 팀명과 토론자 정보를 표시하지 않는다', () => { + render( + , + ); + + expect(screen.queryByTestId('participant-row')).not.toBeInTheDocument(); + expect(screen.queryByText('팀명 없음')).not.toBeInTheDocument(); + expect(screen.queryByText('토론자 없음')).not.toBeInTheDocument(); + expect(screen.getByTestId('timer-progress-fill')).toHaveClass( + 'bg-default-neutral', + ); + }); + + it.each([ + { remainingTime: 80, expectedProgress: 0 }, + { remainingTime: -20, expectedProgress: 100 }, + ])( + '경과 진행률을 0~100으로 제한한다: $remainingTime', + ({ remainingTime, expectedProgress }) => { + render( + , + ); + + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + String(expectedProgress), + ); + }, + ); + + it('남은 시간이 음수이면 마이너스를 표시하고 진행률을 100%로 고정한다', () => { + render( + , + ); + + expect(screen.getByTestId('negative-sign')).toHaveTextContent('-'); + expect(screen.getByTestId('timer-value')).toHaveAttribute( + 'aria-label', + '- 00 : 05', + ); + expect(screen.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + '100', + ); + }); }); diff --git a/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx b/src/page/AudienceSharePage/components/AudienceNormalTimer.tsx index 77b4dc85..0e2033e6 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 || teamName.trim() === '' + ? t('팀명 없음') + : t('{{team}} 팀', { team: t(teamName) }); + const speakerLabel = + !speaker || speaker.trim() === '' + ? t('토론자 없음') + : t('{{speaker}} 토론자', { speaker: t(speaker) }); + const progress = ((totalTime - remainingTime) / totalTime) * 100; + const [minutes, seconds] = Formatting.formatSecondsToMMSS( + Math.abs(remainingTime), + ).split(':'); + const isOvertime = remainingTime < 0; return ( -
+

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

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

{t('찬성')}

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

{t('반대')}

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

+ {teamLabel} +

+ + {timePerSpeaking === null ? ( + <> + + + + ) : ( + <> + + {t('전체 시간')} + + + + {t('현재 시간')} + + + + + )} +
+ ); +} diff --git a/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts new file mode 100644 index 00000000..d27b4d72 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect } from 'vitest'; +import { + isValidNormalTimeBox, + isValidTimeBasedTimeBox, + getNormalTimerTeamName, + resolveAudienceScreenState, + AudienceQueryState, +} from './AudienceScreenState'; +import { AudienceShareError } from '../error'; +import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; +import { AudienceShareState } from './useAudienceShareState'; + +const t = (key: string) => 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/hooks/AudienceScreenState.ts b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts new file mode 100644 index 00000000..606d0f03 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/AudienceScreenState.ts @@ -0,0 +1,183 @@ +import { DebateInfo, DebateTableData, TimeBoxInfo } from '../../../type/type'; +import { + AudienceNormalDisplayData, + AudienceTimeBasedDisplayData, +} from './EventInterpreter'; +import { AudienceShareState } from './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/hooks/EventInterpreter.test.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts new file mode 100644 index 00000000..ff9b0ec1 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.test.ts @@ -0,0 +1,514 @@ +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, + }); + }); + + 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 이벤트', () => { + 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, + }); + }); + + 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, + }); + }); + + 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 이벤트', () => { + 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/hooks/EventInterpreter.ts b/src/page/AudienceSharePage/hooks/EventInterpreter.ts new file mode 100644 index 00000000..742be618 --- /dev/null +++ b/src/page/AudienceSharePage/hooks/EventInterpreter.ts @@ -0,0 +1,253 @@ +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 = Math.max(data.sequence - 1, 0); + 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 = Math.max( + 0, + Math.min(data.sequence + 1, (table?.length ?? Infinity) - 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/useAudienceCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts index 7d35420f..a9999795 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.test.ts @@ -66,7 +66,7 @@ describe('useAudienceCountdown', () => { expect(result.current.currentSeconds).toBe(14); }); - it('0 아래로 내려가지 않음', () => { + it('0 아래에서도 음수로 카운트다운을 계속한다', () => { const { result } = renderHook(() => useAudienceCountdown({ receivedTime: 1, isRunning: true }), ); @@ -75,10 +75,91 @@ describe('useAudienceCountdown', () => { vi.advanceTimersByTime(2000); }); + expect(result.current.currentSeconds).toBe(-1); + expect(result.current.formattedTime).toBe('-00:01'); + }); + + it('최소 시간이 설정되면 해당 값에서 멈춘다', () => { + const { result } = renderHook(() => + useAudienceCountdown({ + receivedTime: 1, + isRunning: true, + minimumTime: 0, + }), + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(0); expect(result.current.formattedTime).toBe('00:00'); }); + it('실행 상태 변경 시 초기화하지 않는 옵션은 정지한 값에서 재개한다', () => { + const { result, rerender } = renderHook( + (props) => useAudienceCountdown(props), + { + initialProps: { + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: false, + shouldResetOnRunStateChange: false, + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.currentSeconds).toBe(7); + }); + + it('동일한 수신값도 syncKey가 변경되면 다시 동기화한다', () => { + const { result, rerender } = renderHook( + (props) => useAudienceCountdown(props), + { + initialProps: { + receivedTime: 10, + isRunning: true, + shouldResetOnRunStateChange: false, + syncKey: 1, + }, + }, + ); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.currentSeconds).toBe(8); + + rerender({ + receivedTime: 10, + isRunning: false, + shouldResetOnRunStateChange: false, + syncKey: 2, + }); + + expect(result.current.currentSeconds).toBe(10); + }); + it('입력 변경과 언마운트 시 interval 정리', () => { const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); diff --git a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts index 2ee1e248..a24b12ba 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceCountdown.ts @@ -4,6 +4,9 @@ import { Formatting } from '../../../util/formatting'; export interface UseAudienceCountdownParams { receivedTime: number | null; isRunning: boolean; + minimumTime?: number; + shouldResetOnRunStateChange?: boolean; + syncKey?: number; } export interface UseAudienceCountdownReturn { @@ -14,34 +17,85 @@ export interface UseAudienceCountdownReturn { export function useAudienceCountdown({ receivedTime, isRunning, + minimumTime, + shouldResetOnRunStateChange = true, + syncKey, }: UseAudienceCountdownParams): UseAudienceCountdownReturn { const [currentSeconds, setCurrentSeconds] = useState( receivedTime, ); const intervalRef = useRef | null>(null); const targetTimeRef = useRef(null); + const currentSecondsRef = useRef(receivedTime); + const previousReceivedTimeRef = useRef(undefined); + const previousSyncKeyRef = useRef(undefined); + const isInitializedRef = useRef(false); useEffect(() => { - // cleanup previous interval if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } - setCurrentSeconds(receivedTime); + const hasReceivedTimeChanged = + previousReceivedTimeRef.current !== receivedTime; + const hasSyncKeyChanged = previousSyncKeyRef.current !== syncKey; + const shouldSynchronize = + !isInitializedRef.current || + hasReceivedTimeChanged || + hasSyncKeyChanged || + shouldResetOnRunStateChange; + const normalizedReceivedTime = + receivedTime === null || minimumTime === undefined + ? receivedTime + : Math.max(minimumTime, receivedTime); + const startingSeconds = shouldSynchronize + ? normalizedReceivedTime + : currentSecondsRef.current; - if (isRunning && receivedTime !== null) { - targetTimeRef.current = Date.now() + receivedTime * 1000; + if (shouldSynchronize) { + currentSecondsRef.current = normalizedReceivedTime; + setCurrentSeconds(normalizedReceivedTime); + } + + previousReceivedTimeRef.current = receivedTime; + previousSyncKeyRef.current = syncKey; + isInitializedRef.current = true; + + const hasReachedMinimum = + minimumTime !== undefined && + startingSeconds !== null && + startingSeconds <= minimumTime; + + if (isRunning && startingSeconds !== null && !hasReachedMinimum) { + targetTimeRef.current = Date.now() + startingSeconds * 1000; intervalRef.current = setInterval(() => { if (targetTimeRef.current === null) return; const now = Date.now(); - const remainingTime = Math.max( - 0, - Math.ceil((targetTimeRef.current - now) / 1000), + const rawRemainingTime = Math.ceil( + (targetTimeRef.current - now) / 1000, ); + const remainingTime = + minimumTime === undefined + ? rawRemainingTime + : Math.max(minimumTime, rawRemainingTime); + + currentSecondsRef.current = remainingTime; setCurrentSeconds(remainingTime); + + if ( + minimumTime !== undefined && + rawRemainingTime <= minimumTime && + intervalRef.current + ) { + clearInterval(intervalRef.current); + intervalRef.current = null; + targetTimeRef.current = null; + } }, 200); + } else { + targetTimeRef.current = null; } return () => { @@ -50,11 +104,19 @@ export function useAudienceCountdown({ intervalRef.current = null; } }; - }, [receivedTime, isRunning]); + }, [ + isRunning, + minimumTime, + receivedTime, + shouldResetOnRunStateChange, + syncKey, + ]); const formattedTime = currentSeconds !== null - ? Formatting.formatSecondsToMMSS(currentSeconds) + ? `${currentSeconds < 0 ? '-' : ''}${Formatting.formatSecondsToMMSS( + Math.abs(currentSeconds), + )}` : '00:00'; return { currentSeconds, formattedTime }; diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts index 3b3398d0..0ec0a429 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.test.ts @@ -4,12 +4,35 @@ import { useAudienceCountdown } from './useAudienceCountdown'; import * as useAudienceSocketModule from '../../../hooks/sockets/useAudienceSocket'; import { SocketMessage } from '../../../apis/sockets/type'; import { SocketError } from '../../../apis/sockets/error'; +import { TimeBoxInfo } from '../../../type/type'; vi.mock('../../../hooks/sockets/useAudienceSocket'); describe('useAudienceShareState', () => { let mockConnect: ReturnType; let mockDisconnect: ReturnType; + const normalTable: TimeBoxInfo[] = [ + { + stance: 'PROS', + speechType: '입론', + bell: null, + boxType: 'NORMAL', + time: 180, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + { + stance: 'CONS', + speechType: '반론', + bell: null, + boxType: 'NORMAL', + time: 90, + timePerTeam: null, + timePerSpeaking: null, + speaker: null, + }, + ]; beforeEach(() => { vi.useFakeTimers(); @@ -50,6 +73,36 @@ describe('useAudienceShareState', () => { expect(mockDisconnect).toHaveBeenCalledTimes(1); }); + it('연결이 비활성화되면 소켓에 연결하거나 연결 해제를 시도하지 않는다.', () => { + const { result, unmount } = renderHook(() => + useAudienceShareState(1, { enabled: false }), + ); + + expect(result.current.status).toBe('connecting'); + expect(mockConnect).not.toHaveBeenCalled(); + + unmount(); + + expect(mockDisconnect).not.toHaveBeenCalled(); + }); + + it('연결이 활성화되는 시점에 소켓 연결을 한 번 시작하고 언마운트 시 정리한다.', () => { + const { rerender, unmount } = renderHook( + ({ enabled }) => useAudienceShareState(1, { enabled }), + { initialProps: { enabled: false } }, + ); + + expect(mockConnect).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + expect(mockConnect).toHaveBeenCalledTimes(1); + + unmount(); + + expect(mockDisconnect).toHaveBeenCalledTimes(1); + }); + it('연결 전에는 connecting, 연결 후 데이터 대기, 데이터 표시, 종료 상태가 입력에 따라 명확히 구분된다.', () => { // 연결 전 connecting const { result, rerender } = renderHook(() => useAudienceShareState(1)); @@ -98,10 +151,104 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.timerType).toBe('NORMAL'); if (result.current.displayData.timerType === 'NORMAL') { expect(result.current.displayData.singleTime).toBe(60); + expect(result.current.displayData.sequence).toBe(1); } } }); + it.each([ + { eventType: 'PLAY' as const, isRunning: true }, + { eventType: 'STOP' as const, isRunning: false }, + ])('$eventType 이벤트를 독립적으로 처리한다', ({ eventType, isRunning }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { timerType: 'NORMAL', sequence: 0, remainingTime: 75 }, + }, + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(isRunning); + expect(result.current.displayData.singleTime).toBe(75); + expect(result.current.displayData.sequence).toBe(0); + } + }); + + it('RESET 이벤트는 현재 순서의 전체 시간으로 되돌린다', () => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'RESET', + data: { timerType: 'NORMAL', sequence: 1, remainingTime: 12 }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: normalTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.singleTime).toBe(90); + } + }); + + it.each([ + { + eventType: 'BEFORE' as const, + receivedSequence: 1, + expectedSequence: 0, + expectedTime: 180, + }, + { + eventType: 'NEXT' as const, + receivedSequence: 0, + expectedSequence: 1, + expectedTime: 90, + }, + ])( + '$eventType 이벤트는 sequence를 이동하고 목표 순서의 전체 시간을 표시한다', + ({ eventType, receivedSequence, expectedSequence, expectedTime }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { + timerType: 'NORMAL', + sequence: receivedSequence, + remainingTime: 12, + }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: normalTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'NORMAL' + ) { + expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(expectedSequence); + expect(result.current.displayData.singleTime).toBe(expectedTime); + } + }, + ); + it('시간 기반 이벤트는 현재 팀 시간만 갱신하고 상대 팀의 마지막 수신 시간 또는 null을 유지한다.', () => { const { result, rerender } = renderHook(() => useAudienceShareState(1)); @@ -177,6 +324,9 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.prosTime).toBe(77); expect(result.current.displayData.consTime).toBeNull(); expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.eventType).toBe('TEAM_SWITCH'); + expect(result.current.displayData.revision).toBe(1); } } @@ -202,7 +352,121 @@ describe('useAudienceShareState', () => { expect(result.current.displayData.prosTime).toBe(77); expect(result.current.displayData.consTime).toBe(55); expect(result.current.displayData.isRunning).toBe(false); + expect(result.current.displayData.sequence).toBe(2); + expect(result.current.displayData.revision).toBe(2); + } + } + }); + + it('실행 중 TEAM_SWITCH는 새 팀에서도 실행 상태를 유지한다.', () => { + let latestMessage: SocketMessage = { + eventType: 'PLAY', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 30, + }, + }; + setSocketState({ isConnected: true, latestMessage }); + + const { result, rerender } = renderHook(() => useAudienceShareState(1)); + + latestMessage = { + eventType: 'TEAM_SWITCH', + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence: 0, + remainingTime: 25, + }, + }; + setSocketState({ isConnected: true, latestMessage }); + rerender(); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.currentTeam).toBe('CONS'); + expect(result.current.displayData.isRunning).toBe(true); + expect(result.current.displayData.eventType).toBe('TEAM_SWITCH'); + expect(result.current.displayData.revision).toBe(2); + } + }); + + it.each([ + { eventType: 'BEFORE' as const, sequence: 2, expectedSequence: 1 }, + { eventType: 'NEXT' as const, sequence: 0, expectedSequence: 1 }, + ])( + 'TIME_BASED $eventType는 실제 이동 대상 sequence를 보존한다.', + ({ eventType, sequence, expectedSequence }) => { + setSocketState({ + isConnected: true, + latestMessage: { + eventType, + data: { + timerType: 'TIME_BASED', + currentTeam: 'PROS', + sequence, + remainingTime: 30, + }, + }, + }); + + const { result } = renderHook(() => useAudienceShareState(1)); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.sequence).toBe(expectedSequence); + expect(result.current.displayData.eventType).toBe(eventType); } + }, + ); + + it('NORMAL에서 TIME_BASED 순서로 이동하면 API 설정으로 시간 기반 표시 상태를 만든다.', () => { + const mixedTable: TimeBoxInfo[] = [ + normalTable[0], + { + stance: 'NEUTRAL', + speechType: '자유토론', + bell: null, + boxType: 'TIME_BASED', + time: null, + timePerTeam: 120, + timePerSpeaking: 30, + speaker: null, + }, + ]; + setSocketState({ + isConnected: true, + latestMessage: { + eventType: 'NEXT', + data: { + timerType: 'NORMAL', + sequence: 0, + remainingTime: 20, + }, + }, + }); + + const { result } = renderHook(() => + useAudienceShareState(1, { table: mixedTable }), + ); + + expect(result.current.status).toBe('displaying'); + if ( + result.current.status === 'displaying' && + result.current.displayData.timerType === 'TIME_BASED' + ) { + expect(result.current.displayData.sequence).toBe(1); + expect(result.current.displayData.currentTeam).toBe('PROS'); + expect(result.current.displayData.prosTime).toBe(30); + expect(result.current.displayData.eventType).toBe('NEXT'); } }); @@ -224,7 +488,7 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('waiting'); }); - it('sequence는 내부 최신 payload에는 유지되지만 반환 UI 상태와 렌더링 props에는 노출되지 않는다.', () => { + it('NORMAL 이벤트의 sequence를 표시 상태에 보존한다.', () => { setSocketState({ isConnected: true, latestMessage: { @@ -236,9 +500,10 @@ describe('useAudienceShareState', () => { expect(result.current.status).toBe('displaying'); if (result.current.status === 'displaying') { - expect( - (result.current.displayData as Record).sequence, - ).toBeUndefined(); + expect(result.current.displayData.timerType).toBe('NORMAL'); + if (result.current.displayData.timerType === 'NORMAL') { + expect(result.current.displayData.sequence).toBe(5); + } } }); diff --git a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts index db895c9e..cd0b63d5 100644 --- a/src/page/AudienceSharePage/hooks/useAudienceShareState.ts +++ b/src/page/AudienceSharePage/hooks/useAudienceShareState.ts @@ -1,27 +1,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import useAudienceSocket from '../../../hooks/sockets/useAudienceSocket'; import { AudienceShareError, AudienceShareErrorCode } from '../error'; -import { TimeBasedStance } from '../../../type/type'; +import { TimeBoxInfo } from '../../../type/type'; import { isSocketError } from '../../../apis/sockets/error'; - -type AudienceNormalDisplayData = { - timerType: 'NORMAL'; - currentTeam: null; - isRunning: boolean; - singleTime: number; -}; - -type AudienceTimeBasedDisplayData = { - timerType: 'TIME_BASED'; - currentTeam: TimeBasedStance; - isRunning: boolean; - prosTime: number | null; - consTime: number | null; -}; - -type AudienceDisplayData = - | AudienceNormalDisplayData - | AudienceTimeBasedDisplayData; +import { + getDisplayDataByEvent, + AudienceDisplayData, +} from './EventInterpreter'; export type AudienceShareState = | { status: 'connecting'; error: AudienceShareError | null } @@ -35,18 +20,23 @@ export type AudienceShareState = const EVENT_TIMEOUT_MS = 600 * 1000; -function getNextTeam(team: TimeBasedStance): TimeBasedStance { - return team === 'PROS' ? 'CONS' : 'PROS'; +interface UseAudienceShareStateOptions { + enabled?: boolean; + table?: TimeBoxInfo[]; } -export function useAudienceShareState(roomId: number): AudienceShareState { +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 +57,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 +84,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 +132,15 @@ export function useAudienceShareState(roomId: number): AudienceShareState { const { eventType, data } = latestMessage; if (!data) return; - const isRunning = eventType === 'PLAY'; - - setDisplayData((prev) => { - if (data.timerType === 'NORMAL') { - return { - timerType: 'NORMAL', - currentTeam: null, - isRunning, - singleTime: data.remainingTime, - }; - } - - if (data.currentTeam !== 'PROS' && data.currentTeam !== 'CONS') { - return prev; - } - - const previousTimeBasedData = - prev?.timerType === 'TIME_BASED' ? prev : null; - const receivedCurrentTeam = data.currentTeam; - const displayCurrentTeam = - eventType === 'TEAM_SWITCH' - ? getNextTeam(receivedCurrentTeam) - : receivedCurrentTeam; - - return { - timerType: 'TIME_BASED', - currentTeam: displayCurrentTeam, - isRunning, - prosTime: - receivedCurrentTeam === 'PROS' - ? data.remainingTime - : (previousTimeBasedData?.prosTime ?? null), - consTime: - receivedCurrentTeam === 'CONS' - ? data.remainingTime - : (previousTimeBasedData?.consTime ?? null), - }; - }); - }, [isConnected, latestMessage, error, isFinished, cleanup]); + setDisplayData((previousDisplayData) => + getDisplayDataByEvent(eventType, data, previousDisplayData, table), + ); + }, [enabled, isConnected, latestMessage, error, isFinished, cleanup, table]); let status: AudienceShareState['status'] = 'connecting'; - if (isFinished) { + if (!enabled) { + status = 'connecting'; + } else if (isFinished) { status = 'finished'; } else if (!isConnected) { status = 'connecting'; diff --git a/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts b/src/page/AudienceSharePage/hooks/useAudienceTimeBasedCountdown.test.ts new file mode 100644 index 00000000..0d7d275e --- /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 './EventInterpreter'; + +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..9061ba27 --- /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 './EventInterpreter'; + +interface TeamCountdownInput { + totalTime: number | null; + speakingTime: number | null; + totalSyncKey: number; + speakingSyncKey: number; +} + +interface CountdownInputs { + sequence: number | null; + timePerTeam: number | null; + timePerSpeaking: number | null; + pros: TeamCountdownInput; + cons: TeamCountdownInput; +} + +interface TeamCountdownResult { + totalRemainingTime: number | null; + currentSpeakingRemainingTime: number | null; + isRunning: boolean; +} + +export interface UseAudienceTimeBasedCountdownParams { + displayData: AudienceTimeBasedDisplayData | null; + timePerTeam: number | null; + timePerSpeaking: number | null; +} + +export interface UseAudienceTimeBasedCountdownReturn { + pros: TeamCountdownResult; + cons: TeamCountdownResult; +} + +interface GetNextSpeakingTimeParams { + totalRemainingTime: number; + timePerSpeaking: number | null; + isOpponentTotalDone: boolean; +} + +const EMPTY_TEAM_INPUT: TeamCountdownInput = { + totalTime: null, + speakingTime: null, + totalSyncKey: 0, + speakingSyncKey: 0, +}; + +function createInitialInputs(): CountdownInputs { + return { + sequence: null, + timePerTeam: null, + timePerSpeaking: null, + pros: EMPTY_TEAM_INPUT, + cons: EMPTY_TEAM_INPUT, + }; +} + +function createTeamInput( + totalTime: number, + speakingTime: number | null, + previousInput: TeamCountdownInput, +): TeamCountdownInput { + return { + totalTime, + speakingTime, + totalSyncKey: previousInput.totalSyncKey + 1, + speakingSyncKey: previousInput.speakingSyncKey + 1, + }; +} + +function getTeamValue(team: TimeBasedStance, prosValue: T, consValue: T) { + return team === 'PROS' ? prosValue : consValue; +} + +function updateTeamInput( + inputs: CountdownInputs, + team: TimeBasedStance, + update: (input: TeamCountdownInput) => TeamCountdownInput, +) { + if (team === 'PROS') { + return { ...inputs, pros: update(inputs.pros) }; + } + + return { ...inputs, cons: update(inputs.cons) }; +} + +export function getNextSpeakingTime({ + totalRemainingTime, + timePerSpeaking, + isOpponentTotalDone, +}: GetNextSpeakingTimeParams) { + if (timePerSpeaking === null) { + return null; + } + + if (isOpponentTotalDone) { + return totalRemainingTime; + } + + return Math.min(totalRemainingTime, timePerSpeaking); +} + +export function useAudienceTimeBasedCountdown({ + displayData, + timePerTeam, + timePerSpeaking, +}: UseAudienceTimeBasedCountdownParams): UseAudienceTimeBasedCountdownReturn { + const [inputs, setInputs] = useState(createInitialInputs); + const [isProsLocallyStopped, setIsProsLocallyStopped] = useState(false); + const [isConsLocallyStopped, setIsConsLocallyStopped] = useState(false); + const configurationKeyRef = useRef(''); + const latestValuesRef = useRef({ + prosTotal: null as number | null, + prosSpeaking: null as number | null, + consTotal: null as number | null, + consSpeaking: null as number | null, + }); + + const isProsRequestedToRun = + displayData?.isRunning === true && + displayData.currentTeam === 'PROS' && + !isProsLocallyStopped; + const isConsRequestedToRun = + displayData?.isRunning === true && + displayData.currentTeam === 'CONS' && + !isConsLocallyStopped; + + const prosTotalCountdown = useAudienceCountdown({ + receivedTime: inputs.pros.totalTime, + isRunning: isProsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.pros.totalSyncKey, + }); + const prosSpeakingCountdown = useAudienceCountdown({ + receivedTime: inputs.pros.speakingTime, + isRunning: isProsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.pros.speakingSyncKey, + }); + const consTotalCountdown = useAudienceCountdown({ + receivedTime: inputs.cons.totalTime, + isRunning: isConsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.cons.totalSyncKey, + }); + const consSpeakingCountdown = useAudienceCountdown({ + receivedTime: inputs.cons.speakingTime, + isRunning: isConsRequestedToRun, + minimumTime: 0, + shouldResetOnRunStateChange: false, + syncKey: inputs.cons.speakingSyncKey, + }); + + const prosTotal = prosTotalCountdown.currentSeconds; + const prosSpeaking = prosSpeakingCountdown.currentSeconds; + const consTotal = consTotalCountdown.currentSeconds; + const consSpeaking = consSpeakingCountdown.currentSeconds; + const displaySequence = displayData?.sequence ?? null; + const displayRevision = displayData?.revision ?? 0; + const displayEventType = displayData?.eventType ?? null; + const displayCurrentTeam = displayData?.currentTeam ?? null; + const displayProsTime = displayData?.prosTime ?? null; + const displayConsTime = displayData?.consTime ?? null; + + latestValuesRef.current = { + prosTotal, + prosSpeaking, + consTotal, + consSpeaking, + }; + + useEffect(() => { + if (prosTotal === 0 || prosSpeaking === 0) { + setIsProsLocallyStopped(true); + } + }, [prosSpeaking, prosTotal]); + + useEffect(() => { + if (consTotal === 0 || consSpeaking === 0) { + setIsConsLocallyStopped(true); + } + }, [consSpeaking, consTotal]); + + useEffect(() => { + if ( + displaySequence === null || + displayEventType === null || + displayCurrentTeam === null || + timePerTeam === null || + timePerTeam <= 0 || + (timePerSpeaking !== null && timePerSpeaking <= 0) + ) { + configurationKeyRef.current = ''; + setInputs(createInitialInputs()); + setIsProsLocallyStopped(false); + setIsConsLocallyStopped(false); + return; + } + + const configurationKey = `${displaySequence}:${timePerTeam}:${timePerSpeaking}`; + const hasConfigurationChanged = + configurationKeyRef.current !== configurationKey; + configurationKeyRef.current = configurationKey; + + if (hasConfigurationChanged) { + setIsProsLocallyStopped(false); + setIsConsLocallyStopped(false); + } else if ( + displayEventType === 'PLAY' || + displayEventType === 'RESET' || + displayEventType === 'TEAM_SWITCH' + ) { + if (displayCurrentTeam === 'PROS') { + setIsProsLocallyStopped(false); + } else { + setIsConsLocallyStopped(false); + } + } + + setInputs((previousInputs) => { + const defaultSpeakingTime = timePerSpeaking; + let nextInputs = hasConfigurationChanged + ? { + sequence: displaySequence, + timePerTeam, + timePerSpeaking, + pros: createTeamInput( + timePerTeam, + defaultSpeakingTime, + previousInputs.pros, + ), + cons: createTeamInput( + timePerTeam, + defaultSpeakingTime, + previousInputs.cons, + ), + } + : previousInputs; + + const currentTeam = displayCurrentTeam; + const opponentTeam = currentTeam === 'PROS' ? 'CONS' : 'PROS'; + const latestCurrentTotal = getTeamValue( + currentTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const currentInput = getTeamValue( + currentTeam, + nextInputs.pros, + nextInputs.cons, + ); + const currentTotal = latestCurrentTotal ?? currentInput.totalTime ?? 0; + const receivedCurrentTime = getTeamValue( + currentTeam, + displayProsTime, + displayConsTime, + ); + + if (displayEventType === 'BEFORE' || displayEventType === 'NEXT') { + return nextInputs; + } + + if (displayEventType === 'RESET') { + const opponentTotal = getTeamValue( + opponentTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const resetSpeakingTime = getNextSpeakingTime({ + totalRemainingTime: timePerTeam, + timePerSpeaking, + isOpponentTotalDone: opponentTotal === 0, + }); + + return updateTeamInput(nextInputs, currentTeam, (input) => + createTeamInput(timePerTeam, resetSpeakingTime, input), + ); + } + + if (displayEventType === 'TEAM_SWITCH') { + const previousTeam = opponentTeam; + const receivedPreviousTime = getTeamValue( + previousTeam, + displayProsTime, + displayConsTime, + ); + const previousTeamTotal = getTeamValue( + previousTeam, + latestValuesRef.current.prosTotal, + latestValuesRef.current.consTotal, + ); + const newSpeakingTime = getNextSpeakingTime({ + totalRemainingTime: currentTotal, + timePerSpeaking, + isOpponentTotalDone: + (timePerSpeaking === null + ? receivedPreviousTime + : previousTeamTotal) === 0, + }); + + nextInputs = updateTeamInput(nextInputs, previousTeam, (input) => ({ + ...input, + ...(timePerSpeaking === null + ? { totalTime: receivedPreviousTime ?? input.totalTime } + : { speakingTime: receivedPreviousTime ?? input.speakingTime }), + totalSyncKey: + timePerSpeaking === null + ? input.totalSyncKey + 1 + : input.totalSyncKey, + speakingSyncKey: + timePerSpeaking === null + ? input.speakingSyncKey + : input.speakingSyncKey + 1, + })); + nextInputs = updateTeamInput(nextInputs, currentTeam, (input) => ({ + ...input, + speakingTime: newSpeakingTime, + speakingSyncKey: input.speakingSyncKey + 1, + })); + + return nextInputs; + } + + return updateTeamInput(nextInputs, currentTeam, (input) => ({ + ...input, + ...(timePerSpeaking === null + ? { totalTime: receivedCurrentTime ?? input.totalTime } + : { speakingTime: receivedCurrentTime ?? input.speakingTime }), + totalSyncKey: + timePerSpeaking === null + ? input.totalSyncKey + 1 + : input.totalSyncKey, + speakingSyncKey: + timePerSpeaking === null + ? input.speakingSyncKey + : input.speakingSyncKey + 1, + })); + }); + }, [ + displayConsTime, + displayCurrentTeam, + displayEventType, + displayProsTime, + displayRevision, + displaySequence, + timePerSpeaking, + timePerTeam, + ]); + + const isProsRunning = + isProsRequestedToRun && + prosTotal !== null && + prosTotal > 0 && + (prosSpeaking === null || prosSpeaking > 0); + const isConsRunning = + isConsRequestedToRun && + consTotal !== null && + consTotal > 0 && + (consSpeaking === null || consSpeaking > 0); + + return { + pros: { + totalRemainingTime: prosTotal, + currentSpeakingRemainingTime: prosSpeaking, + isRunning: isProsRunning, + }, + cons: { + totalRemainingTime: consTotal, + currentSpeakingRemainingTime: consSpeaking, + isRunning: isConsRunning, + }, + }; +} diff --git a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.test.tsx b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.test.tsx new file mode 100644 index 00000000..3798deb9 --- /dev/null +++ b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { GlobalPortal } from '../../../../util/GlobalPortal'; +import { DebateInfo } from '../../../../type/type'; +import TableNameAndType from './TableNameAndType'; + +// ------------------ +// 헬퍼: 유효한 기본 info 를 만들고 필요한 필드만 덮어쓴다. +// ------------------ +function makeInfo(overrides: Partial = {}): DebateInfo { + return { + name: '토론 시간표', + agenda: '토론 주제', + prosTeamName: '우리팀', + consTeamName: '상대팀', + ...overrides, + }; +} + +function renderComponent(info: DebateInfo, onButtonClick = vi.fn()) { + const queryClient = new QueryClient(); + render( + + + + + + + , + ); +} + +async function clickNext() { + await userEvent.click(screen.getByRole('button', { name: '다음' })); +} + +describe('TableNameAndType - 제출 검증 인라인 에러', () => { + // useDebounce 는 초기 useState(value) 라 마운트 시 디바운스값=초기값 → + // 무효값으로 렌더하면 인라인 사유 메시지가 즉시 노출된다. + it('시간표 이름 길이 초과 시 사유 메시지를 인라인으로 노출하고 진행을 막는다', async () => { + const onButtonClick = vi.fn(); + renderComponent(makeInfo({ name: 'a'.repeat(21) }), onButtonClick); + + expect( + await screen.findByText( + '시간표 이름은 최대 20자까지 입력할 수 있습니다.', + ), + ).toBeInTheDocument(); + + await clickNext(); + + expect(onButtonClick).not.toHaveBeenCalled(); + }); + + it('토론 주제 길이 초과 시 사유 메시지를 인라인으로 노출한다', async () => { + renderComponent(makeInfo({ agenda: 'a'.repeat(256) })); + + expect( + await screen.findByText('토론 주제는 최대 255자까지 입력할 수 있습니다.'), + ).toBeInTheDocument(); + }); + + it('팀명 길이 초과 시 사유 메시지를 인라인으로 노출한다', async () => { + renderComponent(makeInfo({ prosTeamName: 'a'.repeat(16) })); + + expect( + await screen.findByText('팀명은 최대 15자까지 입력할 수 있습니다.'), + ).toBeInTheDocument(); + }); + + it('팀명에 사용할 수 없는 문자가 있으면 형식 오류 메시지를 인라인으로 노출한다', async () => { + // 제어문자(U+0001)는 NAME_REGEX 를 통과하지 못한다(FORM 오류). + renderComponent( + makeInfo({ consTeamName: `team${String.fromCharCode(1)}` }), + ); + + expect( + await screen.findByText( + '팀명에 사용할 수 없는 문자가 포함되어 있습니다.', + ), + ).toBeInTheDocument(); + }); + + it('여러 필드가 잘못되면 각 입력창 아래 사유 메시지를 동시에 노출한다', async () => { + const onButtonClick = vi.fn(); + renderComponent( + makeInfo({ name: 'a'.repeat(21), prosTeamName: 'b'.repeat(16) }), + onButtonClick, + ); + + expect( + await screen.findByText( + '시간표 이름은 최대 20자까지 입력할 수 있습니다.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText('팀명은 최대 15자까지 입력할 수 있습니다.'), + ).toBeInTheDocument(); + + await clickNext(); + + expect(onButtonClick).not.toHaveBeenCalled(); + }); + + it('모든 필드가 유효하면 에러 메시지 없이 onButtonClick 을 호출한다', async () => { + const onButtonClick = vi.fn(); + renderComponent(makeInfo(), onButtonClick); + + await clickNext(); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(onButtonClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx index 3337f7b8..88244da7 100644 --- a/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx +++ b/src/page/TableComposition/components/TableNameAndType/TableNameAndType.tsx @@ -1,8 +1,20 @@ +import clsx from 'clsx'; import { useTranslation } from 'react-i18next'; import ClearableInput from '../../../../components/ClearableInput/ClearableInput'; import HeaderTitle from '../../../../components/HeaderTitle/HeaderTitle'; import DefaultLayout from '../../../../layout/defaultLayout/DefaultLayout'; import { DebateInfo, StanceToString } from '../../../../type/type'; +import useDebounce from '../../../../hooks/useDebounce'; +import { + TABLE_FIELD_LIMITS, + VALIDATION_DEBOUNCE_MS, + getAgendaErrorMessage, + getTableNameErrorMessage, + getTeamNameErrorMessage, + validateAgenda, + validateTableName, + validateTeamName, +} from '../../../../util/tableValidation'; interface TableNameAndTypeProps { info: DebateInfo; @@ -22,6 +34,47 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { onButtonClick, } = props; + // 입력이 멈췄다고 판단(디바운스)되면 각 필드를 검증해 붉은 테두리 + 입력창 아래 + // 인라인 사유 메시지로 노출한다. + const debouncedName = useDebounce(info.name ?? '', VALIDATION_DEBOUNCE_MS); + const debouncedAgenda = useDebounce( + info.agenda ?? '', + VALIDATION_DEBOUNCE_MS, + ); + const debouncedProsTeamName = useDebounce( + info.prosTeamName ?? '', + VALIDATION_DEBOUNCE_MS, + ); + const debouncedConsTeamName = useDebounce( + info.consTeamName ?? '', + VALIDATION_DEBOUNCE_MS, + ); + + const nameErrorMsg = getTableNameErrorMessage( + validateTableName(debouncedName), + t, + ); + const agendaErrorMsg = getAgendaErrorMessage( + validateAgenda(debouncedAgenda), + t, + ); + const prosTeamNameErrorMsg = getTeamNameErrorMessage( + validateTeamName(debouncedProsTeamName), + t, + ); + const consTeamNameErrorMsg = getTeamNameErrorMessage( + validateTeamName(debouncedConsTeamName), + t, + ); + + // 조건에 어긋난 필드가 하나라도 있으면 "다음" 버튼을 blocked(회색) 처리한다. + const hasFieldError = + nameErrorMsg !== null || + agendaErrorMsg !== null || + prosTeamNameErrorMsg !== null || + consTeamNameErrorMsg !== null; + const isNextBlocked = isLoading || hasFieldError; + const handleFieldChange = ( field: K, value: DebateInfo[K], @@ -66,22 +119,30 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { {t('토론 시간표 이름')} handleFieldChange('name', e.target.value)} onClear={() => clearField('name')} placeholder={t('시간표 1')} disabled={isLoading} + isError={nameErrorMsg !== null} + errorMessage={nameErrorMsg ?? undefined} + maxCount={TABLE_FIELD_LIMITS.name} /> handleFieldChange('agenda', e.target.value)} onClear={() => clearField('agenda')} placeholder={t('토론 주제를 입력해주세요')} disabled={isLoading} + isError={agendaErrorMsg !== null} + errorMessage={agendaErrorMsg ?? undefined} + maxCount={TABLE_FIELD_LIMITS.agenda} /> <> @@ -90,6 +151,7 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
onInfoChange({ @@ -100,10 +162,14 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { onClear={() => clearTeamNameField('prosTeamName')} placeholder={t(StanceToString['PROS'])} disabled={isLoading} + isError={prosTeamNameErrorMsg !== null} + errorMessage={prosTeamNameErrorMsg ?? undefined} + maxCount={TABLE_FIELD_LIMITS.teamName} /> vs. onInfoChange({ @@ -114,6 +180,9 @@ export default function TableNameAndType(props: TableNameAndTypeProps) { onClear={() => clearTeamNameField('consTeamName')} placeholder={t(StanceToString['CONS'])} disabled={isLoading} + isError={consTeamNameErrorMsg !== null} + errorMessage={consTeamNameErrorMsg ?? undefined} + maxCount={TABLE_FIELD_LIMITS.teamName} />
@@ -123,17 +192,18 @@ export default function TableNameAndType(props: TableNameAndTypeProps) {
diff --git a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx index fa6e67e5..945c6515 100644 --- a/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx +++ b/src/page/TableComposition/components/TimerCreationContent/TimerCreationContent.tsx @@ -30,6 +30,12 @@ import DTBell from '../../../../components/icons/Bell'; import DTAdd from '../../../../components/icons/Add'; import NotificationBadge from '../../../../components/NotificationBadge/NotificationBadge'; import DTExpand from '../../../../components/icons/Expand'; +import useDebounce from '../../../../hooks/useDebounce'; +import { + TABLE_FIELD_LIMITS, + VALIDATION_DEBOUNCE_MS, + validateSpeechType, +} from '../../../../util/tableValidation'; type TimerCreationOption = | 'TIMER_TYPE' @@ -272,6 +278,14 @@ export default function TimerCreationContent({ const options = isNormalTimer ? NORMAL_OPTIONS : TIME_BASED_OPTIONS; + // 입력이 멈췄다고 판단(디바운스)되면 검증해 붉은 테두리로 노출한다. + // 발언자(speaker)는 maxLength+slice 로 하드캡되어 초과가 불가능하므로 검증/카운터를 두지 않는다. + const debouncedSpeechType = useDebounce( + speechTypeTextValue, + VALIDATION_DEBOUNCE_MS, + ); + const speechTypeError = validateSpeechType(debouncedSpeechType) !== null; + const handleSubmit = useCallback(() => { const totalTime = minutes * 60 + seconds; const totalTimePerTeam = teamMinutes * 60 + teamSeconds; @@ -675,6 +689,8 @@ export default function TimerCreationContent({ onChange={(e) => setSpeechTypeTextValue(e.target.value)} onClear={() => setSpeechTypeTextValue('')} placeholder={t('주도권 토론 등')} + isError={speechTypeError} + maxCount={TABLE_FIELD_LIMITS.speechType} /> ); @@ -706,6 +722,8 @@ export default function TimerCreationContent({ } onClear={() => setSpeechTypeTextValue('')} placeholder={t('입론, 반론, 작전 시간 등')} + isError={speechTypeError} + maxCount={TABLE_FIELD_LIMITS.speechType} /> )} diff --git a/src/page/TimerPage/TimerPage.tsx b/src/page/TimerPage/TimerPage.tsx index f945c2e5..5bb9ed47 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'; // 피처 플래그 const IS_LIVE_SHARE_ENABLED = false; @@ -85,12 +86,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, @@ -117,6 +119,15 @@ export default function TimerPage() { // 만약 소켓 열려 있으면, 발송 if (isSocketConnected) { + if (eventType === 'FINISHED') { + issueEvent(eventType, null); + return; + } + + if (remainingTime === null) { + return; + } + // 타입에 따른 페이로드 준비 let innerPayload: TimerDataPayload; @@ -125,24 +136,22 @@ export default function TimerPage() { timerType: timerType, remainingTime: remainingTime, sequence: index, - } as TimerDataPayload; + }; } else if (timerType === 'TIME_BASED') { innerPayload = { currentTeam: prosConsSelected, timerType: timerType, remainingTime: remainingTime, sequence: index, - } as TimerDataPayload; + }; } else { // 피드백 타이머 타입은 여기 올 수 없음 // 따라서 별도 작업 하지 않고 그냥 반환 return; } - const payload = eventType === 'FINISHED' ? null : innerPayload; - // 이벤트 발행 - issueEvent(eventType, payload); + issueEvent(eventType, innerPayload); } }; diff --git a/src/page/TimerPage/getRemainingTimeForShare.test.ts b/src/page/TimerPage/getRemainingTimeForShare.test.ts new file mode 100644 index 00000000..2f4e247d --- /dev/null +++ b/src/page/TimerPage/getRemainingTimeForShare.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { getRemainingTimeForShare } from './getRemainingTimeForShare'; + +describe('getRemainingTimeForShare', () => { + const normalTimer = 45; + const prosTimer = { + totalTimer: 120, + speakingTimer: 30, + isSpeakingTimerAvailable: true, + }; + const consTimer = { + totalTimer: 90, + speakingTimer: null, + isSpeakingTimerAvailable: false, + }; + + it('NORMAL은 일반 타이머의 잔여 시간을 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'NORMAL', + normalTimer, + currentTeam: 'PROS', + prosTimer, + consTimer, + }), + ).toBe(45); + }); + + it('1회당 발언 시간이 있는 TIME_BASED는 선택 팀의 speakingTimer를 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'TIME_BASED', + normalTimer, + currentTeam: 'PROS', + prosTimer, + consTimer, + }), + ).toBe(30); + }); + + it('1회당 발언 시간이 없는 TIME_BASED는 선택 팀의 totalTimer를 반환한다', () => { + expect( + getRemainingTimeForShare({ + timerType: 'TIME_BASED', + normalTimer, + currentTeam: 'CONS', + prosTimer, + consTimer, + }), + ).toBe(90); + }); +}); diff --git a/src/page/TimerPage/getRemainingTimeForShare.ts b/src/page/TimerPage/getRemainingTimeForShare.ts new file mode 100644 index 00000000..36e4c008 --- /dev/null +++ b/src/page/TimerPage/getRemainingTimeForShare.ts @@ -0,0 +1,36 @@ +import { TimeBasedStance, TimeBoxType } from '../../type/type'; + +interface ShareTimerState { + totalTimer: number | null; + speakingTimer: number | null; + isSpeakingTimerAvailable: boolean; +} + +interface GetRemainingTimeForShareParams { + timerType: TimeBoxType | undefined; + normalTimer: number | null; + currentTeam: TimeBasedStance; + prosTimer: ShareTimerState; + consTimer: ShareTimerState; +} + +export function getRemainingTimeForShare({ + timerType, + normalTimer, + currentTeam, + prosTimer, + consTimer, +}: GetRemainingTimeForShareParams) { + if (timerType === 'NORMAL') { + return normalTimer; + } + + if (timerType !== 'TIME_BASED') { + return null; + } + + const currentTimer = currentTeam === 'PROS' ? prosTimer : consTimer; + return currentTimer.isSpeakingTimerAvailable + ? currentTimer.speakingTimer + : currentTimer.totalTimer; +} diff --git a/src/util/sentry.test.ts b/src/util/sentry.test.ts index 1aec7964..b61ecb1c 100644 --- a/src/util/sentry.test.ts +++ b/src/util/sentry.test.ts @@ -3,12 +3,15 @@ import { buildSentryApiErrorMetadata, createSentryApiError, createSentryRenderError, + isSentryCaptured, + markSentryCaptured, normalizeEndpoint, resolveApiErrorLevel, resolveFeatureFromPathname, sanitizeSentryContext, sanitizeSentrySearch, sanitizeSentryUrl, + shouldSkipSentryEvent, shouldSkipApiError, } from './sentry'; @@ -99,6 +102,56 @@ describe('sentry 유틸', () => { expect(shouldSkipApiError(onlineNetworkError)).toBe(false); }); + it('SDK 내부 플래그와 앱 전용 플래그를 분리해 중복 캡처를 판단한다', () => { + const originalError = new AxiosError('Request failed', undefined, { + method: 'get', + url: '/api/polls/123/votes', + headers: new AxiosHeaders(), + }); + const metadata = buildSentryApiErrorMetadata(originalError, '/vote/123'); + + const sentryError = createSentryApiError(originalError, metadata); + const sdkCapturedError = Object.assign(new Error('sdk captured'), { + __sentry_captured__: true, + }); + + expect('__sentry_captured__' in sentryError).toBe(false); + expect(isSentryCaptured(sentryError)).toBe(false); + expect(isSentryCaptured(sdkCapturedError)).toBe(false); + + markSentryCaptured(originalError); + markSentryCaptured(sdkCapturedError); + + expect(isSentryCaptured(originalError)).toBe(true); + expect(isSentryCaptured(sdkCapturedError)).toBe(true); + }); + + it('이미 커스텀 이벤트로 전송한 에러는 전역 Sentry 이벤트에서 제외한다', () => { + const error = new Error('already captured'); + + markSentryCaptured(error); + + expect(shouldSkipSentryEvent(error)).toBe(true); + }); + + it('사용자 이동이나 요청 취소로 발생한 에러와 Script error는 Sentry 이벤트에서 제외한다', () => { + expect(shouldSkipSentryEvent(new DOMException('', 'AbortError'))).toBe( + true, + ); + expect(shouldSkipSentryEvent({ name: 'CanceledError' })).toBe(true); + expect(shouldSkipSentryEvent({ code: 'ERR_CANCELED' })).toBe(true); + expect(shouldSkipSentryEvent(undefined, 'Script error.')).toBe(true); + }); + + it('서비스 에러 메시지에 cancel이나 aborted가 포함되어도 취소성 에러로 오분류하지 않는다', () => { + expect( + shouldSkipSentryEvent(new Error('Debate live session was cancelled')), + ).toBe(false); + expect(shouldSkipSentryEvent(new Error('Request was aborted by server'))).toBe( + false, + ); + }); + it('Sentry context에 원본 요청/응답 데이터가 그대로 전송되지 않도록 민감 필드를 마스킹한다', () => { expect( sanitizeSentryContext({ @@ -144,7 +197,7 @@ describe('sentry 유틸', () => { ); }); - it('알림 제목을 level, error type, feature, status, endpoint 순서로 구성해 대응 판단 흐름을 만든다', () => { + it('API 에러 알림 제목은 level, error type, feature, status, endpoint 순서로 구성한다', () => { const error = new AxiosError('Request failed', undefined, { method: 'post', url: '/api/live/123', @@ -159,7 +212,7 @@ describe('sentry 유틸', () => { ); }); - it('렌더링 에러 알림 제목도 level, error type, feature, 원본 에러 순서로 구성한다', () => { + it('렌더링 에러 알림 제목은 level, error type, feature, 원본 에러 순서로 구성한다', () => { const error = new TypeError('Cannot read properties of undefined'); const sentryError = createSentryRenderError(error, 'timer'); diff --git a/src/util/sentry.ts b/src/util/sentry.ts index a516c89a..f7a079e3 100644 --- a/src/util/sentry.ts +++ b/src/util/sentry.ts @@ -33,9 +33,10 @@ const sensitiveKeys = [ ]; type SentryCapturedError = { - __sentry_captured__?: boolean; + __debate_timer_sentry_captured__?: boolean; }; +// API 에러 알림 메타데이터 export type SentryApiErrorMetadata = { status: number | undefined; statusLabel: string; @@ -46,6 +47,7 @@ export type SentryApiErrorMetadata = { pathname: string; }; +// 엔드포인트 그룹핑 export function normalizeEndpoint(url?: string) { if (!url) { return 'unknown'; @@ -61,6 +63,7 @@ export function normalizeEndpoint(url?: string) { .replace(/\/[0-9]+(?=\/|$)/g, '/:id'); } +// 기능 태그 export function resolveFeatureFromPathname(pathname: string): DebateFeature { const path = removeLanguagePrefix(pathname); @@ -123,6 +126,7 @@ export function resolveFeatureFromPathname(pathname: string): DebateFeature { return 'unknown'; } +// API 에러 심각도 export function resolveApiErrorLevel( status: number | undefined, feature: DebateFeature, @@ -142,6 +146,7 @@ export function resolveApiErrorLevel( return 'error'; } +// API 에러 수집 제외 export function shouldSkipApiError(error: AxiosError) { const status = error.response?.status; @@ -152,6 +157,7 @@ export function shouldSkipApiError(error: AxiosError) { return isOfflineNetworkError(error.code); } +// API 에러 메타데이터 export function buildSentryApiErrorMetadata( error: AxiosError, pathname: string, @@ -172,6 +178,7 @@ export function buildSentryApiErrorMetadata( }; } +// API 이슈 제목 export function createSentryApiError( error: AxiosError, metadata: SentryApiErrorMetadata, @@ -179,23 +186,23 @@ export function createSentryApiError( const sentryError = new Error(error.message); sentryError.name = `${metadata.level} · api-error · ${metadata.feature} · [${metadata.statusLabel}] ${metadata.method} ${metadata.endpoint}`; sentryError.stack = error.stack; - (sentryError as SentryCapturedError).__sentry_captured__ = true; return sentryError; } +// 렌더링 이슈 제목 export function createSentryRenderError(error: Error, feature: DebateFeature) { const sentryError = new Error(error.message); sentryError.name = `fatal · render-error · ${feature} · ${error.name}: ${error.message}`; sentryError.stack = error.stack; - (sentryError as SentryCapturedError).__sentry_captured__ = true; return sentryError; } +// 중복 캡처 표시 export function markSentryCaptured(error: unknown) { if (typeof error === 'object' && error !== null) { - (error as SentryCapturedError).__sentry_captured__ = true; + (error as SentryCapturedError).__debate_timer_sentry_captured__ = true; } } @@ -203,10 +210,27 @@ export function isSentryCaptured(error: unknown) { return ( typeof error === 'object' && error !== null && - (error as SentryCapturedError).__sentry_captured__ === true + (error as SentryCapturedError).__debate_timer_sentry_captured__ === true ); } +// 전역 이벤트 필터 +export function shouldSkipSentryEvent( + originalException: unknown, + exceptionValue?: string, +) { + if (isSentryCaptured(originalException)) { + return true; + } + + if (isCanceledError(originalException)) { + return true; + } + + return exceptionValue === 'Script error.'; +} + +// 컨텍스트 마스킹 export function sanitizeSentrySearch(search: string) { if (!search) { return ''; @@ -224,6 +248,7 @@ export function sanitizeSentrySearch(search: string) { return sanitizedSearch ? `?${sanitizedSearch}` : ''; } +// 요청 URL 마스킹 export function sanitizeSentryUrl(url?: string) { if (!url) { return url; @@ -244,6 +269,7 @@ export function sanitizeSentryUrl(url?: string) { } } +// 요청/응답 데이터 마스킹 export function sanitizeSentryContext(value: unknown): unknown { if (Array.isArray(value)) { return value.map(sanitizeSentryContext); @@ -261,6 +287,7 @@ export function sanitizeSentryContext(value: unknown): unknown { ); } +// URL 파싱 function resolveUrlPath(url: string) { try { return new URL(url, window.location.origin).pathname; @@ -269,20 +296,40 @@ function resolveUrlPath(url: string) { } } +// 라우트 정규화 function removeLanguagePrefix(pathname: string) { const path = pathname.replace(/^\/(ko|en)(?=\/|$)/, ''); return path === '' ? '/' : path; } +// 라우트 접두사 매칭 function matchesPathPrefix(path: string, prefix: string) { return path === prefix || path.startsWith(`${prefix}/`); } +// 라우트 세그먼트 매칭 function hasPathSegment(path: string, segment: string) { return path.split('/').includes(segment); } +// 취소성 에러 판별 +function isCanceledError(originalException: unknown) { + if (typeof originalException !== 'object' || originalException === null) { + return false; + } + + const { name, code } = originalException as { + name?: unknown; + code?: unknown; + }; + + return ( + name === 'AbortError' || name === 'CanceledError' || code === 'ERR_CANCELED' + ); +} + +// 오프라인 네트워크 에러 판별 function isOfflineNetworkError(code?: string) { return ( typeof navigator !== 'undefined' && @@ -291,6 +338,7 @@ function isOfflineNetworkError(code?: string) { ); } +// 민감 필드 판별 function isSensitiveKey(key: string) { return sensitiveKeys.some( (sensitiveKey) => sensitiveKey.toLowerCase() === key.toLowerCase(), diff --git a/src/util/tableValidation.ts b/src/util/tableValidation.ts new file mode 100644 index 00000000..8840a800 --- /dev/null +++ b/src/util/tableValidation.ts @@ -0,0 +1,102 @@ +import type { TFunction } from 'i18next'; + +/** + * 토론 테이블 입력값 검증 규칙. + * 값은 debate-timer-be 의 도메인 제약과 동일하게 유지해야 한다. + * - TableName.NAME_MAX_LENGTH = 20 / TeamName.NAME_MAX_LENGTH = 15 / Agenda.AGENDA_MAX_LENGTH = 255 + * - CustomizeTimeBox.SPEECH_TYPE_MAX_LENGTH = 10 / SPEAKER_MAX_LENGTH = 5 + */ +export const TABLE_FIELD_LIMITS = { + name: 20, + teamName: 15, + agenda: 255, + speechType: 10, + speaker: 5, +} as const; + +// 입력 검증(붉은 테두리) 디바운스 지연(ms). 카운터는 라이브 값이라 즉시 반응하므로, +// 테두리와의 인지 간극을 줄이기 위해 짧게 유지한다. +export const VALIDATION_DEBOUNCE_MS = 200; + +// TableName / TeamName 의 NAME_REGEX 와 동치 (문자/기호/공백 허용, 제어문자 등 제외) +const NAME_REGEX = /^[\p{L}\p{M}\p{N}\p{P}\p{Z}\s]+$/u; + +/** + * 필드별 검증 결과. null 이면 정상. + * 붉은 테두리는 null 여부만으로 판단하고, 사유(LENGTH/FORM)는 추후 회복 메시지 노출에 재사용한다. + */ +export type FieldError = null | 'LENGTH' | 'FORM'; + +export const validateTableName = (value: string): FieldError => { + const v = value ?? ''; + // 빈 값은 제출 시 기본값('시간표 1')으로 대체되므로 입력 중 에러로 보지 않는다. + if (v.length === 0) return null; + if (v.length > TABLE_FIELD_LIMITS.name) return 'LENGTH'; + if (!NAME_REGEX.test(v)) return 'FORM'; + return null; +}; + +export const validateTeamName = (value: string): FieldError => { + const v = value ?? ''; + // 빈 값은 제출 시 기본값('찬성'/'반대')으로 대체되므로 입력 중 에러로 보지 않는다. + if (v.length === 0) return null; + if (v.length > TABLE_FIELD_LIMITS.teamName) return 'LENGTH'; + if (!NAME_REGEX.test(v)) return 'FORM'; + return null; +}; + +export const validateAgenda = (value: string): FieldError => { + const v = value ?? ''; + if (v.length > TABLE_FIELD_LIMITS.agenda) return 'LENGTH'; + return null; +}; + +export const validateSpeechType = (value: string): FieldError => { + const v = value ?? ''; + if (v.length > TABLE_FIELD_LIMITS.speechType) return 'LENGTH'; + return null; +}; + +export const validateSpeaker = (value: string): FieldError => { + const v = value ?? ''; + if (v.trim().length > TABLE_FIELD_LIMITS.speaker) return 'LENGTH'; + return null; +}; + +/** + * 필드 사유(FieldError)를 사용자 노출 메시지로 변환한다. + * 입력창 아래 인라인 노출과 제출 차단이 동일 매핑을 공유하도록 헬퍼로 둔다. + */ +export const getTableNameErrorMessage = ( + reason: FieldError, + t: TFunction, +): string | null => { + if (reason === 'LENGTH') + return t('시간표 이름은 최대 {{val0}}자까지 입력할 수 있습니다.', { + val0: TABLE_FIELD_LIMITS.name, + }); + if (reason === 'FORM') + return t('시간표 이름에 사용할 수 없는 문자가 포함되어 있습니다.'); + return null; +}; + +export const getAgendaErrorMessage = ( + reason: FieldError, + t: TFunction, +): string | null => { + if (reason === 'LENGTH') + return t('토론 주제는 최대 {{val0}}자까지 입력할 수 있습니다.', { + val0: TABLE_FIELD_LIMITS.agenda, + }); + return null; +}; + +export const getTeamNameErrorMessage = ( + reason: FieldError, + t: TFunction, +): string | null => { + if (reason === 'LENGTH') return t('팀명은 최대 15자까지 입력할 수 있습니다.'); + if (reason === 'FORM') + return t('팀명에 사용할 수 없는 문자가 포함되어 있습니다.'); + return null; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 17433776..3e8bfa1a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_MOCK_API: string; readonly VITE_BASE_PATH: string; + readonly VITE_SENTRY_DSN?: string; + readonly VITE_ENABLE_SENTRY?: string; } interface ImportMeta {