From 4e22c3e8b5d9f5b4986179d6f907dfe1fa24e390 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 10 Mar 2026 14:22:37 +0100 Subject: [PATCH 1/9] feat: add test to frontend --- .gitignore | 2 +- client/app/components/game/GameBoard.tsx | 129 +++ client/app/components/game/GamePage.tsx | 166 ++++ client/app/components/game/OpponentCard.tsx | 85 ++ client/app/components/game/PiecePreview.tsx | 58 ++ client/app/components/game/RoomControls.tsx | 260 ++++++ client/app/hooks/useGameSocket.ts | 281 +++++++ client/app/hooks/useKeyboardControls.ts | 80 ++ client/app/routes/game.tsx | 826 +------------------ client/app/utils/pieceHelpers.ts | 42 + client/app/welcome/logo-dark.svg | 23 - client/app/welcome/logo-light.svg | 23 - client/package.json | 12 +- client/tests/GameModeSelector.test.tsx | 159 ++++ client/tests/LoadingOverlay.test.tsx | 23 + client/tests/LoginForm.test.tsx | 128 +++ client/tests/ProtectedRoute.test.tsx | 116 +++ client/tests/PublicOnlyRoute.test.tsx | 97 +++ client/tests/RegisterForm.test.tsx | 147 ++++ client/tests/authService.test.ts | 204 +++++ client/tests/authSlice.test.ts | 256 ++++++ client/tests/constants.test.ts | 112 +++ client/tests/game.test.tsx | 842 ++++++++++++++++++++ client/tests/gamesService.test.ts | 213 +++++ client/tests/hello.test.ts | 10 - client/tests/hooks.test.tsx | 47 ++ client/tests/pieceHelpers.test.ts | 98 +++ client/tests/routes.test.tsx | 131 +++ client/tests/setup.ts | 30 + client/tests/store.test.ts | 27 + client/tests/tetrominos.test.ts | 104 +++ client/tests/welcome.test.tsx | 180 +++++ client/vitest.config.ts | 33 + pnpm-lock.yaml | 801 ++++++++++++++++++- 34 files changed, 4861 insertions(+), 884 deletions(-) create mode 100644 client/app/components/game/GameBoard.tsx create mode 100644 client/app/components/game/GamePage.tsx create mode 100644 client/app/components/game/OpponentCard.tsx create mode 100644 client/app/components/game/PiecePreview.tsx create mode 100644 client/app/components/game/RoomControls.tsx create mode 100644 client/app/hooks/useGameSocket.ts create mode 100644 client/app/hooks/useKeyboardControls.ts create mode 100644 client/app/utils/pieceHelpers.ts delete mode 100644 client/app/welcome/logo-dark.svg delete mode 100644 client/app/welcome/logo-light.svg create mode 100644 client/tests/GameModeSelector.test.tsx create mode 100644 client/tests/LoadingOverlay.test.tsx create mode 100644 client/tests/LoginForm.test.tsx create mode 100644 client/tests/ProtectedRoute.test.tsx create mode 100644 client/tests/PublicOnlyRoute.test.tsx create mode 100644 client/tests/RegisterForm.test.tsx create mode 100644 client/tests/authService.test.ts create mode 100644 client/tests/authSlice.test.ts create mode 100644 client/tests/constants.test.ts create mode 100644 client/tests/game.test.tsx create mode 100644 client/tests/gamesService.test.ts delete mode 100644 client/tests/hello.test.ts create mode 100644 client/tests/hooks.test.tsx create mode 100644 client/tests/pieceHelpers.test.ts create mode 100644 client/tests/routes.test.tsx create mode 100644 client/tests/setup.ts create mode 100644 client/tests/store.test.ts create mode 100644 client/tests/tetrominos.test.ts create mode 100644 client/tests/welcome.test.tsx create mode 100644 client/vitest.config.ts diff --git a/.gitignore b/.gitignore index 0389400..a0805d9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ package-lock.json .pnpm-store # testing -/coverage +coverage # next.js /.next/ diff --git a/client/app/components/game/GameBoard.tsx b/client/app/components/game/GameBoard.tsx new file mode 100644 index 0000000..df9f21f --- /dev/null +++ b/client/app/components/game/GameBoard.tsx @@ -0,0 +1,129 @@ +import { useMemo, type JSX } from 'react'; + +interface GameBoardProps { + board: number[][] | undefined; + idColors: string[]; + width: number; + height: number; + cellSize: number; + maxWidth: number; + maxHeight: number; + score: number; +} + +/** + * Renders the main Tetris game board with all cells, inside a fixed-size container. + */ +export function GameBoard({ board, idColors, width, height, cellSize, maxWidth, maxHeight, score }: GameBoardProps) { + const cells = useMemo(() => { + const result: JSX.Element[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + result.push(renderCell(x, y, board, idColors, cellSize)); + } + } + return result; + }, [board, idColors, width, height, cellSize]); + + return ( +
+
+
+ {cells} +
+
+ +
+
+ {score} +
Score
+
+
+
+ ); +} + +function renderCell( + x: number, + y: number, + board: number[][] | undefined, + idColors: string[], + cellSize: number, +): JSX.Element { + const val = board?.[y]?.[x] ?? 0; + + if (val === 0) { + return ( +
+ ); + } + + if (val === -2) { + return ( +
+ ); + } + + if (val === -1) { + return ( +
+ ); + } + + const color = idColors[val] ?? '#999'; + return ( +
+ ); +} diff --git a/client/app/components/game/GamePage.tsx b/client/app/components/game/GamePage.tsx new file mode 100644 index 0000000..539e754 --- /dev/null +++ b/client/app/components/game/GamePage.tsx @@ -0,0 +1,166 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router'; +import { LoadingOverlay } from '../LoadingOverlay'; +import { GAME_MODE_INFO, GAME_SETUP, type GameMode } from '../../constants'; +import { useGameSocket } from '../../hooks/useGameSocket'; +import { useKeyboardControls } from '../../hooks/useKeyboardControls'; +import { buildPieceMeta } from '../../utils/pieceHelpers'; +import { GameBoard } from './GameBoard'; +import { OpponentCard } from './OpponentCard'; +import { PiecePreview } from './PiecePreview'; +import { RoomControls } from './RoomControls'; + +const CELL = 28; + +// Fixed dimensions based on the widest/tallest mode so layout doesn't shift when switching modes +const MAX_WIDTH = Math.max(...Object.values(GAME_SETUP).map((s) => s.width)); +const MAX_HEIGHT = Math.max(...Object.values(GAME_SETUP).map((s) => s.height)); + +/** + * Main game page component — composes all game sub-components and wires up hooks. + */ +export function GamePage() { + const game = useGameSocket(); + useKeyboardControls(game.socketRef, game.room, game.playerId); + + const setup = GAME_SETUP[game.selectedMode]; + const WIDTH = setup.width; + const HEIGHT = setup.height; + + const pieceMeta = useMemo(() => buildPieceMeta(game.selectedMode), [game.selectedMode]); + + const modeInfo = GAME_MODE_INFO[game.selectedMode]; + + const roomControlProps = { + room: game.room, + setRoom: game.setRoom, + selectedMode: game.selectedMode, + setSelectedMode: game.setSelectedMode, + playerId: game.playerId, + hostId: game.hostId, + state: game.state, + error: game.error, + message: game.message, + hostName: game.hostName, + create: game.create, + join: game.join, + start: game.start, + leave: game.leave, + restart: game.restart, + }; + + return ( +
+
+
+ + Back + +
+

RED TETRIS

+

Play Mode

+
+
+ + {/* Mobile Room Controls */} + + + {/* Main Game Layout */} +
+ {/* Left Side: Room Controls (Desktop) */} + + + {/* Center: Game Area */} +
+ {/* Hold Piece */} +
+

Hold

+
+ +
+
+ + {/* Main Game Board */} +
+

Your Board

+
+ {modeInfo.icon} + + {modeInfo.label} + + ({modeInfo.boardLabel}) +
+ + +
+ + {/* Next Piece */} +
+

Next

+
+ +
+
+
+ + {/* Right Side: Opponents */} + {game.opponents.length > 0 && ( +
+
+

Opponents

+
+ {game.opponents.map((op) => ( + + ))} +
+
+
+ )} +
+
+ {game.actionLoading && } +
+ ); +} diff --git a/client/app/components/game/OpponentCard.tsx b/client/app/components/game/OpponentCard.tsx new file mode 100644 index 0000000..0f86c52 --- /dev/null +++ b/client/app/components/game/OpponentCard.tsx @@ -0,0 +1,85 @@ +import type { JSX } from 'react'; + +interface OpponentCardProps { + id: string; + name: string; + board: number[][] | null; + isAlive: boolean; + score: number; + isHost: boolean; + idColors: string[]; + width: number; + height: number; +} + +/** + * Renders a single opponent card with status info and a mini board. + */ +export function OpponentCard({ name, board, isAlive, score, isHost, idColors, width, height }: OpponentCardProps) { + const cellSize = 5; + + const renderOpponentBoard = () => { + const cells: JSX.Element[] = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const val = board?.[y]?.[x] ?? 0; + let bg = 'transparent'; + if (val === 0) bg = 'transparent'; + else if (val === -2) bg = isAlive ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.03)'; + else if (val === -1) bg = isAlive ? '#111' : '#444'; + else bg = isAlive ? (idColors[val] ?? '#999') : '#777'; + + const isPiece = val > 0; + cells.push( +
, + ); + } + } + + return ( +
+
{cells}
+
+ ); + }; + + return ( +
+ {/* Left: Info */} +
+
+
+ {name} +
+ {isHost && Host} +
+ Score: {score} +
+
+ {isAlive ? 'ALIVE' : 'DEAD'} +
+
+ {/* Right: Board */} +
+ {renderOpponentBoard()} +
+
+ ); +} diff --git a/client/app/components/game/PiecePreview.tsx b/client/app/components/game/PiecePreview.tsx new file mode 100644 index 0000000..bada650 --- /dev/null +++ b/client/app/components/game/PiecePreview.tsx @@ -0,0 +1,58 @@ +import type { JSX } from 'react'; + +interface PiecePreviewProps { + type: string | null; + shapes: Record; + colors: Record; + cellSize: number; +} + +/** + * Renders a 4×4 preview grid for a Tetris piece (used for Hold and Next panels). + * The piece is centered within the grid. + */ +export function PiecePreview({ type, shapes, colors, cellSize }: PiecePreviewProps) { + const mat = type && shapes[type] ? shapes[type] : null; + const gridSize = 4; + const color = (type && colors[type]) ?? '#60a5fa'; + + const matRows = mat ? mat.length : 0; + const matCols = mat ? mat[0].length : 0; + const rowOffset = Math.floor((gridSize - matRows) / 2); + const colOffset = Math.floor((gridSize - matCols) / 2); + + const cells: JSX.Element[] = []; + for (let r = 0; r < gridSize; r++) { + for (let c = 0; c < gridSize; c++) { + const mr = r - rowOffset; + const mc = c - colOffset; + const inMat = Boolean(mat && mr >= 0 && mr < matRows && mc >= 0 && mc < matCols && mat[mr][mc]); + cells.push( + inMat ? ( +
+ ) : ( +
+ ), + ); + } + } + return ( +
{cells}
+ ); +} diff --git a/client/app/components/game/RoomControls.tsx b/client/app/components/game/RoomControls.tsx new file mode 100644 index 0000000..b6e6847 --- /dev/null +++ b/client/app/components/game/RoomControls.tsx @@ -0,0 +1,260 @@ +import { GameModeSelector } from '../GameModeSelector'; +import type { GameMode } from '../../constants'; +import type { GameState } from '../../types/socket'; + +interface RoomControlsProps { + room: string; + setRoom: (v: string) => void; + selectedMode: GameMode; + setSelectedMode: (m: GameMode) => void; + playerId: string | null; + hostId: string | null; + state: GameState | null; + error: string | null; + message: string | null; + hostName: string | null; + create: () => void; + join: () => void; + start: () => void; + leave: () => void; + restart: () => void; + compact?: boolean; +} + +/** + * Room settings panel — shown both in the mobile header area and the desktop sidebar. + */ +export function RoomControls({ + room, + setRoom, + selectedMode, + setSelectedMode, + playerId, + hostId, + state, + error, + message, + hostName, + create, + join, + start, + leave, + restart, + compact = false, +}: RoomControlsProps) { + const isHost = hostId === playerId || state?.hostId === playerId; + const canRestart = state?.status === 'finished' && playerId && hostId && playerId === hostId; + + if (compact) { + return ( +
+

Room Settings

+ +
+
+ + setRoom(e.target.value)} + disabled={!!playerId} + className='w-full px-3 py-2 text-sm bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + placeholder='Room code' + /> +
+ +
+ + +
+ +
+ + + + {canRestart && ( + + )} + {playerId && ( + + )} +
+ + {/* Status Messages */} + +
+
+ ); + } + + /* Desktop variant */ + return ( +
+
+

Room Settings

+ +
+
+ + setRoom(e.target.value)} + disabled={!!playerId} + className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' + placeholder='Enter room code' + /> +
+ +
+ + +
+ +
+ {!playerId && ( + + )} + {!playerId && ( + + )} + + {canRestart && ( + + )} + {playerId && ( + + )} +
+ + {/* Status Messages */} + +
+
+
+ ); +} + +function StatusMessages({ + room, + hostId, + playerId, + hostName, + isHost, + error, + message, +}: { + room: string; + hostId: string | null; + playerId: string | null; + hostName: string | null; + isHost: boolean; + error: string | null; + message: string | null; +}) { + return ( +
+ {hostId && playerId === hostId && ( +
+ Room: + {room} +
+ )} + {hostName && ( +
+ Host: + {hostName} + {isHost ? (you) : null} +
+ )} + {error && ( +
+ {error} +
+ )} + {message && ( +
+ {message} +
+ )} +
+ ); +} diff --git a/client/app/hooks/useGameSocket.ts b/client/app/hooks/useGameSocket.ts new file mode 100644 index 0000000..afc17ec --- /dev/null +++ b/client/app/hooks/useGameSocket.ts @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { io } from 'socket.io-client'; +import { GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; +import { authService } from '../services/auth'; +import type { GameState, PlayerState, Socket } from '../types/socket'; + +export interface UseGameSocketReturn { + /* state */ + room: string; + setRoom: (v: string) => void; + playerId: string | null; + selectedMode: GameMode; + setSelectedMode: (m: GameMode) => void; + state: GameState | null; + hostId: string | null; + error: string | null; + message: string | null; + actionLoading: boolean; + myView: PlayerState | null; + hostName: string | null; + opponents: OpponentInfo[]; + join: () => void; + create: () => void; + start: () => void; + leave: () => void; + restart: () => void; + socketRef: React.RefObject; +} + +export interface OpponentInfo { + id: string; + name: string; + spectrum: number[]; + board: number[][] | null; + isAlive: boolean; + score: number; + isHost: boolean; +} + +export function useGameSocket(): UseGameSocketReturn { + const [room, setRoom] = useState(''); + const [playerId, setPlayerId] = useState(null); + const [selectedMode, setSelectedMode] = useState('classic'); + const [state, setState] = useState(null); + const [hostId, setHostId] = useState(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const socketRef = useRef(null); + + const setup = GAME_SETUP[selectedMode]; + const WIDTH = setup.width; + const HEIGHT = setup.height; + + /* socket setup */ + useEffect(() => { + if (socketRef.current) return; + const url = (import.meta.env.VITE_SERVER_URL as string) ?? 'http://localhost:3002'; + const token = authService.getToken(); + const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; + socketRef.current = s; + + s.on('connect', () => {}); + + s.on('state', (payload: GameState) => { + setActionLoading(false); + setState(payload); + setHostId(payload.hostId ?? null); + const m = payload.mode; + if (m && (GAME_MODES as readonly string[]).includes(m)) { + setSelectedMode(m as GameMode); + } + }); + + s.on('joined', async (info: { roomId?: string }) => { + setActionLoading(false); + setRoom(info.roomId ?? room); + try { + const profile = await authService.getProfile(); + setPlayerId(String(profile.id)); + } catch (err) { + console.warn('failed to fetch profile after join', err); + } + setError(null); + }); + + s.on('created', async (info: { roomId?: string }) => { + setActionLoading(false); + setRoom(info.roomId ?? room); + try { + const profile = await authService.getProfile(); + setPlayerId(String(profile.id)); + } catch (err) { + console.warn('failed to fetch profile after create', err); + } + setError(null); + }); + + s.on('host_changed', (info: { newHostId?: string; message?: string }) => { + setHostId(info?.newHostId ?? null); + if (info?.message) setMessage(info.message); + }); + + s.on('restarted', (info: { message?: string }) => { + setActionLoading(false); + if (info?.message) setMessage(info.message); + }); + + s.on('restart_failed', (info: { code?: string; message?: string; reason?: string }) => { + setActionLoading(false); + setError(info?.message ?? info?.reason ?? 'RESTART_FAILED'); + }); + + s.on('game_over', (info: { message?: string }) => { + setMessage(info?.message ?? 'game over'); + }); + + s.on('winner', (info: { message?: string }) => { + console.log('client: received winner', info); + setMessage(info?.message ?? 'YOU WIN'); + }); + + s.on('create_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); + setError(info?.message ?? info?.code ?? 'CREATE_FAILED'); + }); + + s.on('join_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); + setError(info?.message ?? info?.code ?? 'JOIN_FAILED'); + }); + + s.on('start_failed', (info: { code?: string; message?: string }) => { + setActionLoading(false); + setError(info?.message ?? info?.code ?? 'START_FAILED'); + }); + + s.on('connect_error', (err: Error | string) => { + setActionLoading(false); + if (typeof err === 'string') setError(err); + else setError(err?.message ?? String(err)); + }); + + return () => { + s.off(); + s.disconnect(); + socketRef.current = null; + }; + }, []); + + /* win / lose detection */ + const prevAliveRef = useRef(null); + useEffect(() => { + if (!state || !playerId) return; + const alive = Boolean(state.players?.[playerId]?.isAlive); + const prev = prevAliveRef.current; + if (prev === true && alive === false) setMessage('You lost'); + if (state.status === 'finished' && state.winnerId === playerId) setMessage('You win'); + prevAliveRef.current = alive; + }, [state, playerId]); + + /* actions */ + const join = () => { + const s = socketRef.current; + if (!s) return; + if (!s.connected) s.connect(); + setActionLoading(true); + s.emit('join', { roomId: room }); + }; + + const create = () => { + const s = socketRef.current; + if (!s) return; + if (!s.connected) s.connect(); + setActionLoading(true); + s.emit('create', { mode: selectedMode }); + setError(null); + }; + + const start = () => { + const s = socketRef.current; + if (!s || !playerId) return; + if (hostId && playerId !== hostId) { + setError('ONLY_HOST_CAN_START'); + return; + } + setActionLoading(true); + s.emit('start', { roomId: room }); + }; + + const leave = () => { + const s = socketRef.current; + if (!s) return; + s.disconnect(); + setPlayerId(null); + setState(null); + setHostId(null); + setMessage(null); + setError(null); + setRoom(''); + }; + + const restart = () => { + setActionLoading(true); + socketRef.current?.emit('restart', { roomId: room }); + }; + + /* derived data */ + const myView = useMemo(() => { + if (!state || !playerId) return null; + return (state.players?.[playerId] ?? null) as PlayerState | null; + }, [state, playerId]); + + const hostName = useMemo(() => { + if (!state) return hostId ?? null; + const hid = hostId ?? state.hostId ?? null; + return hid ? (state.players?.[hid]?.name ?? hid) : null; + }, [state, hostId]); + + const computeSpectrumFromBoard = (board: number[][]): number[] => { + const cols = Array.from({ length: WIDTH }, () => 0); + for (let x = 0; x < WIDTH; x++) { + let h = 0; + for (let y = 0; y < HEIGHT; y++) { + if (board[y]?.[x] || board[y]?.[x] === -1) { + h = HEIGHT - y; + break; + } + } + cols[x] = h; + } + return cols; + }; + + const opponents = useMemo(() => { + if (!state || !playerId) return []; + const entries = Object.entries(state.players ?? ({} as Record)); + return entries + .filter(([id]) => id !== playerId) + .map(([id, p]) => { + const board = p?.board ?? null; + const spectrum = Array.isArray(p?.spectrum) + ? p.spectrum + : board + ? computeSpectrumFromBoard(board) + : Array.from({ length: WIDTH }, () => 0); + + return { + id, + name: p.name ?? id, + spectrum, + board, + isAlive: Boolean(p.isAlive), + score: p?.score ?? 0, + isHost: id === (hostId ?? state.hostId), + }; + }); + }, [state, playerId]); + + return { + room, + setRoom, + playerId, + selectedMode, + setSelectedMode, + state, + hostId, + error, + message, + actionLoading, + myView, + hostName, + opponents, + join, + create, + start, + leave, + restart, + socketRef, + }; +} diff --git a/client/app/hooks/useKeyboardControls.ts b/client/app/hooks/useKeyboardControls.ts new file mode 100644 index 0000000..26dab7b --- /dev/null +++ b/client/app/hooks/useKeyboardControls.ts @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; +import type { Socket } from '../types/socket'; + +const DAS_DELAY = 170; // ms before auto-repeat kicks in +const REPEAT_INTERVAL = 50; // ms between repeated moves + +/** + * Keyboard controls for the Tetris game. + * Replicates the exact intent payloads the server expects. + */ +export function useKeyboardControls( + socketRef: React.RefObject, + room: string, + playerId: string | null, +) { + useEffect(() => { + const held = new Map>(); + + const fireIntent = (key: string) => { + const s = socketRef.current; + if (!s) return; + if (key === 'ArrowLeft') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); + else if (key === 'ArrowRight') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); + else if (key === 'ArrowDown') s.emit('intent', { roomId: room, intent: { type: 'soft' } }); + }; + + const clearKey = (key: string) => { + const timer = held.get(key); + if (timer != null) { + clearTimeout(timer); + held.delete(key); + } + }; + + const repeatableKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowDown']); + + const onKeyDown = (e: KeyboardEvent) => { + if (!playerId || !socketRef.current) return; + const s = socketRef.current; + + if (repeatableKeys.has(e.key)) { + e.preventDefault(); + if (held.has(e.key)) return; // already held + fireIntent(e.key); // fire once immediately + // after DAS_DELAY, start rapid repeat + const dasTimer = setTimeout(() => { + const interval = setInterval(() => fireIntent(e.key), REPEAT_INTERVAL); + held.set(e.key, interval as unknown as ReturnType); + }, DAS_DELAY); + held.set(e.key, dasTimer); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'rotate' } }); + } + if (e.key === 'Shift') s.emit('intent', { roomId: room, intent: { type: 'hold' } }); + if (e.code === 'Space') { + e.preventDefault(); + s.emit('intent', { roomId: room, intent: { type: 'hard' } }); + } + }; + + const onKeyUp = (e: KeyboardEvent) => { + clearKey(e.key); + }; + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + held.forEach((t) => { + clearTimeout(t); + clearInterval(t); + }); + held.clear(); + }; + }, [playerId, room, socketRef]); +} diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index a1aa388..f6f6cb8 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -1,20 +1,7 @@ -import { useEffect, useMemo, useRef, useState, type JSX } from 'react'; -import { Link } from 'react-router'; -import { io } from 'socket.io-client'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; -import { GameModeSelector } from '../components/GameModeSelector'; -import { LoadingOverlay } from '../components/LoadingOverlay'; -import { GAME_MODE_INFO, GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; -import { authService } from '../services/auth'; -import type { GameState, PlayerState, Socket } from '../types/socket'; +import { GamePage } from '../components/game/GamePage'; import type { Route } from './+types/game'; -const CELL = 28; - -// Fixed dimensions based on the widest/tallest mode so layout doesn't shift when switching modes -const MAX_WIDTH = Math.max(...Object.values(GAME_SETUP).map((s) => s.width)); -const MAX_HEIGHT = Math.max(...Object.values(GAME_SETUP).map((s) => s.height)); - export function meta({}: Route.MetaArgs) { return [ { title: 'Red Tetris — Play' }, @@ -22,818 +9,11 @@ export function meta({}: Route.MetaArgs) { ]; } -function GameComponent() { - const [room, setRoom] = useState(''); - const [playerId, setPlayerId] = useState(null); - const [selectedMode, setSelectedMode] = useState('classic'); - const [state, setState] = useState(null); - const [hostId, setHostId] = useState(null); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - const [actionLoading, setActionLoading] = useState(false); - const socketRef = useRef(null); - - const setup = GAME_SETUP[selectedMode]; - const WIDTH = setup.width; - const HEIGHT = setup.height; - - const computeSpectrumFromBoard = (board: number[][]): number[] => { - const cols = Array.from({ length: WIDTH }, () => 0); - for (let x = 0; x < WIDTH; x++) { - let h = 0; - for (let y = 0; y < HEIGHT; y++) { - if (board[y]?.[x] || board[y]?.[x] === -1) { - h = HEIGHT - y; - break; - } - } - cols[x] = h; - } - return cols; - }; - - useEffect(() => { - if (socketRef.current) return; // avoid duplicate sockets in StrictMode / HMR - const url = (import.meta.env.VITE_SERVER_URL as string) ?? 'http://localhost:3002'; - const token = authService.getToken(); - const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; - socketRef.current = s; - - s.on('connect', () => { - // connection established - }); - - // server sends a flat state object with mode & hostId - s.on('state', (payload: GameState) => { - setActionLoading(false); - setState(payload); - setHostId(payload.hostId ?? null); - // sync selected mode with the room's actual mode - const m = payload.mode; - if (m && (GAME_MODES as readonly string[]).includes(m)) { - setSelectedMode(m as GameMode); - } - }); - - s.on('joined', async (info: { roomId?: string }) => { - setActionLoading(false); - setRoom(info.roomId ?? room); - try { - const profile = await authService.getProfile(); - setPlayerId(String(profile.id)); - } catch (err) { - console.warn('failed to fetch profile after join', err); - } - setError(null); - }); - s.on('created', async (info: { roomId?: string }) => { - setActionLoading(false); - setRoom(info.roomId ?? room); - try { - const profile = await authService.getProfile(); - setPlayerId(String(profile.id)); - } catch (err) { - console.warn('failed to fetch profile after create', err); - } - setError(null); - }); - s.on('host_changed', (info: { newHostId?: string; message?: string }) => { - setHostId(info?.newHostId ?? null); - if (info?.message) setMessage(info.message); - }); - s.on('restarted', (info: { message?: string }) => { - setActionLoading(false); - if (info?.message) setMessage(info.message); - }); - s.on('restart_failed', (info: { code?: string; message?: string; reason?: string }) => { - setActionLoading(false); - setError(info?.message ?? info?.reason ?? 'RESTART_FAILED'); - }); - s.on('game_over', (info: { message?: string }) => { - setMessage(info?.message ?? 'game over'); - }); - s.on('winner', (info: { message?: string }) => { - console.log('client: received winner', info); - setMessage(info?.message ?? 'YOU WIN'); - }); - s.on('create_failed', (info: { code?: string; message?: string }) => { - setActionLoading(false); - setError(info?.message ?? info?.code ?? 'CREATE_FAILED'); - }); - s.on('join_failed', (info: { code?: string; message?: string }) => { - setActionLoading(false); - setError(info?.message ?? info?.code ?? 'JOIN_FAILED'); - }); - s.on('start_failed', (info: { code?: string; message?: string }) => { - setActionLoading(false); - setError(info?.message ?? info?.code ?? 'START_FAILED'); - }); - s.on('connect_error', (err: Error | string) => { - setActionLoading(false); - if (typeof err === 'string') setError(err); - else setError(err?.message ?? String(err)); - }); - - return () => { - s.off(); - s.disconnect(); - socketRef.current = null; - }; - }, []); - - useEffect(() => { - const DAS_DELAY = 170; // ms before auto-repeat kicks in - const REPEAT_INTERVAL = 50; // ms between repeated moves - const held = new Map>(); - - const fireIntent = (key: string) => { - const s = socketRef.current; - if (!s) return; - if (key === 'ArrowLeft') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'left' } }); - else if (key === 'ArrowRight') s.emit('intent', { roomId: room, intent: { type: 'move', dir: 'right' } }); - else if (key === 'ArrowDown') s.emit('intent', { roomId: room, intent: { type: 'soft' } }); - }; - - const clearKey = (key: string) => { - const timer = held.get(key); - if (timer != null) { - clearTimeout(timer); - held.delete(key); - } - }; - - const repeatableKeys = new Set(['ArrowLeft', 'ArrowRight', 'ArrowDown']); - - const onKeyDown = (e: KeyboardEvent) => { - if (!playerId || !socketRef.current) return; - const s = socketRef.current; - - if (repeatableKeys.has(e.key)) { - e.preventDefault(); - if (held.has(e.key)) return; // already held - fireIntent(e.key); // fire once immediately - // after DAS_DELAY, start rapid repeat - const dasTimer = setTimeout(() => { - const interval = setInterval(() => fireIntent(e.key), REPEAT_INTERVAL); - held.set(e.key, interval as unknown as ReturnType); - }, DAS_DELAY); - held.set(e.key, dasTimer); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - s.emit('intent', { roomId: room, intent: { type: 'rotate' } }); - } - if (e.key === 'Shift') s.emit('intent', { roomId: room, intent: { type: 'hold' } }); - if (e.code === 'Space') { - e.preventDefault(); - s.emit('intent', { roomId: room, intent: { type: 'hard' } }); - } - }; - - const onKeyUp = (e: KeyboardEvent) => { - clearKey(e.key); - }; - - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - return () => { - window.removeEventListener('keydown', onKeyDown); - window.removeEventListener('keyup', onKeyUp); - held.forEach((t) => { - clearTimeout(t); - clearInterval(t); - }); - held.clear(); - }; - }, [playerId, room]); - - const join = () => { - const s = socketRef.current; - if (!s) return; - if (!s.connected) s.connect(); - setActionLoading(true); - s.emit('join', { roomId: room }); - }; - - const create = () => { - const s = socketRef.current; - if (!s) return; - if (!s.connected) s.connect(); - setActionLoading(true); - // server uses authenticated user for creation; include selected game mode - s.emit('create', { mode: selectedMode }); - setError(null); - }; - - const start = () => { - const s = socketRef.current; - if (!s || !playerId) return; - if (hostId && playerId !== hostId) { - setError('ONLY_HOST_CAN_START'); - return; - } - setActionLoading(true); - s.emit('start', { roomId: room }); - }; - - const leave = () => { - const s = socketRef.current; - if (!s) return; - s.disconnect(); - setPlayerId(null); - setState(null); - setHostId(null); - setMessage(null); - setError(null); - setRoom(''); - }; - - const myView = useMemo(() => { - if (!state || !playerId) return null; - return (state.players?.[playerId] ?? null) as PlayerState | null; - }, [state, playerId]); - - const hostName = useMemo(() => { - if (!state) return hostId ?? null; - const hid = hostId ?? state.hostId ?? null; - return hid ? (state.players?.[hid]?.name ?? hid) : null; - }, [state, hostId]); - - // show win/lose messages based on state transitions - const prevAliveRef = useRef(null); - useEffect(() => { - if (!state || !playerId) return; - const alive = Boolean(state.players?.[playerId]?.isAlive); - const prev = prevAliveRef.current; - if (prev === true && alive === false) { - setMessage('You lost'); - } - if (state.status === 'finished' && state.winnerId === playerId) { - setMessage('You win'); - } - prevAliveRef.current = alive; - }, [state, playerId]); - - const renderCell = (x: number, y: number) => { - const val = myView?.board?.[y]?.[x] ?? 0; - if (val === 0) { - return ( -
- ); - } - if (val === -2) { - return ( -
- ); - } - if (val === -1) { - return ( -
- ); - } - const color = PIECE_ID_COLORS[val] ?? '#999'; - return ( -
- ); - }; - - const opponents = useMemo< - { - id: string; - name: string; - spectrum: number[]; - board: number[][] | null; - isAlive: boolean; - score: number; - isHost: boolean; - }[] - >(() => { - if (!state || !playerId) return []; - const entries = Object.entries(state.players ?? ({} as Record)); - return entries - .filter(([id]) => id !== playerId) - .map(([id, p]) => { - const board = p?.board ?? null; - const spectrum = Array.isArray(p?.spectrum) - ? p.spectrum - : board - ? computeSpectrumFromBoard(board) - : Array.from({ length: WIDTH }, () => 0); - - return { - id, - name: p.name ?? id, - spectrum, - board, - isAlive: Boolean(p.isAlive), - score: p?.score ?? 0, - isHost: id === (hostId ?? state.hostId), - }; - }); - }, [state, playerId]); - - // Next-piece shapes (rotation 0) for preview; pad to 4x4 for consistent rendering - // derive shapes/colors from selected mode's tetrominos when possible - const tetrominos = setup.tetrominos; - const pieceKeys = Object.keys(tetrominos); - - const baseClassicColors: Record = { - I: '#0d9488', - O: '#ca8a04', - T: '#9333ea', - S: '#16a34a', - Z: '#dc2626', - J: '#2563eb', - L: '#c2410c', - }; - - const palette = ['#60a5fa', '#f97316', '#e11d48', '#7c3aed', '#059669', '#d946ef', '#facc15', '#06b6d4']; - - const PIECE_SHAPES: Record = {}; - const PIECE_COLORS: Record = {}; - pieceKeys.forEach((k, idx) => { - const mats = tetrominos[k]; - PIECE_SHAPES[k] = mats?.length ? mats[0] : [[1]]; - PIECE_COLORS[k] = baseClassicColors[k] ?? palette[idx % palette.length]; - }); - - const PIECE_ID_COLORS: string[] = [ - '#000000', - // numeric mapping fallback: map first few pieceKeys to indexes 1..n - ...pieceKeys.slice(0, 15).map((k) => PIECE_COLORS[k] ?? palette[0]), - ]; - - const renderOpponentBoard = (board: number[][] | null, isAlive: boolean, cell = 4) => { - const cells: JSX.Element[] = []; - for (let y = 0; y < HEIGHT; y++) { - for (let x = 0; x < WIDTH; x++) { - const val = board?.[y]?.[x] ?? 0; - let bg = 'transparent'; - if (val === 0) bg = 'transparent'; - else if (val === -2) bg = isAlive ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.03)'; - else if (val === -1) bg = isAlive ? '#111' : '#444'; - else bg = isAlive ? (PIECE_ID_COLORS[val] ?? '#999') : '#777'; - - const isPiece = val > 0; - cells.push( -
- ); - } - } - - return ( -
-
{cells}
-
- ); - }; - - const renderNextPreview = (type: string | null, size = 12) => { - const mat = type && PIECE_SHAPES[type] ? PIECE_SHAPES[type] : null; - const gridSize = 4; - const color = (type && PIECE_COLORS[type]) ?? '#60a5fa'; - - // center the piece matrix within the 4x4 grid - const matRows = mat ? mat.length : 0; - const matCols = mat ? mat[0].length : 0; - const rowOffset = Math.floor((gridSize - matRows) / 2); - const colOffset = Math.floor((gridSize - matCols) / 2); - - const cells: JSX.Element[] = []; - for (let r = 0; r < gridSize; r++) { - for (let c = 0; c < gridSize; c++) { - const mr = r - rowOffset; - const mc = c - colOffset; - const inMat = Boolean(mat && mr >= 0 && mr < matRows && mc >= 0 && mc < matCols && mat[mr][mc]); - cells.push( - inMat ? ( -
- ) : ( -
- ) - ); - } - } - return ( -
{cells}
- ); - }; - - return ( -
-
-
- - Back - -
-

RED TETRIS

-

Play Mode

-
-
- - {/* Room Controls */} -
-

Room Settings

- -
-
- - setRoom(e.target.value)} - disabled={!!playerId} - className='w-full px-3 py-2 text-sm bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' - placeholder='Room code' - /> -
- -
- - -
- -
- - - - {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( - - )} - {playerId && ( - - )} -
- - {/* Status Messages */} -
- {hostId && playerId === hostId && ( -
- Room: - {room} -
- )} - {hostName && ( -
- Host: - {hostName} - {hostId === playerId || state?.hostId === playerId ? ( - (you) - ) : null} -
- )} - {error && ( -
- {error} -
- )} - {message && ( -
- {message} -
- )} -
-
-
- - {/* Main Game Layout */} -
- {/* Left Side: Room Controls (Desktop) */} -
-
-

Room Settings

- -
-
- - setRoom(e.target.value)} - disabled={!!playerId} - className='w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500/50 focus:border-red-500/50 disabled:opacity-50 disabled:cursor-not-allowed' - placeholder='Enter room code' - /> -
- -
- - -
- -
- {!playerId && ( - - )} - {!playerId && ( - - )} - - {state?.status === 'finished' && playerId && hostId && playerId === hostId && ( - - )} - {playerId && ( - - )} -
- - {/* Status Messages */} -
- {hostId && playerId === hostId && ( -
- Room Code: - {room} -
- )} - {hostName && ( -
- Host: - {hostName} - {hostId === playerId || state?.hostId === playerId ? ( - (you) - ) : null} -
- )} - {error && ( -
- {error} -
- )} - {message && ( -
- {message} -
- )} -
-
-
-
- - {/* Center: Game Area */} -
- {/* Hold Piece */} -
-

Hold

-
- {renderNextPreview(myView?.holdPiece ?? null, CELL)} -
-
- - {/* Main Game Board */} -
-

Your Board

-
- {GAME_MODE_INFO[selectedMode].icon} - - {GAME_MODE_INFO[selectedMode].label} - - ({GAME_MODE_INFO[selectedMode].boardLabel}) -
- -
-
-
- {(function rows() { - const cells: JSX.Element[] = []; - for (let y = 0; y < HEIGHT; y++) { - for (let x = 0; x < WIDTH; x++) cells.push(renderCell(x, y)); - } - return cells; - })()} -
-
- -
-
- {myView?.score ?? 0} -
Score
-
-
-
-
- - {/* Next Piece */} -
-

Next

-
- {renderNextPreview(myView?.nextPiece ?? null, CELL)} -
-
-
- - {/* Right Side: Opponents */} - {opponents.length > 0 && ( -
-
-

Opponents

-
- {opponents.map((op) => ( -
- {/* Left: Info */} -
-
-
- {op.name} -
- {op.isHost && Host} -
- Score: {op.score} -
-
- {op.isAlive ? 'ALIVE' : 'DEAD'} -
-
- {/* Right: Board */} -
- {renderOpponentBoard(op.board ?? null, op.isAlive, 5)} -
-
- ))} -
-
-
- )} -
-
- {actionLoading && } -
- ); -} - export default function GameRoute() { return ( - + ); } + diff --git a/client/app/utils/pieceHelpers.ts b/client/app/utils/pieceHelpers.ts new file mode 100644 index 0000000..6dbfb8d --- /dev/null +++ b/client/app/utils/pieceHelpers.ts @@ -0,0 +1,42 @@ +import { GAME_SETUP, type GameMode } from '../constants'; + +/** + * Shared piece colour / shape helpers used by board rendering components. + * Derived from the selected game-mode's tetromino definitions. + */ + +const BASE_CLASSIC_COLORS: Record = { + I: '#0d9488', + O: '#ca8a04', + T: '#9333ea', + S: '#16a34a', + Z: '#dc2626', + J: '#2563eb', + L: '#c2410c', +}; + +const PALETTE = ['#60a5fa', '#f97316', '#e11d48', '#7c3aed', '#059669', '#d946ef', '#facc15', '#06b6d4']; + +export interface PieceMeta { + shapes: Record; + colors: Record; + idColors: string[]; +} + +export function buildPieceMeta(mode: GameMode): PieceMeta { + const tetrominos = GAME_SETUP[mode].tetrominos; + const keys = Object.keys(tetrominos); + + const shapes: Record = {}; + const colors: Record = {}; + + keys.forEach((k, idx) => { + const mats = tetrominos[k]; + shapes[k] = mats?.length ? mats[0] : [[1]]; + colors[k] = BASE_CLASSIC_COLORS[k] ?? PALETTE[idx % PALETTE.length]; + }); + + const idColors: string[] = ['#000000', ...keys.slice(0, 15).map((k) => colors[k] ?? PALETTE[0])]; + + return { shapes, colors, idColors }; +} diff --git a/client/app/welcome/logo-dark.svg b/client/app/welcome/logo-dark.svg deleted file mode 100644 index dd82028..0000000 --- a/client/app/welcome/logo-dark.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/app/welcome/logo-light.svg b/client/app/welcome/logo-light.svg deleted file mode 100644 index 7328492..0000000 --- a/client/app/welcome/logo-light.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/package.json b/client/package.json index 01e9b85..fb53047 100644 --- a/client/package.json +++ b/client/package.json @@ -11,8 +11,8 @@ "prettier": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --write", "prettier:check": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --list-different", "typecheck": "react-router typegen && tsc", - "test": "mocha -r ts-node/register tests/**/*.ts", - "coverage": "nyc mocha -r ts-node/register tests/**/*.ts" + "test": "vitest run", + "coverage": "vitest run --coverage" }, "dependencies": { "@react-router/node": "7.12.0", @@ -33,14 +33,20 @@ "devDependencies": { "@react-router/dev": "7.12.0", "@tailwindcss/vite": "^4.1.13", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/chai": "^5.2.3", "@types/mocha": "^10.0.10", "@types/node": "^22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.0.18", + "jsdom": "^28.1.0", "tailwindcss": "^4.1.13", "typescript": "^5.9.2", "vite": "^7.1.7", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.18" } } \ No newline at end of file diff --git a/client/tests/GameModeSelector.test.tsx b/client/tests/GameModeSelector.test.tsx new file mode 100644 index 0000000..e5ac941 --- /dev/null +++ b/client/tests/GameModeSelector.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GameModeSelector } from '~/components/GameModeSelector'; +import { GAME_MODES, type GameMode } from '~/constants'; + +describe('GameModeSelector', () => { + const defaultProps = { + selected: 'classic' as GameMode, + onChange: vi.fn(), + }; + + describe('full mode (non-compact)', () => { + it('renders all game modes', () => { + render(); + expect(screen.getByText('Classic')).toBeInTheDocument(); + expect(screen.getByText('Narrow')).toBeInTheDocument(); + expect(screen.getByText('Pentominoes')).toBeInTheDocument(); + expect(screen.getByText('Cyber')).toBeInTheDocument(); + }); + + it('renders descriptions for each mode', () => { + render(); + expect(screen.getByText(/original Tetris experience/)).toBeInTheDocument(); + expect(screen.getByText(/tight 6-wide board/)).toBeInTheDocument(); + expect(screen.getByText(/Five-cell pieces/)).toBeInTheDocument(); + expect(screen.getByText(/Futuristic custom shapes/)).toBeInTheDocument(); + }); + + it('calls onChange when a mode is clicked', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Narrow')); + expect(onChange).toHaveBeenCalledWith('narrow'); + }); + + it('renders piece count info', () => { + render(); + // Classic has 7 pieces, others may too — just check at least one "pcs" label + const pcsElements = screen.getAllByText(/pcs$/); + expect(pcsElements.length).toBeGreaterThan(0); + }); + + it('shows board dimensions', () => { + render(); + expect(screen.getByText('10 × 20')).toBeInTheDocument(); + expect(screen.getByText('6 × 22')).toBeInTheDocument(); + }); + }); + + describe('compact mode', () => { + it('renders in compact layout', () => { + const { container } = render( + , + ); + const grid = container.firstElementChild; + expect(grid?.className).toContain('grid-cols-2'); + }); + + it('renders all modes in compact mode', () => { + render( + , + ); + expect(screen.getByText('Classic')).toBeInTheDocument(); + expect(screen.getByText('Narrow')).toBeInTheDocument(); + }); + + it('calls onChange when clicking in compact mode', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Cyber')); + expect(onChange).toHaveBeenCalledWith('cyber'); + }); + + it('shows board labels in compact mode', () => { + render( + , + ); + expect(screen.getByText('10 × 20')).toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + it('only renders the selected mode when disabled', () => { + render( + , + ); + expect(screen.getByText('Narrow')).toBeInTheDocument(); + expect(screen.queryByText('Classic')).not.toBeInTheDocument(); + expect(screen.queryByText('Pentominoes')).not.toBeInTheDocument(); + }); + + it('buttons are disabled', () => { + render( + , + ); + const buttons = screen.getAllByRole('button'); + buttons.forEach((btn) => { + expect(btn).toBeDisabled(); + }); + }); + + it('only shows selected mode when disabled + compact', () => { + render( + , + ); + expect(screen.getByText('Cyber')).toBeInTheDocument(); + expect(screen.queryByText('Classic')).not.toBeInTheDocument(); + }); + }); + + describe('active styling', () => { + it('highlights the selected mode', () => { + render(); + // The classic button should contain the accent styling + const classicBtn = screen.getByText('Classic').closest('button'); + expect(classicBtn?.className).toContain('ring-1'); + }); + + it('does not highlight non-selected modes', () => { + render(); + const narrowBtn = screen.getByText('Narrow').closest('button'); + expect(narrowBtn?.className).toContain('bg-gray-800'); + expect(narrowBtn?.className).not.toContain('ring-1'); + }); + }); +}); diff --git a/client/tests/LoadingOverlay.test.tsx b/client/tests/LoadingOverlay.test.tsx new file mode 100644 index 0000000..484653e --- /dev/null +++ b/client/tests/LoadingOverlay.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { LoadingOverlay } from '~/components/LoadingOverlay'; + +describe('LoadingOverlay', () => { + it('renders Loading... text', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders the spinner element', () => { + const { container } = render(); + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('renders a fixed overlay', () => { + const { container } = render(); + const overlay = container.firstElementChild; + expect(overlay?.className).toContain('fixed'); + expect(overlay?.className).toContain('inset-0'); + }); +}); diff --git a/client/tests/LoginForm.test.tsx b/client/tests/LoginForm.test.tsx new file mode 100644 index 0000000..009c331 --- /dev/null +++ b/client/tests/LoginForm.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; +import { LoginForm } from '~/components/auth/LoginForm'; + +const mockNavigate = vi.fn(); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; + +const renderLoginForm = (authState = {}) => { + const store = configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: false, + ...authState, + }, + }, + }); + + return { + store, + ...render( + + + + + , + ), + }; +}; + +describe('LoginForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the login form', () => { + renderLoginForm(); + expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); + expect(screen.getByText('Welcome Back!')).toBeInTheDocument(); + expect(screen.getByLabelText('Username')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument(); + }); + + it('renders link to register page', () => { + renderLoginForm(); + expect(screen.getByText('Create one here')).toBeInTheDocument(); + }); + + it('shows error message when error exists', () => { + renderLoginForm({ error: 'Invalid credentials' }); + expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + renderLoginForm({ isLoading: true }); + expect(screen.getByText('Signing in...')).toBeInTheDocument(); + expect(screen.getByLabelText('Username')).toBeDisabled(); + expect(screen.getByLabelText('Password')).toBeDisabled(); + }); + + it('submits the form and navigates on success', async () => { + vi.mocked(authService.login).mockResolvedValue({ + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'tok', + }, + }); + + renderLoginForm(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('shows error on failed login', async () => { + vi.mocked(authService.login).mockRejectedValue(new Error('Wrong password')); + + renderLoginForm(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); + + await waitFor(() => { + expect(screen.getByText('Wrong password')).toBeInTheDocument(); + }); + }); + + it('disables submit button while loading', () => { + renderLoginForm({ isLoading: true }); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + }); +}); diff --git a/client/tests/ProtectedRoute.test.tsx b/client/tests/ProtectedRoute.test.tsx new file mode 100644 index 0000000..40e1d9c --- /dev/null +++ b/client/tests/ProtectedRoute.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; +import { ProtectedRoute } from '~/components/auth/ProtectedRoute'; + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; + +const renderProtectedRoute = (authState = {}) => { + const store = configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: false, + ...authState, + }, + }, + }); + + return render( + + + +
Protected Content
+
+
+
, + ); +}; + +describe('ProtectedRoute', () => { + it('redirects when not hydrated and no token (hydrate runs immediately)', () => { + vi.mocked(authService.getToken).mockReturnValue(null); + renderProtectedRoute({ hydrated: false }); + // hydrateAuth runs via useEffect, sets hydrated=true, isAuthenticated=false → redirects + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + + it('redirects to /login when not authenticated', () => { + renderProtectedRoute({ hydrated: true, isAuthenticated: false }); + // When Navigate renders, the child content should not show + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + + it('renders children when authenticated with user', () => { + renderProtectedRoute({ + hydrated: true, + isAuthenticated: true, + user: { id: 1, username: 'test', email: 'test@test.com', created_at: '', updated_at: '' }, + }); + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + }); + + it('shows loading overlay when authenticated but user is null', () => { + renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null }); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('shows loading overlay when isLoading is true', () => { + renderProtectedRoute({ + hydrated: true, + isAuthenticated: true, + user: { id: 1, username: 'test', email: 'test@test.com', created_at: '', updated_at: '' }, + isLoading: true, + }); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByTestId('protected-content')).toBeInTheDocument(); + }); + + it('hydrates and shows content when token exists and not yet hydrated', () => { + vi.mocked(authService.getToken).mockReturnValue('existing-token'); + vi.mocked(authService.getProfile).mockResolvedValue({ + id: 1, + username: 'test', + email: 'test@test.com', + created_at: '', + updated_at: '', + }); + renderProtectedRoute({ hydrated: false }); + // hydrateAuth runs, finds token, sets isAuthenticated=true + // Then fetchProfile is dispatched; meanwhile children + LoadingOverlay show + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('fetches profile when authenticated but no user', () => { + vi.mocked(authService.getProfile).mockResolvedValue({ + id: 1, + username: 'test', + email: 'test@test.com', + created_at: '', + updated_at: '', + }); + + renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null }); + expect(authService.getProfile).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/PublicOnlyRoute.test.tsx b/client/tests/PublicOnlyRoute.test.tsx new file mode 100644 index 0000000..63796fb --- /dev/null +++ b/client/tests/PublicOnlyRoute.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; +import { PublicOnlyRoute } from '~/components/auth/PublicOnlyRoute'; + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; + +const renderPublicOnlyRoute = (authState = {}) => { + const store = configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: false, + ...authState, + }, + }, + }); + + return render( + + + +
Public Content
+
+
+
, + ); +}; + +describe('PublicOnlyRoute', () => { + it('renders children after hydration when not authenticated', () => { + vi.mocked(authService.getToken).mockReturnValue(null); + renderPublicOnlyRoute({ hydrated: false }); + // hydrateAuth fires immediately, sets hydrated=true, isAuthenticated=false → renders children + expect(screen.getByTestId('public-content')).toBeInTheDocument(); + }); + + it('redirects to / when authenticated', () => { + renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true }); + // Navigate to / should fire, content should not show + expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + }); + + it('renders children when not authenticated', () => { + renderPublicOnlyRoute({ hydrated: true, isAuthenticated: false }); + expect(screen.getByTestId('public-content')).toBeInTheDocument(); + }); + + it('shows loading overlay when isLoading and not authenticated', () => { + renderPublicOnlyRoute({ + hydrated: true, + isAuthenticated: false, + isLoading: true, + }); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByTestId('public-content')).toBeInTheDocument(); + }); + + it('redirects when hydrated with token (not hydrated initially)', () => { + vi.mocked(authService.getToken).mockReturnValue('existing-token'); + renderPublicOnlyRoute({ hydrated: false }); + expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + }); + + it('fetches profile when authenticated but no user', () => { + vi.mocked(authService.getProfile).mockResolvedValue({ + id: 1, + username: 'test', + email: 'test@test.com', + created_at: '', + updated_at: '', + }); + + renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true, user: null }); + expect(authService.getProfile).toHaveBeenCalled(); + }); +}); diff --git a/client/tests/RegisterForm.test.tsx b/client/tests/RegisterForm.test.tsx new file mode 100644 index 0000000..c56e1ca --- /dev/null +++ b/client/tests/RegisterForm.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; +import { RegisterForm } from '~/components/auth/RegisterForm'; + +const mockNavigate = vi.fn(); +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; + +const renderRegisterForm = (authState = {}) => { + const store = configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: false, + ...authState, + }, + }, + }); + + return { + store, + ...render( + + + + + , + ), + }; +}; + +describe('RegisterForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the registration form', () => { + renderRegisterForm(); + expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); + expect(screen.getByText('Join the Game!')).toBeInTheDocument(); + expect(screen.getByLabelText('Username')).toBeInTheDocument(); + expect(screen.getByLabelText('Email Address')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create Account' })).toBeInTheDocument(); + }); + + it('renders link to login page', () => { + renderRegisterForm(); + expect(screen.getByText('Sign in here')).toBeInTheDocument(); + }); + + it('shows error from redux state', () => { + renderRegisterForm({ error: 'Username already exists' }); + expect(screen.getByText('Username already exists')).toBeInTheDocument(); + }); + + it('shows loading state', () => { + renderRegisterForm({ isLoading: true }); + expect(screen.getByText('Creating account...')).toBeInTheDocument(); + expect(screen.getByLabelText('Username')).toBeDisabled(); + expect(screen.getByLabelText('Email Address')).toBeDisabled(); + }); + + it('shows local error when passwords do not match', async () => { + renderRegisterForm(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'test@test.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'different' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Account' })); + + await waitFor(() => { + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + }); + }); + + it('submits the form and navigates on success', async () => { + vi.mocked(authService.register).mockResolvedValue({ + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'tok', + }, + }); + + renderRegisterForm(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'test@test.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'pass123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Account' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('shows error on failed registration', async () => { + vi.mocked(authService.register).mockRejectedValue(new Error('Username taken')); + + renderRegisterForm(); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'taken' } }); + fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'a@b.com' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'pass123' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create Account' })); + + await waitFor(() => { + expect(screen.getByText('Username taken')).toBeInTheDocument(); + }); + }); + + it('disables submit button while loading', () => { + renderRegisterForm({ isLoading: true }); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + }); +}); diff --git a/client/tests/authService.test.ts b/client/tests/authService.test.ts new file mode 100644 index 0000000..022c5c5 --- /dev/null +++ b/client/tests/authService.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { authService } from '~/services/auth'; + +describe('authService', () => { + beforeEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + describe('token management', () => { + it('setToken stores token in localStorage', () => { + authService.setToken('test-token-123'); + expect(localStorage.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123'); + }); + + it('getToken retrieves token from localStorage', () => { + localStorage.setItem('auth_token', 'my-token'); + const token = authService.getToken(); + expect(token).toBe('my-token'); + }); + + it('getToken returns null when no token exists', () => { + const token = authService.getToken(); + expect(token).toBeNull(); + }); + + it('clearToken removes token from localStorage', () => { + localStorage.setItem('auth_token', 'my-token'); + authService.clearToken(); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); + }); + }); + + describe('isAuthenticated', () => { + it('returns false when no token', () => { + expect(authService.isAuthenticated()).toBe(false); + }); + + it('returns true when token exists', () => { + localStorage.setItem('auth_token', 'some-token'); + expect(authService.isAuthenticated()).toBe(true); + }); + }); + + describe('register', () => { + it('sends POST request and stores token on success', async () => { + const mockResponse = { + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'new-token', + }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await authService.register({ username: 'testuser', email: 'test@test.com', password: 'pass123' }); + expect(result.data.token).toBe('new-token'); + expect(result.data.user.username).toBe('testuser'); + expect(localStorage.setItem).toHaveBeenCalledWith('auth_token', 'new-token'); + }); + + it('throws error on failed registration', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: { code: 'ERR', message: 'Username taken' } }), + } as Response); + + await expect( + authService.register({ username: 'taken', email: 'a@b.com', password: '123456' }), + ).rejects.toThrow('Username taken'); + }); + + it('throws generic error when no message provided', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: {} }), + } as Response); + + await expect( + authService.register({ username: 'test', email: 'a@b.com', password: '123456' }), + ).rejects.toThrow('Registration failed'); + }); + }); + + describe('login', () => { + it('sends POST request and stores token on success', async () => { + const mockResponse = { + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'login-token', + }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await authService.login({ username: 'testuser', password: 'pass123' }); + expect(result.data.token).toBe('login-token'); + expect(localStorage.setItem).toHaveBeenCalledWith('auth_token', 'login-token'); + }); + + it('throws error on failed login', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: { code: 'ERR', message: 'Invalid credentials' } }), + } as Response); + + await expect(authService.login({ username: 'bad', password: 'wrong' })).rejects.toThrow( + 'Invalid credentials', + ); + }); + + it('throws generic error when no message provided', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: {} }), + } as Response); + + await expect(authService.login({ username: 'test', password: '123' })).rejects.toThrow('Login failed'); + }); + }); + + describe('logout', () => { + it('sends POST with token and clears localStorage', async () => { + localStorage.setItem('auth_token', 'my-token'); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + } as Response); + + await authService.logout(); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/api/auth/logout'), + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer my-token' }, + }), + ); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); + }); + + it('clears token even if fetch fails', async () => { + localStorage.setItem('auth_token', 'my-token'); + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error')); + + // logout uses try/finally (no catch), so the error propagates + await authService.logout().catch(() => {}); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); + }); + + it('skips fetch when no token exists', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + await authService.logout(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); + }); + }); + + describe('getProfile', () => { + it('fetches profile with auth header', async () => { + localStorage.setItem('auth_token', 'my-token'); + const user = { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, data: user }), + } as Response); + + const result = await authService.getProfile(); + expect(result).toEqual(user); + }); + + it('throws when no token exists', async () => { + await expect(authService.getProfile()).rejects.toThrow('No authentication token'); + }); + + it('throws on failed profile fetch', async () => { + localStorage.setItem('auth_token', 'my-token'); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: { code: 'ERR', message: 'Token expired' } }), + } as Response); + + await expect(authService.getProfile()).rejects.toThrow('Token expired'); + }); + + it('throws generic error when no message in error response', async () => { + localStorage.setItem('auth_token', 'my-token'); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false, error: {} }), + } as Response); + + await expect(authService.getProfile()).rejects.toThrow('Failed to fetch profile'); + }); + }); +}); diff --git a/client/tests/authSlice.test.ts b/client/tests/authSlice.test.ts new file mode 100644 index 0000000..c975396 --- /dev/null +++ b/client/tests/authSlice.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { + hydrateAuth, + clearError, + setCredentials, + registerUser, + loginUser, + logoutUser, + fetchProfile, + type AuthState, +} from '~/store/authSlice'; + +// Mock the auth service +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; + +const createStore = (preloadedState?: Partial) => { + return configureStore({ + reducer: { auth: authReducer }, + preloadedState: preloadedState ? { auth: { ...initialState(), ...preloadedState } } : undefined, + }); +}; + +const initialState = (): AuthState => ({ + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: false, +}); + +describe('authSlice', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('initial state', () => { + it('has correct default values', () => { + const store = createStore(); + const state = store.getState().auth; + expect(state).toEqual(initialState()); + }); + }); + + describe('synchronous reducers', () => { + describe('hydrateAuth', () => { + it('sets hydrated=true and isAuthenticated=false when no token', () => { + vi.mocked(authService.getToken).mockReturnValue(null); + const store = createStore(); + store.dispatch(hydrateAuth()); + const state = store.getState().auth; + expect(state.hydrated).toBe(true); + expect(state.isAuthenticated).toBe(false); + expect(state.token).toBeNull(); + }); + + it('sets hydrated=true and isAuthenticated=true when token exists', () => { + vi.mocked(authService.getToken).mockReturnValue('existing-token'); + const store = createStore(); + store.dispatch(hydrateAuth()); + const state = store.getState().auth; + expect(state.hydrated).toBe(true); + expect(state.isAuthenticated).toBe(true); + expect(state.token).toBe('existing-token'); + }); + }); + + describe('clearError', () => { + it('clears the error field', () => { + const store = createStore({ error: 'Some error' }); + store.dispatch(clearError()); + expect(store.getState().auth.error).toBeNull(); + }); + }); + + describe('setCredentials', () => { + it('sets user, token, isAuthenticated and calls setToken', () => { + const store = createStore(); + const user = { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }; + store.dispatch(setCredentials({ user, token: 'my-token' })); + const state = store.getState().auth; + expect(state.user).toEqual(user); + expect(state.token).toBe('my-token'); + expect(state.isAuthenticated).toBe(true); + expect(authService.setToken).toHaveBeenCalledWith('my-token'); + }); + }); + }); + + describe('registerUser thunk', () => { + it('sets isLoading on pending', () => { + const store = createStore(); + // Dispatch without resolving to test pending + vi.mocked(authService.register).mockReturnValue(new Promise(() => {})); + store.dispatch(registerUser({ username: 'test', email: 'a@b.com', password: '123456' })); + const state = store.getState().auth; + expect(state.isLoading).toBe(true); + expect(state.error).toBeNull(); + }); + + it('sets user and token on fulfilled', async () => { + const mockData = { + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'reg-token', + }, + }; + vi.mocked(authService.register).mockResolvedValue(mockData); + + const store = createStore(); + await store.dispatch(registerUser({ username: 'testuser', email: 'test@test.com', password: '123456' })); + const state = store.getState().auth; + expect(state.isLoading).toBe(false); + expect(state.isAuthenticated).toBe(true); + expect(state.user?.username).toBe('testuser'); + expect(state.token).toBe('reg-token'); + }); + + it('sets error on rejected', async () => { + vi.mocked(authService.register).mockRejectedValue(new Error('Username taken')); + + const store = createStore(); + await store.dispatch(registerUser({ username: 'taken', email: 'a@b.com', password: '123456' })); + const state = store.getState().auth; + expect(state.isLoading).toBe(false); + expect(state.error).toBe('Username taken'); + }); + }); + + describe('loginUser thunk', () => { + it('sets user and token on fulfilled', async () => { + const mockData = { + success: true, + data: { + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'login-token', + }, + }; + vi.mocked(authService.login).mockResolvedValue(mockData); + + const store = createStore(); + await store.dispatch(loginUser({ username: 'testuser', password: '123456' })); + const state = store.getState().auth; + expect(state.isLoading).toBe(false); + expect(state.isAuthenticated).toBe(true); + expect(state.token).toBe('login-token'); + }); + + it('sets error on rejected', async () => { + vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials')); + + const store = createStore(); + await store.dispatch(loginUser({ username: 'bad', password: 'wrong' })); + const state = store.getState().auth; + expect(state.isLoading).toBe(false); + expect(state.error).toBe('Invalid credentials'); + }); + + it('sets isLoading on pending', () => { + vi.mocked(authService.login).mockReturnValue(new Promise(() => {})); + const store = createStore(); + store.dispatch(loginUser({ username: 'test', password: '123' })); + expect(store.getState().auth.isLoading).toBe(true); + expect(store.getState().auth.error).toBeNull(); + }); + }); + + describe('logoutUser thunk', () => { + it('clears auth state on fulfilled', async () => { + vi.mocked(authService.logout).mockResolvedValue(undefined); + + const store = createStore({ + user: { id: 1, username: 'test', email: 'a@b.com', created_at: '', updated_at: '' }, + token: 'tok', + isAuthenticated: true, + }); + await store.dispatch(logoutUser()); + const state = store.getState().auth; + expect(state.user).toBeNull(); + expect(state.token).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.isLoading).toBe(false); + }); + + it('clears auth state even on rejection', async () => { + vi.mocked(authService.logout).mockRejectedValue(new Error('Network error')); + + const store = createStore({ + user: { id: 1, username: 'test', email: 'a@b.com', created_at: '', updated_at: '' }, + token: 'tok', + isAuthenticated: true, + }); + await store.dispatch(logoutUser()); + const state = store.getState().auth; + expect(state.user).toBeNull(); + expect(state.token).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + + it('sets isLoading on pending', () => { + vi.mocked(authService.logout).mockReturnValue(new Promise(() => {})); + const store = createStore(); + store.dispatch(logoutUser()); + expect(store.getState().auth.isLoading).toBe(true); + }); + }); + + describe('fetchProfile thunk', () => { + it('sets user on fulfilled', async () => { + const user = { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }; + vi.mocked(authService.getProfile).mockResolvedValue(user); + + const store = createStore({ isAuthenticated: true, token: 'tok' }); + await store.dispatch(fetchProfile()); + const state = store.getState().auth; + expect(state.user).toEqual(user); + expect(state.isLoading).toBe(false); + }); + + it('clears auth on rejected and calls clearToken', async () => { + vi.mocked(authService.getProfile).mockRejectedValue(new Error('Token expired')); + + const store = createStore({ isAuthenticated: true, token: 'tok' }); + await store.dispatch(fetchProfile()); + const state = store.getState().auth; + expect(state.user).toBeNull(); + expect(state.token).toBeNull(); + expect(state.isAuthenticated).toBe(false); + expect(state.error).toBe('Token expired'); + expect(authService.clearToken).toHaveBeenCalled(); + }); + + it('sets isLoading and clears error on pending', () => { + vi.mocked(authService.getProfile).mockReturnValue(new Promise(() => {})); + const store = createStore({ error: 'old error' }); + store.dispatch(fetchProfile()); + const state = store.getState().auth; + expect(state.isLoading).toBe(true); + expect(state.error).toBeNull(); + }); + }); +}); diff --git a/client/tests/constants.test.ts b/client/tests/constants.test.ts new file mode 100644 index 0000000..7318e98 --- /dev/null +++ b/client/tests/constants.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { + GAME_MODES, + GAME_MODE_INFO, + GAME_SETUP, + BSP_SCORES, + STANDARD_FIRST_SPEED, + TICK_INTERVAL_MS, +} from '~/constants'; +import type { GameMode } from '~/constants'; + +describe('constants/index', () => { + describe('GAME_MODES', () => { + it('contains exactly four modes', () => { + expect(GAME_MODES).toHaveLength(4); + }); + + it('includes classic, narrow, pentominoes, cyber', () => { + expect(GAME_MODES).toContain('classic'); + expect(GAME_MODES).toContain('narrow'); + expect(GAME_MODES).toContain('pentominoes'); + expect(GAME_MODES).toContain('cyber'); + }); + }); + + describe('GAME_SETUP', () => { + it('has an entry for every game mode', () => { + for (const mode of GAME_MODES) { + expect(GAME_SETUP[mode]).toBeDefined(); + } + }); + + it.each([ + ['classic', 10, 20], + ['narrow', 6, 22], + ['pentominoes', 12, 22], + ['cyber', 8, 18], + ] as [GameMode, number, number][])( + '%s has width=%d and height=%d', + (mode, width, height) => { + expect(GAME_SETUP[mode].width).toBe(width); + expect(GAME_SETUP[mode].height).toBe(height); + }, + ); + + it('each setup has tetrominos as a non-empty object', () => { + for (const mode of GAME_MODES) { + const keys = Object.keys(GAME_SETUP[mode].tetrominos); + expect(keys.length).toBeGreaterThan(0); + } + }); + }); + + describe('GAME_MODE_INFO', () => { + it('has an entry for every game mode', () => { + for (const mode of GAME_MODES) { + expect(GAME_MODE_INFO[mode]).toBeDefined(); + } + }); + + it('each entry has required fields', () => { + for (const mode of GAME_MODES) { + const info = GAME_MODE_INFO[mode]; + expect(info.label).toBeTruthy(); + expect(info.description).toBeTruthy(); + expect(info.icon).toBeDefined(); + expect(info.accentColor).toBeTruthy(); + expect(info.accentBg).toBeTruthy(); + expect(info.accentBorder).toBeTruthy(); + expect(info.boardLabel).toBeTruthy(); + expect(info.piecesLabel).toBeTruthy(); + } + }); + + it('classic mode has correct label', () => { + expect(GAME_MODE_INFO.classic.label).toBe('Classic'); + }); + + it('narrow mode has correct label', () => { + expect(GAME_MODE_INFO.narrow.label).toBe('Narrow'); + }); + + it('pentominoes mode has correct label', () => { + expect(GAME_MODE_INFO.pentominoes.label).toBe('Pentominoes'); + }); + + it('cyber mode has correct label', () => { + expect(GAME_MODE_INFO.cyber.label).toBe('Cyber'); + }); + }); + + describe('BSP_SCORES', () => { + it('maps line clears 0-5 to expected scores', () => { + expect(BSP_SCORES[0]).toBe(0); + expect(BSP_SCORES[1]).toBe(40); + expect(BSP_SCORES[2]).toBe(100); + expect(BSP_SCORES[3]).toBe(300); + expect(BSP_SCORES[4]).toBe(1200); + expect(BSP_SCORES[5]).toBe(5000); + }); + }); + + describe('numeric constants', () => { + it('STANDARD_FIRST_SPEED is 20', () => { + expect(STANDARD_FIRST_SPEED).toBe(20); + }); + + it('TICK_INTERVAL_MS is 50', () => { + expect(TICK_INTERVAL_MS).toBe(50); + }); + }); +}); diff --git a/client/tests/game.test.tsx b/client/tests/game.test.tsx new file mode 100644 index 0000000..ac0b8ce --- /dev/null +++ b/client/tests/game.test.tsx @@ -0,0 +1,842 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; + +// --- Mocks --- + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => 'mock-token'), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn().mockResolvedValue({ + id: 42, + username: 'testplayer', + email: 'test@test.com', + created_at: '', + updated_at: '', + }), + }, +})); + +function createMockSocket() { + const handlers: Record void)[]> = {}; + const socket = { + connected: false, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + if (!handlers[event]) handlers[event] = []; + handlers[event].push(handler); + return socket; + }), + off: vi.fn(() => { + Object.keys(handlers).forEach((k) => delete handlers[k]); + return socket; + }), + emit: vi.fn(), + connect: vi.fn(() => { + socket.connected = true; + }), + disconnect: vi.fn(() => { + socket.connected = false; + }), + _trigger: (event: string, ...args: any[]) => { + (handlers[event] ?? []).forEach((h) => h(...args)); + }, + _handlers: handlers, + }; + return socket; +} + +let mockSocket: ReturnType; + +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => mockSocket), +})); + +import { authService } from '~/services/auth'; + +const delay = (ms = 20) => new Promise((r) => setTimeout(r, ms)); + +const createStore = () => + configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: { id: 42, username: 'testplayer', email: 'test@test.com', created_at: '', updated_at: '' }, + token: 'mock-token', + isAuthenticated: true, + isLoading: false, + error: null, + hydrated: true, + }, + }, + }); + +async function renderGame() { + const { default: GameRoute, meta } = await import('~/routes/game'); + const store = createStore(); + + const result = render( + + + + + , + ); + + return { ...result, store, meta }; +} + +/** Trigger a mock socket event inside an async act block with a flush delay */ +async function triggerSocket(event: string, ...args: any[]) { + await act(async () => { + mockSocket._trigger(event, ...args); + await delay(); + }); +} + +/** Assert text exists in DOM (handles duplicate mobile/desktop renders) */ +function expectTextPresent(text: string) { + const els = screen.queryAllByText(text); + expect(els.length).toBeGreaterThan(0); +} + +describe('Game Route', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSocket = createMockSocket(); + }); + + describe('meta', () => { + it('returns correct page title', async () => { + const { meta } = await import('~/routes/game'); + const result = meta({} as any); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris — Play' })]), + ); + }); + }); + + describe('GameComponent rendering', () => { + it('renders the main game UI', async () => { + await renderGame(); + expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); + expect(screen.getByText('Play Mode')).toBeInTheDocument(); + }); + + it('renders Back link', async () => { + await renderGame(); + expect(screen.getByText('Back')).toBeInTheDocument(); + }); + + it('renders Room Settings heading', async () => { + await renderGame(); + expectTextPresent('Room Settings'); + }); + + it('renders game mode selector', async () => { + await renderGame(); + const classicLabels = screen.getAllByText('Classic'); + expect(classicLabels.length).toBeGreaterThan(0); + }); + + it('renders Your Board section', async () => { + await renderGame(); + expect(screen.getByText('Your Board')).toBeInTheDocument(); + }); + + it('renders Hold and Next panels', async () => { + await renderGame(); + expect(screen.getByText('Hold')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('shows score as 0 initially', async () => { + await renderGame(); + const scoreElements = screen.getAllByText('0'); + expect(scoreElements.length).toBeGreaterThan(0); + expect(screen.getByText('Score')).toBeInTheDocument(); + }); + }); + + describe('room actions', () => { + it('renders Create button', async () => { + await renderGame(); + expectTextPresent('Create'); + }); + + it('renders Join button', async () => { + await renderGame(); + expectTextPresent('Join'); + }); + + it('renders Start Game button', async () => { + await renderGame(); + expectTextPresent('Start'); + }); + + it('clicking Create connects socket and emits create', async () => { + await renderGame(); + const createBtns = screen.getAllByRole('button', { name: /Create/ }); + fireEvent.click(createBtns[0]); + + expect(mockSocket.connect).toHaveBeenCalled(); + expect(mockSocket.emit).toHaveBeenCalledWith('create', { mode: 'classic' }); + }); + + it('clicking Join connects socket and emits join', async () => { + await renderGame(); + const inputs = screen.getAllByPlaceholderText(/room code/i); + fireEvent.change(inputs[0], { target: { value: 'ROOM123' } }); + + const joinBtns = screen.getAllByRole('button', { name: /Join/ }); + const enabledJoinBtn = joinBtns.find((b) => !b.hasAttribute('disabled')); + if (enabledJoinBtn) { + fireEvent.click(enabledJoinBtn); + expect(mockSocket.connect).toHaveBeenCalled(); + expect(mockSocket.emit).toHaveBeenCalledWith('join', { roomId: 'ROOM123' }); + } + }); + }); + + describe('socket events', () => { + it('handles "created" event - sets room and playerId', async () => { + await renderGame(); + + await triggerSocket('created', { roomId: 'NEW_ROOM' }); + + await waitFor(() => { + expect(screen.getAllByText(/Leave/).length).toBeGreaterThan(0); + }); + }); + + it('handles "joined" event - sets room and playerId', async () => { + await renderGame(); + + await triggerSocket('joined', { roomId: 'JOINED_ROOM' }); + + await waitFor(() => { + expect(screen.getAllByText(/Leave/).length).toBeGreaterThan(0); + }); + }); + + it('handles "state" event - updates game state', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'running' as const, + tick: 1, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, + nextPiece: 'T', + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 150, + isAlive: true, + name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => expectTextPresent('150')); + }); + + it('handles "host_changed" event', async () => { + await renderGame(); + await triggerSocket('host_changed', { newHostId: '99', message: 'Host changed!' }); + await waitFor(() => expectTextPresent('Host changed!')); + }); + + it('handles "game_over" event', async () => { + await renderGame(); + await triggerSocket('game_over', { message: 'Game Over!' }); + await waitFor(() => expectTextPresent('Game Over!')); + }); + + it('handles "game_over" with no message', async () => { + await renderGame(); + await triggerSocket('game_over', {}); + await waitFor(() => expectTextPresent('game over')); + }); + + it('handles "winner" event', async () => { + await renderGame(); + await triggerSocket('winner', { message: 'Player wins!' }); + await waitFor(() => expectTextPresent('Player wins!')); + }); + + it('handles "winner" with no message', async () => { + await renderGame(); + await triggerSocket('winner', {}); + await waitFor(() => expectTextPresent('YOU WIN')); + }); + + it('handles "create_failed" event', async () => { + await renderGame(); + await triggerSocket('create_failed', { code: 'ERR', message: 'Room exists' }); + await waitFor(() => expectTextPresent('Room exists')); + }); + + it('handles "create_failed" with only code', async () => { + await renderGame(); + await triggerSocket('create_failed', { code: 'ROOM_EXISTS' }); + await waitFor(() => expectTextPresent('ROOM_EXISTS')); + }); + + it('handles "create_failed" with no info', async () => { + await renderGame(); + await triggerSocket('create_failed', {}); + await waitFor(() => expectTextPresent('CREATE_FAILED')); + }); + + it('handles "join_failed" event', async () => { + await renderGame(); + await triggerSocket('join_failed', { message: 'Room full' }); + await waitFor(() => expectTextPresent('Room full')); + }); + + it('handles "join_failed" with code only', async () => { + await renderGame(); + await triggerSocket('join_failed', { code: 'ROOM_FULL' }); + await waitFor(() => expectTextPresent('ROOM_FULL')); + }); + + it('handles "join_failed" with no info', async () => { + await renderGame(); + await triggerSocket('join_failed', {}); + await waitFor(() => expectTextPresent('JOIN_FAILED')); + }); + + it('handles "start_failed" event', async () => { + await renderGame(); + await triggerSocket('start_failed', { message: 'Not enough players' }); + await waitFor(() => expectTextPresent('Not enough players')); + }); + + it('handles "start_failed" with code only', async () => { + await renderGame(); + await triggerSocket('start_failed', { code: 'NOT_ENOUGH' }); + await waitFor(() => expectTextPresent('NOT_ENOUGH')); + }); + + it('handles "start_failed" with no info', async () => { + await renderGame(); + await triggerSocket('start_failed', {}); + await waitFor(() => expectTextPresent('START_FAILED')); + }); + + it('handles "restarted" event with message', async () => { + await renderGame(); + await triggerSocket('restarted', { message: 'Game restarted!' }); + await waitFor(() => expectTextPresent('Game restarted!')); + }); + + it('handles "restarted" event without message', async () => { + await renderGame(); + await triggerSocket('restarted', {}); + // No error thrown, actionLoading set to false + }); + + it('handles "restart_failed" event with message', async () => { + await renderGame(); + await triggerSocket('restart_failed', { message: 'Cannot restart' }); + await waitFor(() => expectTextPresent('Cannot restart')); + }); + + it('handles "restart_failed" event with reason', async () => { + await renderGame(); + await triggerSocket('restart_failed', { reason: 'Game not finished' }); + await waitFor(() => expectTextPresent('Game not finished')); + }); + + it('handles "restart_failed" event with no info', async () => { + await renderGame(); + await triggerSocket('restart_failed', {}); + await waitFor(() => expectTextPresent('RESTART_FAILED')); + }); + + it('handles "connect_error" with Error object', async () => { + await renderGame(); + await triggerSocket('connect_error', new Error('Connection refused')); + await waitFor(() => expectTextPresent('Connection refused')); + }); + + it('handles "connect_error" with string', async () => { + await renderGame(); + await triggerSocket('connect_error', 'Auth failed'); + await waitFor(() => expectTextPresent('Auth failed')); + }); + + it('handles "state" event with mode syncing', async () => { + await renderGame(); + + await triggerSocket('state', { + status: 'waiting' as const, + tick: 0, + speed: 20, + players: {}, + hostId: '42', + mode: 'narrow', + }); + + await waitFor(() => { + const narrowLabels = screen.getAllByText('6 × 22'); + expect(narrowLabels.length).toBeGreaterThan(0); + }); + }); + + it('handles "created" with profile fetch failure', async () => { + vi.mocked(authService.getProfile).mockRejectedValueOnce(new Error('Fetch failed')); + await renderGame(); + await triggerSocket('created', { roomId: 'R1' }); + // Should not crash, error is just logged + }); + + it('handles "joined" with profile fetch failure', async () => { + vi.mocked(authService.getProfile).mockRejectedValueOnce(new Error('Fetch failed')); + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + // Should not crash + }); + }); + + describe('leave action', () => { + it('disconnects and resets state', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + const leaveBtns = screen.getAllByText(/Leave/); + fireEvent.click(leaveBtns[0]); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + }); + + describe('start action', () => { + it('emits start when player is host', async () => { + await renderGame(); + await triggerSocket('created', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'waiting', + tick: 0, + speed: 20, + players: { + '42': { board: [], activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer' }, + }, + hostId: '42', + mode: 'classic', + }); + + const startBtns = screen.getAllByRole('button', { name: /Start/ }); + const enabledStart = startBtns.find((b) => !b.hasAttribute('disabled')); + if (enabledStart) { + fireEvent.click(enabledStart); + expect(mockSocket.emit).toHaveBeenCalledWith('start', { roomId: 'R1' }); + } + }); + }); + + describe('game board rendering', () => { + it('renders board cells with different values', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + const board = Array.from({ length: 20 }, () => Array(10).fill(0)); + board[19][0] = 1; + board[19][1] = -1; + board[19][2] = -2; + board[18][0] = 2; + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board, + activePiece: null, + nextPiece: 'I', + piecesPlaced: 1, + speed: 20, + holdPiece: 'T', + holdLocked: false, + score: 100, + isAlive: true, + name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => expectTextPresent('100')); + }); + + it('renders opponents when they exist', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + const emptyBoard = Array.from({ length: 20 }, () => Array(10).fill(0)); + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board: emptyBoard, + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', + }, + '99': { + board: emptyBoard, + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 200, + isAlive: true, + name: 'opponent1', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => { + expectTextPresent('Opponents'); + expectTextPresent('opponent1'); + expectTextPresent('ALIVE'); + }); + }); + + it('renders dead opponent correctly', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + const emptyBoard = Array.from({ length: 20 }, () => Array(10).fill(0)); + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board: emptyBoard, + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', + }, + '99': { + board: emptyBoard, + activePiece: null, + nextPiece: null, + piecesPlaced: 5, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 300, + isAlive: false, + name: 'deadguy', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => { + expectTextPresent('deadguy'); + expectTextPresent('DEAD'); + }); + }); + }); + + describe('keyboard controls', () => { + it('sends intent on ArrowUp (rotate)', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: 'ArrowUp' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'rotate' }, + }); + }); + + it('sends intent on Space (hard drop)', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: ' ', code: 'Space' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'hard' }, + }); + }); + + it('sends intent on Shift (hold)', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: 'Shift' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'hold' }, + }); + }); + + it('sends move left on ArrowLeft', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'move', dir: 'left' }, + }); + fireEvent.keyUp(window, { key: 'ArrowLeft' }); + }); + + it('sends move right on ArrowRight', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'move', dir: 'right' }, + }); + fireEvent.keyUp(window, { key: 'ArrowRight' }); + }); + + it('sends soft drop on ArrowDown', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + fireEvent.keyDown(window, { key: 'ArrowDown' }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { + roomId: 'ROOM1', + intent: { type: 'soft' }, + }); + fireEvent.keyUp(window, { key: 'ArrowDown' }); + }); + + it('does not fire repeated keyDown for held keys', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'ROOM1' }); + + mockSocket.emit.mockClear(); + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + const leftCalls = mockSocket.emit.mock.calls.filter( + ([ev, payload]: any) => ev === 'intent' && payload?.intent?.type === 'move' && payload?.intent?.dir === 'left', + ); + expect(leftCalls).toHaveLength(1); + fireEvent.keyUp(window, { key: 'ArrowLeft' }); + }); + + it('does not send intents when playerId is null', async () => { + await renderGame(); + mockSocket.emit.mockClear(); + fireEvent.keyDown(window, { key: 'ArrowUp' }); + const intentCalls = mockSocket.emit.mock.calls.filter(([ev]: any) => ev === 'intent'); + expect(intentCalls).toHaveLength(0); + }); + }); + + describe('win/lose detection', () => { + it('shows "You lost" when player dies', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await triggerSocket('state', { + status: 'running', + tick: 2, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 0, isAlive: false, name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => expectTextPresent('You lost')); + }); + + it('shows "You win" when player is the winner', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await triggerSocket('state', { + status: 'finished', + tick: 10, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 500, isAlive: true, name: 'testplayer', + }, + }, + hostId: '42', + winnerId: '42', + mode: 'classic', + }); + + await waitFor(() => expectTextPresent('You win')); + }); + }); + + describe('finished game with restart', () => { + it('shows restart button when game is finished and player is host', async () => { + await renderGame(); + await triggerSocket('created', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'finished', + tick: 100, + speed: 20, + players: { + '42': { + board: Array.from({ length: 20 }, () => Array(10).fill(0)), + activePiece: null, nextPiece: null, piecesPlaced: 10, speed: 20, + holdPiece: null, holdLocked: false, score: 1000, isAlive: false, name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => { + const restartBtns = screen.getAllByText(/Restart/); + expect(restartBtns.length).toBeGreaterThan(0); + }); + + const restartBtns = screen.getAllByRole('button', { name: /Restart/ }); + fireEvent.click(restartBtns[0]); + expect(mockSocket.emit).toHaveBeenCalledWith('restart', { roomId: 'R1' }); + }); + }); + + describe('opponent board with spectrum', () => { + it('renders opponent with spectrum data', async () => { + await renderGame(); + await triggerSocket('joined', { roomId: 'R1' }); + + const emptyBoard = Array.from({ length: 20 }, () => Array(10).fill(0)); + + await triggerSocket('state', { + status: 'running', + tick: 1, + speed: 20, + players: { + '42': { + board: emptyBoard, + activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + }, + '50': { + board: emptyBoard, + activePiece: null, nextPiece: null, piecesPlaced: 3, speed: 20, + holdPiece: null, holdLocked: false, score: 500, isAlive: true, name: 'player2', + spectrum: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => expectTextPresent('player2')); + }); + }); + + describe('hostName display', () => { + it('shows host name with (you) suffix when player is host', async () => { + await renderGame(); + await triggerSocket('created', { roomId: 'R1' }); + + await triggerSocket('state', { + status: 'waiting', + tick: 0, + speed: 20, + players: { + '42': { + board: [], activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, + holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + }, + }, + hostId: '42', + mode: 'classic', + }); + + await waitFor(() => { + const youElements = screen.getAllByText('(you)'); + expect(youElements.length).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/client/tests/gamesService.test.ts b/client/tests/gamesService.test.ts new file mode 100644 index 0000000..935936d --- /dev/null +++ b/client/tests/gamesService.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gamesService } from '~/services/games'; + +// We need to mock authService.getToken inside the games module +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => 'mock-token'), + setToken: vi.fn(), + clearToken: vi.fn(), + }, +})); + +describe('gamesService', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('getGames', () => { + it('fetches paginated games', async () => { + const mockData = { + page: 0, + size: 10, + totalItems: 1, + totalPages: 1, + content: [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [{ player: { id: 1, username: 'user1' }, score: 100, place: 1 }], + }, + ], + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: mockData }), + } as Response); + + const result = await gamesService.getGames(0, 10); + expect(result.content).toHaveLength(1); + expect(result.totalPages).toBe(1); + }); + + it('throws error on failed fetch', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: { message: 'Unauthorized' } }), + } as Response); + + await expect(gamesService.getGames()).rejects.toThrow('Unauthorized'); + }); + + it('throws generic error when no message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: {} }), + } as Response); + + await expect(gamesService.getGames()).rejects.toThrow('Failed to fetch games'); + }); + }); + + describe('getAllGames', () => { + it('fetches all pages of games', async () => { + const page0 = { + page: 0, + size: 100, + totalItems: 150, + totalPages: 2, + content: Array.from({ length: 100 }, (_, i) => ({ + game_id: i, + finished_at: '2025-01-01', + players: [], + })), + }; + const page1 = { + page: 1, + size: 100, + totalItems: 150, + totalPages: 2, + content: Array.from({ length: 50 }, (_, i) => ({ + game_id: 100 + i, + finished_at: '2025-01-01', + players: [], + })), + }; + + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: page0 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: page1 }), + } as Response); + + const result = await gamesService.getAllGames(); + expect(result).toHaveLength(150); + }); + + it('returns single page when totalPages is 1', async () => { + const page0 = { + page: 0, + size: 100, + totalItems: 3, + totalPages: 1, + content: [ + { game_id: 1, finished_at: '2025-01-01', players: [] }, + { game_id: 2, finished_at: '2025-01-01', players: [] }, + { game_id: 3, finished_at: '2025-01-01', players: [] }, + ], + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: page0 }), + } as Response); + + const result = await gamesService.getAllGames(); + expect(result).toHaveLength(3); + }); + }); + + describe('computeStats', () => { + it('returns zeros for empty games array', () => { + const stats = gamesService.computeStats([], 1); + expect(stats).toEqual({ + gamesPlayed: 0, + wins: 0, + winRate: 0, + highScore: 0, + averageScore: 0, + bestPlace: 0, + }); + }); + + it('computes stats correctly for a single game with a win', () => { + const games = [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [ + { player: { id: 1, username: 'me' }, score: 500, place: 1 }, + { player: { id: 2, username: 'other' }, score: 200, place: 2 }, + ], + }, + ]; + + const stats = gamesService.computeStats(games, 1); + expect(stats.gamesPlayed).toBe(1); + expect(stats.wins).toBe(1); + expect(stats.winRate).toBe(100); + expect(stats.highScore).toBe(500); + expect(stats.averageScore).toBe(500); + expect(stats.bestPlace).toBe(1); + }); + + it('computes stats correctly for multiple games', () => { + const games = [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [ + { player: { id: 1, username: 'me' }, score: 1000, place: 1 }, + { player: { id: 2, username: 'other' }, score: 200, place: 2 }, + ], + }, + { + game_id: 2, + finished_at: '2025-01-02', + players: [ + { player: { id: 1, username: 'me' }, score: 300, place: 2 }, + { player: { id: 2, username: 'other' }, score: 500, place: 1 }, + ], + }, + { + game_id: 3, + finished_at: '2025-01-03', + players: [ + { player: { id: 1, username: 'me' }, score: 200, place: 3 }, + { player: { id: 3, username: 'third' }, score: 400, place: 1 }, + ], + }, + ]; + + const stats = gamesService.computeStats(games, 1); + expect(stats.gamesPlayed).toBe(3); + expect(stats.wins).toBe(1); + expect(stats.winRate).toBe(33); // Math.round(1/3*100) + expect(stats.highScore).toBe(1000); + expect(stats.averageScore).toBe(500); + expect(stats.bestPlace).toBe(1); + }); + + it('handles games where the user is not found in players', () => { + const games = [ + { + game_id: 1, + finished_at: '2025-01-01', + players: [{ player: { id: 2, username: 'other' }, score: 500, place: 1 }], + }, + ]; + + const stats = gamesService.computeStats(games, 1); + // gamesPlayed is games.length (1), but no scores found + expect(stats.gamesPlayed).toBe(1); + expect(stats.wins).toBe(0); + expect(stats.highScore).toBe(0); + expect(stats.bestPlace).toBe(0); // Infinity becomes 0 + }); + }); +}); diff --git a/client/tests/hello.test.ts b/client/tests/hello.test.ts deleted file mode 100644 index 2576092..0000000 --- a/client/tests/hello.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { should } from 'chai'; - -should(); - -describe('Hi there!', function () { - it('1+1 == 2', () => { - const res = 1 + 1; - should().equal(res, 2); - }); -}); diff --git a/client/tests/hooks.test.tsx b/client/tests/hooks.test.tsx new file mode 100644 index 0000000..e61844e --- /dev/null +++ b/client/tests/hooks.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from '~/store/authSlice'; +import { useAppDispatch, useAppSelector } from '~/store/hooks'; +import type { ReactNode } from 'react'; + +const createWrapper = () => { + const store = configureStore({ + reducer: { auth: authReducer }, + }); + + return ({ children }: { children: ReactNode }) => {children}; +}; + +describe('store/hooks', () => { + describe('useAppDispatch', () => { + it('returns a dispatch function', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAppDispatch(), { wrapper }); + expect(typeof result.current).toBe('function'); + }); + }); + + describe('useAppSelector', () => { + it('selects auth state', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAppSelector((state) => state.auth.isAuthenticated), { + wrapper, + }); + expect(result.current).toBe(false); + }); + + it('selects user as null by default', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAppSelector((state) => state.auth.user), { wrapper }); + expect(result.current).toBeNull(); + }); + + it('selects hydrated as false by default', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAppSelector((state) => state.auth.hydrated), { wrapper }); + expect(result.current).toBe(false); + }); + }); +}); diff --git a/client/tests/pieceHelpers.test.ts b/client/tests/pieceHelpers.test.ts new file mode 100644 index 0000000..25d5f39 --- /dev/null +++ b/client/tests/pieceHelpers.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { buildPieceMeta } from '~/utils/pieceHelpers'; +import { GAME_SETUP, GAME_MODES, type GameMode } from '~/constants'; + +describe('buildPieceMeta', () => { + it.each(GAME_MODES)('returns shapes, colors and idColors for "%s" mode', (mode) => { + const meta = buildPieceMeta(mode); + + expect(meta.shapes).toBeDefined(); + expect(meta.colors).toBeDefined(); + expect(meta.idColors).toBeDefined(); + }); + + it('produces one shape entry per tetromino key', () => { + const meta = buildPieceMeta('classic'); + const expectedKeys = Object.keys(GAME_SETUP.classic.tetrominos); + + expect(Object.keys(meta.shapes)).toEqual(expectedKeys); + expect(Object.keys(meta.colors)).toEqual(expectedKeys); + }); + + it('shapes are the first rotation (index 0) of each tetromino', () => { + const meta = buildPieceMeta('classic'); + const tetrominos = GAME_SETUP.classic.tetrominos; + + for (const key of Object.keys(tetrominos)) { + expect(meta.shapes[key]).toEqual(tetrominos[key][0]); + } + }); + + it('classic pieces get their well-known colours', () => { + const meta = buildPieceMeta('classic'); + + expect(meta.colors['I']).toBe('#0d9488'); + expect(meta.colors['O']).toBe('#ca8a04'); + expect(meta.colors['T']).toBe('#9333ea'); + expect(meta.colors['S']).toBe('#16a34a'); + expect(meta.colors['Z']).toBe('#dc2626'); + expect(meta.colors['J']).toBe('#2563eb'); + expect(meta.colors['L']).toBe('#c2410c'); + }); + + it('non-classic pieces fall back to the palette', () => { + const meta = buildPieceMeta('pentominoes'); + const tetrominos = GAME_SETUP.pentominoes.tetrominos; + const keys = Object.keys(tetrominos); + + // pentominoes have keys beyond I/O/T/S/Z/J/L — those should get palette colours + const nonClassic = keys.filter((k) => !['I', 'O', 'T', 'S', 'Z', 'J', 'L'].includes(k)); + expect(nonClassic.length).toBeGreaterThan(0); + + for (const k of nonClassic) { + expect(meta.colors[k]).toMatch(/^#[0-9a-f]{6}$/i); + } + }); + + it('idColors[0] is always black (#000000)', () => { + for (const mode of GAME_MODES) { + const meta = buildPieceMeta(mode); + expect(meta.idColors[0]).toBe('#000000'); + } + }); + + it('idColors has length = 1 + number of piece keys (up to 15)', () => { + for (const mode of GAME_MODES) { + const meta = buildPieceMeta(mode); + const keyCount = Math.min(Object.keys(GAME_SETUP[mode].tetrominos).length, 15); + expect(meta.idColors).toHaveLength(1 + keyCount); + } + }); + + it('idColors[1..n] match the corresponding piece colour', () => { + const meta = buildPieceMeta('classic'); + const keys = Object.keys(GAME_SETUP.classic.tetrominos); + + keys.forEach((k, i) => { + expect(meta.idColors[i + 1]).toBe(meta.colors[k]); + }); + }); + + it('returns the same structure for every mode', () => { + for (const mode of GAME_MODES) { + const meta = buildPieceMeta(mode); + expect(Array.isArray(meta.idColors)).toBe(true); + expect(typeof meta.shapes).toBe('object'); + expect(typeof meta.colors).toBe('object'); + + // every shape should be a 2D number array + for (const shape of Object.values(meta.shapes)) { + expect(Array.isArray(shape)).toBe(true); + expect(shape.length).toBeGreaterThan(0); + for (const row of shape) { + expect(Array.isArray(row)).toBe(true); + } + } + } + }); +}); diff --git a/client/tests/routes.test.tsx b/client/tests/routes.test.tsx new file mode 100644 index 0000000..89bef19 --- /dev/null +++ b/client/tests/routes.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; + +// Mock services +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => null), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +vi.mock('~/services/games', () => ({ + gamesService: { + getAllGames: vi.fn().mockResolvedValue([]), + computeStats: vi.fn().mockReturnValue({ + gamesPlayed: 0, + wins: 0, + winRate: 0, + highScore: 0, + averageScore: 0, + bestPlace: 0, + }), + }, +})); + +const createStore = (authState = {}) => + configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: null, + token: null, + isAuthenticated: false, + isLoading: false, + error: null, + hydrated: true, + ...authState, + }, + }, + }); + +describe('Route: Login', () => { + it('renders login form inside PublicOnlyRoute (unauthenticated)', async () => { + const { default: Login } = await import('~/routes/login'); + const store = createStore({ hydrated: true, isAuthenticated: false }); + + render( + + + + + , + ); + + expect(screen.getByText('Welcome Back!')).toBeInTheDocument(); + }); +}); + +describe('Route: Register', () => { + it('renders register form inside PublicOnlyRoute (unauthenticated)', async () => { + const { default: Register } = await import('~/routes/register'); + const store = createStore({ hydrated: true, isAuthenticated: false }); + + render( + + + + + , + ); + + expect(screen.getByText('Join the Game!')).toBeInTheDocument(); + }); +}); + +describe('Route: Home', () => { + it('renders ProtectedRoute with Welcome when authenticated', async () => { + const { default: Home } = await import('~/routes/home'); + const store = createStore({ + hydrated: true, + isAuthenticated: true, + user: { id: 1, username: 'testuser', email: 'test@test.com', created_at: '', updated_at: '' }, + }); + + render( + + + + + , + ); + + expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + }); +}); + +describe('Route meta functions', () => { + it('login meta returns correct title', async () => { + const { meta } = await import('~/routes/login'); + const result = meta({} as any); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Login - Red Tetris' })]), + ); + }); + + it('register meta returns correct title', async () => { + const { meta } = await import('~/routes/register'); + const result = meta({} as any); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Register - Red Tetris' })]), + ); + }); + + it('home meta returns correct title', async () => { + const { meta } = await import('~/routes/home'); + const result = meta({} as any); + expect(result).toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris - Home' })]), + ); + }); +}); diff --git a/client/tests/setup.ts b/client/tests/setup.ts new file mode 100644 index 0000000..f7d68f1 --- /dev/null +++ b/client/tests/setup.ts @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +afterEach(() => { + cleanup(); +}); + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); diff --git a/client/tests/store.test.ts b/client/tests/store.test.ts new file mode 100644 index 0000000..72f61f0 --- /dev/null +++ b/client/tests/store.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { store } from '~/store/store'; + +describe('store', () => { + it('has an auth reducer', () => { + const state = store.getState(); + expect(state).toHaveProperty('auth'); + }); + + it('auth initial state has expected shape', () => { + const { auth } = store.getState(); + expect(auth).toHaveProperty('user'); + expect(auth).toHaveProperty('token'); + expect(auth).toHaveProperty('isAuthenticated'); + expect(auth).toHaveProperty('isLoading'); + expect(auth).toHaveProperty('error'); + expect(auth).toHaveProperty('hydrated'); + }); + + it('dispatch is a function', () => { + expect(typeof store.dispatch).toBe('function'); + }); + + it('getState is a function', () => { + expect(typeof store.getState).toBe('function'); + }); +}); diff --git a/client/tests/tetrominos.test.ts b/client/tests/tetrominos.test.ts new file mode 100644 index 0000000..19862b0 --- /dev/null +++ b/client/tests/tetrominos.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { CLASSIC_TETROMINOS, CYBER_TETROMINOS, PENTOMINOES } from '~/constants/tetrominos'; + +describe('constants/tetrominos', () => { + describe('CLASSIC_TETROMINOS', () => { + it('has exactly 7 pieces (I, O, T, S, Z, J, L)', () => { + const keys = Object.keys(CLASSIC_TETROMINOS); + expect(keys).toHaveLength(7); + expect(keys).toEqual(expect.arrayContaining(['I', 'O', 'T', 'S', 'Z', 'J', 'L'])); + }); + + it('each piece has at least one rotation', () => { + for (const [key, rotations] of Object.entries(CLASSIC_TETROMINOS)) { + expect(rotations.length).toBeGreaterThanOrEqual(1); + // O-piece has 1 rotation, others have 2-4 + if (key === 'O') { + expect(rotations).toHaveLength(1); + } + } + }); + + it('each rotation is a 2D array of 0s and 1s', () => { + for (const rotations of Object.values(CLASSIC_TETROMINOS)) { + for (const rotation of rotations) { + expect(Array.isArray(rotation)).toBe(true); + for (const row of rotation) { + expect(Array.isArray(row)).toBe(true); + for (const cell of row) { + expect(cell === 0 || cell === 1).toBe(true); + } + } + } + } + }); + + it('each classic piece has exactly 4 filled cells', () => { + for (const rotations of Object.values(CLASSIC_TETROMINOS)) { + for (const rotation of rotations) { + const count = rotation.flat().filter((c) => c === 1).length; + expect(count).toBe(4); + } + } + }); + }); + + describe('CYBER_TETROMINOS', () => { + it('has 6 pieces', () => { + const keys = Object.keys(CYBER_TETROMINOS); + expect(keys).toHaveLength(6); + }); + + it('each piece has at least one rotation', () => { + for (const rotations of Object.values(CYBER_TETROMINOS)) { + expect(rotations.length).toBeGreaterThanOrEqual(1); + } + }); + + it('each rotation contains only 0s and 1s', () => { + for (const rotations of Object.values(CYBER_TETROMINOS)) { + for (const rotation of rotations) { + for (const row of rotation) { + for (const cell of row) { + expect(cell === 0 || cell === 1).toBe(true); + } + } + } + } + }); + }); + + describe('PENTOMINOES', () => { + it('has at least 10 pieces', () => { + const keys = Object.keys(PENTOMINOES); + expect(keys.length).toBeGreaterThanOrEqual(10); + }); + + it('each piece has at least one rotation', () => { + for (const rotations of Object.values(PENTOMINOES)) { + expect(rotations.length).toBeGreaterThanOrEqual(1); + } + }); + + it('each pentomino rotation has exactly 5 filled cells', () => { + for (const rotations of Object.values(PENTOMINOES)) { + for (const rotation of rotations) { + const count = rotation.flat().filter((c) => c === 1).length; + expect(count).toBe(5); + } + } + }); + + it('each rotation contains only 0s and 1s', () => { + for (const rotations of Object.values(PENTOMINOES)) { + for (const rotation of rotations) { + for (const row of rotation) { + for (const cell of row) { + expect(cell === 0 || cell === 1).toBe(true); + } + } + } + } + }); + }); +}); diff --git a/client/tests/welcome.test.tsx b/client/tests/welcome.test.tsx new file mode 100644 index 0000000..85a4f37 --- /dev/null +++ b/client/tests/welcome.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { MemoryRouter } from 'react-router'; +import authReducer from '~/store/authSlice'; +import { Welcome } from '~/welcome/welcome'; + +vi.mock('~/services/auth', () => ({ + authService: { + getToken: vi.fn(() => 'mock-token'), + setToken: vi.fn(), + clearToken: vi.fn(), + register: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + getProfile: vi.fn(), + }, +})); + +vi.mock('~/services/games', () => ({ + gamesService: { + getAllGames: vi.fn(), + computeStats: vi.fn(), + }, +})); + +import { authService } from '~/services/auth'; +import { gamesService } from '~/services/games'; + +const mockUser = { + id: 1, + username: 'testuser', + email: 'test@test.com', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + +const renderWelcome = (authState = {}) => { + const store = configureStore({ + reducer: { auth: authReducer }, + preloadedState: { + auth: { + user: mockUser, + token: 'tok', + isAuthenticated: true, + isLoading: false, + error: null, + hydrated: true, + ...authState, + }, + }, + }); + + return { + store, + ...render( + + + + + , + ), + }; +}; + +describe('Welcome', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(gamesService.getAllGames).mockResolvedValue([]); + vi.mocked(gamesService.computeStats).mockReturnValue({ + gamesPlayed: 0, + wins: 0, + winRate: 0, + highScore: 0, + averageScore: 0, + bestPlace: 0, + }); + }); + + it('renders the main heading', () => { + renderWelcome(); + expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); + }); + + it('shows user information', () => { + renderWelcome(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText('test@test.com')).toBeInTheDocument(); + }); + + it('displays user initial in avatar', () => { + renderWelcome(); + expect(screen.getByText('T')).toBeInTheDocument(); + }); + + it('renders PLAY NOW link', () => { + renderWelcome(); + expect(screen.getByText('PLAY NOW')).toBeInTheDocument(); + }); + + it('renders logout button and dispatches logoutUser', () => { + vi.mocked(authService.logout).mockResolvedValue(undefined); + const { store } = renderWelcome(); + const logoutBtn = screen.getByText('Logout'); + expect(logoutBtn).toBeInTheDocument(); + fireEvent.click(logoutBtn); + }); + + it('shows "No games played yet" when stats show 0 games', async () => { + renderWelcome(); + await waitFor(() => { + expect(screen.getByText('No games played yet')).toBeInTheDocument(); + }); + }); + + it('shows stats when user has games', async () => { + vi.mocked(gamesService.getAllGames).mockResolvedValue([ + { + game_id: 1, + finished_at: '2025-01-01', + players: [{ player: { id: 1, username: 'testuser' }, score: 1000, place: 1 }], + }, + ]); + vi.mocked(gamesService.computeStats).mockReturnValue({ + gamesPlayed: 1, + wins: 1, + winRate: 100, + highScore: 1000, + averageScore: 1000, + bestPlace: 1, + }); + + renderWelcome(); + await waitFor(() => { + expect(screen.getByText('Games Played')).toBeInTheDocument(); + expect(screen.getByText('High Score')).toBeInTheDocument(); + expect(screen.getByText('Wins')).toBeInTheDocument(); + }); + }); + + it('shows recent games when they exist', async () => { + const games = [ + { + game_id: 1, + finished_at: '2025-06-01T12:00:00Z', + players: [{ player: { id: 1, username: 'testuser' }, score: 500, place: 1 }], + }, + ]; + vi.mocked(gamesService.getAllGames).mockResolvedValue(games); + vi.mocked(gamesService.computeStats).mockReturnValue({ + gamesPlayed: 1, + wins: 1, + winRate: 100, + highScore: 500, + averageScore: 500, + bestPlace: 1, + }); + + renderWelcome(); + await waitFor(() => { + expect(screen.getByText('Recent Games')).toBeInTheDocument(); + }); + }); + + it('handles stats loading failure gracefully', async () => { + vi.mocked(gamesService.getAllGames).mockRejectedValue(new Error('Network error')); + + renderWelcome(); + await waitFor(() => { + expect(screen.getByText('No games played yet')).toBeInTheDocument(); + }); + }); + + it('does not render user section when user is null', () => { + renderWelcome({ user: null }); + expect(screen.queryByText('testuser')).not.toBeInTheDocument(); + expect(screen.queryByText('PLAY NOW')).not.toBeInTheDocument(); + }); +}); diff --git a/client/vitest.config.ts b/client/vitest.config.ts new file mode 100644 index 0000000..ee5a42b --- /dev/null +++ b/client/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + include: ['app/**/*.{ts,tsx}'], + exclude: [ + 'app/root.tsx', + 'app/routes.ts', + 'app/+types/**', + 'app/routes/+types/**', + 'app/app.css', + 'app/types/**', + ], + thresholds: { + statements: 70, + functions: 70, + lines: 70, + branches: 50, + }, + }, + }, + define: { + 'import.meta.env.VITE_SERVER_URL': JSON.stringify('http://localhost:3002'), + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b204625..aca92a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,15 @@ importers: '@tailwindcss/vite': specifier: ^4.1.13 version: 4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -121,6 +130,12 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.8) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 tailwindcss: specifier: ^4.1.13 version: 4.1.18 @@ -133,6 +148,9 @@ importers: vite-tsconfig-paths: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2) server: dependencies: @@ -230,6 +248,12 @@ importers: packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@apidevtools/json-schema-ref-parser@9.1.2': resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} @@ -245,6 +269,16 @@ packages: peerDependencies: openapi-types: '>=7' + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -332,6 +366,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} @@ -369,6 +408,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -381,10 +424,53 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -579,6 +665,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -959,6 +1054,35 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -971,6 +1095,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -1120,6 +1247,44 @@ packages: resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1138,6 +1303,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -1157,6 +1326,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1184,6 +1357,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1219,6 +1399,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1253,6 +1436,9 @@ packages: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1427,9 +1613,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1467,6 +1668,9 @@ packages: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.1: resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} peerDependencies: @@ -1498,6 +1702,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1526,6 +1734,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -1571,6 +1785,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -1706,6 +1924,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1718,6 +1939,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.2.1: resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} engines: {node: '>= 16'} @@ -1886,7 +2111,7 @@ packages: glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -1955,6 +2180,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1962,6 +2191,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2098,6 +2335,9 @@ packages: resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -2201,6 +2441,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2212,6 +2455,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -2390,6 +2642,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2397,9 +2653,16 @@ packages: resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -2415,6 +2678,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -2451,6 +2717,10 @@ packages: engines: {node: '>=4'} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2561,6 +2831,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -2626,6 +2899,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2693,6 +2969,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-on-spawn@1.1.0: resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==} engines: {node: '>=8'} @@ -2740,6 +3020,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -2778,6 +3061,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -2802,6 +3089,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} @@ -2866,6 +3157,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2941,6 +3236,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2992,10 +3290,16 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3039,6 +3343,10 @@ packages: resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} engines: {node: '>=8'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3077,6 +3385,9 @@ packages: peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -3088,10 +3399,28 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3104,6 +3433,14 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3205,6 +3542,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -3303,6 +3644,56 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3327,6 +3718,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3364,6 +3760,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -3435,6 +3838,10 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + '@apidevtools/json-schema-ref-parser@9.1.2': dependencies: '@jsdevtools/ono': 7.1.3 @@ -3456,6 +3863,24 @@ snapshots: openapi-types: 12.1.3 z-schema: 5.0.5 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3579,6 +4004,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.28.6)': dependencies: '@babel/core': 7.28.6 @@ -3627,6 +4056,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.28.6 @@ -3650,10 +4081,43 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -3778,6 +4242,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4123,6 +4589,40 @@ snapshots: tailwindcss: 4.1.18 vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -4131,6 +4631,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/bcrypt@6.0.0': dependencies: '@types/node': 25.0.10 @@ -4326,6 +4828,59 @@ snapshots: '@typescript-eslint/types': 8.53.1 eslint-visitor-keys: 4.2.1 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -4341,6 +4896,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -4361,6 +4918,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} anymatch@3.1.3: @@ -4384,6 +4943,12 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -4445,6 +5010,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -4477,6 +5048,10 @@ snapshots: node-addon-api: 8.5.0 node-gyp-build: 4.8.4 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} body-parser@1.20.4: @@ -4669,8 +5244,29 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -4713,6 +5309,8 @@ snapshots: decamelize@4.0.0: {} + decimal.js@10.6.0: {} + dedent@1.7.1: {} deep-is@0.1.4: {} @@ -4737,6 +5335,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destroy@1.2.0: {} detect-libc@2.1.2: {} @@ -4755,6 +5355,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -4814,6 +5418,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -5083,12 +5689,18 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} exit-hook@2.2.1: {} + expect-type@1.3.0: {} + express-rate-limit@8.2.1(express@4.22.1): dependencies: express: 4.22.1 @@ -5358,6 +5970,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -5368,6 +5986,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -5491,6 +6123,8 @@ snapshots: is-plain-obj@2.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-property@1.0.2: {} is-regex@1.2.1: @@ -5607,6 +6241,8 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -5618,6 +6254,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.0.2: {} jsesc@3.1.0: {} @@ -5766,16 +6429,26 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 lru.min@1.1.3: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -5788,6 +6461,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@0.3.0: {} merge-descriptors@1.0.3: {} @@ -5811,6 +6486,8 @@ snapshots: mime@1.6.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5978,6 +6655,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -6046,6 +6725,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -6095,6 +6778,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-on-spawn@1.1.0: dependencies: fromentries: 1.3.2 @@ -6146,6 +6835,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -6173,6 +6864,11 @@ snapshots: readdirp@4.1.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -6205,6 +6901,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} reselect@5.1.1: {} @@ -6295,6 +6993,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6398,6 +7100,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -6477,8 +7181,12 @@ snapshots: sqlstring@2.3.3: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -6550,6 +7258,10 @@ snapshots: strip-bom@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@5.5.0: @@ -6592,6 +7304,8 @@ snapshots: express: 4.22.1 swagger-ui-dist: 5.31.0 + symbol-tree@3.2.4: {} + tailwindcss@4.1.18: {} tapable@2.3.0: {} @@ -6602,11 +7316,23 @@ snapshots: glob: 7.1.6 minimatch: 3.1.2 + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6615,6 +7341,14 @@ snapshots: touch@3.1.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@2.4.0(typescript@5.9.3): @@ -6741,6 +7475,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.22.0: {} + unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -6817,6 +7553,60 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 + vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.7 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6864,6 +7654,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workerpool@9.3.4: {} @@ -6897,6 +7692,10 @@ snapshots: ws@8.18.3: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} y18n@4.0.3: {} From d008e56a44471eb84ecaf5da35abb51cc986d29e Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 10 Mar 2026 14:47:00 +0100 Subject: [PATCH 2/9] style: improve code formatting and consistency across multiple components and tests --- client/app/components/game/GameBoard.tsx | 9 +- client/app/components/game/GamePage.tsx | 11 +- client/app/components/game/OpponentCard.tsx | 2 +- client/app/components/game/PiecePreview.tsx | 2 +- client/app/hooks/useGameSocket.ts | 4 +- client/app/hooks/useKeyboardControls.ts | 6 +- client/app/routes/game.tsx | 1 - client/package.json | 4 +- client/tests/GameModeSelector.test.tsx | 23 ++-- client/tests/authService.test.ts | 38 ++---- client/tests/constants.test.ts | 11 +- client/tests/game.test.tsx | 144 +++++++++++++------- client/tests/gamesService.test.ts | 42 +----- client/tests/hooks.test.tsx | 8 +- client/vitest.config.ts | 11 +- eslint.config.mts | 15 +- 16 files changed, 169 insertions(+), 162 deletions(-) diff --git a/client/app/components/game/GameBoard.tsx b/client/app/components/game/GameBoard.tsx index df9f21f..241ba26 100644 --- a/client/app/components/game/GameBoard.tsx +++ b/client/app/components/game/GameBoard.tsx @@ -58,7 +58,7 @@ function renderCell( y: number, board: number[][] | undefined, idColors: string[], - cellSize: number, + cellSize: number ): JSX.Element { const val = board?.[y]?.[x] ?? 0; @@ -66,7 +66,12 @@ function renderCell( return (
); } diff --git a/client/app/components/game/GamePage.tsx b/client/app/components/game/GamePage.tsx index 539e754..9b5407a 100644 --- a/client/app/components/game/GamePage.tsx +++ b/client/app/components/game/GamePage.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Link } from 'react-router'; import { LoadingOverlay } from '../LoadingOverlay'; -import { GAME_MODE_INFO, GAME_SETUP, type GameMode } from '../../constants'; +import { GAME_MODE_INFO, GAME_SETUP } from '../../constants'; import { useGameSocket } from '../../hooks/useGameSocket'; import { useKeyboardControls } from '../../hooks/useKeyboardControls'; import { buildPieceMeta } from '../../utils/pieceHelpers'; @@ -69,7 +69,10 @@ export function GamePage() { {/* Mobile Room Controls */} - + {/* Main Game Layout */}
@@ -99,9 +102,7 @@ export function GamePage() {

Your Board

{modeInfo.icon} - - {modeInfo.label} - + {modeInfo.label} ({modeInfo.boardLabel})
diff --git a/client/app/components/game/OpponentCard.tsx b/client/app/components/game/OpponentCard.tsx index 0f86c52..f899a21 100644 --- a/client/app/components/game/OpponentCard.tsx +++ b/client/app/components/game/OpponentCard.tsx @@ -40,7 +40,7 @@ export function OpponentCard({ name, board, isAlive, score, isHost, idColors, wi borderRadius: isPiece && isAlive ? 1 : 0, background: bg, }} - />, + /> ); } } diff --git a/client/app/components/game/PiecePreview.tsx b/client/app/components/game/PiecePreview.tsx index bada650..4c1c287 100644 --- a/client/app/components/game/PiecePreview.tsx +++ b/client/app/components/game/PiecePreview.tsx @@ -48,7 +48,7 @@ export function PiecePreview({ type, shapes, colors, cellSize }: PiecePreviewPro key={`n-${r}-${c}`} style={{ width: cellSize, height: cellSize, boxSizing: 'border-box' }} /> - ), + ) ); } } diff --git a/client/app/hooks/useGameSocket.ts b/client/app/hooks/useGameSocket.ts index afc17ec..7dddcaf 100644 --- a/client/app/hooks/useGameSocket.ts +++ b/client/app/hooks/useGameSocket.ts @@ -60,7 +60,9 @@ export function useGameSocket(): UseGameSocketReturn { const s = io(url, { autoConnect: false, auth: { token } }) as unknown as Socket; socketRef.current = s; - s.on('connect', () => {}); + s.on('connect', () => { + /* connected */ + }); s.on('state', (payload: GameState) => { setActionLoading(false); diff --git a/client/app/hooks/useKeyboardControls.ts b/client/app/hooks/useKeyboardControls.ts index 26dab7b..b919019 100644 --- a/client/app/hooks/useKeyboardControls.ts +++ b/client/app/hooks/useKeyboardControls.ts @@ -8,11 +8,7 @@ const REPEAT_INTERVAL = 50; // ms between repeated moves * Keyboard controls for the Tetris game. * Replicates the exact intent payloads the server expects. */ -export function useKeyboardControls( - socketRef: React.RefObject, - room: string, - playerId: string | null, -) { +export function useKeyboardControls(socketRef: React.RefObject, room: string, playerId: string | null) { useEffect(() => { const held = new Map>(); diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index f6f6cb8..754f8cb 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -16,4 +16,3 @@ export default function GameRoute() { ); } - diff --git a/client/package.json b/client/package.json index fb53047..3a79fdf 100644 --- a/client/package.json +++ b/client/package.json @@ -6,8 +6,8 @@ "dev": "sh -c 'react-router dev --port ${CLIENT_PORT:-3001} --host 0.0.0.0'", "build": "react-router build", "start": "react-router-serve build/server/index.js", - "lint": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --fix", - "lint:check": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules", + "lint": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --ignore-pattern coverage --fix", + "lint:check": "eslint . --ext js,jsx,ts,tsx --ignore-pattern .react-router --ignore-pattern build --ignore-pattern node_modules --ignore-pattern coverage", "prettier": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --write", "prettier:check": "prettier \"**/*.{js,jsx,ts,tsx,json,css,md}\" --ignore-path ../.prettierignore --list-different", "typecheck": "react-router typegen && tsc", diff --git a/client/tests/GameModeSelector.test.tsx b/client/tests/GameModeSelector.test.tsx index e5ac941..e6cef52 100644 --- a/client/tests/GameModeSelector.test.tsx +++ b/client/tests/GameModeSelector.test.tsx @@ -1,13 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { GameModeSelector } from '~/components/GameModeSelector'; -import { GAME_MODES, type GameMode } from '~/constants'; +import { type GameMode } from '~/constants'; describe('GameModeSelector', () => { - const defaultProps = { - selected: 'classic' as GameMode, - onChange: vi.fn(), - }; + const defaultProps = { selected: 'classic' as GameMode, onChange: vi.fn() }; describe('full mode (non-compact)', () => { it('renders all game modes', () => { @@ -32,7 +29,7 @@ describe('GameModeSelector', () => { , + /> ); fireEvent.click(screen.getByText('Narrow')); expect(onChange).toHaveBeenCalledWith('narrow'); @@ -58,7 +55,7 @@ describe('GameModeSelector', () => { , + /> ); const grid = container.firstElementChild; expect(grid?.className).toContain('grid-cols-2'); @@ -69,7 +66,7 @@ describe('GameModeSelector', () => { , + /> ); expect(screen.getByText('Classic')).toBeInTheDocument(); expect(screen.getByText('Narrow')).toBeInTheDocument(); @@ -82,7 +79,7 @@ describe('GameModeSelector', () => { {...defaultProps} onChange={onChange} compact - />, + /> ); fireEvent.click(screen.getByText('Cyber')); expect(onChange).toHaveBeenCalledWith('cyber'); @@ -93,7 +90,7 @@ describe('GameModeSelector', () => { , + /> ); expect(screen.getByText('10 × 20')).toBeInTheDocument(); }); @@ -106,7 +103,7 @@ describe('GameModeSelector', () => { selected='narrow' onChange={vi.fn()} disabled - />, + /> ); expect(screen.getByText('Narrow')).toBeInTheDocument(); expect(screen.queryByText('Classic')).not.toBeInTheDocument(); @@ -119,7 +116,7 @@ describe('GameModeSelector', () => { selected='classic' onChange={vi.fn()} disabled - />, + /> ); const buttons = screen.getAllByRole('button'); buttons.forEach((btn) => { @@ -134,7 +131,7 @@ describe('GameModeSelector', () => { onChange={vi.fn()} disabled compact - />, + /> ); expect(screen.getByText('Cyber')).toBeInTheDocument(); expect(screen.queryByText('Classic')).not.toBeInTheDocument(); diff --git a/client/tests/authService.test.ts b/client/tests/authService.test.ts index 022c5c5..345bc7e 100644 --- a/client/tests/authService.test.ts +++ b/client/tests/authService.test.ts @@ -52,10 +52,7 @@ describe('authService', () => { }, }; - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => mockResponse } as Response); const result = await authService.register({ username: 'testuser', email: 'test@test.com', password: 'pass123' }); expect(result.data.token).toBe('new-token'); @@ -69,9 +66,9 @@ describe('authService', () => { json: async () => ({ success: false, error: { code: 'ERR', message: 'Username taken' } }), } as Response); - await expect( - authService.register({ username: 'taken', email: 'a@b.com', password: '123456' }), - ).rejects.toThrow('Username taken'); + await expect(authService.register({ username: 'taken', email: 'a@b.com', password: '123456' })).rejects.toThrow( + 'Username taken' + ); }); it('throws generic error when no message provided', async () => { @@ -80,9 +77,9 @@ describe('authService', () => { json: async () => ({ success: false, error: {} }), } as Response); - await expect( - authService.register({ username: 'test', email: 'a@b.com', password: '123456' }), - ).rejects.toThrow('Registration failed'); + await expect(authService.register({ username: 'test', email: 'a@b.com', password: '123456' })).rejects.toThrow( + 'Registration failed' + ); }); }); @@ -96,10 +93,7 @@ describe('authService', () => { }, }; - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ - ok: true, - json: async () => mockResponse, - } as Response); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => mockResponse } as Response); const result = await authService.login({ username: 'testuser', password: 'pass123' }); expect(result.data.token).toBe('login-token'); @@ -112,9 +106,7 @@ describe('authService', () => { json: async () => ({ success: false, error: { code: 'ERR', message: 'Invalid credentials' } }), } as Response); - await expect(authService.login({ username: 'bad', password: 'wrong' })).rejects.toThrow( - 'Invalid credentials', - ); + await expect(authService.login({ username: 'bad', password: 'wrong' })).rejects.toThrow('Invalid credentials'); }); it('throws generic error when no message provided', async () => { @@ -130,18 +122,14 @@ describe('authService', () => { describe('logout', () => { it('sends POST with token and clears localStorage', async () => { localStorage.setItem('auth_token', 'my-token'); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ - ok: true, - json: async () => ({}), - } as Response); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response); await authService.logout(); expect(fetchSpy).toHaveBeenCalledWith( expect.stringContaining('/api/auth/logout'), - expect.objectContaining({ - method: 'POST', - headers: { Authorization: 'Bearer my-token' }, - }), + expect.objectContaining({ method: 'POST', headers: { Authorization: 'Bearer my-token' } }) ); expect(localStorage.removeItem).toHaveBeenCalledWith('auth_token'); }); diff --git a/client/tests/constants.test.ts b/client/tests/constants.test.ts index 7318e98..4ee1cdd 100644 --- a/client/tests/constants.test.ts +++ b/client/tests/constants.test.ts @@ -35,13 +35,10 @@ describe('constants/index', () => { ['narrow', 6, 22], ['pentominoes', 12, 22], ['cyber', 8, 18], - ] as [GameMode, number, number][])( - '%s has width=%d and height=%d', - (mode, width, height) => { - expect(GAME_SETUP[mode].width).toBe(width); - expect(GAME_SETUP[mode].height).toBe(height); - }, - ); + ] as [GameMode, number, number][])('%s has width=%d and height=%d', (mode, width, height) => { + expect(GAME_SETUP[mode].width).toBe(width); + expect(GAME_SETUP[mode].height).toBe(height); + }); it('each setup has tetrominos as a non-empty object', () => { for (const mode of GAME_MODES) { diff --git a/client/tests/game.test.tsx b/client/tests/game.test.tsx index ac0b8ce..15106b1 100644 --- a/client/tests/game.test.tsx +++ b/client/tests/game.test.tsx @@ -15,13 +15,9 @@ vi.mock('~/services/auth', () => ({ register: vi.fn(), login: vi.fn(), logout: vi.fn(), - getProfile: vi.fn().mockResolvedValue({ - id: 42, - username: 'testplayer', - email: 'test@test.com', - created_at: '', - updated_at: '', - }), + getProfile: vi + .fn() + .mockResolvedValue({ id: 42, username: 'testplayer', email: 'test@test.com', created_at: '', updated_at: '' }), }, })); @@ -55,9 +51,7 @@ function createMockSocket() { let mockSocket: ReturnType; -vi.mock('socket.io-client', () => ({ - io: vi.fn(() => mockSocket), -})); +vi.mock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) })); import { authService } from '~/services/auth'; @@ -87,7 +81,7 @@ async function renderGame() { - , + ); return { ...result, store, meta }; @@ -117,9 +111,7 @@ describe('Game Route', () => { it('returns correct page title', async () => { const { meta } = await import('~/routes/game'); const result = meta({} as any); - expect(result).toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris — Play' })]), - ); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris — Play' })])); }); }); @@ -436,7 +428,18 @@ describe('Game Route', () => { tick: 0, speed: 20, players: { - '42': { board: [], activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer' }, + '42': { + board: [], + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', + }, }, hostId: '42', mode: 'classic', @@ -587,10 +590,7 @@ describe('Game Route', () => { await triggerSocket('joined', { roomId: 'ROOM1' }); fireEvent.keyDown(window, { key: 'ArrowUp' }); - expect(mockSocket.emit).toHaveBeenCalledWith('intent', { - roomId: 'ROOM1', - intent: { type: 'rotate' }, - }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { roomId: 'ROOM1', intent: { type: 'rotate' } }); }); it('sends intent on Space (hard drop)', async () => { @@ -598,10 +598,7 @@ describe('Game Route', () => { await triggerSocket('joined', { roomId: 'ROOM1' }); fireEvent.keyDown(window, { key: ' ', code: 'Space' }); - expect(mockSocket.emit).toHaveBeenCalledWith('intent', { - roomId: 'ROOM1', - intent: { type: 'hard' }, - }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { roomId: 'ROOM1', intent: { type: 'hard' } }); }); it('sends intent on Shift (hold)', async () => { @@ -609,10 +606,7 @@ describe('Game Route', () => { await triggerSocket('joined', { roomId: 'ROOM1' }); fireEvent.keyDown(window, { key: 'Shift' }); - expect(mockSocket.emit).toHaveBeenCalledWith('intent', { - roomId: 'ROOM1', - intent: { type: 'hold' }, - }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { roomId: 'ROOM1', intent: { type: 'hold' } }); }); it('sends move left on ArrowLeft', async () => { @@ -644,10 +638,7 @@ describe('Game Route', () => { await triggerSocket('joined', { roomId: 'ROOM1' }); fireEvent.keyDown(window, { key: 'ArrowDown' }); - expect(mockSocket.emit).toHaveBeenCalledWith('intent', { - roomId: 'ROOM1', - intent: { type: 'soft' }, - }); + expect(mockSocket.emit).toHaveBeenCalledWith('intent', { roomId: 'ROOM1', intent: { type: 'soft' } }); fireEvent.keyUp(window, { key: 'ArrowDown' }); }); @@ -659,7 +650,7 @@ describe('Game Route', () => { fireEvent.keyDown(window, { key: 'ArrowLeft' }); fireEvent.keyDown(window, { key: 'ArrowLeft' }); const leftCalls = mockSocket.emit.mock.calls.filter( - ([ev, payload]: any) => ev === 'intent' && payload?.intent?.type === 'move' && payload?.intent?.dir === 'left', + ([ev, payload]: any) => ev === 'intent' && payload?.intent?.type === 'move' && payload?.intent?.dir === 'left' ); expect(leftCalls).toHaveLength(1); fireEvent.keyUp(window, { key: 'ArrowLeft' }); @@ -686,8 +677,15 @@ describe('Game Route', () => { players: { '42': { board: Array.from({ length: 20 }, () => Array(10).fill(0)), - activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', }, }, hostId: '42', @@ -701,8 +699,15 @@ describe('Game Route', () => { players: { '42': { board: Array.from({ length: 20 }, () => Array(10).fill(0)), - activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 0, isAlive: false, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: false, + name: 'testplayer', }, }, hostId: '42', @@ -723,8 +728,15 @@ describe('Game Route', () => { players: { '42': { board: Array.from({ length: 20 }, () => Array(10).fill(0)), - activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', }, }, hostId: '42', @@ -738,8 +750,15 @@ describe('Game Route', () => { players: { '42': { board: Array.from({ length: 20 }, () => Array(10).fill(0)), - activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 500, isAlive: true, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 500, + isAlive: true, + name: 'testplayer', }, }, hostId: '42', @@ -763,8 +782,15 @@ describe('Game Route', () => { players: { '42': { board: Array.from({ length: 20 }, () => Array(10).fill(0)), - activePiece: null, nextPiece: null, piecesPlaced: 10, speed: 20, - holdPiece: null, holdLocked: false, score: 1000, isAlive: false, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 10, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 1000, + isAlive: false, + name: 'testplayer', }, }, hostId: '42', @@ -796,13 +822,27 @@ describe('Game Route', () => { players: { '42': { board: emptyBoard, - activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', }, '50': { board: emptyBoard, - activePiece: null, nextPiece: null, piecesPlaced: 3, speed: 20, - holdPiece: null, holdLocked: false, score: 500, isAlive: true, name: 'player2', + activePiece: null, + nextPiece: null, + piecesPlaced: 3, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 500, + isAlive: true, + name: 'player2', spectrum: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], }, }, @@ -825,8 +865,16 @@ describe('Game Route', () => { speed: 20, players: { '42': { - board: [], activePiece: null, nextPiece: null, piecesPlaced: 0, speed: 20, - holdPiece: null, holdLocked: false, score: 0, isAlive: true, name: 'testplayer', + board: [], + activePiece: null, + nextPiece: null, + piecesPlaced: 0, + speed: 20, + holdPiece: null, + holdLocked: false, + score: 0, + isAlive: true, + name: 'testplayer', }, }, hostId: '42', diff --git a/client/tests/gamesService.test.ts b/client/tests/gamesService.test.ts index 935936d..9d76436 100644 --- a/client/tests/gamesService.test.ts +++ b/client/tests/gamesService.test.ts @@ -3,11 +3,7 @@ import { gamesService } from '~/services/games'; // We need to mock authService.getToken inside the games module vi.mock('~/services/auth', () => ({ - authService: { - getToken: vi.fn(() => 'mock-token'), - setToken: vi.fn(), - clearToken: vi.fn(), - }, + authService: { getToken: vi.fn(() => 'mock-token'), setToken: vi.fn(), clearToken: vi.fn() }, })); describe('gamesService', () => { @@ -51,10 +47,7 @@ describe('gamesService', () => { }); it('throws generic error when no message', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ - ok: false, - json: async () => ({ error: {} }), - } as Response); + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: false, json: async () => ({ error: {} }) } as Response); await expect(gamesService.getGames()).rejects.toThrow('Failed to fetch games'); }); @@ -67,33 +60,19 @@ describe('gamesService', () => { size: 100, totalItems: 150, totalPages: 2, - content: Array.from({ length: 100 }, (_, i) => ({ - game_id: i, - finished_at: '2025-01-01', - players: [], - })), + content: Array.from({ length: 100 }, (_, i) => ({ game_id: i, finished_at: '2025-01-01', players: [] })), }; const page1 = { page: 1, size: 100, totalItems: 150, totalPages: 2, - content: Array.from({ length: 50 }, (_, i) => ({ - game_id: 100 + i, - finished_at: '2025-01-01', - players: [], - })), + content: Array.from({ length: 50 }, (_, i) => ({ game_id: 100 + i, finished_at: '2025-01-01', players: [] })), }; vi.spyOn(globalThis, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: page0 }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: page1 }), - } as Response); + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: page0 }) } as Response) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: page1 }) } as Response); const result = await gamesService.getAllGames(); expect(result).toHaveLength(150); @@ -125,14 +104,7 @@ describe('gamesService', () => { describe('computeStats', () => { it('returns zeros for empty games array', () => { const stats = gamesService.computeStats([], 1); - expect(stats).toEqual({ - gamesPlayed: 0, - wins: 0, - winRate: 0, - highScore: 0, - averageScore: 0, - bestPlace: 0, - }); + expect(stats).toEqual({ gamesPlayed: 0, wins: 0, winRate: 0, highScore: 0, averageScore: 0, bestPlace: 0 }); }); it('computes stats correctly for a single game with a win', () => { diff --git a/client/tests/hooks.test.tsx b/client/tests/hooks.test.tsx index e61844e..6c57bfd 100644 --- a/client/tests/hooks.test.tsx +++ b/client/tests/hooks.test.tsx @@ -7,9 +7,7 @@ import { useAppDispatch, useAppSelector } from '~/store/hooks'; import type { ReactNode } from 'react'; const createWrapper = () => { - const store = configureStore({ - reducer: { auth: authReducer }, - }); + const store = configureStore({ reducer: { auth: authReducer } }); return ({ children }: { children: ReactNode }) => {children}; }; @@ -26,9 +24,7 @@ describe('store/hooks', () => { describe('useAppSelector', () => { it('selects auth state', () => { const wrapper = createWrapper(); - const { result } = renderHook(() => useAppSelector((state) => state.auth.isAuthenticated), { - wrapper, - }); + const { result } = renderHook(() => useAppSelector((state) => state.auth.isAuthenticated), { wrapper }); expect(result.current).toBe(false); }); diff --git a/client/vitest.config.ts b/client/vitest.config.ts index ee5a42b..9e590e4 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -19,15 +19,8 @@ export default defineConfig({ 'app/app.css', 'app/types/**', ], - thresholds: { - statements: 70, - functions: 70, - lines: 70, - branches: 50, - }, + thresholds: { statements: 70, functions: 70, lines: 70, branches: 50 }, }, }, - define: { - 'import.meta.env.VITE_SERVER_URL': JSON.stringify('http://localhost:3002'), - }, + define: { 'import.meta.env.VITE_SERVER_URL': JSON.stringify('http://localhost:3002') }, }); diff --git a/eslint.config.mts b/eslint.config.mts index fe5c718..86a7fda 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -9,10 +9,23 @@ import tseslint from 'typescript-eslint'; /** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigFile} */ export default [ { - ignores: ['client/.next/**', 'client/public/**/*.js', 'server/dist/**', 'node_modules/**', 'build/**', '**/*.d.ts'], + ignores: ['client/.next/**', 'client/public/**/*.js', 'server/dist/**', 'node_modules/**', 'build/**', '**/coverage/**', '**/*.d.ts'], }, ...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked, + { + files: ['**/tests/**', '**/*.test.*', '**/*.spec.*'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-floating-promises': 'off', + }, + }, { files: ['**/*.{mjs,cjs,ts,mts,cts,tsx}'], plugins: { From 066c276dacebc062acea56372ee539a6192c5bcf Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 10 Mar 2026 14:48:21 +0100 Subject: [PATCH 3/9] style: improve code formatting and consistency in test files --- client/tests/LoginForm.test.tsx | 7 ++----- client/tests/ProtectedRoute.test.tsx | 2 +- client/tests/PublicOnlyRoute.test.tsx | 8 ++------ client/tests/RegisterForm.test.tsx | 7 ++----- client/tests/pieceHelpers.test.ts | 16 +++++++-------- client/tests/routes.test.tsx | 29 +++++++++------------------ client/tests/welcome.test.tsx | 11 +++------- server/tests/gameEngine.test.ts | 1 - 8 files changed, 27 insertions(+), 54 deletions(-) diff --git a/client/tests/LoginForm.test.tsx b/client/tests/LoginForm.test.tsx index 009c331..d8df3db 100644 --- a/client/tests/LoginForm.test.tsx +++ b/client/tests/LoginForm.test.tsx @@ -9,10 +9,7 @@ import { LoginForm } from '~/components/auth/LoginForm'; const mockNavigate = vi.fn(); vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; + return { ...actual, useNavigate: () => mockNavigate }; }); vi.mock('~/services/auth', () => ({ @@ -52,7 +49,7 @@ const renderLoginForm = (authState = {}) => { - , + ), }; }; diff --git a/client/tests/ProtectedRoute.test.tsx b/client/tests/ProtectedRoute.test.tsx index 40e1d9c..6c46cf6 100644 --- a/client/tests/ProtectedRoute.test.tsx +++ b/client/tests/ProtectedRoute.test.tsx @@ -43,7 +43,7 @@ const renderProtectedRoute = (authState = {}) => {
Protected Content
- , + ); }; diff --git a/client/tests/PublicOnlyRoute.test.tsx b/client/tests/PublicOnlyRoute.test.tsx index 63796fb..94c62f4 100644 --- a/client/tests/PublicOnlyRoute.test.tsx +++ b/client/tests/PublicOnlyRoute.test.tsx @@ -43,7 +43,7 @@ const renderPublicOnlyRoute = (authState = {}) => {
Public Content
- , + ); }; @@ -67,11 +67,7 @@ describe('PublicOnlyRoute', () => { }); it('shows loading overlay when isLoading and not authenticated', () => { - renderPublicOnlyRoute({ - hydrated: true, - isAuthenticated: false, - isLoading: true, - }); + renderPublicOnlyRoute({ hydrated: true, isAuthenticated: false, isLoading: true }); expect(screen.getByText('Loading...')).toBeInTheDocument(); expect(screen.getByTestId('public-content')).toBeInTheDocument(); }); diff --git a/client/tests/RegisterForm.test.tsx b/client/tests/RegisterForm.test.tsx index c56e1ca..67bf201 100644 --- a/client/tests/RegisterForm.test.tsx +++ b/client/tests/RegisterForm.test.tsx @@ -9,10 +9,7 @@ import { RegisterForm } from '~/components/auth/RegisterForm'; const mockNavigate = vi.fn(); vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; + return { ...actual, useNavigate: () => mockNavigate }; }); vi.mock('~/services/auth', () => ({ @@ -52,7 +49,7 @@ const renderRegisterForm = (authState = {}) => { - , + ), }; }; diff --git a/client/tests/pieceHelpers.test.ts b/client/tests/pieceHelpers.test.ts index 25d5f39..a836559 100644 --- a/client/tests/pieceHelpers.test.ts +++ b/client/tests/pieceHelpers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildPieceMeta } from '~/utils/pieceHelpers'; -import { GAME_SETUP, GAME_MODES, type GameMode } from '~/constants'; +import { GAME_SETUP, GAME_MODES } from '~/constants'; describe('buildPieceMeta', () => { it.each(GAME_MODES)('returns shapes, colors and idColors for "%s" mode', (mode) => { @@ -31,13 +31,13 @@ describe('buildPieceMeta', () => { it('classic pieces get their well-known colours', () => { const meta = buildPieceMeta('classic'); - expect(meta.colors['I']).toBe('#0d9488'); - expect(meta.colors['O']).toBe('#ca8a04'); - expect(meta.colors['T']).toBe('#9333ea'); - expect(meta.colors['S']).toBe('#16a34a'); - expect(meta.colors['Z']).toBe('#dc2626'); - expect(meta.colors['J']).toBe('#2563eb'); - expect(meta.colors['L']).toBe('#c2410c'); + expect(meta.colors.I).toBe('#0d9488'); + expect(meta.colors.O).toBe('#ca8a04'); + expect(meta.colors.T).toBe('#9333ea'); + expect(meta.colors.S).toBe('#16a34a'); + expect(meta.colors.Z).toBe('#dc2626'); + expect(meta.colors.J).toBe('#2563eb'); + expect(meta.colors.L).toBe('#c2410c'); }); it('non-classic pieces fall back to the palette', () => { diff --git a/client/tests/routes.test.tsx b/client/tests/routes.test.tsx index 89bef19..81780a0 100644 --- a/client/tests/routes.test.tsx +++ b/client/tests/routes.test.tsx @@ -21,14 +21,9 @@ vi.mock('~/services/auth', () => ({ vi.mock('~/services/games', () => ({ gamesService: { getAllGames: vi.fn().mockResolvedValue([]), - computeStats: vi.fn().mockReturnValue({ - gamesPlayed: 0, - wins: 0, - winRate: 0, - highScore: 0, - averageScore: 0, - bestPlace: 0, - }), + computeStats: vi + .fn() + .mockReturnValue({ gamesPlayed: 0, wins: 0, winRate: 0, highScore: 0, averageScore: 0, bestPlace: 0 }), }, })); @@ -58,7 +53,7 @@ describe('Route: Login', () => { - , + ); expect(screen.getByText('Welcome Back!')).toBeInTheDocument(); @@ -75,7 +70,7 @@ describe('Route: Register', () => { - , + ); expect(screen.getByText('Join the Game!')).toBeInTheDocument(); @@ -96,7 +91,7 @@ describe('Route: Home', () => { - , + ); expect(screen.getByText('RED TETRIS')).toBeInTheDocument(); @@ -108,24 +103,18 @@ describe('Route meta functions', () => { it('login meta returns correct title', async () => { const { meta } = await import('~/routes/login'); const result = meta({} as any); - expect(result).toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Login - Red Tetris' })]), - ); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Login - Red Tetris' })])); }); it('register meta returns correct title', async () => { const { meta } = await import('~/routes/register'); const result = meta({} as any); - expect(result).toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Register - Red Tetris' })]), - ); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Register - Red Tetris' })])); }); it('home meta returns correct title', async () => { const { meta } = await import('~/routes/home'); const result = meta({} as any); - expect(result).toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris - Home' })]), - ); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Red Tetris - Home' })])); }); }); diff --git a/client/tests/welcome.test.tsx b/client/tests/welcome.test.tsx index 85a4f37..71e1eb2 100644 --- a/client/tests/welcome.test.tsx +++ b/client/tests/welcome.test.tsx @@ -18,12 +18,7 @@ vi.mock('~/services/auth', () => ({ }, })); -vi.mock('~/services/games', () => ({ - gamesService: { - getAllGames: vi.fn(), - computeStats: vi.fn(), - }, -})); +vi.mock('~/services/games', () => ({ gamesService: { getAllGames: vi.fn(), computeStats: vi.fn() } })); import { authService } from '~/services/auth'; import { gamesService } from '~/services/games'; @@ -59,7 +54,7 @@ const renderWelcome = (authState = {}) => { - , + ), }; }; @@ -101,7 +96,7 @@ describe('Welcome', () => { it('renders logout button and dispatches logoutUser', () => { vi.mocked(authService.logout).mockResolvedValue(undefined); - const { store } = renderWelcome(); + renderWelcome(); const logoutBtn = screen.getByText('Logout'); expect(logoutBtn).toBeInTheDocument(); fireEvent.click(logoutBtn); diff --git a/server/tests/gameEngine.test.ts b/server/tests/gameEngine.test.ts index 0cdbbab..ea99e69 100644 --- a/server/tests/gameEngine.test.ts +++ b/server/tests/gameEngine.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/unbound-method */ import { expect } from 'chai'; import { BSP_SCORES, GAME_SETUP } from '../constants'; import { GameEngine } from '../game/GameEngine'; From b9edc52e542a9fdbd8a928dbcf7c49920bd50d6e Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Tue, 10 Mar 2026 15:59:07 +0100 Subject: [PATCH 4/9] fix: remove console log for winner event in useGameSocket --- client/app/hooks/useGameSocket.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/app/hooks/useGameSocket.ts b/client/app/hooks/useGameSocket.ts index 7dddcaf..6eea76d 100644 --- a/client/app/hooks/useGameSocket.ts +++ b/client/app/hooks/useGameSocket.ts @@ -118,7 +118,6 @@ export function useGameSocket(): UseGameSocketReturn { }); s.on('winner', (info: { message?: string }) => { - console.log('client: received winner', info); setMessage(info?.message ?? 'YOU WIN'); }); From a965129d8d7a1ac8bdc6b71e4c3b8edf60d7735e Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 22:03:03 +0100 Subject: [PATCH 5/9] feat: add OpponentCard component tests for player info, status, and board rendering --- client/tests/OpponentCard.test.tsx | 168 +++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 client/tests/OpponentCard.test.tsx diff --git a/client/tests/OpponentCard.test.tsx b/client/tests/OpponentCard.test.tsx new file mode 100644 index 0000000..22ccbcf --- /dev/null +++ b/client/tests/OpponentCard.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { OpponentCard } from '~/components/game/OpponentCard'; + +const idColors = ['#000', '#f00', '#0f0', '#00f', '#ff0', '#f0f', '#0ff', '#fff']; + +const defaultProps = { + id: 'p1', + name: 'TestPlayer', + board: null, + isAlive: true, + score: 100, + isHost: false, + idColors, + width: 4, + height: 4, +}; + +describe('OpponentCard', () => { + describe('player info', () => { + it('renders player name', () => { + render(); + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + it('renders score', () => { + render(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('shows Host label when isHost is true', () => { + render(); + expect(screen.getByText('Host')).toBeInTheDocument(); + }); + + it('does not show Host label when isHost is false', () => { + render(); + expect(screen.queryByText('Host')).not.toBeInTheDocument(); + }); + }); + + describe('alive/dead status', () => { + it('shows ALIVE status and green indicator when alive', () => { + const { container } = render(); + expect(screen.getByText('ALIVE')).toBeInTheDocument(); + const dot = container.querySelector('.bg-green-400'); + expect(dot).toBeInTheDocument(); + }); + + it('shows DEAD status and red indicator when dead', () => { + const { container } = render(); + expect(screen.getByText('DEAD')).toBeInTheDocument(); + const dot = container.querySelector('.bg-red-400'); + expect(dot).toBeInTheDocument(); + }); + + it('applies green badge styling when alive', () => { + render(); + const badge = screen.getByText('ALIVE'); + expect(badge.className).toContain('bg-green-500/20'); + expect(badge.className).toContain('text-green-300'); + }); + + it('applies red badge styling when dead', () => { + render(); + const badge = screen.getByText('DEAD'); + expect(badge.className).toContain('bg-red-500/20'); + expect(badge.className).toContain('text-red-300'); + }); + }); + + describe('board rendering', () => { + it('renders board cells with null board (all transparent)', () => { + const { container } = render(); + const cells = container.querySelectorAll('[style*="box-sizing"]'); + expect(cells.length).toBe(16); // 4x4 + cells.forEach((cell) => { + expect(cell.style.background).toBe('transparent'); + }); + }); + + it('renders empty cells (val=0) as transparent', () => { + const board = [ + [0, 0], + [0, 0], + ]; + const { container } = render(); + const cells = container.querySelectorAll('[style*="box-sizing"]'); + cells.forEach((cell) => { + expect(cell.style.background).toBe('transparent'); + }); + }); + + it('renders penalty cells (val=-2) with alive color', () => { + const board = [[-2]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.06)'); + }); + + it('renders penalty cells (val=-2) with dead color', () => { + const board = [[-2]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.03)'); + }); + + it('renders locked cells (val=-1) with alive color', () => { + const board = [[-1]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgb(17, 17, 17)'); + }); + + it('renders locked cells (val=-1) with dead color', () => { + const board = [[-1]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgb(68, 68, 68)'); + }); + + it('renders piece cells (val>0) with idColors when alive', () => { + const board = [[1]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgb(255, 0, 0)'); // idColors[1] + }); + + it('renders piece cells (val>0) as #777 when dead', () => { + const board = [[1]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgb(119, 119, 119)'); + }); + + it('falls back to #999 when idColors does not have the piece index', () => { + const board = [[99]]; // index 99 doesn't exist in idColors + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.background).toBe('rgb(153, 153, 153)'); + }); + + it('applies borderRadius 1 for alive pieces and 0 otherwise', () => { + const board = [[1, 0]]; + const { container } = render(); + const cells = container.querySelectorAll('[style*="box-sizing"]'); + expect(cells[0].style.borderRadius).toBe('1px'); + expect(cells[1].style.borderRadius).toBe('0px'); + }); + + it('applies borderRadius 0 for dead pieces', () => { + const board = [[1]]; + const { container } = render(); + const cell = container.querySelector('[style*="box-sizing"]'); + expect(cell?.style.borderRadius).toBe('0px'); + }); + + it('renders correct grid dimensions', () => { + const board = [ + [0, 0, 0], + [0, 0, 0], + ]; + const { container } = render(); + const cells = container.querySelectorAll('[style*="box-sizing"]'); + expect(cells.length).toBe(6); // 3x2 + }); + }); +}); From 4939ab3f10de5cb728c8c6c00c8324ed07ec03c5 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 22:13:56 +0100 Subject: [PATCH 6/9] test: enhance ProtectedRoute and PublicOnlyRoute tests with async handling and waitFor and suppress console warnings --- client/tests/ProtectedRoute.test.tsx | 20 +++++++++++++------- client/tests/PublicOnlyRoute.test.tsx | 20 +++++++++++++------- client/tests/setup.ts | 12 +++++++++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/client/tests/ProtectedRoute.test.tsx b/client/tests/ProtectedRoute.test.tsx index 6c46cf6..1a09eb4 100644 --- a/client/tests/ProtectedRoute.test.tsx +++ b/client/tests/ProtectedRoute.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import { MemoryRouter } from 'react-router'; @@ -70,9 +70,11 @@ describe('ProtectedRoute', () => { expect(screen.getByTestId('protected-content')).toBeInTheDocument(); }); - it('shows loading overlay when authenticated but user is null', () => { + it('shows loading overlay when authenticated but user is null', async () => { renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null }); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); }); it('shows loading overlay when isLoading is true', () => { @@ -86,7 +88,7 @@ describe('ProtectedRoute', () => { expect(screen.getByTestId('protected-content')).toBeInTheDocument(); }); - it('hydrates and shows content when token exists and not yet hydrated', () => { + it('hydrates and shows content when token exists and not yet hydrated', async () => { vi.mocked(authService.getToken).mockReturnValue('existing-token'); vi.mocked(authService.getProfile).mockResolvedValue({ id: 1, @@ -98,10 +100,12 @@ describe('ProtectedRoute', () => { renderProtectedRoute({ hydrated: false }); // hydrateAuth runs, finds token, sets isAuthenticated=true // Then fetchProfile is dispatched; meanwhile children + LoadingOverlay show - expect(screen.getByText('Loading...')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); }); - it('fetches profile when authenticated but no user', () => { + it('fetches profile when authenticated but no user', async () => { vi.mocked(authService.getProfile).mockResolvedValue({ id: 1, username: 'test', @@ -111,6 +115,8 @@ describe('ProtectedRoute', () => { }); renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null }); - expect(authService.getProfile).toHaveBeenCalled(); + await waitFor(() => { + expect(authService.getProfile).toHaveBeenCalled(); + }); }); }); diff --git a/client/tests/PublicOnlyRoute.test.tsx b/client/tests/PublicOnlyRoute.test.tsx index 94c62f4..4038bd7 100644 --- a/client/tests/PublicOnlyRoute.test.tsx +++ b/client/tests/PublicOnlyRoute.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import { MemoryRouter } from 'react-router'; @@ -55,10 +55,12 @@ describe('PublicOnlyRoute', () => { expect(screen.getByTestId('public-content')).toBeInTheDocument(); }); - it('redirects to / when authenticated', () => { + it('redirects to / when authenticated', async () => { renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true }); // Navigate to / should fire, content should not show - expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + }); }); it('renders children when not authenticated', () => { @@ -72,13 +74,15 @@ describe('PublicOnlyRoute', () => { expect(screen.getByTestId('public-content')).toBeInTheDocument(); }); - it('redirects when hydrated with token (not hydrated initially)', () => { + it('redirects when hydrated with token (not hydrated initially)', async () => { vi.mocked(authService.getToken).mockReturnValue('existing-token'); renderPublicOnlyRoute({ hydrated: false }); - expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('public-content')).not.toBeInTheDocument(); + }); }); - it('fetches profile when authenticated but no user', () => { + it('fetches profile when authenticated but no user', async () => { vi.mocked(authService.getProfile).mockResolvedValue({ id: 1, username: 'test', @@ -88,6 +92,8 @@ describe('PublicOnlyRoute', () => { }); renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true, user: null }); - expect(authService.getProfile).toHaveBeenCalled(); + await waitFor(() => { + expect(authService.getProfile).toHaveBeenCalled(); + }); }); }); diff --git a/client/tests/setup.ts b/client/tests/setup.ts index f7d68f1..f2bfee0 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -1,11 +1,21 @@ import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; -import { afterEach, vi } from 'vitest'; +import { afterEach, beforeEach, vi } from 'vitest'; afterEach(() => { cleanup(); }); +// Suppress expected console.warn/error output during tests (e.g. caught fetch errors) +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + // Mock localStorage const localStorageMock = (() => { let store: Record = {}; From 7890bcd364f0b254290be31d1160f4e2ab072fd0 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 22:15:50 +0100 Subject: [PATCH 7/9] style: prettier --- client/tests/OpponentCard.test.tsx | 164 +++++++++++++++++++++++++---- 1 file changed, 145 insertions(+), 19 deletions(-) diff --git a/client/tests/OpponentCard.test.tsx b/client/tests/OpponentCard.test.tsx index 22ccbcf..5766bf1 100644 --- a/client/tests/OpponentCard.test.tsx +++ b/client/tests/OpponentCard.test.tsx @@ -24,45 +24,80 @@ describe('OpponentCard', () => { }); it('renders score', () => { - render(); + render( + + ); expect(screen.getByText('42')).toBeInTheDocument(); }); it('shows Host label when isHost is true', () => { - render(); + render( + + ); expect(screen.getByText('Host')).toBeInTheDocument(); }); it('does not show Host label when isHost is false', () => { - render(); + render( + + ); expect(screen.queryByText('Host')).not.toBeInTheDocument(); }); }); describe('alive/dead status', () => { it('shows ALIVE status and green indicator when alive', () => { - const { container } = render(); + const { container } = render( + + ); expect(screen.getByText('ALIVE')).toBeInTheDocument(); const dot = container.querySelector('.bg-green-400'); expect(dot).toBeInTheDocument(); }); it('shows DEAD status and red indicator when dead', () => { - const { container } = render(); + const { container } = render( + + ); expect(screen.getByText('DEAD')).toBeInTheDocument(); const dot = container.querySelector('.bg-red-400'); expect(dot).toBeInTheDocument(); }); it('applies green badge styling when alive', () => { - render(); + render( + + ); const badge = screen.getByText('ALIVE'); expect(badge.className).toContain('bg-green-500/20'); expect(badge.className).toContain('text-green-300'); }); it('applies red badge styling when dead', () => { - render(); + render( + + ); const badge = screen.getByText('DEAD'); expect(badge.className).toContain('bg-red-500/20'); expect(badge.className).toContain('text-red-300'); @@ -71,7 +106,12 @@ describe('OpponentCard', () => { describe('board rendering', () => { it('renders board cells with null board (all transparent)', () => { - const { container } = render(); + const { container } = render( + + ); const cells = container.querySelectorAll('[style*="box-sizing"]'); expect(cells.length).toBe(16); // 4x4 cells.forEach((cell) => { @@ -84,7 +124,14 @@ describe('OpponentCard', () => { [0, 0], [0, 0], ]; - const { container } = render(); + const { container } = render( + + ); const cells = container.querySelectorAll('[style*="box-sizing"]'); cells.forEach((cell) => { expect(cell.style.background).toBe('transparent'); @@ -93,56 +140,120 @@ describe('OpponentCard', () => { it('renders penalty cells (val=-2) with alive color', () => { const board = [[-2]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.06)'); }); it('renders penalty cells (val=-2) with dead color', () => { const board = [[-2]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgba(255, 255, 255, 0.03)'); }); it('renders locked cells (val=-1) with alive color', () => { const board = [[-1]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgb(17, 17, 17)'); }); it('renders locked cells (val=-1) with dead color', () => { const board = [[-1]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgb(68, 68, 68)'); }); it('renders piece cells (val>0) with idColors when alive', () => { const board = [[1]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgb(255, 0, 0)'); // idColors[1] }); it('renders piece cells (val>0) as #777 when dead', () => { const board = [[1]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgb(119, 119, 119)'); }); it('falls back to #999 when idColors does not have the piece index', () => { const board = [[99]]; // index 99 doesn't exist in idColors - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.background).toBe('rgb(153, 153, 153)'); }); it('applies borderRadius 1 for alive pieces and 0 otherwise', () => { const board = [[1, 0]]; - const { container } = render(); + const { container } = render( + + ); const cells = container.querySelectorAll('[style*="box-sizing"]'); expect(cells[0].style.borderRadius).toBe('1px'); expect(cells[1].style.borderRadius).toBe('0px'); @@ -150,7 +261,15 @@ describe('OpponentCard', () => { it('applies borderRadius 0 for dead pieces', () => { const board = [[1]]; - const { container } = render(); + const { container } = render( + + ); const cell = container.querySelector('[style*="box-sizing"]'); expect(cell?.style.borderRadius).toBe('0px'); }); @@ -160,7 +279,14 @@ describe('OpponentCard', () => { [0, 0, 0], [0, 0, 0], ]; - const { container } = render(); + const { container } = render( + + ); const cells = container.querySelectorAll('[style*="box-sizing"]'); expect(cells.length).toBe(6); // 3x2 }); From a448129eff7703c875d46cbc5603623f7119b6bd Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 22:18:16 +0100 Subject: [PATCH 8/9] feat: add CI steps for running server and client tests with coverage --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49d6d1a..0b4847e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,31 @@ jobs: - name: Build packages run: pnpm run build + + test: + runs-on: ubuntu-latest + needs: lint-and-format + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run server tests + run: pnpm --filter=server test + + - name: Run client tests with coverage + run: pnpm --filter=client coverage From b0e03d50fc221d756b531467026aa3e5feb26123 Mon Sep 17 00:00:00 2001 From: timofeykafanov Date: Wed, 11 Mar 2026 22:21:05 +0100 Subject: [PATCH 9/9] feat: add MySQL service configuration for test environment in CI workflow --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b4847e..01805e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,31 @@ jobs: runs-on: ubuntu-latest needs: lint-and-format + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: rootpw + MYSQL_DATABASE: red_tetris_test + MYSQL_USER: app + MYSQL_PASSWORD: app_pw_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + NODE_ENV: test + DB_HOST: 127.0.0.1 + DB_USER: app + DB_PASSWORD_TEST: app_pw_test + DB_NAME_TEST: red_tetris_test + DB_PORT: 3306 + JWT_SECRET: ci_test_secret_key_not_for_production + steps: - name: Checkout code uses: actions/checkout@v4