diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 648dd9520..1968888a1 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -1,8 +1,8 @@ name: backend test on: - push: - pull_request: [develop, main] + pull_request: + branches: [develop, main] paths: - apps/backend/** diff --git a/README.md b/README.md index 167863de2..389f6a542 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,13 @@ ## πŸ›  기술 μŠ€νƒ -| μ˜μ—­ | 기술 μŠ€νƒ | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **곡톡** | ![TypeScript](https://img.shields.io/badge/TypeScript-5.6.3-3178C6?style=flat-square&logo=typescript&logoColor=white) ![WebSocket](https://img.shields.io/badge/WebSocket-8.18.0-010101?style=flat-square&logo=socket.io&logoColor=white) ![TSDoc](https://img.shields.io/badge/TSDoc-0.26.11-3178C6?style=flat-square&logo=typescript&logoColor=white) | -| **Frontend** | ![React](https://img.shields.io/badge/React-18.3.1-61DAFB?style=flat-square&logo=react&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-5.4.10-646CFF?style=flat-square&logo=vite&logoColor=white) ![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-3.4-38B2AC?style=flat-square&logo=tailwind-css&logoColor=white) ![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-latest-000000?style=flat-square) ![Vitest](https://img.shields.io/badge/Vitest-latest-6E9F18?style=flat-square&logo=vitest&logoColor=white) ![Testing Library](https://img.shields.io/badge/Testing%20Library-latest-E33332?style=flat-square&logo=testing-library&logoColor=white) ![Storybook](https://img.shields.io/badge/Storybook-8.4.2-FF4785?style=flat-square&logo=storybook&logoColor=white) | -| **Backend** | ![NestJS](https://img.shields.io/badge/NestJS-10.4.7-E0234E?style=flat-square&logo=nestjs&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-2-4479A1?style=flat-square&logo=mysql&logoColor=white) ![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) ![TypeORM](https://img.shields.io/badge/TypeORM-0.3.20-E93524?style=flat-square&logo=typeorm&logoColor=white) ![Swagger](https://img.shields.io/badge/Swagger-8.0.5-85EA2D?style=flat-square&logo=swagger&logoColor=black) ![Jest](https://img.shields.io/badge/Jest-Testing-C21325?style=flat-square&logo=jest&logoColor=white) ![SuperTest](https://img.shields.io/badge/SuperTest-Testing-009688?style=flat-square&logo=testing-library&logoColor=white) ![Artillery](https://img.shields.io/badge/Artillery-2.0.21-CA2B2B?style=flat-square&logoColor=white) | -| **인프라** | ![NCP](https://img.shields.io/badge/Naver%20Cloud%20Platform-latest-03C75A?style=flat-square&logo=naver&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-1.24.0-009639?style=flat-square&logo=nginx&logoColor=white) ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-3.0-2088FF?style=flat-square&logo=github-actions&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-24.0.7-2496ED?style=flat-square&logo=docker&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-Ubuntu%2022.04-FCC624?style=flat-square&logo=linux&logoColor=black) | -| **ν˜‘μ—… 도ꡬ** | ![Notion](https://img.shields.io/badge/Notion-2.0.41-000000?style=flat-square&logo=notion&logoColor=white) ![Figma](https://img.shields.io/badge/Figma-latest-F24E1E?style=flat-square&logo=figma&logoColor=white) ![Excalidraw](https://img.shields.io/badge/Excalidraw-latest-6965DB?style=flat-square&logo=excalidraw&logoColor=white) ![Zoom](https://img.shields.io/badge/Zoom-5.17.0-2D8CFF?style=flat-square&logo=zoom&logoColor=white) ![Git](https://img.shields.io/badge/Git-2.42.0-F05032?style=flat-square&logo=git&logoColor=white) ![GitHub](https://img.shields.io/badge/GitHub-latest-181717?style=flat-square&logo=github&logoColor=white) ![GitHub Projects](https://img.shields.io/badge/GitHub%20Projects-latest-181717?style=flat-square&logo=github&logoColor=white) | +| μ˜μ—­ | 기술 μŠ€νƒ | +| ------------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **곡톡** | ![TypeScript](https://img.shields.io/badge/TypeScript-5.6.3-3178C6?style=flat-square&logo=typescript&logoColor=white) ![WebSocket](https://img.shields.io/badge/WebSocket-8.18.0-010101?style=flat-square&logo=socket.io&logoColor=white) ![TSDoc](https://img.shields.io/badge/TSDoc-0.26.11-3178C6?style=flat-square&logo=typescript&logoColor=white) | +| **Frontend** | ![React](https://img.shields.io/badge/React-18.3.1-61DAFB?style=flat-square&logo=react&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-5.4.10-646CFF?style=flat-square&logo=vite&logoColor=white) ![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-3.4-38B2AC?style=flat-square&logo=tailwind-css&logoColor=white) ![shadcn/ui](https://img.shields.io/badge/shadcn%2Fui-latest-000000?style=flat-square) ![Vitest](https://img.shields.io/badge/Vitest-latest-6E9F18?style=flat-square&logo=vitest&logoColor=white) ![Testing Library](https://img.shields.io/badge/Testing%20Library-latest-E33332?style=flat-square&logo=testing-library&logoColor=white) ![Storybook](https://img.shields.io/badge/Storybook-8.4.2-FF4785?style=flat-square&logo=storybook&logoColor=white) | +| **Backend** | ![NestJS](https://img.shields.io/badge/NestJS-10.4.7-E0234E?style=flat-square&logo=nestjs&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-2-4479A1?style=flat-square&logo=mysql&logoColor=white) ![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) ![TypeORM](https://img.shields.io/badge/TypeORM-0.3.20-E93524?style=flat-square&logo=typeorm&logoColor=white) ![Swagger](https://img.shields.io/badge/Swagger-8.0.5-85EA2D?style=flat-square&logo=swagger&logoColor=black) ![Jest](https://img.shields.io/badge/Jest-Testing-C21325?style=flat-square&logo=jest&logoColor=white) ![SuperTest](https://img.shields.io/badge/SuperTest-Testing-009688?style=flat-square&logo=testing-library&logoColor=white) | +| **인프라** | ![NCP](https://img.shields.io/badge/Naver%20Cloud%20Platform-latest-03C75A?style=flat-square&logo=naver&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-1.24.0-009639?style=flat-square&logo=nginx&logoColor=white) ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-3.0-2088FF?style=flat-square&logo=github-actions&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-24.0.7-2496ED?style=flat-square&logo=docker&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-Ubuntu%2022.04-FCC624?style=flat-square&logo=linux&logoColor=black) ![Grafana](https://img.shields.io/badge/Grafana-10.2.3-F46800?style=flat-square&logo=grafana&logoColor=white) ![Prometheus](https://img.shields.io/badge/Prometheus-2.43.0-E6522C?style=flat-square&logo=prometheus&logoColor=white) ![k6](https://img.shields.io/badge/k6-0.55.0-7D64FF?style=flat-square&logo=k6&logoColor=white)| +| **ν˜‘μ—… 도ꡬ** | ![Notion](https://img.shields.io/badge/Notion-2.0.41-000000?style=flat-square&logo=notion&logoColor=white) ![Figma](https://img.shields.io/badge/Figma-latest-F24E1E?style=flat-square&logo=figma&logoColor=white) ![Excalidraw](https://img.shields.io/badge/Excalidraw-latest-6965DB?style=flat-square&logo=excalidraw&logoColor=white) ![Zoom](https://img.shields.io/badge/Zoom-5.17.0-2D8CFF?style=flat-square&logo=zoom&logoColor=white) ![Git](https://img.shields.io/badge/Git-2.42.0-F05032?style=flat-square&logo=git&logoColor=white) ![GitHub](https://img.shields.io/badge/GitHub-latest-181717?style=flat-square&logo=github&logoColor=white) ![GitHub Projects](https://img.shields.io/badge/GitHub%20Projects-latest-181717?style=flat-square&logo=github&logoColor=white) | ## πŸ— μ‹œμŠ€ν…œ μ•„ν‚€ν…μ²˜ @@ -456,7 +456,7 @@ pnpm run start └── ... ``` -## νŒ€ μ†Œκ°œ(ν‘œμΈλ° μ–˜λŠ” μˆ˜μ •ν•˜μ§€ λ§μ•„μ£Όμ„Έμš”) +## νŒ€ μ†Œκ°œ | [J004 κ°•μ€€ν˜„](https://github.com/JunhyunKang) | [J074 κΉ€ν˜„μš°](https://github.com/krokerdile) | [J086 λ„μ„ λΉˆ](https://github.com/typingmistake) | [J175 μ΄λ™ν˜„](https://github.com/codemario318) | [J217 μ „ν˜„λ―Ό](https://github.com/joyjhm) | | --------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------- | diff --git a/apps/backend/src/play/entities/quiz-summary.entity.ts b/apps/backend/src/play/entities/quiz-summary.entity.ts new file mode 100644 index 000000000..1edc0de4d --- /dev/null +++ b/apps/backend/src/play/entities/quiz-summary.entity.ts @@ -0,0 +1,6 @@ +import { Rank } from './rank.entity'; + +export interface QuizSummary { + readonly ranks: Rank[]; + readonly endSocketTime?: number; +} \ No newline at end of file diff --git a/apps/backend/src/play/entities/rank.entity.ts b/apps/backend/src/play/entities/rank.entity.ts new file mode 100644 index 000000000..7854840df --- /dev/null +++ b/apps/backend/src/play/entities/rank.entity.ts @@ -0,0 +1,6 @@ +export interface Rank { + readonly id: string; + readonly nickname: string; + readonly score: number; + readonly ranking: number; +} \ No newline at end of file diff --git a/apps/backend/src/play/play.gateway.ts b/apps/backend/src/play/play.gateway.ts index d83309b3e..5cf7a7c39 100644 --- a/apps/backend/src/play/play.gateway.ts +++ b/apps/backend/src/play/play.gateway.ts @@ -15,11 +15,10 @@ import { SendEventMessage } from './entities/send-event.entity'; import { ClientInfo } from './entities/client-info.entity'; import { WebSocketWithSession } from '../core/SessionWsAdapter'; import { RuntimeException } from '@nestjs/core/errors/exceptions'; -import { CLOSE_CODE } from '../common/constants'; import { SubmitResponseDto } from './dto/submit-response.dto'; -import { clearTimeout } from 'node:timers'; import { ChatMessage } from 'src/chat/entities/chat-message.entity'; -import { ChatService } from '../chat/chat.service'; // 경둜 μˆ˜μ • +import { ChatService } from '../chat/chat.service'; +import { CLOSE_CODE } from '../common/constants'; // 경둜 μˆ˜μ • /** * ν€΄μ¦ˆ κ²Œμž„μ— λŒ€ν•œ WebSocket 연결을 κ΄€λ¦¬ν•˜λŠ” Gatewayμž…λ‹ˆλ‹€. @@ -248,7 +247,7 @@ export class PlayGateway implements OnGatewayInit { const clientsIds = summaries.map(({ id }) => id); - this.clearQuizZone(clientsIds, quizZoneId, endSocketTime); + this.clearQuizZone(clientsIds, quizZoneId, endSocketTime - Date.now()); } /** @@ -258,16 +257,16 @@ export class PlayGateway implements OnGatewayInit { * - 일반 ν”Œλ ˆμ΄μ–΄κ°€ λ‚˜κ°€λ©΄ ν€΄μ¦ˆ μ‘΄μ—μ„œ λ‚˜κ°€κ³  λ‹€λ₯Έ ν”Œλ ˆμ΄μ–΄μ—κ²Œ λ‚˜κ°”λ‹€κ³  μ•Œλ¦½λ‹ˆλ‹€. * @param clientIds - ν€΄μ¦ˆμ‘΄μ— μ°Έμ—¬ν•˜κ³  μžˆλŠ” ν΄λΌμ΄μ–ΈνŠΈ id 리슀트 * @param quizZoneId - ν€΄μ¦ˆκ°€ λλ‚œ ν€΄μ¦ˆμ‘΄ id - * @param endSocketTime - μ†ŒμΌ“ μ—°κ²° μ’…λ£Œ μ‹œκ°„ μ’…λ£Œ μ‹œκ°„ + * @param time - μ†ŒμΌ“ μ—°κ²° μ’…λ£Œ μ‹œκ°„ μ’…λ£Œ μ‹œκ°„ */ - private clearQuizZone(clientIds: string[], quizZoneId: string, endSocketTime: number) { + private clearQuizZone(clientIds: string[], quizZoneId: string, time: number) { setTimeout(() => { clientIds.forEach((id) => { this.clearClient(id, 'finish'); }); this.playService.clearQuizZone(quizZoneId); this.chatService.delete(quizZoneId); - }, endSocketTime - Date.now()); + }, time); } /** @@ -286,7 +285,7 @@ export class PlayGateway implements OnGatewayInit { if (isHost) { this.broadcast(playerIds, 'close'); - playerIds.forEach((id) => this.clearClient(id, 'Host leave.')); + this.clearQuizZone(playerIds, quizZoneId, 0); } else { this.broadcast(playerIds, 'someone_leave', clientId); this.clearClient(clientId, 'Client leave'); diff --git a/apps/backend/src/play/play.service.ts b/apps/backend/src/play/play.service.ts index 2badf6b3a..fd033c338 100644 --- a/apps/backend/src/play/play.service.ts +++ b/apps/backend/src/play/play.service.ts @@ -304,7 +304,8 @@ export class PlayService { const now = Date.now(); const endSocketTime = now + socketConnectTime; - return [...players.values()].map(({ id, score, submits }) => ({ + + const summaries = [...players.values()].map(({ id, score, submits }) => ({ id, score, submits, @@ -312,6 +313,9 @@ export class PlayService { ranks, endSocketTime })); + + quizZone.summaries = {ranks, endSocketTime}; + return summaries; } public clearQuizZone(quizZoneId: string) { diff --git a/apps/backend/src/quiz-zone/dto/create-quiz-zone.dto.ts b/apps/backend/src/quiz-zone/dto/create-quiz-zone.dto.ts index 008433d71..bb257971a 100644 --- a/apps/backend/src/quiz-zone/dto/create-quiz-zone.dto.ts +++ b/apps/backend/src/quiz-zone/dto/create-quiz-zone.dto.ts @@ -24,7 +24,7 @@ export class CreateQuizZoneDto { @IsInt({ message: 'μ΅œλŒ€ ν”Œλ ˆμ΄μ–΄ μˆ˜κ°€ μ—†μŠ΅λ‹ˆλ‹€.' }) @Min(1, { message: 'μ΅œμ†Œ 1λͺ… 이상이어야 ν•©λ‹ˆλ‹€.' }) - // @Max(300, { message: 'μ΅œλŒ€ 300λͺ…κΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€.' }) + @Max(300, { message: 'μ΅œλŒ€ 300λͺ…κΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€.' }) readonly limitPlayerCount: number; @IsNotEmpty({ message: 'ν€΄μ¦ˆμ‘΄μ„ μ„ νƒν•΄μ£Όμ„Έμš”.' }) diff --git a/apps/backend/src/quiz-zone/dto/find-quiz-zone.dto.ts b/apps/backend/src/quiz-zone/dto/find-quiz-zone.dto.ts index c5f708f85..37e3b3da1 100644 --- a/apps/backend/src/quiz-zone/dto/find-quiz-zone.dto.ts +++ b/apps/backend/src/quiz-zone/dto/find-quiz-zone.dto.ts @@ -2,6 +2,8 @@ import { PLAYER_STATE, QUIZ_ZONE_STAGE } from '../../common/constants'; import { CurrentQuizDto } from '../../play/dto/current-quiz.dto'; import { SubmittedQuiz } from '../entities/submitted-quiz.entity'; import { ChatMessage } from 'src/chat/entities/chat-message.entity'; +import { Rank } from '../../play/entities/rank.entity'; +import { Quiz } from '../entities/quiz.entity'; /** * ν€΄μ¦ˆ κ²Œμž„μ— μ°Έμ—¬ν•˜λŠ” ν”Œλ ˆμ΄μ–΄ μ—”ν‹°ν‹° @@ -42,4 +44,10 @@ export interface FindQuizZoneDto { readonly currentQuiz?: CurrentQuizDto; readonly maxPlayers?: number; readonly chatMessages?: ChatMessage[]; + + readonly ranks?: Rank[]; + readonly endSocketTime?: number; + readonly score?: number; + readonly quizzes?: Quiz[]; + readonly submits?: SubmittedQuiz[]; } diff --git a/apps/backend/src/quiz-zone/entities/quiz-zone.entity.ts b/apps/backend/src/quiz-zone/entities/quiz-zone.entity.ts index 59c762e5e..4e6bbbffc 100644 --- a/apps/backend/src/quiz-zone/entities/quiz-zone.entity.ts +++ b/apps/backend/src/quiz-zone/entities/quiz-zone.entity.ts @@ -1,6 +1,7 @@ import { Quiz } from './quiz.entity'; import { Player } from './player.entity'; import { QUIZ_ZONE_STAGE } from '../../common/constants'; +import { QuizSummary } from '../../play/entities/quiz-summary.entity'; /** * ν€΄μ¦ˆ κ²Œμž„μ„ μ§„ν–‰ν•˜λŠ” 곡간을 λ‚˜νƒ€λ‚΄λŠ” ν€΄μ¦ˆμ‘΄ μΈν„°νŽ˜μ΄μŠ€ * @@ -28,4 +29,5 @@ export interface QuizZone { currentQuizStartTime: number; currentQuizDeadlineTime: number; intervalTime: number; + summaries?: QuizSummary; } diff --git a/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts b/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts index b46f80cf0..0f307e34b 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.controller.spec.ts @@ -81,8 +81,11 @@ describe('QuizZoneController', () => { stage: 'LOBBY', playerCount: 1, maxPlayers: 8, + serverTime: 0, }; + Date.now = jest.fn().mockReturnValue(0); + it('ν€΄μ¦ˆμ‘΄ 정보λ₯Ό μ„±κ³΅μ μœΌλ‘œ μ‘°νšŒν•œλ‹€', async () => { const session = { id: 'sessionId' }; mockQuizZoneService.getQuizZoneInfo.mockResolvedValue(mockQuizZoneInfo); diff --git a/apps/backend/src/quiz-zone/quiz-zone.controller.ts b/apps/backend/src/quiz-zone/quiz-zone.controller.ts index 415ea4e91..ab8d6da79 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.controller.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.controller.ts @@ -68,12 +68,16 @@ export class QuizZoneController { @Session() session: Record, @Param('quizZoneId') quizZoneId: string, ) { - const quizZoneInfo = this.quizZoneService.getQuizZoneInfo( + const serverTime = Date.now(); + const quizZoneInfo = await this.quizZoneService.getQuizZoneInfo( session.id, quizZoneId, session.quizZoneId, ); session['quizZoneId'] = quizZoneId; - return quizZoneInfo; + return { + ...quizZoneInfo, + serverTime, + }; } } diff --git a/apps/backend/src/quiz-zone/quiz-zone.service.spec.ts b/apps/backend/src/quiz-zone/quiz-zone.service.spec.ts index a2e8b9281..b6a8e13de 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.service.spec.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.service.spec.ts @@ -6,7 +6,6 @@ import { IQuizZoneRepository } from './repository/quiz-zone.repository.interface import { Quiz } from './entities/quiz.entity'; import { PLAYER_STATE, QUIZ_TYPE, QUIZ_ZONE_STAGE } from '../common/constants'; import { QuizService } from '../quiz/quiz.service'; -import { max } from 'class-validator'; import { ChatService } from '../chat/chat.service'; const nickNames: string[] = [ @@ -571,6 +570,13 @@ describe('QuizZoneService', () => { currentQuizStartTime: Date.now(), currentQuizDeadlineTime: Date.now() + playTime, intervalTime: 5000, + summaries: { + ranks: [ + {id: "player1", nickname: "λ―ΈμΉœνˆ¬μ‚¬", score: 0, ranking: 1}, + {id: "player2", nickname: "λ―ΈμΉœνˆ¬μ‚¬", score: 0, ranking: 1} + ], + endSocketTime: Date.now(), + } }; mockQuizZoneRepository.get.mockResolvedValue(mockQuizZone); @@ -579,7 +585,7 @@ describe('QuizZoneService', () => { const result = await service.getQuizZoneInfo(clientId, quizZoneId); // then - expect(result).toEqual({ + expect(result).toMatchObject({ currentPlayer: { id: clientId, nickname: 'λ‹‰λ„€μž„', @@ -591,8 +597,12 @@ describe('QuizZoneService', () => { description: 'ν…ŒμŠ€νŠΈ ν€΄μ¦ˆμž…λ‹ˆλ‹€', quizCount: quizzes.length, stage: QUIZ_ZONE_STAGE.RESULT, - maxPlayers: 10, hostId: 'adminId', + ranks: [ + {id: "player1", nickname: "λ―ΈμΉœνˆ¬μ‚¬", score: 0, ranking: 1}, + {id: "player2", nickname: "λ―ΈμΉœνˆ¬μ‚¬", score: 0, ranking: 1} + ], + endSocketTime: expect.any(Number), }); }); diff --git a/apps/backend/src/quiz-zone/quiz-zone.service.ts b/apps/backend/src/quiz-zone/quiz-zone.service.ts index 135b6914f..d2aeda3e2 100644 --- a/apps/backend/src/quiz-zone/quiz-zone.service.ts +++ b/apps/backend/src/quiz-zone/quiz-zone.service.ts @@ -172,7 +172,7 @@ export class QuizZoneService { } private async getResultInfo(clientId: string, quizZoneId: string): Promise { - const { players, stage, title, description, hostId, quizzes, maxPlayers } = + const { players, stage, title, description, hostId, quizzes, summaries } = await this.findOne(quizZoneId); const { id, nickname, state, submits, score } = players.get(clientId); const chatMessages = await this.chatService.get(quizZoneId); @@ -181,11 +181,15 @@ export class QuizZoneService { currentPlayer: { id, nickname, state, score, submits }, title, description, - maxPlayers: maxPlayers, quizCount: quizzes.length, stage: stage, hostId, - chatMessages: chatMessages, + chatMessages, + ranks: summaries.ranks, + endSocketTime: summaries.endSocketTime, + quizzes, + score, + submits }; } diff --git a/apps/backend/src/quiz/quiz.service.ts b/apps/backend/src/quiz/quiz.service.ts index a191163cc..a04abb4ac 100644 --- a/apps/backend/src/quiz/quiz.service.ts +++ b/apps/backend/src/quiz/quiz.service.ts @@ -54,7 +54,7 @@ export class QuizService { } async deleteQuizSet(quizSetId: number) { - const quiz = await this.findQuizSet(quizSetId); + const quizSet = await this.findQuizSet(quizSetId); await this.quizSetRepository.delete({ id: quizSetId }); } @@ -80,10 +80,6 @@ export class QuizService { async searchQuizSet(searchQuery: SearchQuizSetRequestDTO) { const { name, page, size } = searchQuery; - if (!name) { - return this.findDefaultQuizSet(page, size); - } - const [quizSets, count] = await Promise.all([ this.quizSetRepository.searchByName(name, page, size), this.quizSetRepository.countByName(name), @@ -93,12 +89,4 @@ export class QuizService { return { quizSetDetails, total: count, currentPage: page }; } - private async findDefaultQuizSet(page: number, size: number) { - const [quizSets, count] = await Promise.all([ - this.quizSetRepository.findByRecommend(page, size), - this.quizSetRepository.countByRecommend(), - ]); - const quizSetDetails = quizSets.map(QuizSetDetails.from); - return { quizSetDetails, total: count, currentPage: page }; - } } diff --git a/apps/backend/src/quiz/repository/quiz-set.repository.ts b/apps/backend/src/quiz/repository/quiz-set.repository.ts index 66ee5425f..4275e55fc 100644 --- a/apps/backend/src/quiz/repository/quiz-set.repository.ts +++ b/apps/backend/src/quiz/repository/quiz-set.repository.ts @@ -11,7 +11,7 @@ export class QuizSetRepository extends Repository { searchByName(name: string, page: number, pageSize: number) { return this.find({ where: {name: ILike(`${name}%`)}, - order: {createAt: 'desc'}, + order: {recommended: 'DESC', createAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -20,17 +20,4 @@ export class QuizSetRepository extends Repository { countByName(name: string) { return this.count({ where: { name: ILike(`${name}%`) } }); } - - countByRecommend() { - return this.count({where: {recommended: true}}); - } - - findByRecommend(page: number, pageSize: number) { - return this.find({ - where: {recommended: true}, - order: {createAt: 'desc'}, - skip: (page - 1) * pageSize, - take: pageSize, - }) - } } diff --git a/apps/frontend/src/blocks/CreateQuizZone/CreateQuizZoneBasic.tsx b/apps/frontend/src/blocks/CreateQuizZone/CreateQuizZoneBasic.tsx index b433f259d..ed11e7c6b 100644 --- a/apps/frontend/src/blocks/CreateQuizZone/CreateQuizZoneBasic.tsx +++ b/apps/frontend/src/blocks/CreateQuizZone/CreateQuizZoneBasic.tsx @@ -80,10 +80,11 @@ const CreateQuizZoneBasic = ({ onChange={(e) => handleChangeQuizZoneBasic(e, 'QUIZ_ZONE_ID')} isBorder={true} className="rounded-md w-full" - placeholder="ν€΄μ¦ˆμ‘΄ μ½”λ“œλ₯Ό μž…λ ₯ν•˜μ„Έμš”" + placeholder="μˆ«μžμ™€ 영문자 5자 이상 10자 μ΄ν•˜" error={ validationError == '5자 이상 μž…λ ₯ν•΄μ£Όμ„Έμš”.' || - validationError == '10자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.' + validationError == '10자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.' || + validationError == 'μˆ«μžμ™€ μ•ŒνŒŒλ²³ μ‘°ν•©λ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€.' ? validationError : '' } @@ -95,12 +96,13 @@ const CreateQuizZoneBasic = ({ label="μ΅œλŒ€ 인원" name="quiz-zone-limit-player-count" step={10} - min={1} - max={300} value={limitPlayerCount} onChange={(e) => handleChangeQuizZoneBasic(e, 'LIMIT')} isBorder={true} className="w-20" + isShowCount={true} + min={1} + max={300} /> handleChangeQuizZoneBasic(e, 'DESC')} isBorder={true} placeholder="ν€΄μ¦ˆμ‘΄ μ„€λͺ…을 μž…λ ₯ν•˜μ„Έμš”" - error={validationError == '500자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.' ? validationError : ''} + error={validationError == '300자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.' ? validationError : ''} isShowCount={true} - max={500} + max={300} /> diff --git a/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx b/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx index 1d0ab98ba..0c327c48b 100644 --- a/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx +++ b/apps/frontend/src/blocks/QuizZone/QuizInProgress.tsx @@ -3,11 +3,13 @@ import ContentBox from '@/components/common/ContentBox'; import Input from '@/components/common/Input'; import ProgressBar from '@/components/common/ProgressBar'; import Typography from '@/components/common/Typogrpahy'; -import { useState } from 'react'; +import { useTimer } from '@/hook/useTimer'; +import { CurrentQuiz } from '@/types/quizZone.types'; +import { useEffect, useState } from 'react'; interface QuizInProgressProps { playTime: number | null; - currentQuiz: any; + currentQuiz: CurrentQuiz; submitAnswer: (e: any) => void; } @@ -17,6 +19,18 @@ const QuizInProgress = ({ currentQuiz, submitAnswer }: QuizInProgressProps) => { const MAX_TEXT_LENGTH = 100; const MIN_TEXT_LENGTH = 1; + const now = new Date().getTime(); + const { playTime, deadlineTime } = currentQuiz; + + const { start, time } = useTimer({ + initialTime: (deadlineTime - now) / 1000, + onComplete: () => {}, + }); + + useEffect(() => { + start(); + }, []); + const handleSubmitAnswer = () => { if (answer.length >= MIN_TEXT_LENGTH && answer.length <= MAX_TEXT_LENGTH) { submitAnswer(answer); @@ -25,7 +39,7 @@ const QuizInProgress = ({ currentQuiz, submitAnswer }: QuizInProgressProps) => { return (
- {}} /> + {}} /> ); diff --git a/apps/frontend/src/components/common/Input.tsx b/apps/frontend/src/components/common/Input.tsx index a9e0e137e..36e7df9ee 100644 --- a/apps/frontend/src/components/common/Input.tsx +++ b/apps/frontend/src/components/common/Input.tsx @@ -148,7 +148,7 @@ const Input = forwardRef( )} {typeof value === 'string' && isShowCount && ( -
+
( />
)} + {typeof value === 'number' && isShowCount && ( +
+ +
+ )}
); diff --git a/apps/frontend/src/components/common/ProgressBar.stories.tsx b/apps/frontend/src/components/common/ProgressBar.stories.tsx index 7f2f5849a..efc4e6515 100644 --- a/apps/frontend/src/components/common/ProgressBar.stories.tsx +++ b/apps/frontend/src/components/common/ProgressBar.stories.tsx @@ -13,7 +13,8 @@ type Story = StoryObj; export const Default: Story = { args: { - deadlineTime: Date.now() + 30000, + playTime: 30000, + time: Date.now(), onTimeEnd: () => { alert('time end'); }, diff --git a/apps/frontend/src/components/common/ProgressBar.tsx b/apps/frontend/src/components/common/ProgressBar.tsx index 6833349b7..e3acab8d7 100644 --- a/apps/frontend/src/components/common/ProgressBar.tsx +++ b/apps/frontend/src/components/common/ProgressBar.tsx @@ -1,83 +1,37 @@ -import { Progress } from '../ui/progress'; -import { useState, useEffect, useRef } from 'react'; +import { Progress } from '@/components/ui/progress'; import Typography from './Typogrpahy'; export interface ProgressBarProps { - deadlineTime: number; // ISO string or Date object + playTime: number; + time: number; onTimeEnd?: () => void; } /** * @description - * ProgressBar μ»΄ν¬λ„ŒνŠΈλŠ” μ£Όμ–΄μ§„ 마감 μ‹œκ°„κΉŒμ§€ 남은 μ‹œκ°„μ„ μ‹œκ°μ μœΌλ‘œ ν‘œμ‹œν•©λ‹ˆλ‹€. - * ν˜„μž¬ μ‹œκ°„κ³Ό 마감 μ‹œκ°„ μ‚¬μ΄μ˜ 진행도λ₯Ό 보여주며, μ‹œκ°„μ΄ λ‹€ 되면 μ½œλ°±μ„ μ‹€ν–‰ν•©λ‹ˆλ‹€. + * ProgressBar μ»΄ν¬λ„ŒνŠΈλŠ” μ£Όμ–΄μ§„ ν”Œλ ˆμ΄ μ‹œκ°„κ³Ό ν˜„μž¬ μ‹œκ°„μ„ 기반으둜 진행도λ₯Ό μ‹œκ°μ μœΌλ‘œ ν‘œμ‹œν•©λ‹ˆλ‹€. * * @example * console.log('Time ended!')} * /> */ -const ProgressBar = ({ deadlineTime, onTimeEnd }: ProgressBarProps) => { - const totalDurationRef = useRef(deadlineTime - Date.now()); +const ProgressBar = ({ playTime, time, onTimeEnd }: ProgressBarProps) => { + // 진행도 계산 (남은 μ‹œκ°„ / 전체 μ‹œκ°„ * 100) + const progress = Math.max(0, Math.min(100, (time / playTime) * 100000)); - const calculateInitialProgress = () => { - const now = Date.now(); - const totalDuration = totalDurationRef.current; - const remaining = deadlineTime - now; - const progress = Math.max(0, (remaining / totalDuration) * 100); - return progress; - }; - - const calculateInitialSeconds = () => { - const now = Date.now(); - return Math.max(0, (deadlineTime - now) / 1000); - }; - - const [progress, setProgress] = useState(calculateInitialProgress()); - const [remainingSeconds, setRemainingSeconds] = useState(calculateInitialSeconds()); - - useEffect(() => { - const startTime = Date.now(); - const totalDuration = totalDurationRef.current; - - // deadlineTime이 이미 μ§€λ‚œ 경우 - if (deadlineTime <= startTime) { - setProgress(0); - setRemainingSeconds(0); - onTimeEnd?.(); - return; - } - - const updateProgress = () => { - const currentTime = Date.now(); - const remaining = deadlineTime - currentTime; - - if (remaining <= 0) { - setProgress(0); - setRemainingSeconds(0); - onTimeEnd?.(); - return false; - } - - // totalDurationRef.currentλ₯Ό μ‚¬μš©ν•˜μ—¬ μΌμ •ν•œ λΉ„μœ¨λ‘œ κ°μ†Œ - const newProgress = (remaining / totalDuration) * 100; - setProgress(Math.max(0, Math.min(100, newProgress))); - setRemainingSeconds(remaining / 1000); - - return true; - }; - - const interval = setInterval(updateProgress, 100); - - return () => clearInterval(interval); - }, [deadlineTime, onTimeEnd]); + // μ‹œκ°„μ΄ λ‹€ λ˜μ—ˆμ„ λ•Œ 콜백 μ‹€ν–‰ + if (time <= 0) { + onTimeEnd?.(); + } return (
({ + default: () => ({ + beginConnection: vi.fn(), + sendMessage: vi.fn(), + closeConnection: vi.fn(), + messageHandler: vi.fn(), + }), +})); + +// env λͺ¨ν‚Ή +vi.mock('@/utils/atob', () => ({ + default: vi.fn((str) => str), +})); + +describe('useQuizZone', () => { + const mockQuizZoneId = 'test-quiz-zone-id'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('초기 μƒνƒœκ°€ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + expect(result.current.quizZoneState).toEqual({ + stage: 'LOBBY', + currentPlayer: { + id: '', + nickname: '', + }, + title: '', + description: '', + hostId: '', + quizCount: 0, + players: [], + score: 0, + submits: [], + quizzes: [], + chatMessages: [], + maxPlayers: 0, + offset: 0, + serverTime: 0, + }); + }); + + it('playQuiz μ•‘μ…˜μ΄ μƒνƒœλ₯Ό μ˜¬λ°”λ₯΄κ²Œ μ—…λ°μ΄νŠΈν•΄μ•Ό ν•©λ‹ˆλ‹€', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + act(() => { + result.current.playQuiz(); + }); + + expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); + expect(result.current.quizZoneState.currentPlayer.state).toBe('PLAY'); + }); + + it('init μ•‘μ…˜μ΄ μƒνƒœλ₯Ό μ˜¬λ°”λ₯΄κ²Œ μ—…λ°μ΄νŠΈν•΄μ•Ό ν•©λ‹ˆλ‹€', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + const mockQuizZone: Partial = { + stage: 'LOBBY', + currentPlayer: { + id: 'player1', + nickname: 'Player 1', + state: 'WAIT', + }, + title: 'Test Quiz', + description: 'Test Description', + hostId: 'host1', + quizCount: 5, + serverTime: Date.now(), + }; + + act(() => { + result.current.initQuizZoneData(mockQuizZone as QuizZone, Date.now()); + }); + + expect(result.current.quizZoneState.title).toBe('Test Quiz'); + expect(result.current.quizZoneState.currentPlayer.id).toBe('player1'); + expect(result.current.quizZoneState.quizCount).toBe(5); + }); + + describe('state transitions', () => { + it('LOBBYμ—μ„œ IN_PROGRESS둜 μƒνƒœ μ „ν™˜μ΄ μ˜¬λ°”λ₯΄κ²Œ λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€', () => { + const { result } = renderHook(() => useQuizZone(mockQuizZoneId)); + + expect(result.current.quizZoneState.stage).toBe('LOBBY'); + + act(() => { + result.current.playQuiz(); + }); + + expect(result.current.quizZoneState.stage).toBe('IN_PROGRESS'); + }); + }); +}); diff --git a/apps/frontend/src/hook/quizZone/useQuizZone.tsx b/apps/frontend/src/hook/quizZone/useQuizZone.tsx index 3a18a2a34..cb75cfabc 100644 --- a/apps/frontend/src/hook/quizZone/useQuizZone.tsx +++ b/apps/frontend/src/hook/quizZone/useQuizZone.tsx @@ -2,6 +2,7 @@ import { useReducer } from 'react'; import useWebSocket from '@/hook/useWebSocket.tsx'; import { ChatMessage, + InitQuizZoneResponse, NextQuizResponse, Player, QuizZone, @@ -12,7 +13,7 @@ import { import atob from '@/utils/atob'; export type QuizZoneAction = - | { type: 'init'; payload: QuizZone } + | { type: 'init'; payload: InitQuizZoneResponse } | { type: 'join'; payload: Player[] } | { type: 'someone_join'; payload: Player } | { type: 'someone_leave'; payload: string } @@ -39,23 +40,19 @@ const quizZoneReducer: Reducer = (state, action) => { switch (type) { case 'init': + const { quizZone, now } = payload; + const receiveTime = new Date().getTime(); return { ...state, - stage: payload.stage, - title: payload.title, - description: payload.description, - quizCount: payload.quizCount, - hostId: payload.hostId, - currentPlayer: payload.currentPlayer, - chatMessages: payload.chatMessages, + ...quizZone, currentQuiz: - payload.currentQuiz !== undefined + quizZone.currentQuiz !== undefined ? { - ...payload.currentQuiz, - question: atob(payload.currentQuiz?.question ?? ''), + ...quizZone.currentQuiz, + question: atob(quizZone.currentQuiz?.question ?? ''), } : undefined, - maxPlayers: payload.maxPlayers, + offset: quizZone.serverTime - (now + receiveTime) / 2, players: [], }; case 'join': @@ -84,13 +81,11 @@ const quizZoneReducer: Reducer = (state, action) => { ...state.currentPlayer, state: 'SUBMIT', }, - chatMessages: payload.chatMessages, currentQuizResult: { + ...payload, fastestPlayers: payload.fastestPlayerIds .map((id) => state.players?.find((p) => p.id === id)) .filter((p) => !!p), - submittedCount: payload.submittedCount, - totalPlayerCount: payload.totalPlayerCount, }, }; case 'someone_submit': @@ -118,11 +113,10 @@ const quizZoneReducer: Reducer = (state, action) => { }, currentQuiz: { ...state.currentQuiz, + ...nextQuiz, question: atob(nextQuiz.question), - currentIndex: nextQuiz.currentIndex, - playTime: nextQuiz.playTime, - startTime: nextQuiz.startTime, - deadlineTime: nextQuiz.deadlineTime, + startTime: nextQuiz.startTime - state.offset, + deadlineTime: nextQuiz.deadlineTime - state.offset, quizType: 'SHORT', }, currentQuizResult: { @@ -157,12 +151,9 @@ const quizZoneReducer: Reducer = (state, action) => { case 'summary': return { ...state, + ...payload, stage: 'RESULT', - score: payload.score, - submits: payload.submits, - quizzes: payload.quizzes, - ranks: payload.ranks, - endSocketTime: payload.endSocketTime, + endSocketTime: payload.endSocketTime - state.offset, }; case 'chat': return { @@ -241,6 +232,8 @@ const useQuizZone = (quizZoneId: string, handleReconnect?: () => void) => { quizzes: [], chatMessages: [], maxPlayers: 0, + offset: 0, + serverTime: 0, }; const [quizZoneState, dispatch] = useReducer(quizZoneReducer, initialQuizZoneState); @@ -267,8 +260,8 @@ const useQuizZone = (quizZoneId: string, handleReconnect?: () => void) => { }); //initialize QuizZOne - const initQuizZoneData = async (quizZone: QuizZone) => { - dispatch({ type: 'init', payload: quizZone }); + const initQuizZoneData = async (quizZone: QuizZone, now: number) => { + dispatch({ type: 'init', payload: { quizZone, now } }); beginConnection(); joinQuizZone({ quizZoneId }); }; diff --git a/apps/frontend/src/hook/useTimer.test.ts b/apps/frontend/src/hook/useTimer.test.ts deleted file mode 100644 index 371442aa6..000000000 --- a/apps/frontend/src/hook/useTimer.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// useTimer.test.ts -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { useTimer } from './useTimer'; - -describe('useTimer', () => { - beforeEach(() => { - // κ°€μ§œ 타이머 μ„€μ • - vi.useFakeTimers(); - }); - - afterEach(() => { - // ν…ŒμŠ€νŠΈ κ°„ 타이머 정리 - vi.clearAllTimers(); - vi.restoreAllMocks(); - }); - - it('초기 μ‹œκ°„μ΄ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ–΄μ•Ό ν•œλ‹€', () => { - const { result } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - expect(result.current.time).toBe(10); - }); - - it('start 호좜 μ‹œ μΉ΄μš΄νŠΈλ‹€μš΄μ΄ μ‹œμž‘λ˜μ–΄μ•Ό ν•œλ‹€', () => { - const { result } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - act(() => { - result.current.start(); - }); - - act(() => { - vi.advanceTimersByTime(100); - }); - - expect(result.current.time).toBe(9.9); - }); - - it('타이머가 0에 λ„λ‹¬ν•˜λ©΄ onCompleteκ°€ ν•œ 번만 ν˜ΈμΆœλ˜μ–΄μ•Ό ν•œλ‹€', () => { - const onComplete = vi.fn(); - const { result } = renderHook(() => - useTimer({ - initialTime: 0.2, - onComplete, - }), - ); - - act(() => { - result.current.start(); - }); - - // 타이머가 0에 도달할 λ•ŒκΉŒμ§€ μ‹œκ°„ μ§„ν–‰ - act(() => { - vi.advanceTimersByTime(200); - }); - - // onCompleteκ°€ μ •ν™•νžˆ ν•œ 번만 ν˜ΈμΆœλ˜μ—ˆλŠ”μ§€ 확인 - expect(onComplete).toHaveBeenCalledTimes(1); - }); - - it('μ»΄ν¬λ„ŒνŠΈ μ–Έλ§ˆμš΄νŠΈ μ‹œ λͺ¨λ“  타이머가 μ •λ¦¬λ˜μ–΄μ•Ό ν•œλ‹€', () => { - const { unmount } = renderHook(() => - useTimer({ - initialTime: 10, - onComplete: () => {}, - }), - ); - - unmount(); - - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/apps/frontend/src/hook/useValidInput.test.ts b/apps/frontend/src/hook/useValidInput.test.ts new file mode 100644 index 000000000..6b69f63d8 --- /dev/null +++ b/apps/frontend/src/hook/useValidInput.test.ts @@ -0,0 +1,124 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, test, expect } from 'vitest'; +import useValidState from './useValidInput'; + +describe('useValidState', () => { + test('μ΄ˆκΈ°κ°’μ΄ μœ νš¨ν•œ 경우', () => { + const validator = (value: number) => { + if (value < 0) return 'μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState(10, validator)); + const [state, errorMessage, _, isInvalid] = result.current; + + expect(state).toBe(10); + expect(errorMessage).toBe(''); + expect(isInvalid).toBe(false); + }); + + test('μ΄ˆκΈ°κ°’μ΄ μœ νš¨ν•˜μ§€ μ•Šμ€ 경우', () => { + const validator = (value: number) => { + if (value < 0) return 'μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState(-5, validator)); + const [state, errorMessage, _, isInvalid] = result.current; + + expect(state).toBe(-5); + expect(errorMessage).toBe('μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'); + expect(isInvalid).toBe(true); + }); + + test('μœ νš¨ν•œ κ°’μœΌλ‘œ μƒνƒœ μ—…λ°μ΄νŠΈ', () => { + const validator = (value: number) => { + if (value < 0) return 'μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + act(() => { + const [, , setValue] = result.current; + setValue(5); + }); + + const [state, errorMessage, _, isInvalid] = result.current; + expect(state).toBe(5); + expect(errorMessage).toBe(''); + expect(isInvalid).toBe(false); + }); + + test('μœ νš¨ν•˜μ§€ μ•Šμ€ κ°’μœΌλ‘œ μƒνƒœ μ—…λ°μ΄νŠΈ', () => { + const validator = (value: number) => { + if (value < 0) return 'μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + act(() => { + const [, , setValue] = result.current; + setValue(-10); + }); + + const [state, errorMessage, _, isInvalid] = result.current; + expect(state).toBe(-10); + expect(errorMessage).toBe('μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'); + expect(isInvalid).toBe(true); + }); + + test('닀쀑 쑰건 검증', () => { + const validator = (value: number) => { + if (value < 0) return 'μŒμˆ˜λŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + if (value > 100) return '100보닀 큰 μˆ«μžλŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState(0, validator)); + + // μœ νš¨ν•œ κ°’ ν…ŒμŠ€νŠΈ + act(() => { + const [, , setValue] = result.current; + setValue(50); + }); + + expect(result.current[0]).toBe(50); + expect(result.current[1]).toBe(''); + expect(result.current[3]).toBe(false); + + // λ²”μœ„λ₯Ό μ΄ˆκ³Όν•˜λŠ” κ°’ ν…ŒμŠ€νŠΈ + act(() => { + const [, , setValue] = result.current; + setValue(150); + }); + + expect(result.current[0]).toBe(150); + expect(result.current[1]).toBe('100보닀 큰 μˆ«μžλŠ” μž…λ ₯ν•  수 μ—†μŠ΅λ‹ˆλ‹€.'); + expect(result.current[3]).toBe(true); + }); + + test('λ¬Έμžμ—΄ μœ νš¨μ„± 검사', () => { + const validator = (value: string) => { + if (value.length < 3) return 'μ΅œμ†Œ 3κΈ€μž 이상이어야 ν•©λ‹ˆλ‹€.'; + }; + + const { result } = renderHook(() => useValidState('', validator)); + + // μœ νš¨ν•˜μ§€ μ•Šμ€ κ°’ ν…ŒμŠ€νŠΈ + act(() => { + const [, , setValue] = result.current; + setValue('ab'); + }); + + expect(result.current[0]).toBe('ab'); + expect(result.current[1]).toBe('μ΅œμ†Œ 3κΈ€μž 이상이어야 ν•©λ‹ˆλ‹€.'); + expect(result.current[3]).toBe(true); + + // μœ νš¨ν•œ κ°’ ν…ŒμŠ€νŠΈ + act(() => { + const [, , setValue] = result.current; + setValue('abc'); + }); + + expect(result.current[0]).toBe('abc'); + expect(result.current[1]).toBe(''); + expect(result.current[3]).toBe(false); + }); +}); diff --git a/apps/frontend/src/pages/NotFoundPage.tsx b/apps/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 000000000..705b0a020 --- /dev/null +++ b/apps/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,50 @@ +import CommonButton from '@/components/common/CommonButton'; +import ContentBox from '@/components/common/ContentBox'; +import Typography from '@/components/common/Typogrpahy'; +import TooltipWrapper from '@/components/common/TooltipWrapper'; +import { useNavigate } from 'react-router-dom'; +import Logo from '@/components/common/Logo'; + +const NotFound = () => { + const navigate = useNavigate(); + const handleMoveMainPage = () => { + navigate(`/`); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); +}; + +export default NotFound; diff --git a/apps/frontend/src/pages/QuizZonePage.tsx b/apps/frontend/src/pages/QuizZonePage.tsx index 1bf3a30c2..cdd673b73 100644 --- a/apps/frontend/src/pages/QuizZonePage.tsx +++ b/apps/frontend/src/pages/QuizZonePage.tsx @@ -37,8 +37,8 @@ const QuizZoneContent = () => { setIsLoading(true); const quizZone = await requestQuizZone(quizZoneId); - - await initQuizZoneData(quizZone); + const now = new Date().getTime(); + await initQuizZoneData(quizZone, now); setIsLoading(false); setIsDisconnection(false); diff --git a/apps/frontend/src/router/router.tsx b/apps/frontend/src/router/router.tsx index d6c3453d8..81bddd9ce 100644 --- a/apps/frontend/src/router/router.tsx +++ b/apps/frontend/src/router/router.tsx @@ -3,6 +3,7 @@ import MainPage from '@/pages/MainPage'; import RootLayout from '../pages/RootLayout'; import QuizZonePage from '@/pages/QuizZonePage'; import CreateQuizZonePage from '@/pages/CreateQuizZonePage.tsx'; +import NotFound from '@/pages/NotFoundPage'; function Router() { return ( @@ -11,6 +12,7 @@ function Router() { } /> } /> } /> + } /> ); diff --git a/apps/frontend/src/types/quizZone.types.ts b/apps/frontend/src/types/quizZone.types.ts index aeb13ec32..c55c2c08a 100644 --- a/apps/frontend/src/types/quizZone.types.ts +++ b/apps/frontend/src/types/quizZone.types.ts @@ -27,6 +27,8 @@ export interface QuizZone { chatMessages?: ChatMessage[]; isQuizZoneEnd?: boolean; endSocketTime?: number; + serverTime: number; + offset: number; } export interface Rank { @@ -119,3 +121,8 @@ export interface ChatMessage { nickname: string; message: string; } + +export interface InitQuizZoneResponse { + quizZone: QuizZone; + now: number; +} diff --git a/apps/frontend/src/types/timer.types.ts b/apps/frontend/src/types/timer.types.ts new file mode 100644 index 000000000..63ad825b3 --- /dev/null +++ b/apps/frontend/src/types/timer.types.ts @@ -0,0 +1,14 @@ +export interface TimerMessage { + type: 'START' | 'STOP' | 'RESET'; + payload?: { + duration: number; + serverTime?: number; + }; +} + +export interface TimerResponse { + type: 'TICK' | 'COMPLETE'; + payload?: { + time: number; + }; +} diff --git a/apps/frontend/src/utils/validators.ts b/apps/frontend/src/utils/validators.ts index 0c274a5a5..a4fa943fa 100644 --- a/apps/frontend/src/utils/validators.ts +++ b/apps/frontend/src/utils/validators.ts @@ -32,17 +32,18 @@ export const validateQuizZoneSetName = (name: string) => { //ν€΄μ¦ˆμ‘΄ μ„€λͺ… μœ νš¨μ„± 검사 export const validateQuizZoneSetDescription = (description: string) => { - if (description.length > 500) return '500자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.'; + if (description.length > 300) return '300자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.'; }; //ν€΄μ¦ˆμ‘΄ μž…μž₯ μ½”λ“œ μœ νš¨μ„± 검사 export const validateQuizZoneSetCode = (code: string) => { if (code.length < 5) return '5자 이상 μž…λ ₯ν•΄μ£Όμ„Έμš”.'; if (code.length > 10) return '10자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.'; + if (!/^[a-zA-Z0-9]*$/g.test(code)) return 'μˆ«μžμ™€ μ•ŒνŒŒλ²³ μ‘°ν•©λ§Œ κ°€λŠ₯ν•©λ‹ˆλ‹€.'; }; //μž…μž₯ 인원 μ œν•œ μœ νš¨μ„± 검사 export const validateQuizZoneSetLimit = (limit: number) => { if (limit < 1) return 'μ΅œμ†Œ 1λͺ… 이상 μ§€μ •ν•΄μ£Όμ„Έμš”.'; - // if (limit > 300) return 'μ΅œλŒ€ 인원은 300λͺ…μž…λ‹ˆλ‹€.'; + if (limit > 300) return 'μ΅œλŒ€ 인원은 300λͺ…μž…λ‹ˆλ‹€.'; }; diff --git a/apps/frontend/src/vite-env.d.ts b/apps/frontend/src/vite-env.d.ts index 11f02fe2a..ce0977e1c 100644 --- a/apps/frontend/src/vite-env.d.ts +++ b/apps/frontend/src/vite-env.d.ts @@ -1 +1,7 @@ /// +declare module '*?worker' { + const workerConstructor: { + new (): Worker; + }; + export default workerConstructor; +} diff --git a/apps/frontend/src/workers/timer.worker.ts b/apps/frontend/src/workers/timer.worker.ts new file mode 100644 index 000000000..c1940c45e --- /dev/null +++ b/apps/frontend/src/workers/timer.worker.ts @@ -0,0 +1,85 @@ +let timerId: ReturnType | null = null; +let startTime: number | null = null; +let duration: number | null = null; +let timeOffset: number = 0; +let pausedTimeRemaining: number | null = null; + +self.onmessage = (event: MessageEvent) => { + const { type, payload } = event.data; + + switch (type) { + case 'START': + if (!payload?.duration) return; + + if (pausedTimeRemaining !== null) { + startTimer(pausedTimeRemaining, self.postMessage); + pausedTimeRemaining = null; + } else { + if (payload.serverTime) { + timeOffset = Date.now() - payload.serverTime; + } + startTimer(payload.duration, self.postMessage); + } + break; + + case 'STOP': + if (timerId !== null && startTime !== null && duration !== null) { + const currentTime = Date.now() - timeOffset; + const elapsed = (currentTime - startTime) / 1000; + pausedTimeRemaining = Math.max(0, duration - elapsed); + } + stopTimer(); + break; + + case 'RESET': + resetTimer(); + break; + } +}; + +function startTimer( + newDuration: number, + postMessage: { + (message: any, targetOrigin: string, transfer?: Transferable[]): void; + (message: any, options?: WindowPostMessageOptions): void; + }, +) { + stopTimer(); // κΈ°μ‘΄ 타이머가 μžˆλ‹€λ©΄ 정리 + + duration = newDuration; + startTime = Date.now() - timeOffset; + + timerId = setInterval(() => { + if (!startTime || !duration) return; + + const currentTime = Date.now() - timeOffset; + const elapsed = (currentTime - startTime) / 1000; + const remaining = Math.max(0, duration - elapsed); + const roundedRemaining = Math.round(remaining * 10) / 10; + + if (roundedRemaining <= 0) { + postMessage({ type: 'COMPLETE' }); + stopTimer(); + } else { + postMessage({ + type: 'TICK', + payload: { time: roundedRemaining }, + }); + } + }, 100); +} + +function stopTimer() { + if (timerId !== null) { + clearInterval(timerId); + timerId = null; + } +} + +function resetTimer() { + stopTimer(); + startTime = null; + duration = null; + timeOffset = 0; + pausedTimeRemaining = null; +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index 75ade8bf1..5bd43f4db 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -6,7 +6,8 @@ "paths": { "@/*": ["./src/*"] }, - "types": ["vitest/globals", "@testing-library/jest-dom"] + "types": ["vitest/globals", "@testing-library/jest-dom"], + "lib": ["webworker", "es2015"] }, "files": [], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 887d59784..86c411032 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig(({ mode }) => { return { plugins: [react()], + worker: { + format: 'es', + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -22,6 +25,25 @@ export default defineConfig(({ mode }) => { }, }, }, + build: { + clean: true, // λΉŒλ“œ 전에 outDir을 μ²­μ†Œν•©λ‹ˆλ‹€ + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('timer.worker')) { + return 'worker'; + } + }, + // μΊμ‹œ λ¬΄νš¨ν™”λ₯Ό μœ„ν•œ 더 μ•ˆμ „ν•œ 방법 + entryFileNames: `assets/[name].[hash].js`, + chunkFileNames: `assets/[name].[hash].js`, + assetFileNames: `assets/[name].[hash].[ext]`, + }, + }, + // μΊμ‹œ μ„€μ • + manifest: true, // manifest 파일 생성 + sourcemap: true, + }, test: { environment: 'jsdom', globals: true,