From 3655d4d45600e2c3da44c06b351bd6938193433e Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Mon, 19 Jan 2026 15:26:01 -0700 Subject: [PATCH] Add show rankings --- components/Podium.tsx | 85 +++++++++++-- tests/components/Podium.test.tsx | 206 +++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 tests/components/Podium.test.tsx diff --git a/components/Podium.tsx b/components/Podium.tsx index 9173e06..0632837 100644 --- a/components/Podium.tsx +++ b/components/Podium.tsx @@ -1,7 +1,7 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Player } from '../types'; -import { motion } from 'framer-motion'; -import { Trophy, Medal, RotateCcw } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Trophy, Medal, RotateCcw, List, X } from 'lucide-react'; import confetti from 'canvas-confetti'; import { PlayerAvatar } from './PlayerAvatar'; @@ -11,6 +11,7 @@ interface PodiumProps { } export const Podium: React.FC = ({ players, onRestart }) => { + const [showRankings, setShowRankings] = useState(false); const sorted = [...players].sort((a, b) => b.score - a.score); const winner = sorted[0]; const second = sorted[1]; @@ -87,12 +88,78 @@ export const Podium: React.FC = ({ players, onRestart }) => { )} - +
+ + +
+ + + {showRankings && ( + setShowRankings(false)} + > + e.stopPropagation()} + > +
+

Final Rankings

+ +
+ +
+ {sorted.map((player, index) => ( +
+
+ {index + 1} +
+ +
+

{player.name}

+
+
{player.score}
+
+ ))} +
+
+
+ )} +
); }; diff --git a/tests/components/Podium.test.tsx b/tests/components/Podium.test.tsx new file mode 100644 index 0000000..9a8bba7 --- /dev/null +++ b/tests/components/Podium.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Podium } from '../../components/Podium'; +import { Player } from '../../types'; + +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: React.PropsWithChildren>) =>
{children}
, + }, + AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, +})); + +vi.mock('canvas-confetti', () => ({ + default: vi.fn(), +})); + +describe('Podium', () => { + const createPlayer = (overrides: Partial = {}): Player => ({ + id: 'player-1', + name: 'Test Player', + score: 100, + previousScore: 0, + streak: 0, + lastAnswerCorrect: null, + selectedShape: null, + pointsBreakdown: null, + isBot: false, + avatarSeed: 0.5, + color: '#ff0000', + ...overrides, + }); + + const defaultPlayers = [ + createPlayer({ id: '1', name: 'Alice', score: 300 }), + createPlayer({ id: '2', name: 'Bob', score: 200 }), + createPlayer({ id: '3', name: 'Charlie', score: 100 }), + ]; + + const mockOnRestart = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('podium display', () => { + it('renders podium title', () => { + render(); + + expect(screen.getByText('Podium')).toBeInTheDocument(); + }); + + it('displays top 3 players on podium', () => { + render(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + it('shows Play Again button', () => { + render(); + + expect(screen.getByText('Play Again')).toBeInTheDocument(); + }); + + it('calls onRestart when Play Again clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Play Again')); + + expect(mockOnRestart).toHaveBeenCalled(); + }); + }); + + describe('all rankings feature', () => { + it('shows All Rankings button', () => { + render(); + + expect(screen.getByText('All Rankings')).toBeInTheDocument(); + }); + + it('opens rankings modal when All Rankings clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('All Rankings')); + + expect(screen.getByText('Final Rankings')).toBeInTheDocument(); + }); + + it('shows all players in rankings modal', async () => { + const user = userEvent.setup(); + const players = [ + createPlayer({ id: '1', name: 'RankAlice', score: 300 }), + createPlayer({ id: '2', name: 'RankBob', score: 100 }), + createPlayer({ id: '3', name: 'RankCharlie', score: 200 }), + createPlayer({ id: '4', name: 'RankDiana', score: 50 }), + ]; + + render(); + await user.click(screen.getByText('All Rankings')); + + const modal = screen.getByText('Final Rankings').parentElement?.parentElement; + expect(modal).toHaveTextContent('RankAlice'); + expect(modal).toHaveTextContent('RankBob'); + expect(modal).toHaveTextContent('RankCharlie'); + expect(modal).toHaveTextContent('RankDiana'); + }); + + it('shows rank numbers in modal', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('All Rankings')); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('shows player scores in modal', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('All Rankings')); + + const modal = screen.getByText('Final Rankings').parentElement?.parentElement; + expect(modal).toHaveTextContent('300'); + expect(modal).toHaveTextContent('200'); + expect(modal).toHaveTextContent('100'); + }); + + it('closes modal when X button clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('All Rankings')); + expect(screen.getByText('Final Rankings')).toBeInTheDocument(); + + const modal = screen.getByText('Final Rankings').parentElement; + const closeButton = modal?.querySelector('button'); + await user.click(closeButton!); + + expect(screen.queryByText('Final Rankings')).not.toBeInTheDocument(); + }); + + it('modal content does not close when clicking inside', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('All Rankings')); + expect(screen.getByText('Final Rankings')).toBeInTheDocument(); + + await user.click(screen.getByText('Final Rankings')); + + expect(screen.getByText('Final Rankings')).toBeInTheDocument(); + }); + + it('handles many players in rankings', async () => { + const user = userEvent.setup(); + const manyPlayers = Array.from({ length: 20 }, (_, i) => + createPlayer({ id: `${i}`, name: `ManyPlayer${i + 1}`, score: 1000 - i * 50 }) + ); + + render(); + await user.click(screen.getByText('All Rankings')); + + const modal = screen.getByText('Final Rankings').parentElement?.parentElement; + expect(modal).toHaveTextContent('ManyPlayer1'); + expect(modal).toHaveTextContent('ManyPlayer20'); + }); + + it('handles single player', async () => { + const user = userEvent.setup(); + const singlePlayer = [createPlayer({ id: '1', name: 'SoloPlayer', score: 500 })]; + + render(); + await user.click(screen.getByText('All Rankings')); + + const modal = screen.getByText('Final Rankings').parentElement?.parentElement; + expect(modal).toHaveTextContent('SoloPlayer'); + expect(modal).toHaveTextContent('500'); + }); + + it('handles players with same score', async () => { + const user = userEvent.setup(); + const tiedPlayers = [ + createPlayer({ id: '1', name: 'TiedAlice', score: 200 }), + createPlayer({ id: '2', name: 'TiedBob', score: 200 }), + createPlayer({ id: '3', name: 'TiedCharlie', score: 200 }), + ]; + + render(); + await user.click(screen.getByText('All Rankings')); + + expect(screen.getByText('Final Rankings')).toBeInTheDocument(); + const modal = screen.getByText('Final Rankings').parentElement?.parentElement; + expect(modal).toHaveTextContent('TiedAlice'); + expect(modal).toHaveTextContent('TiedBob'); + expect(modal).toHaveTextContent('TiedCharlie'); + }); + }); +});