Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,461 changes: 1,405 additions & 56 deletions frontend/package-lock.json

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"type-check": "tsc -b --noEmit",
"clean": "rm -rf dist node_modules/.vite"
},
Expand All @@ -23,20 +26,26 @@
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.16",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.6.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"@vitest/coverage-v8": "^4.0.18",
"autoprefixer": "^10.4.21",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"prettier": "^3.3.3",
"tailwindcss": "^4.1.16",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
"vite": "^7.1.7",
"vitest": "^4.0.18"
}
}
204 changes: 204 additions & 0 deletions frontend/src/test/GameCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import GameCard from '../components/GameCard';
import type { Game, Team } from '../types/nba';

const homeTeam: Team = {
id: 'h1',
name: 'Lakers',
abbreviation: 'LAL',
city: 'Los Angeles',
};

const awayTeam: Team = {
id: 'a1',
name: 'Celtics',
abbreviation: 'BOS',
city: 'Boston',
};

const scheduledGame: Game = {
id: 'g1',
homeTeam,
awayTeam,
date: '2025-01-15T19:30:00.000Z',
status: 'scheduled',
};

const completedGame: Game = {
id: 'g2',
homeTeam,
awayTeam,
date: '2025-01-10T19:30:00.000Z',
homeScore: 110,
awayScore: 105,
status: 'completed',
};

describe('GameCard', () => {
it('renders home team abbreviation', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('LAL')).toBeInTheDocument();
});

it('renders away team abbreviation', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('BOS')).toBeInTheDocument();
});

it('renders home team city', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('Los Angeles')).toBeInTheDocument();
});

it('renders away team city', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('Boston')).toBeInTheDocument();
});

it('shows "VS" divider for a scheduled game', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('VS')).toBeInTheDocument();
});

it('shows "@" divider for a non-scheduled game', () => {
render(<GameCard game={completedGame} />);
expect(screen.getByText('@')).toBeInTheDocument();
});

it('renders scores for a completed game', () => {
render(<GameCard game={completedGame} />);
expect(screen.getByText('110')).toBeInTheDocument();
expect(screen.getByText('105')).toBeInTheDocument();
});

it('does not render scores for a scheduled game', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.queryByText('110')).not.toBeInTheDocument();
});

it('renders status badge', () => {
render(<GameCard game={scheduledGame} />);
expect(screen.getByText('scheduled')).toBeInTheDocument();
});

it('renders "in progress" status badge (hyphen replaced by space)', () => {
const game: Game = { ...scheduledGame, status: 'in-progress' };
render(<GameCard game={game} />);
expect(screen.getByText('in progress')).toBeInTheDocument();
});

it('applies green badge for completed status', () => {
render(<GameCard game={completedGame} />);
const badge = screen.getByText('completed');
expect(badge).toHaveClass('bg-green-100', 'text-green-800');
});

it('applies blue badge for scheduled status', () => {
render(<GameCard game={scheduledGame} />);
const badge = screen.getByText('scheduled');
expect(badge).toHaveClass('bg-blue-100', 'text-blue-800');
});

it('applies yellow badge for in-progress status', () => {
const game: Game = { ...scheduledGame, status: 'in-progress' };
render(<GameCard game={game} />);
const badge = screen.getByText('in progress');
expect(badge).toHaveClass('bg-yellow-100', 'text-yellow-800');
});

it('applies red badge for cancelled status', () => {
const game: Game = { ...scheduledGame, status: 'cancelled' };
render(<GameCard game={game} />);
const badge = screen.getByText('cancelled');
expect(badge).toHaveClass('bg-red-100', 'text-red-800');
});

it('applies gray badge for unknown/default status', () => {
const game: Game = { ...scheduledGame, status: 'postponed' as 'scheduled' };
render(<GameCard game={game} />);
const badge = screen.getByText('postponed');
expect(badge).toHaveClass('bg-gray-100', 'text-gray-800');
});

it('shows date/time row when showDate is true (default)', () => {
render(<GameCard game={scheduledGame} />);
// status badge is inside the date row — just verify it is present
expect(screen.getByText('scheduled')).toBeInTheDocument();
});

it('hides the date/time row when showDate is false', () => {
render(<GameCard game={scheduledGame} showDate={false} />);
expect(screen.queryByText('scheduled')).not.toBeInTheDocument();
});

it('renders season information when provided', () => {
const game = { ...completedGame, season: 2025 };
render(<GameCard game={game} />);
expect(screen.getByText('2025 Season')).toBeInTheDocument();
});

it('does not render season row when season is absent', () => {
render(<GameCard game={completedGame} />);
expect(screen.queryByText(/Season/)).not.toBeInTheDocument();
});

it('highlights winning team score in green', () => {
render(<GameCard game={completedGame} />);
const winnerScore = screen.getByText('110');
expect(winnerScore).toHaveClass('text-green-600');
});

it('calls onClick with game when card is clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<GameCard game={scheduledGame} onClick={handleClick} />);
await user.click(screen.getByText('LAL'));
expect(handleClick).toHaveBeenCalledOnce();
expect(handleClick).toHaveBeenCalledWith(scheduledGame);
});

it('does not throw when clicked without onClick handler', async () => {
const user = userEvent.setup();
render(<GameCard game={scheduledGame} />);
await user.click(screen.getByText('LAL'));
});

it('applies custom className to the card', () => {
const { container } = render(<GameCard game={scheduledGame} className="custom-game" />);
expect(container.firstChild).toHaveClass('custom-game');
});

it('renders logos for both teams when logoUrl is set', () => {
const game: Game = {
...completedGame,
homeTeam: { ...homeTeam, logoUrl: 'https://example.com/lal.png' },
awayTeam: { ...awayTeam, logoUrl: 'https://example.com/bos.png' },
};
render(<GameCard game={game} />);
expect(screen.getByAltText('Boston Celtics logo')).toHaveAttribute('src', 'https://example.com/bos.png');
expect(screen.getByAltText('Los Angeles Lakers logo')).toHaveAttribute('src', 'https://example.com/lal.png');
});

it('hides team logo on image load error', () => {
const game: Game = {
...completedGame,
homeTeam: { ...homeTeam, logoUrl: 'https://example.com/lal.png' },
awayTeam: { ...awayTeam, logoUrl: 'https://example.com/bos.png' },
};
render(<GameCard game={game} />);
const imgs = screen.getAllByRole('img');
imgs.forEach((img) => {
fireEvent.error(img);
expect(img).toHaveStyle({ display: 'none' });
});
});

it('highlights away team score in green when away team wins', () => {
const game: Game = { ...completedGame, homeScore: 100, awayScore: 115 };
render(<GameCard game={game} />);
const winnerScore = screen.getByText('115');
expect(winnerScore).toHaveClass('text-green-600');
});
});
128 changes: 128 additions & 0 deletions frontend/src/test/GameList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import GameList from '../components/GameList';
import type { Game, Team } from '../types/nba';

const teamA: Team = { id: 't1', name: 'Lakers', abbreviation: 'LAL', city: 'Los Angeles' };
const teamB: Team = { id: 't2', name: 'Celtics', abbreviation: 'BOS', city: 'Boston' };
const teamC: Team = { id: 't3', name: 'Warriors', abbreviation: 'GSW', city: 'Golden State' };

const games: Game[] = [
{
id: 'g1',
homeTeam: teamA,
awayTeam: teamB,
date: '2025-01-15T20:00:00.000Z',
homeScore: 110,
awayScore: 105,
status: 'completed',
},
{
id: 'g2',
homeTeam: teamC,
awayTeam: teamA,
date: '2025-01-16T21:00:00.000Z',
status: 'scheduled',
},
];

describe('GameList', () => {
it('renders all game cards', () => {
render(<GameList games={games} />);
expect(screen.getAllByText('LAL').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('BOS')).toBeInTheDocument();
expect(screen.getByText('GSW')).toBeInTheDocument();
});

it('shows loading spinner when loading is true', () => {
render(<GameList games={[]} loading />);
expect(screen.getByText('Loading games...')).toBeInTheDocument();
});

it('does not render cards while loading', () => {
render(<GameList games={games} loading />);
expect(screen.queryByText('LAL')).not.toBeInTheDocument();
});

it('shows error message when error prop is set', () => {
render(<GameList games={[]} error="Failed to load games" />);
expect(screen.getByText('Failed to load games')).toBeInTheDocument();
});

it('shows "No games found" for an empty list', () => {
render(<GameList games={[]} />);
expect(screen.getByText('No games found')).toBeInTheDocument();
});

it('shows filter hint when filterStatus causes empty result', () => {
render(<GameList games={games} filterStatus="cancelled" />);
expect(screen.getByText('No games found')).toBeInTheDocument();
expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument();
});

it('shows filter hint when filterTeam causes empty result', () => {
render(<GameList games={games} filterTeam="XYZ" />);
expect(screen.getByText('No games found')).toBeInTheDocument();
expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument();
});

it('does not show filter hint for empty list without active filters', () => {
render(<GameList games={[]} />);
expect(screen.queryByText('Try adjusting your filters')).not.toBeInTheDocument();
});

it('filters games by status', () => {
render(<GameList games={games} filterStatus="completed" />);
expect(screen.getByText('BOS')).toBeInTheDocument();
expect(screen.queryByText('GSW')).not.toBeInTheDocument();
});

it('filters games by team abbreviation', () => {
render(<GameList games={games} filterTeam="BOS" />);
expect(screen.getByText('BOS')).toBeInTheDocument();
expect(screen.queryByText('GSW')).not.toBeInTheDocument();
});

it('filters games by team name (partial, case-insensitive)', () => {
render(<GameList games={games} filterTeam="warriors" />);
expect(screen.getByText('GSW')).toBeInTheDocument();
expect(screen.queryByText('BOS')).not.toBeInTheDocument();
});

it('groups games by date and renders a date heading', () => {
render(<GameList games={games} />);
const headings = screen.getAllByRole('heading', { level: 3 });
expect(headings.length).toBeGreaterThanOrEqual(1);
});

it('groups multiple games with the same date under a single heading', () => {
const sameDay: Game[] = [
{ ...games[0], id: 'g-a', date: '2025-02-01T18:00:00.000Z' },
{ ...games[1], id: 'g-b', date: '2025-02-01T21:00:00.000Z' },
];
render(<GameList games={sameDay} />);
const headings = screen.getAllByRole('heading', { level: 3 });
expect(headings).toHaveLength(1);
});

it('sorts games so the most recent date appears first', () => {
render(<GameList games={games} />);
const headings = screen.getAllByRole('heading', { level: 3 });
// g2 (Jan 16) should come before g1 (Jan 15)
expect(headings[0].textContent).toMatch(/16/);
});

it('calls onGameClick when a game card is clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<GameList games={[games[0]]} onGameClick={handleClick} />);
await user.click(screen.getByText('LAL'));
expect(handleClick).toHaveBeenCalledWith(games[0]);
});

it('applies custom className', () => {
const { container } = render(<GameList games={games} className="custom-list" />);
expect(container.firstChild).toHaveClass('custom-list');
});
});
Loading