diff --git a/frontend/src/components/Play/Play.tsx b/frontend/src/components/Play/Play.tsx index 70e68f0..5a7f5d2 100644 --- a/frontend/src/components/Play/Play.tsx +++ b/frontend/src/components/Play/Play.tsx @@ -1,36 +1,14 @@ /** @jsxImportSource @emotion/react */ import { css } from "@emotion/react"; -import { PlayersProps as PlayersAPI } from "components/shared/Chess/ActionBar/ActionBar"; -import Chess, { PlayersProps } from "components/shared/Chess/Chess"; -import { Move, MoveInfo, MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board"; +import Chess from "components/shared/Chess/Chess"; import { Color } from "components/shared/Chess/ChessBoard/ChessLogic/pieces"; import { useChessBoardState } from "components/shared/Chess/useChessBoardState/useChessBoardState"; -import { ErrorQueueClass } from "components/shared/ErrorQueue/ErrorQueue"; import Loading from "components/shared/Loading"; import UserMenu from "components/shared/UserMenu/UserMenu"; import { AppContext } from "hooks/appContext"; import React from "react"; import { useParams } from "react-router-dom"; -import { GameResultApiResponse } from "types/api/gameResult"; -import { validateId } from "utils/chess"; -import { getWSUri } from "utils/websockets"; - -const ColorName = { white: Color.White, black: Color.Black }; -interface JoinAPIResponse { - players: PlayersProps; - moves: MoveName[]; - offer_draw: boolean; - game_started: boolean; -} - -interface MoveAPIResponse { - move: MoveName; - players: PlayersAPI; -} - -interface GameStartedAPIResponse { - players: PlayersAPI; -} +import { CONNECTION_STATE, usePlayApi } from "./usePlayApi"; const playCss = css` display: flex; @@ -39,188 +17,21 @@ const playCss = css` height: 100%; `; -const enum ConnectingState { - Connecting, - Connected, - Error, -} - export default function Play() { - const [connectingState, setConnectingState] = React.useState(ConnectingState.Connecting); - const [color, setColor] = React.useState(Color.White); - const [gameResult, setGameResult] = React.useState(null); - const [highlightDrawButton, setHighlightDrawButton] = React.useState(false); - const [gameStarted, setGameStarted] = React.useState(true); - const [players, setPlayers] = React.useState(null); - const appContext = React.useContext(AppContext); + const { username: clientUsername } = appContext; - const ws = React.useRef(null); - const clientUsername = React.useRef(appContext.username); + const [color, setColor] = React.useState(Color.White); + const [gameStarted, setGameStarted] = React.useState(true); - const chessBoardStateHandlers = useChessBoardState({ color: color, isEnabled: gameStarted }); + const chessBoardStateHandlers = useChessBoardState({ color, isEnabled: gameStarted }); const { handleClientMakeMove } = chessBoardStateHandlers; - const { id } = useParams(); - - React.useEffect(() => { - clientUsername.current = appContext.username; - }, [appContext.username]); - - const setError = (error: string) => { - ErrorQueueClass.addError({ errorMessage: error }); - setConnectingState(ConnectingState.Error); - }; - - const handleGameIdValidation = (): boolean => { - const isValidId = validateId(id); - if (isValidId !== true) { - setError(isValidId); - return false; - } - return true; - }; - - const broadcastMove = (move: MoveInfo) => { - setHighlightDrawButton(false); - sendMessage(JSON.stringify({ type: "move", move: move.toName() })); - }; - - const handleOnMessage = (msg: MessageEvent) => { - const data = JSON.parse(msg.data); - switch (data.type) { - case "join": - handleJoined(data); - break; - case "game_started": - handleGameStarted(data); - break; - case "move": - handleMove(data); - break; - case "game_result": - handleGameResult(data); - break; - case "offer_draw": - handleReceivedDrawOffer(); - break; - case "error": - ErrorQueueClass.addError({ errorMessage: data.message }); - setConnectingState(ConnectingState.Error); - break; - default: - ErrorQueueClass.addError({ errorMessage: `Unknown message type received: ${data.type}` }); - } - }; - - const handleResign = () => { - sendMessage(JSON.stringify({ type: "resign" })); - }; - - const handleOfferDraw = () => { - sendMessage(JSON.stringify({ type: "offer_draw" })); - }; - - const handleReceivedDrawOffer = () => { - setHighlightDrawButton(true); - }; - - const updatePlayersFromAPI = (playersAPI: PlayersAPI) => { - const playersProps = {} as PlayersProps; - for (const [color, player] of Object.entries(playersAPI)) { - playersProps[ColorName[color as "white" | "black"]] = { - ...player, - }; - } - setPlayers(playersProps); - }; - - const updateMove = (moveName: MoveName) => { - const { move, promotionPiece } = Move.fromName(moveName); - handleClientMakeMove(move, promotionPiece); - }; - - const handleMove = (data: MoveAPIResponse) => { - setHighlightDrawButton(false); - updatePlayersFromAPI(data.players); - updateMove(data.move); - }; - - const handleGameResult = (data: GameResultApiResponse) => { - setHighlightDrawButton(false); - setGameResult({ - winner: data.winner, - termination: data.termination, - }); - }; - - const handleJoined = (data: JoinAPIResponse) => { - if (!clientUsername.current) return setError("Username is not set"); - - for (const [color, player] of Object.entries(data.players)) { - if (player.username === clientUsername.current) { - setColor(ColorName[color as "white" | "black"]); - } - } - updatePlayersFromAPI(data.players); - - for (const move of data.moves) { - updateMove(move); - } - - setGameStarted(data.game_started); - setHighlightDrawButton(data.offer_draw); - - setConnectingState(ConnectingState.Connected); - }; - - const joinGame = () => { - sendMessage( - JSON.stringify({ - type: "join", - game_id: id, - }), - ); - }; - - const handleGameStarted = (data: GameStartedAPIResponse) => { - setGameStarted(true); - updatePlayersFromAPI(data.players); - }; - - const sendMessage = (msg: string) => { - if (!ws.current || ws.current.readyState !== ws.current.OPEN) { - setError("Not connected to server"); - return; - } - ws.current.send(msg); - }; - - React.useEffect(() => { - if (!handleGameIdValidation()) return; - if (ws.current) return; - - const createWs = new WebSocket(getWSUri() + "/api/play/" + id); - - createWs.onopen = () => { - ws.current = createWs; - joinGame(); - }; - createWs.onmessage = (e) => { - handleOnMessage(e); - }; - createWs.onclose = (ev) => { - if (ev.code === 1000) return; // Normal closure - setError("Connection closed - CODE: " + ev.code); - }; - createWs.onerror = () => { - setError("Error connecting to server"); - }; + const { id: gameId } = useParams(); - return () => { - if (createWs.readyState === createWs.OPEN) createWs.close(); - }; - }, []); + const playGameApi = usePlayApi({ gameId, clientUsername, setColor, setGameStarted, handleClientMakeMove }); + const { connectionState, players, highlightDrawButton, gameResult, broadcastMove, handleResign, handleOfferDraw } = + playGameApi; const chessProps = { color: color, @@ -244,8 +55,8 @@ export default function Play() {
- {connectingState === ConnectingState.Connecting && } - {connectingState === ConnectingState.Connected && } + {connectionState === CONNECTION_STATE.CONNECTING && } + {connectionState === CONNECTION_STATE.CONNECTED && }
); diff --git a/frontend/src/components/Play/usePlayApi.ts b/frontend/src/components/Play/usePlayApi.ts new file mode 100644 index 0000000..46a25da --- /dev/null +++ b/frontend/src/components/Play/usePlayApi.ts @@ -0,0 +1,208 @@ +import { PlayersProps as PlayersAPI } from "components/shared/Chess/ActionBar/ActionBar"; +import { PlayersProps } from "components/shared/Chess/Chess"; +import { Move, MoveInfo, MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board"; +import { Color, PromotionPieceType } from "components/shared/Chess/ChessBoard/ChessLogic/pieces"; +import { ErrorQueueClass } from "components/shared/ErrorQueue/ErrorQueue"; +import React from "react"; +import { GameResultApiResponse } from "types/api/gameResult"; +import { + ErrorApiResponse, + GameStartedApiResponse, + JoinApiResponse, + MoveApiResponse, + PLAY_API_RESPONSE_TYPE, + PlayApiMessageType, + PlayGameResultApiResponse, + PlayOnMessageApiResponse, + SendApiMessageData, +} from "types/api/play"; +import { validateId } from "utils/chess"; +import { typedEntries } from "utils/utils"; +import { getWSUri } from "utils/websockets"; + +const ColorName = { white: Color.White, black: Color.Black }; + +export const CONNECTION_STATE = { + CONNECTING: "CONNECTING", + CONNECTED: "CONNECTED", + ERROR: "ERROR", +} as const; + +type Props = { + gameId: string | undefined; + clientUsername: string | null; + setColor: (color: Color) => void; + setGameStarted: (gameStarted: boolean) => void; + handleClientMakeMove: (move: Move, promotionPiece: PromotionPieceType | null) => void; +}; + +export const usePlayApi = (props: Props) => { + const { gameId, clientUsername, setColor, setGameStarted, handleClientMakeMove } = props; + + const [connectionState, setConnectionState] = React.useState( + CONNECTION_STATE.CONNECTING, + ); + const [gameResult, setGameResult] = React.useState(null); + const [highlightDrawButton, setHighlightDrawButton] = React.useState(false); + const [players, setPlayers] = React.useState(null); + + const ws = React.useRef(null); + + const setError = (error: string) => { + ErrorQueueClass.addError({ errorMessage: error }); + setConnectionState(CONNECTION_STATE.ERROR); + }; + + const handleGameIdValidation = (): boolean => { + const isValidId = validateId(gameId); + if (!isValidId) setError(isValidId); + + return !!isValidId; + }; + + const handleOnMessage = (message: MessageEvent) => { + const data: PlayOnMessageApiResponse = JSON.parse(message.data); + + const ON_MESSAGE_HANDLERS: Record void> = { + [PLAY_API_RESPONSE_TYPE.JOIN]: handleJoined, + [PLAY_API_RESPONSE_TYPE.GAME_STARTED]: handleGameStarted, + [PLAY_API_RESPONSE_TYPE.MOVE]: handleMove, + [PLAY_API_RESPONSE_TYPE.GAME_RESULT]: handleGameResult, + [PLAY_API_RESPONSE_TYPE.OFFER_DRAW]: handleReceivedDrawOffer, + [PLAY_API_RESPONSE_TYPE.ERROR]: handleErrorResponse, + }; + + const onMessageHandler = ON_MESSAGE_HANDLERS[data.type]; + if (!onMessageHandler) { + ErrorQueueClass.addError({ errorMessage: `Unknown message type received: ${data.type}` }); + return; + } + + onMessageHandler(data); + }; + + const handleErrorResponse = (data: ErrorApiResponse) => { + ErrorQueueClass.addError({ errorMessage: data.message }); + setConnectionState(CONNECTION_STATE.ERROR); + }; + + const handleReceivedDrawOffer = () => { + setHighlightDrawButton(true); + }; + + const updatePlayersFromAPI = (playersAPI: PlayersAPI) => { + const playersProps = {} as PlayersProps; + for (const [color, player] of typedEntries(playersAPI)) { + playersProps[ColorName[color]] = { ...player }; + } + setPlayers(playersProps); + }; + + const updateMove = (moveName: MoveName) => { + const { move, promotionPiece } = Move.fromName(moveName); + handleClientMakeMove(move, promotionPiece); + }; + + const handleMove = (data: MoveApiResponse) => { + setHighlightDrawButton(false); + updatePlayersFromAPI(data.players); + updateMove(data.move); + }; + + const handleGameResult = (data: PlayGameResultApiResponse) => { + const { winner, termination } = data; + + setHighlightDrawButton(false); + setGameResult({ winner, termination }); + }; + + const handleJoined = (data: JoinApiResponse) => { + if (!clientUsername) return setError("Username is not set"); + + for (const [color, player] of typedEntries(data.players)) { + const isPlayer = player.username === clientUsername; + if (isPlayer) setColor(ColorName[color]); + } + updatePlayersFromAPI(data.players); + + for (const move of data.moves) { + updateMove(move); + } + + setGameStarted(data.game_started); + setHighlightDrawButton(data.offer_draw); + + setConnectionState(CONNECTION_STATE.CONNECTED); + }; + + const handleGameStarted = (data: GameStartedApiResponse) => { + setGameStarted(true); + updatePlayersFromAPI(data.players); + }; + + const sendMessage = (data: SendApiMessageData) => { + if (!ws.current || ws.current.readyState !== ws.current.OPEN) { + setError("Not connected to server"); + return; + } + + const messageData = JSON.stringify(data); + ws.current.send(messageData); + }; + + React.useEffect(() => { + if (!handleGameIdValidation()) return; + if (!clientUsername) return; + if (ws.current) return; + + const createWs = new WebSocket(getWSUri() + "/api/play/" + gameId); + + createWs.onopen = () => { + ws.current = createWs; + joinGame(); + }; + createWs.onmessage = (event) => { + handleOnMessage(event); + }; + createWs.onclose = (event) => { + const REGULAR_CLOSE_CODE = 1000; + if (event.code === REGULAR_CLOSE_CODE) return; + + setError("Connection closed - CODE: " + event.code); + }; + createWs.onerror = () => { + setError("Error connecting to server"); + }; + + return () => { + if (createWs.readyState === createWs.OPEN) createWs.close(); + }; + }, [clientUsername]); + + const joinGame = () => { + sendMessage({ type: "join", game_id: gameId }); + }; + + const broadcastMove = (move: MoveInfo) => { + setHighlightDrawButton(false); + sendMessage({ type: "move", move: move.toName() }); + }; + + const handleResign = () => { + sendMessage({ type: "resign" }); + }; + + const handleOfferDraw = () => { + sendMessage({ type: "offer_draw" }); + }; + + return { + players, + gameResult, + highlightDrawButton, + connectionState, + broadcastMove, + handleResign, + handleOfferDraw, + }; +}; diff --git a/frontend/src/components/shared/ErrorQueue/ErrorQueue.tsx b/frontend/src/components/shared/ErrorQueue/ErrorQueue.tsx index 83c7652..80773dd 100644 --- a/frontend/src/components/shared/ErrorQueue/ErrorQueue.tsx +++ b/frontend/src/components/shared/ErrorQueue/ErrorQueue.tsx @@ -7,6 +7,8 @@ export class ErrorQueueClass { private static ErrorQueue: ErrorType[] = []; public static setErrorFnCallback: (error: ErrorType) => void = () => {}; public static addError(error: ErrorType) { + console.error("WebSocket error:", error); + ErrorQueueClass.ErrorQueue.push(error); if (ErrorQueueClass.ErrorQueue.length > 1) return; diff --git a/frontend/src/hooks/appContext.ts b/frontend/src/hooks/appContext.ts index 048c99e..450fe12 100644 --- a/frontend/src/hooks/appContext.ts +++ b/frontend/src/hooks/appContext.ts @@ -1,6 +1,6 @@ import React from "react"; -type AppContextType = { +export type AppContextType = { username: string | null; setUsername: (username: string | null) => void; }; diff --git a/frontend/src/types/api/play.ts b/frontend/src/types/api/play.ts new file mode 100644 index 0000000..3ce41de --- /dev/null +++ b/frontend/src/types/api/play.ts @@ -0,0 +1,84 @@ +import { PlayersProps } from "components/shared/Chess/Chess"; +import { MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board"; +import { GameResultApiResponse } from "./gameResult"; + +export const PLAY_API_RESPONSE_TYPE = { + JOIN: "join", + GAME_STARTED: "game_started", + MOVE: "move", + GAME_RESULT: "game_result", + OFFER_DRAW: "offer_draw", + ERROR: "error", +} as const; +export type PlayApiMessageType = (typeof PLAY_API_RESPONSE_TYPE)[keyof typeof PLAY_API_RESPONSE_TYPE]; + +export type PlayOnMessageApiResponse = + | JoinApiResponse + | GameStartedApiResponse + | MoveApiResponse + | PlayGameResultApiResponse + | OfferDrawApiResponse + | ErrorApiResponse; + +export type JoinApiResponse = { + type: typeof PLAY_API_RESPONSE_TYPE.JOIN; + players: PlayersProps; + moves: MoveName[]; + offer_draw: boolean; + game_started: boolean; +}; + +export type GameStartedApiResponse = { + type: typeof PLAY_API_RESPONSE_TYPE.GAME_STARTED; + players: PlayersProps; +}; + +export type MoveApiResponse = { + type: typeof PLAY_API_RESPONSE_TYPE.MOVE; + move: MoveName; + players: PlayersProps; +}; + +export type PlayGameResultApiResponse = GameResultApiResponse & { + type: typeof PLAY_API_RESPONSE_TYPE.GAME_RESULT; +}; + +type OfferDrawApiResponse = { + type: typeof PLAY_API_RESPONSE_TYPE.OFFER_DRAW; +}; + +export type ErrorApiResponse = { + type: typeof PLAY_API_RESPONSE_TYPE.ERROR; + message: string; +}; + +export const PLAY_API_MESSAGE_TYPE = { + JOIN: PLAY_API_RESPONSE_TYPE.JOIN, + MOVE: PLAY_API_RESPONSE_TYPE.MOVE, + OFFER_DRAW: PLAY_API_RESPONSE_TYPE.OFFER_DRAW, + RESIGN: "resign", +}; + +export type SendApiMessageData = + | SendApiJoinMessageData + | SendApiMoveMessageData + | SendApiOfferDrawMessageData + | SendApiResignMessageData; + +type SendApiJoinMessageData = { + type: typeof PLAY_API_MESSAGE_TYPE.JOIN; + game_id: string; +}; + +export type SendApiMoveMessageData = { + type: typeof PLAY_API_MESSAGE_TYPE.MOVE; + move: MoveName; +}; + +type SendApiOfferDrawMessageData = { + type: typeof PLAY_API_MESSAGE_TYPE.OFFER_DRAW; +}; + +type SendApiResignMessageData = { + type: typeof PLAY_API_MESSAGE_TYPE.RESIGN; +}; diff --git a/frontend/src/utils/utils.tsx b/frontend/src/utils/utils.tsx index 94ef94c..9ce5b72 100644 --- a/frontend/src/utils/utils.tsx +++ b/frontend/src/utils/utils.tsx @@ -43,3 +43,7 @@ export function getBaseUri() { export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } + +export function typedEntries(obj: T): Array<[keyof T, T[keyof T]]> { + return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; +} diff --git a/frontend/test/components/Play/Play.test.tsx b/frontend/test/components/Play/Play.test.tsx new file mode 100644 index 0000000..04a1195 --- /dev/null +++ b/frontend/test/components/Play/Play.test.tsx @@ -0,0 +1,66 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import Play from "components/Play/Play"; +import { AppContext, AppContextType } from "hooks/appContext"; +import { expect, test, vi } from "vitest"; +import { MockWebSocket } from "../../mockWebSocket"; + +const mockWebsocket = () => { + const mockedWebSocket = new MockWebSocket(); + + global.WebSocket = MockWebSocket as unknown as typeof WebSocket; + + return mockedWebSocket; +}; + +const loadGame = async () => { + const websocket = mockWebsocket(); + vi.mock("react-router-dom", async () => ({ + useParams: () => ({ id: "test-game-id" }), + useNavigate: () => vi.fn(), + })); + + render( + + + , + ); + + await act(async () => { + websocket.triggerOpen(); + }); + + await act(async () => { + websocket.triggerMessage( + JSON.stringify({ + type: "join", + players: { + white: { username: "Player1", color: "white" }, + black: { username: "Player2", color: "black" }, + }, + moves: [], + offer_draw: false, + game_started: true, + }), + ); + }); + + await waitFor(() => screen.queryAllByText("Connecting...").length === 0); +}; + +test("Should display loading screen before game is loaded", async () => { + mockWebsocket(); + vi.mock("react-router-dom", async () => ({ + useParams: () => ({ id: "test-game-id" }), + useNavigate: () => vi.fn(), + })); + + render(); + + expect(screen.getByText("Connecting...")).toBeTruthy(); +}); + +test("Should display chessboard after game is loaded", async () => { + await loadGame(); + + expect(screen.getByTestId("square-0-0", { exact: false })).toBeTruthy(); +}); diff --git a/frontend/test/mockWebSocket.ts b/frontend/test/mockWebSocket.ts new file mode 100644 index 0000000..cec28b2 --- /dev/null +++ b/frontend/test/mockWebSocket.ts @@ -0,0 +1,56 @@ +export class MockWebSocket { + static singletonOnopen: (() => void) | null = null; + static singletonOnmessage: ((event: MessageEvent) => void) | null = null; + static singletonOnclose: ((event: CloseEvent) => void) | null = null; + static singletonOnerror: (() => void) | null = null; + + set onopen(callback: () => void) { + MockWebSocket.singletonOnopen = callback; + } + + set onmessage(callback: (event: MessageEvent) => void) { + MockWebSocket.singletonOnmessage = callback; + } + + set onclose(callback: (event: CloseEvent) => void) { + MockWebSocket.singletonOnclose = callback; + } + + set onerror(callback: () => void) { + MockWebSocket.singletonOnerror = callback; + } + + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + + static readyState = MockWebSocket.CONNECTING; + + triggerOpen() { + MockWebSocket.readyState = MockWebSocket.OPEN; + MockWebSocket.singletonOnopen?.(); + } + + triggerMessage(data: unknown) { + MockWebSocket.singletonOnmessage?.({ data } as MessageEvent); + } + + triggerClose(code = 1000) { + MockWebSocket.readyState = MockWebSocket.CLOSED; + MockWebSocket.singletonOnclose?.({ code } as CloseEvent); + } + + triggerError() { + MockWebSocket.singletonOnerror?.(); + } + + close() { + MockWebSocket.readyState = MockWebSocket.CLOSING; + setTimeout(() => { + MockWebSocket.readyState = MockWebSocket.CLOSED; + }, 10); + } + + send() {} +}