Add React race hub frontend

This commit is contained in:
2026-05-25 01:10:44 -04:00
parent e08255db70
commit 32d500f5af
29 changed files with 5976 additions and 105 deletions

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ClassificationTable } from '../components/ClassificationTable'
import type { EnrichedResult, EnrichedGrid } from '../types'
const mockResults: EnrichedResult[] = [
{
driver_number: 16,
position: 1,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: 5534.456,
gap_to_leader: null,
number_of_laps: 78,
points: 25,
session_key: 9472,
meeting_key: 1234,
},
{
driver_number: 1,
position: 2,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 3.456,
number_of_laps: 78,
points: 18,
session_key: 9472,
meeting_key: 1234,
},
{
driver_number: 44,
position: 5,
name_acronym: 'HAM',
full_name: 'Lewis Hamilton',
team_name: 'Ferrari',
team_colour: 'e8002d',
dnf: false,
dns: false,
dsq: false,
duration: null,
gap_to_leader: 21.234,
number_of_laps: 78,
points: 10,
session_key: 9472,
meeting_key: 1234,
},
]
const mockGrid: EnrichedGrid[] = [
{
driver_number: 1,
position: 1,
name_acronym: 'VER',
full_name: 'Max Verstappen',
team_name: 'Red Bull Racing',
team_colour: '3671c6',
session_key: 9472,
meeting_key: 1234,
lap_duration: 74.892,
},
{
driver_number: 16,
position: 3,
name_acronym: 'LEC',
full_name: 'Charles Leclerc',
team_name: 'Ferrari',
team_colour: 'e8002d',
session_key: 9472,
meeting_key: 1234,
lap_duration: 75.123,
},
]
describe('ClassificationTable', () => {
it('renders driver acronyms', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('LEC')).toBeInTheDocument()
expect(screen.getByText('VER')).toBeInTheDocument()
expect(screen.getByText('HAM')).toBeInTheDocument()
})
it('renders P1 badge for LEC', () => {
const { container } = render(<ClassificationTable results={mockResults} grid={mockGrid} />)
const p1 = container.querySelector('.pos-p1')
expect(p1).toBeInTheDocument()
expect(p1?.textContent).toBe('1')
})
it('shows grid gain arrow for LEC (started P3, finished P1)', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('↑2')).toBeInTheDocument()
})
it('shows grid loss arrow for VER (started P1, finished P2)', () => {
render(<ClassificationTable results={mockResults} grid={mockGrid} />)
expect(screen.getByText('↓1')).toBeInTheDocument()
})
it('renders missing notice when results are empty', () => {
render(<ClassificationTable results={[]} grid={[]} />)
expect(screen.getByText(/not ingested/i)).toBeInTheDocument()
})
})
describe('ClassificationTable — DNF/DNS/DSQ', () => {
it('shows DNF label', () => {
const dnfResult: EnrichedResult = {
...mockResults[0],
driver_number: 23,
name_acronym: 'ALB',
position: 20,
dnf: true,
duration: null,
gap_to_leader: null,
points: 0,
}
render(<ClassificationTable results={[dnfResult]} grid={[]} />)
expect(screen.getByText('DNF')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import {
teamColor,
formatDuration,
formatGap,
formatLapTime,
gridDelta,
gridDeltaClass,
positionClass,
} from '../utils'
describe('teamColor', () => {
it('prepends # to bare hex', () => {
expect(teamColor('e8002d')).toBe('#e8002d')
})
it('passes through already-prefixed hex', () => {
expect(teamColor('#3671c6')).toBe('#3671c6')
})
it('returns fallback for empty string', () => {
expect(teamColor('')).toBe('#444444')
})
it('returns fallback for undefined', () => {
expect(teamColor(undefined)).toBe('#444444')
})
})
describe('formatDuration', () => {
it('formats a race duration with hours', () => {
expect(formatDuration(5534.456)).toBe('1:32:14.456')
})
it('formats a sub-hour duration', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatDuration(null)).toBe('—')
})
it('returns — for undefined', () => {
expect(formatDuration(undefined)).toBe('—')
})
it('handles array (qualifying)', () => {
expect(formatDuration([74.892])).toBe('1:14.892')
})
it('returns — for zero', () => {
expect(formatDuration(0)).toBe('—')
})
})
describe('formatGap', () => {
it('formats a numeric gap', () => {
expect(formatGap(3.456)).toBe('+3.456')
})
it('passes through a string gap (lapped)', () => {
expect(formatGap('+1 LAP')).toBe('+1 LAP')
})
it('returns — for null', () => {
expect(formatGap(null)).toBe('—')
})
it('handles array gap', () => {
expect(formatGap([8.123])).toBe('+8.123')
})
})
describe('formatLapTime', () => {
it('formats qualifying lap time', () => {
expect(formatLapTime(74.892)).toBe('1:14.892')
})
it('returns — for null', () => {
expect(formatLapTime(null)).toBe('—')
})
it('pads seconds correctly', () => {
expect(formatLapTime(64.5)).toBe('1:04.500')
})
})
describe('gridDelta', () => {
it('shows gain when finish position improved', () => {
expect(gridDelta(1, 3)).toBe('↑2')
})
it('shows loss when finish position dropped', () => {
expect(gridDelta(5, 2)).toBe('↓3')
})
it('shows — for same position', () => {
expect(gridDelta(4, 4)).toBe('—')
})
it('shows — when grid position is 0', () => {
expect(gridDelta(1, 0)).toBe('—')
})
})
describe('gridDeltaClass', () => {
it('returns pos-gain for improvement', () => {
expect(gridDeltaClass(1, 5)).toBe('pos-gain')
})
it('returns pos-loss for drop', () => {
expect(gridDeltaClass(6, 2)).toBe('pos-loss')
})
it('returns pos-same for no change', () => {
expect(gridDeltaClass(3, 3)).toBe('pos-same')
})
})
describe('positionClass', () => {
it('returns pos-p1 for first', () => {
expect(positionClass(1)).toBe('pos-p1')
})
it('returns pos-p2 for second', () => {
expect(positionClass(2)).toBe('pos-p2')
})
it('returns pos-p3 for third', () => {
expect(positionClass(3)).toBe('pos-p3')
})
it('returns pos-n for other positions', () => {
expect(positionClass(10)).toBe('pos-n')
})
})