mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
160
frontend/src/test/DriverProfilePage.test.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { DriverProfilePage } from '../pages/DriverProfilePage'
|
||||
import type { DriverSummary } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchDriverSummary: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchSeasons, fetchDriverSummary } from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchSummary = vi.mocked(fetchDriverSummary)
|
||||
|
||||
const summary: DriverSummary = {
|
||||
season: 2025,
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
headshot_url: '',
|
||||
points: 50,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 2,
|
||||
poles: 1,
|
||||
form: [25, 25],
|
||||
cumulative: [25, 50],
|
||||
round_labels: ['R1', 'R2'],
|
||||
rounds: [
|
||||
{
|
||||
meeting_key: 1201,
|
||||
meeting_name: 'Bahrain Grand Prix',
|
||||
country_code: 'BHR',
|
||||
country_name: 'Bahrain',
|
||||
race_position: 1,
|
||||
grid_position: 1,
|
||||
quali_position: 1,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
},
|
||||
{
|
||||
meeting_key: 1202,
|
||||
meeting_name: 'Saudi Arabian Grand Prix',
|
||||
country_code: 'SAU',
|
||||
country_name: 'Saudi Arabia',
|
||||
race_position: 1,
|
||||
grid_position: 4,
|
||||
quali_position: 4,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderPage(props: { driverNumber: number; year?: number }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DriverProfilePage driverNumber={props.driverNumber} year={props.year} />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: () => null,
|
||||
})
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('DriverProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchSummary.mockResolvedValue(summary)
|
||||
})
|
||||
|
||||
it('renders header, form, quali-vs-race, and rounds sections', async () => {
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Header: identity + season stats.
|
||||
expect(screen.getByTestId('dp-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Max Verstappen')).toBeInTheDocument()
|
||||
expect(screen.getByText('#1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Red Bull', { exact: false })).toBeInTheDocument()
|
||||
// 'P1' / '50' also appear in the rounds table and chart axis.
|
||||
expect(screen.getAllByText('P1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('50').length).toBeGreaterThan(0)
|
||||
|
||||
// Season form strip.
|
||||
expect(screen.getByTestId('dp-form')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cumulative points')).toBeInTheDocument()
|
||||
|
||||
// Quali-vs-race chart shows the delta from the Saudi round (P4 → P1 = +3).
|
||||
expect(screen.getByTestId('dp-quali-race')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('+3').length).toBeGreaterThan(0)
|
||||
|
||||
// Track-by-track table rows with flags.
|
||||
const rounds = screen.getByTestId('dp-rounds')
|
||||
expect(rounds).toBeInTheDocument()
|
||||
expect(screen.getByText('Bahrain Grand Prix')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saudi Arabian Grand Prix')).toBeInTheDocument()
|
||||
expect(rounds.textContent).toContain('🇧🇭')
|
||||
expect(rounds.textContent).toContain('🇸🇦')
|
||||
|
||||
// With an explicit year, the seasons list is not needed.
|
||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
})
|
||||
|
||||
it('defaults to the latest season when no year is given', async () => {
|
||||
renderPage({ driverNumber: 1 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
expect(mockFetchSummary).toHaveBeenCalledWith(1, 2025)
|
||||
})
|
||||
|
||||
it('shows the API error message on failure', async () => {
|
||||
mockFetchSummary.mockRejectedValue(new Error('API 404: Not Found'))
|
||||
renderPage({ driverNumber: 99, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('API 404: Not Found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty states when no rounds are completed', async () => {
|
||||
mockFetchSummary.mockResolvedValue({
|
||||
...summary,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_labels: [],
|
||||
rounds: [],
|
||||
})
|
||||
renderPage({ driverNumber: 1, year: 2025 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('driver-profile')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getAllByText('No completed rounds yet.').length).toBe(2)
|
||||
expect(screen.getByText('No grid-vs-finish data yet.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
104
frontend/src/test/driverProfile.test.ts
Normal file
104
frontend/src/test/driverProfile.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatDelta,
|
||||
formatPosition,
|
||||
gridFinishDeltas,
|
||||
shortGpLabel,
|
||||
} from '../lib/driverProfile'
|
||||
import type { DriverSummaryRound } from '../types'
|
||||
|
||||
function round(over: Partial<DriverSummaryRound>): DriverSummaryRound {
|
||||
return {
|
||||
meeting_key: 1201,
|
||||
meeting_name: 'Bahrain Grand Prix',
|
||||
country_code: 'BHR',
|
||||
country_name: 'Bahrain',
|
||||
race_position: 1,
|
||||
grid_position: 1,
|
||||
quali_position: 1,
|
||||
points: 25,
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('gridFinishDeltas', () => {
|
||||
it('computes positive delta for places gained', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 5, race_position: 2, points: 18 })])
|
||||
expect(d.grid).toBe(5)
|
||||
expect(d.finish).toBe(2)
|
||||
expect(d.delta).toBe(3)
|
||||
expect(d.points).toBe(18)
|
||||
expect(d.status).toBe('classified')
|
||||
expect(d.round).toBe(1)
|
||||
})
|
||||
|
||||
it('computes negative delta for places lost', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 1, race_position: 4 })])
|
||||
expect(d.delta).toBe(-3)
|
||||
})
|
||||
|
||||
it('returns null delta when the grid slot is unknown (position 0)', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 6 })])
|
||||
expect(d.grid).toBeNull()
|
||||
expect(d.finish).toBe(6)
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('flags DNS rounds and never computes a delta for them', () => {
|
||||
const [d] = gridFinishDeltas([round({ dns: true, grid_position: 8, race_position: 0 })])
|
||||
expect(d.status).toBe('dns')
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('marks rounds the driver did not enter as absent', () => {
|
||||
const [d] = gridFinishDeltas([round({ grid_position: 0, race_position: 0, points: 0 })])
|
||||
expect(d.status).toBe('absent')
|
||||
expect(d.grid).toBeNull()
|
||||
expect(d.finish).toBeNull()
|
||||
expect(d.delta).toBeNull()
|
||||
})
|
||||
|
||||
it('still computes the delta for a classified DNF with known positions', () => {
|
||||
const [d] = gridFinishDeltas([round({ dnf: true, grid_position: 3, race_position: 15 })])
|
||||
expect(d.status).toBe('dnf')
|
||||
expect(d.delta).toBe(-12)
|
||||
})
|
||||
|
||||
it('numbers rounds sequentially and shortens labels', () => {
|
||||
const ds = gridFinishDeltas([
|
||||
round({}),
|
||||
round({ meeting_name: 'Saudi Arabian Grand Prix', meeting_key: 1202 }),
|
||||
])
|
||||
expect(ds.map((d) => d.round)).toEqual([1, 2])
|
||||
expect(ds[1].label).toBe('Saudi Arabian')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortGpLabel', () => {
|
||||
it('strips "Grand Prix" wherever it appears', () => {
|
||||
expect(shortGpLabel('Bahrain Grand Prix')).toBe('Bahrain')
|
||||
expect(shortGpLabel('Grand Prix of Monaco')).toBe('of Monaco')
|
||||
})
|
||||
|
||||
it('falls back to the original name when stripping empties it', () => {
|
||||
expect(shortGpLabel('Grand Prix')).toBe('Grand Prix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPosition / formatDelta', () => {
|
||||
it('formats positions', () => {
|
||||
expect(formatPosition(4)).toBe('P4')
|
||||
expect(formatPosition(null)).toBe('—')
|
||||
expect(formatPosition(0)).toBe('—')
|
||||
})
|
||||
|
||||
it('formats deltas', () => {
|
||||
expect(formatDelta(3)).toBe('+3')
|
||||
expect(formatDelta(-2)).toBe('−2')
|
||||
expect(formatDelta(0)).toBe('=')
|
||||
expect(formatDelta(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user