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/"),
},
},
-};
+});