diff --git a/backend/api/play/utils.py b/backend/api/play/utils.py index 084c33b..1035d91 100644 --- a/backend/api/play/utils.py +++ b/backend/api/play/utils.py @@ -126,4 +126,4 @@ def get_player_games_json(player: Player, page: int, limit: int, include_moves: """Returns a list of games played by the player with the given username.""" games = Game.getGamesByPlayer(player).order_by("-date")[limit * (page - 1) : min(limit * page, 2**63)] - return [game_to_dict(game, include_moves) for game in games] + return [game_to_dict(game, include_moves, relativeUserStatusToPlayer=player) for game in games] diff --git a/backend/tests/unit/api/play/test_utils.py b/backend/tests/unit/api/play/test_utils.py index 1db3e67..70cf52a 100644 --- a/backend/tests/unit/api/play/test_utils.py +++ b/backend/tests/unit/api/play/test_utils.py @@ -63,14 +63,19 @@ def test_get_player_games_json() -> None: games = get_player_games_json(player1, page=1, limit=10) assert len(games) == 1 assert games[0]["game_id"] == 1 - assert games[0]["players"]["white"] == {"user_type": "registered", "username": "user1"} - assert games[0]["players"]["black"] == {"user_type": "registered", "username": "user2"} + assert games[0]["players"]["white"] == {"user_type": "registered", "username": "user1", "is_current_user": True} + assert games[0]["players"]["black"] == { + "user_type": "registered", + "username": "user2", + "is_current_user": False, + "status": FriendStatus.notFriends.value, + } games2 = get_player_games_json(player2, page=1, limit=10) assert len(games2) == 2 assert games2[0]["game_id"] == 2 - assert games2[0]["players"]["white"] == {"user_type": "registered", "username": "user2"} - assert games2[0]["players"]["black"] == {"user_type": "anonymous", "user_id": 1} + assert games2[0]["players"]["white"] == {"user_type": "registered", "username": "user2", "is_current_user": True} + assert games2[0]["players"]["black"] == {"user_type": "anonymous", "user_id": 1, "is_current_user": False} assert games2[1]["game_id"] == 1 diff --git a/frontend/src/components/Profile/Profile.tsx b/frontend/src/components/Profile/Profile.tsx index 2574bd4..187384a 100644 --- a/frontend/src/components/Profile/Profile.tsx +++ b/frontend/src/components/Profile/Profile.tsx @@ -61,7 +61,7 @@ export default function Profile() { friendStatus={(profileData && profileData.friend_status) || null} username={username} /> - + ); diff --git a/frontend/src/components/Profile/RecentGames/GameRow/GameRow.tsx b/frontend/src/components/Profile/RecentGames/GameRow/GameRow.tsx index 9cf0c48..8b135ec 100644 --- a/frontend/src/components/Profile/RecentGames/GameRow/GameRow.tsx +++ b/frontend/src/components/Profile/RecentGames/GameRow/GameRow.tsx @@ -2,10 +2,12 @@ import { GAME_TERMINATION_EXPLANATION } from "components/constants"; import React from "react"; import { useNavigate } from "react-router-dom"; -import { GAME_WINNER, GameApiResponse, GameWinner } from "types/api/game"; +import { GAME_WINNER, GameWinner, SimpleGameApiResponse } from "types/api/game"; +import { PlayersApi } from "types/api/player"; import { GAME_OUTCOME, GameOutcome, PlayerColor, PlayerInfo } from "types/game"; import { formatDateString, secToTime } from "utils/utils"; import { CSS } from "./css"; +import OpponentUserButton from "./OpponentUserButton/OpponentUserButton"; const GAME_OUTCOME_TEXT = { [GAME_OUTCOME.WON]: "Won", @@ -13,26 +15,21 @@ const GAME_OUTCOME_TEXT = { [GAME_OUTCOME.DRAW]: "Draw", } as const; -type Props = { game: GameApiResponse; username: string; width: number }; +type Props = { game: SimpleGameApiResponse; width: number }; export default function GameRow(props: Props) { - const { game, username, width } = props; + const { game, width } = props; - const [userHovered, setUserHovered] = React.useState(false); + const [isOpponentButtonHovered, setIsOpponentButtonHovered] = React.useState(false); const navigate = useNavigate(); - const handleUserOnClick = (event: React.MouseEvent) => { - event.stopPropagation(); - navigate(`/profile/${opponent.username}`); - }; - const handleGameOnClick = () => { navigate(`/replay_game/${game.game_id}`); }; const displayResultLong = width > 800; - const { player, opponent } = getPlayerInfos(game.players, username); + const { player, opponent } = getPlayerInfos(game.players); const gameOutcome = getGameOutcome(game.winner_color, player.color); const gameOutcomeText = GAME_OUTCOME_TEXT[gameOutcome]; @@ -44,17 +41,16 @@ export default function GameRow(props: Props) { const dateString = formatDateString(props.game.date); return ( - - setUserHovered(true)} - onMouseLeave={() => setUserHovered(false)} - > - {opponent.username} - + + - {gameOutcomeText} + + {gameOutcomeText} + {displayResultLong && ` ${resultText}`} {timeControl} @@ -77,11 +73,8 @@ const getGameOutcome = (winnerColor: GameWinner, playerColor: PlayerColor): Game /** * Gets the player infos matching their username to their color, returns both player and opponent. */ -const getPlayerInfos = ( - players: GameApiResponse["players"], - username: string, -): { player: PlayerInfo; opponent: PlayerInfo } => { - const isPlayerWhite = players.white.username === username; +const getPlayerInfos = (players: PlayersApi): { player: PlayerInfo; opponent: PlayerInfo } => { + const isPlayerWhite = players.white.is_current_user; const playerColor = isPlayerWhite ? GAME_WINNER.WHITE : GAME_WINNER.BLACK; const player = getPlayerInfo(players, playerColor); @@ -91,8 +84,8 @@ const getPlayerInfos = ( return { player, opponent }; }; -const getPlayerInfo = (players: GameApiResponse["players"], playerColor: PlayerColor): PlayerInfo => { - const { username } = players[playerColor]; +const getPlayerInfo = (players: PlayersApi, playerColor: PlayerColor): PlayerInfo => { + const playerApi = players[playerColor]; - return { username, color: playerColor }; + return { ...playerApi, color: playerColor }; }; diff --git a/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/OpponentUserButton.tsx b/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/OpponentUserButton.tsx new file mode 100644 index 0000000..5b80672 --- /dev/null +++ b/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/OpponentUserButton.tsx @@ -0,0 +1,60 @@ +/** @jsxImportSource @emotion/react */ +import ProfilePicture from "components/shared/ProfilePicture"; +import React from "react"; +import { IoIosGlobe } from "react-icons/io"; +import { useNavigate } from "react-router-dom"; +import { PlayerApi, RegisteredPlayerApi } from "types/api/player"; +import { CSS } from "./css"; + +type Props = { + opponent: PlayerApi; + setIsOpponentButtonHovered: React.Dispatch>; +}; + +export default function OpponentUserButton(props: Props) { + const { opponent, setIsOpponentButtonHovered } = props; + + const navigate = useNavigate(); + + const handleOpponentOnClick = (event: React.MouseEvent) => { + if (!isOpponentRegistered) return; + + event.stopPropagation(); + navigate(`/profile/${opponent.username}`); + }; + + const isOpponentRegistered = opponent.user_type !== "anonymous"; + const OpponentDisplay = isOpponentRegistered + ? getRegisteredOpponentDisplay(opponent) + : getAnonymousOpponentDisplay(); + + return ( + isOpponentRegistered && setIsOpponentButtonHovered(true)} + onMouseLeave={() => isOpponentRegistered && setIsOpponentButtonHovered(false)} + data-testid={`opponent-user-button`} + > + {OpponentDisplay} + + ); +} + +const getRegisteredOpponentDisplay = (opponent: RegisteredPlayerApi) => { + return ( +
+ + {opponent.username} +
+ ); +}; + +const getAnonymousOpponentDisplay = () => { + return ( +
+ + Anonymous +
+ ); +}; diff --git a/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/css.ts b/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/css.ts new file mode 100644 index 0000000..06a5956 --- /dev/null +++ b/frontend/src/components/Profile/RecentGames/GameRow/OpponentUserButton/css.ts @@ -0,0 +1,30 @@ +import { css } from "@emotion/react"; + +const OPPONENT_PROFILE_BUTTON = css` + :hover { + text-decoration: underline; + background-color: #4d4d4d; + } +`; +const PROFILE_PICTURE_ICON = css` + width: 1.75em; + height: 1.75em; +`; +const ANONYMOUS_USER_ICON = css` + width: 2.2em; + height: 2.2em; + // The icon is not 100% size so to scale it the same as the profile picture we have to adjust the margin + margin: -0.25em; +`; +const OPPONENT_USER_WRAPPER = css` + display: flex; + align-items: center; + gap: 0.5em; +`; + +export const CSS = { + OPPONENT_PROFILE_BUTTON, + PROFILE_PICTURE_ICON, + ANONYMOUS_USER_ICON, + OPPONENT_USER_WRAPPER, +} as const; diff --git a/frontend/src/components/Profile/RecentGames/GameRow/css.ts b/frontend/src/components/Profile/RecentGames/GameRow/css.ts index ebe5b0d..a14259c 100644 --- a/frontend/src/components/Profile/RecentGames/GameRow/css.ts +++ b/frontend/src/components/Profile/RecentGames/GameRow/css.ts @@ -19,12 +19,6 @@ const HOVER_ENABLED = css` } } `; -const USERNAME = css` - :hover { - text-decoration: underline; - background-color: #4d4d4d; - } -`; const WINNER_COLORS = { [GAME_OUTCOME.WON]: css` color: #00ff00; @@ -40,6 +34,5 @@ const WINNER_COLORS = { export const CSS = { GAME_ROW, HOVER_ENABLED, - USERNAME, WINNER_COLORS, } as const; diff --git a/frontend/src/components/Profile/RecentGames/RecentGames.tsx b/frontend/src/components/Profile/RecentGames/RecentGames.tsx index aa106b6..c3f4232 100644 --- a/frontend/src/components/Profile/RecentGames/RecentGames.tsx +++ b/frontend/src/components/Profile/RecentGames/RecentGames.tsx @@ -2,7 +2,7 @@ import { css } from "@emotion/react"; import Paper from "components/shared/Paper"; import useWindowSize from "hooks/useWindowSize"; -import { GameApiResponse } from "types/api/game"; +import { SimpleGameApiResponse } from "types/api/game"; import GameRow from "./GameRow/GameRow"; import HeaderRow from "./HeaderRow/HeaderRow"; @@ -42,22 +42,23 @@ const GamesTableCss = css` } `; -type Props = { games: GameApiResponse[] | null; username: string }; +type Props = { games: SimpleGameApiResponse[] | null }; export default function RecentGames(props: Props) { const size = useWindowSize(); if (size.width === undefined) return null; + const GameRows = + props.games && + props.games.map((game, index) => { + return ; + }); + return (

Recent Games

- - {props.games && - props.games.map((game, index) => ( - - ))} - + {GameRows}
); diff --git a/frontend/src/components/ReplayGame/hooks/useReplayGame.ts b/frontend/src/components/ReplayGame/hooks/useReplayGame.ts index d5d148d..d7dc8a9 100644 --- a/frontend/src/components/ReplayGame/hooks/useReplayGame.ts +++ b/frontend/src/components/ReplayGame/hooks/useReplayGame.ts @@ -6,8 +6,8 @@ import { useChessBoardState } from "components/shared/Chess/useChessBoardState/u import { ErrorQueueClass } from "components/shared/ErrorQueue/ErrorQueue"; import React from "react"; import { useParams } from "react-router-dom"; +import { GameApiResponse } from "types/api/game"; import { GameResultApiResponse } from "types/api/gameResult"; -import { ReplayGameAPIResponse } from "types/api/replayGame"; import { validateId } from "utils/chess"; import { parsePlayerApiResponse } from "utils/players"; import { useReplayGameActions } from "./useReplayGameActions"; @@ -65,7 +65,7 @@ export const useReplayGame = () => { /** * Updates the state with the response from the API. */ - const handleReplayFromApiResponse = (response: ReplayGameAPIResponse) => { + const handleReplayFromApiResponse = (response: GameApiResponse) => { const { moves, players, termination, winner_color } = response; const color = players.white.is_current_user ? Color.White : Color.Black; diff --git a/frontend/src/types/api/game.ts b/frontend/src/types/api/game.ts index ab41b10..170c6e4 100644 --- a/frontend/src/types/api/game.ts +++ b/frontend/src/types/api/game.ts @@ -1,12 +1,19 @@ -export type GameApiResponse = { +import { MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board"; +import { GamePlayersApi, PlayersApi } from "./player"; + +export type GameApiResponse = SimpleGameApiResponse & { + moves: MoveName[]; + players: GamePlayersApi; +}; + +export type SimpleGameApiResponse = { game_id: number; - players: { white: { username: string }; black: { username: string } }; + players: PlayersApi; termination: GameTermination; winner_color: GameWinner; time_control: number; date: string; }; - export const GAME_WINNER = { WHITE: "white", BLACK: "black", diff --git a/frontend/src/types/api/player.ts b/frontend/src/types/api/player.ts index b46a5a0..9fc8814 100644 --- a/frontend/src/types/api/player.ts +++ b/frontend/src/types/api/player.ts @@ -16,7 +16,7 @@ export type GamePlayerApi = PlayerApi & { time: number; }; -type RegisteredPlayerApi = { +export type RegisteredPlayerApi = { user_type: "registered"; username: string; }; diff --git a/frontend/src/types/api/profile.ts b/frontend/src/types/api/profile.ts index 72034fe..9a45df8 100644 --- a/frontend/src/types/api/profile.ts +++ b/frontend/src/types/api/profile.ts @@ -1,9 +1,9 @@ import { Statuses } from "types/friendStatuses"; -import { GameApiResponse } from "./game"; +import { SimpleGameApiResponse } from "./game"; export type ProfileApiResponse = { date_joined: string; - games: GameApiResponse[]; + games: SimpleGameApiResponse[]; total_games: number; total_friends: number; friend_status?: Statuses; diff --git a/frontend/src/types/api/replayGame.ts b/frontend/src/types/api/replayGame.ts deleted file mode 100644 index 2b7b46a..0000000 --- a/frontend/src/types/api/replayGame.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MoveName } from "components/shared/Chess/ChessBoard/ChessLogic/board"; -import { GameTermination, GameWinner } from "./game"; -import { GamePlayersApi } from "./player"; - -export type ReplayGameAPIResponse = { - moves: MoveName[]; - players: GamePlayersApi; - termination: GameTermination; - date: Date; - winner_color: GameWinner; -}; diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 464a7b5..15e8028 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -1,4 +1,5 @@ import { GAME_WINNER } from "./api/game"; +import { PlayerApi } from "./api/player"; export const GAME_OUTCOME = { WON: "WON", @@ -7,8 +8,7 @@ export const GAME_OUTCOME = { } as const; export type GameOutcome = keyof typeof GAME_OUTCOME; -export type PlayerInfo = { - username: string; +export type PlayerInfo = PlayerApi & { color: PlayerColor; }; diff --git a/frontend/test/components/Profile/Profile.test.tsx b/frontend/test/components/Profile/Profile.test.tsx new file mode 100644 index 0000000..670f6ef --- /dev/null +++ b/frontend/test/components/Profile/Profile.test.tsx @@ -0,0 +1,62 @@ +import { act, render, screen, within } from "@testing-library/react"; +import * as axios from "axios"; +import Profile from "components/Profile/Profile"; +import { ProfileApiResponse } from "types/api/profile"; +import { expect, test, vi } from "vitest"; + +const mockGameResponse = { termination: "checkmate", time_control: 1800, date: "2023-01-01T00:00:00Z" } as const; +const MOCK_API_RESPONSE: ProfileApiResponse = { + date_joined: "2023-01-01T00:00:00Z", + games: [ + { + ...mockGameResponse, + players: { + white: { user_type: "registered", username: "Player1", is_current_user: true }, + black: { user_type: "anonymous", user_id: 1, is_current_user: false }, + }, + winner_color: "white", + game_id: 1, + }, + { + ...mockGameResponse, + players: { + white: { user_type: "registered", username: "Player1", is_current_user: true }, + black: { user_type: "registered", username: "Player2", is_current_user: false }, + }, + winner_color: "black", + game_id: 2, + }, + ], + total_friends: 1, + total_games: 1, +}; + +vi.mock("axios"); +vi.mock("react-router-dom", () => ({ + ...vi.importActual("react-router-dom"), + useParams: vi.fn().mockReturnValue(() => {}), + useNavigate: vi.fn().mockReturnValue(() => {}), +})); + +test("Should load user games information", async () => { + vi.spyOn(axios, "default").mockImplementationOnce( + () => new Promise((resolve) => resolve({ data: MOCK_API_RESPONSE })), + ); + + await act(async () => { + render(); + }); + + const gamesRows = screen.getAllByTestId(/game-row/i); + expect(gamesRows).toHaveLength(2); + + const gameOutcome1 = within(gamesRows[0]).getByTestId("game-outcome-text"); + expect(gameOutcome1).toHaveTextContent("Won"); + const opponent1 = within(gamesRows[0]).getByTestId("opponent-user-button"); + expect(opponent1).toHaveTextContent("Anonymous"); + + const gameOutcome2 = within(gamesRows[1]).getByTestId("game-outcome-text"); + expect(gameOutcome2).toHaveTextContent("Lost"); + const opponent2 = within(gamesRows[1]).getByTestId("opponent-user-button"); + expect(opponent2).toHaveTextContent("Player2"); +}); diff --git a/frontend/test/components/ReplayGame/ReplayGame.test.tsx b/frontend/test/components/ReplayGame/ReplayGame.test.tsx index 80437de..f8c4216 100644 --- a/frontend/test/components/ReplayGame/ReplayGame.test.tsx +++ b/frontend/test/components/ReplayGame/ReplayGame.test.tsx @@ -2,10 +2,13 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as axios from "axios"; import { ReplayGame } from "components/ReplayGame/ReplayGame"; -import { ReplayGameAPIResponse } from "types/api/replayGame"; +import { GameApiResponse } from "types/api/game"; import { describe, expect, test, vi } from "vitest"; -const MOCK_API_RESPONSE: ReplayGameAPIResponse = { +const MOCK_API_RESPONSE: GameApiResponse = { + game_id: 1, + time_control: 1800, + date: "2023-01-01T00:00:00Z", moves: ["e2e4", "e7e5", "d2d4"], players: { white: { user_type: "registered", username: "Player1", time: 0, status: "not_friends" }, @@ -13,7 +16,6 @@ const MOCK_API_RESPONSE: ReplayGameAPIResponse = { }, termination: "resignation", winner_color: "white", - date: new Date(), }; vi.mock("axios"); diff --git a/frontend/test/setupTests.ts b/frontend/test/setupTests.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/frontend/test/setupTests.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js index 3a2b70a..b58e3b2 100644 --- a/frontend/vitest.config.js +++ b/frontend/vitest.config.js @@ -1,7 +1,12 @@ import path from "path"; +import { defineConfig } from "vitest/config"; -export default { - test: { environment: "jsdom", globals: true }, +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + setupFiles: "./test/setupTests.ts", + }, resolve: { alias: { components: path.resolve(__dirname, "./src/components/"), @@ -11,4 +16,4 @@ export default { css: path.resolve(__dirname, "./src/css/"), }, }, -}; +});