diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49d6d1a..01805e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -58,3 +58,56 @@ jobs:
- name: Build packages
run: pnpm run build
+
+ test:
+ 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
+
+ - 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
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..241ba26
--- /dev/null
+++ b/client/app/components/game/GameBoard.tsx
@@ -0,0 +1,134 @@
+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 (
+
+ );
+}
+
+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..9b5407a
--- /dev/null
+++ b/client/app/components/game/GamePage.tsx
@@ -0,0 +1,167 @@
+import { useMemo } from 'react';
+import { Link } from 'react-router';
+import { LoadingOverlay } from '../LoadingOverlay';
+import { GAME_MODE_INFO, GAME_SETUP } 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 */}
+
+
+ {/* Main Game Board */}
+
+
Your Board
+
+ {modeInfo.icon}
+ {modeInfo.label}
+ ({modeInfo.boardLabel})
+
+
+
+
+
+ {/* Next Piece */}
+
+
+
+ {/* 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..f899a21
--- /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 (
+
+ );
+ };
+
+ return (
+
+ {/* Left: Info */}
+
+
+ {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..4c1c287
--- /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..6eea76d
--- /dev/null
+++ b/client/app/hooks/useGameSocket.ts
@@ -0,0 +1,282 @@
+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', () => {
+ /* connected */
+ });
+
+ 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 }) => {
+ 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..b919019
--- /dev/null
+++ b/client/app/hooks/useKeyboardControls.ts
@@ -0,0 +1,76 @@
+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..754f8cb 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,10 @@ 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 (
-
- );
- };
-
- 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.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..3a79fdf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -6,13 +6,13 @@
"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",
- "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..e6cef52
--- /dev/null
+++ b/client/tests/GameModeSelector.test.tsx
@@ -0,0 +1,156 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { GameModeSelector } from '~/components/GameModeSelector';
+import { 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..d8df3db
--- /dev/null
+++ b/client/tests/LoginForm.test.tsx
@@ -0,0 +1,125 @@
+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/OpponentCard.test.tsx b/client/tests/OpponentCard.test.tsx
new file mode 100644
index 0000000..5766bf1
--- /dev/null
+++ b/client/tests/OpponentCard.test.tsx
@@ -0,0 +1,294 @@
+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
+ });
+ });
+});
diff --git a/client/tests/ProtectedRoute.test.tsx b/client/tests/ProtectedRoute.test.tsx
new file mode 100644
index 0000000..1a09eb4
--- /dev/null
+++ b/client/tests/ProtectedRoute.test.tsx
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, 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 { 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', async () => {
+ renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null });
+ await waitFor(() => {
+ 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', async () => {
+ 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
+ await waitFor(() => {
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+ });
+
+ it('fetches profile when authenticated but no user', async () => {
+ vi.mocked(authService.getProfile).mockResolvedValue({
+ id: 1,
+ username: 'test',
+ email: 'test@test.com',
+ created_at: '',
+ updated_at: '',
+ });
+
+ renderProtectedRoute({ hydrated: true, isAuthenticated: true, user: null });
+ await waitFor(() => {
+ expect(authService.getProfile).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/client/tests/PublicOnlyRoute.test.tsx b/client/tests/PublicOnlyRoute.test.tsx
new file mode 100644
index 0000000..4038bd7
--- /dev/null
+++ b/client/tests/PublicOnlyRoute.test.tsx
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, 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 { 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', async () => {
+ renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true });
+ // Navigate to / should fire, content should not show
+ await waitFor(() => {
+ 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)', async () => {
+ vi.mocked(authService.getToken).mockReturnValue('existing-token');
+ renderPublicOnlyRoute({ hydrated: false });
+ await waitFor(() => {
+ expect(screen.queryByTestId('public-content')).not.toBeInTheDocument();
+ });
+ });
+
+ it('fetches profile when authenticated but no user', async () => {
+ vi.mocked(authService.getProfile).mockResolvedValue({
+ id: 1,
+ username: 'test',
+ email: 'test@test.com',
+ created_at: '',
+ updated_at: '',
+ });
+
+ renderPublicOnlyRoute({ hydrated: true, isAuthenticated: true, user: null });
+ await waitFor(() => {
+ expect(authService.getProfile).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/client/tests/RegisterForm.test.tsx b/client/tests/RegisterForm.test.tsx
new file mode 100644
index 0000000..67bf201
--- /dev/null
+++ b/client/tests/RegisterForm.test.tsx
@@ -0,0 +1,144 @@
+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..345bc7e
--- /dev/null
+++ b/client/tests/authService.test.ts
@@ -0,0 +1,192 @@
+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..4ee1cdd
--- /dev/null
+++ b/client/tests/constants.test.ts
@@ -0,0 +1,109 @@
+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..15106b1
--- /dev/null
+++ b/client/tests/game.test.tsx
@@ -0,0 +1,890 @@
+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..9d76436
--- /dev/null
+++ b/client/tests/gamesService.test.ts
@@ -0,0 +1,185 @@
+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..6c57bfd
--- /dev/null
+++ b/client/tests/hooks.test.tsx
@@ -0,0 +1,43 @@
+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..a836559
--- /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 } 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..81780a0
--- /dev/null
+++ b/client/tests/routes.test.tsx
@@ -0,0 +1,120 @@
+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..f2bfee0
--- /dev/null
+++ b/client/tests/setup.ts
@@ -0,0 +1,40 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+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 = {};
+ 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..71e1eb2
--- /dev/null
+++ b/client/tests/welcome.test.tsx
@@ -0,0 +1,175 @@
+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);
+ 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..9e590e4
--- /dev/null
+++ b/client/vitest.config.ts
@@ -0,0 +1,26 @@
+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/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: {
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: {}
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';