mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Merge remote-tracking branch 'origin/phase-1' into feat/issue-13-live-tyre-degradation-pit-window-panel
This commit is contained in:
@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
|
||||
fetchSessions: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
fetchLiveState,
|
||||
fetchChampionshipHub,
|
||||
fetchRaceHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
@@ -102,6 +115,48 @@ describe('CommandCenterPage', () => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
|
||||
mockFetchSessions.mockResolvedValue([])
|
||||
mockFetchChampionshipHub.mockResolvedValue({
|
||||
season: 2025,
|
||||
round: 1,
|
||||
total_rounds: 1,
|
||||
rounds_left: 0,
|
||||
last_race: 'Monaco',
|
||||
round_labels: ['R1'],
|
||||
drivers: [],
|
||||
teams: [],
|
||||
})
|
||||
mockFetchRaceHub.mockResolvedValue({
|
||||
source: 'local',
|
||||
session_key: 9472,
|
||||
datasets: fullDatasets,
|
||||
results: [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
],
|
||||
starting_grid: [],
|
||||
drivers: [],
|
||||
stints: [],
|
||||
pit_stops: [],
|
||||
positions: [],
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no seasons are ingested', async () => {
|
||||
@@ -131,11 +186,10 @@ describe('CommandCenterPage', () => {
|
||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
|
||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
|
||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||
})
|
||||
|
||||
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
|
||||
@@ -181,9 +235,9 @@ describe('CommandCenterPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('TimingTower', () => {
|
||||
CircuitName: 'Silverstone',
|
||||
SessionType: 'Sprint Qualifying',
|
||||
SessionName: 'Sprint Qualifying',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal file
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { radioClipUrl } from '../lib/radio'
|
||||
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
|
||||
|
||||
const session: LiveSessionMeta = {
|
||||
MeetingName: 'British Grand Prix',
|
||||
CircuitName: 'Silverstone',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
|
||||
}
|
||||
|
||||
const driverInfo: Record<string, LiveDriverInfo> = {
|
||||
'4': {
|
||||
RacingNumber: '4',
|
||||
BroadcastName: 'L NORRIS',
|
||||
Tla: 'NOR',
|
||||
TeamName: 'McLaren',
|
||||
TeamColour: 'ff8000',
|
||||
FirstName: 'Lando',
|
||||
LastName: 'Norris',
|
||||
},
|
||||
'16': {
|
||||
RacingNumber: '16',
|
||||
BroadcastName: 'C LECLERC',
|
||||
Tla: 'LEC',
|
||||
TeamName: 'Ferrari',
|
||||
TeamColour: 'e8002d',
|
||||
FirstName: 'Charles',
|
||||
LastName: 'Leclerc',
|
||||
},
|
||||
}
|
||||
|
||||
const captures: LiveRadioCapture[] = [
|
||||
{ Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
|
||||
{ Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
|
||||
]
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('radioClipUrl', () => {
|
||||
it('builds static CDN URLs from the session path and capture path', () => {
|
||||
expect(radioClipUrl(session, captures[0])).toBe(
|
||||
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty URL without path data', () => {
|
||||
expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
|
||||
expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TeamRadioTicker', () => {
|
||||
it('renders captures newest first with driver labels', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
|
||||
|
||||
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
|
||||
|
||||
const rows = screen.getAllByRole('button')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(screen.getByText('NOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('LEC')).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
|
||||
})
|
||||
|
||||
it('uses one audio element and toggles play/pause per clip', () => {
|
||||
const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
|
||||
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Play NOR radio'))
|
||||
expect(play).toHaveBeenCalledTimes(1)
|
||||
expect(document.querySelectorAll('audio')).toHaveLength(1)
|
||||
expect(document.querySelector('audio')?.getAttribute('src')).toBe(
|
||||
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Pause NOR radio'))
|
||||
expect(pause).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
121
frontend/src/test/delta-time-graph.test.tsx
Normal file
121
frontend/src/test/delta-time-graph.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DeltaTimeGraph } from '../components/charts/DeltaTimeGraph'
|
||||
import {
|
||||
computeCumulativeDeltas,
|
||||
deltaPolylineSegments,
|
||||
formatDeltaSeconds,
|
||||
type DeltaSeries,
|
||||
} from '../lib/delta'
|
||||
|
||||
const reference: DeltaSeries = {
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
lapTimes: [90, 91, 92],
|
||||
}
|
||||
|
||||
const challenger: DeltaSeries = {
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
lapTimes: [89, 92, 90],
|
||||
}
|
||||
|
||||
describe('formatDeltaSeconds', () => {
|
||||
it('formats signed deltas with one decimal and s suffix', () => {
|
||||
expect(formatDeltaSeconds(2.5)).toBe('+2.5s')
|
||||
expect(formatDeltaSeconds(-1.2)).toBe('-1.2s')
|
||||
expect(formatDeltaSeconds(0)).toBe('+0.0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCumulativeDeltas', () => {
|
||||
it('computes cumulative delta vs the first series by default', () => {
|
||||
const result = computeCumulativeDeltas([reference, challenger])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].label).toBe('HAM')
|
||||
// Lap 1: 89-90=-1, Lap 2: (89+92)-(90+91)=0, Lap 3: (89+92+90)-(90+91+92)=-2
|
||||
expect(result[0].deltas[0]).toBeCloseTo(-1)
|
||||
expect(result[0].deltas[1]).toBeCloseTo(0)
|
||||
expect(result[0].deltas[2]).toBeCloseTo(-2)
|
||||
})
|
||||
|
||||
it('respects an explicit reference label', () => {
|
||||
const result = computeCumulativeDeltas([reference, challenger], 'HAM')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].label).toBe('VER')
|
||||
expect(result[0].deltas[0]).toBeCloseTo(1)
|
||||
expect(result[0].deltas[2]).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('emits null for missing lap times while carrying cumulative forward', () => {
|
||||
const withNull: DeltaSeries = {
|
||||
label: 'NOR',
|
||||
color: '#FF8000',
|
||||
lapTimes: [88, null, 93],
|
||||
}
|
||||
const result = computeCumulativeDeltas([reference, withNull])
|
||||
expect(result[0].deltas[0]).toBeCloseTo(-2)
|
||||
expect(result[0].deltas[1]).toBeNull()
|
||||
// After null: NOR cum=181, VER cum=273 → delta -92
|
||||
expect(result[0].deltas[2]).toBeCloseTo(-92)
|
||||
})
|
||||
|
||||
it('returns an empty array when only one series is provided', () => {
|
||||
expect(computeCumulativeDeltas([reference])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deltaPolylineSegments', () => {
|
||||
it('splits polylines at null laps', () => {
|
||||
const segments = deltaPolylineSegments(
|
||||
[1, null, 2],
|
||||
(lap, delta) => `${lap},${delta}`,
|
||||
)
|
||||
expect(segments).toEqual(['0,1', '2,2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeltaTimeGraph', () => {
|
||||
it('renders an empty state with no series', () => {
|
||||
render(<DeltaTimeGraph series={[]} />)
|
||||
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No lap data/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty state with only the reference driver', () => {
|
||||
render(<DeltaTimeGraph series={[reference]} />)
|
||||
expect(screen.getByTestId('delta-time-graph-empty')).toBeInTheDocument()
|
||||
expect(screen.getByText(/at least two drivers/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a zero line and one polyline per non-reference driver', () => {
|
||||
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
|
||||
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
|
||||
expect(container.querySelector('[data-testid="delta-zero-line"]')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delta-line-HAM')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.delta-graph-driver-line')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a crosshair tooltip on hover', () => {
|
||||
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 640,
|
||||
height: 220,
|
||||
right: 640,
|
||||
bottom: 220,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
const { container } = render(<DeltaTimeGraph series={[reference, challenger]} />)
|
||||
const hoverLayer = container.querySelector('.delta-graph-hover-layer')
|
||||
expect(hoverLayer).toBeTruthy()
|
||||
fireEvent.mouseMove(hoverLayer!, { clientX: 44, clientY: 100 })
|
||||
expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('delta-tooltip')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
123
frontend/src/test/hero.test.ts
Normal file
123
frontend/src/test/hero.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { heroState, classifySessionStatus } from '../lib/hero'
|
||||
import { currentAndNextSession } from '../lib/schedule'
|
||||
import type { Session } from '../types'
|
||||
|
||||
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('heroState', () => {
|
||||
it('returns live when the live snapshot is active', () => {
|
||||
const now = new Date('2025-05-24T12:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: true,
|
||||
currentSession: null,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('returns live when a session is in progress', () => {
|
||||
const now = new Date('2025-05-25T14:00:00Z')
|
||||
const sessions = [session()]
|
||||
const { current } = currentAndNextSession(sessions, now)
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('returns upcoming during a focus weekend gap before the next session', () => {
|
||||
const now = new Date('2025-05-24T12:00:00Z')
|
||||
const sessions = [
|
||||
session({
|
||||
session_key: 1,
|
||||
session_name: 'FP1',
|
||||
date_start: '2025-05-23T10:00:00+00:00',
|
||||
date_end: '2025-05-23T11:00:00+00:00',
|
||||
}),
|
||||
session({
|
||||
session_key: 2,
|
||||
session_name: 'Race',
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
}),
|
||||
]
|
||||
const { current, next } = currentAndNextSession(sessions, now)
|
||||
expect(current).toBeNull()
|
||||
expect(next?.session_key).toBe(2)
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('upcoming')
|
||||
})
|
||||
|
||||
it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
|
||||
const now = new Date('2025-05-25T16:00:00Z')
|
||||
const sessions = [session()]
|
||||
const { current } = currentAndNextSession(sessions, now)
|
||||
expect(current).toBeNull()
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('upcoming')
|
||||
})
|
||||
|
||||
it('returns between before the next grand prix weekend', () => {
|
||||
const now = new Date('2025-06-01T12:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: null,
|
||||
focusKind: 'next',
|
||||
}),
|
||||
).toBe('between')
|
||||
})
|
||||
|
||||
it('returns between after the season when only a recent meeting remains', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: null,
|
||||
focusKind: 'recent',
|
||||
}),
|
||||
).toBe('between')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifySessionStatus', () => {
|
||||
it('marks a session as done immediately after it ends', () => {
|
||||
const now = new Date('2025-05-25T15:00:00Z')
|
||||
expect(classifySessionStatus(session(), now)).toBe('done')
|
||||
})
|
||||
|
||||
it('marks a session as live during its window', () => {
|
||||
const now = new Date('2025-05-25T14:00:00Z')
|
||||
expect(classifySessionStatus(session(), now)).toBe('live')
|
||||
})
|
||||
})
|
||||
257
frontend/src/test/hero.test.tsx
Normal file
257
frontend/src/test/hero.test.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({
|
||||
to,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
to: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
'data-testid'?: string
|
||||
}) => (
|
||||
<a href={to} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
||||
|
||||
const focusMeeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T00:00:00+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const weekendSessions: WeekendSession[] = [
|
||||
{
|
||||
session: {
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1229,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
source: 'local',
|
||||
datasets: {} as WeekendSession['datasets'],
|
||||
},
|
||||
]
|
||||
|
||||
const liveData: LiveStreamData = {
|
||||
Drivers: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
Position: 1,
|
||||
PrevPosition: 1,
|
||||
GapToLeader: '',
|
||||
Interval: '',
|
||||
LastLapTime: '1:14.000',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:13.500',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 10,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
},
|
||||
'44': {
|
||||
RacingNumber: '44',
|
||||
Position: 2,
|
||||
PrevPosition: 2,
|
||||
GapToLeader: '+1.2',
|
||||
Interval: '+1.2',
|
||||
LastLapTime: '1:14.200',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:13.700',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 10,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
},
|
||||
},
|
||||
DriverInfo: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
BroadcastName: 'M VERSTAPPEN',
|
||||
Tla: 'VER',
|
||||
TeamName: 'Red Bull Racing',
|
||||
TeamColour: '3671c6',
|
||||
FirstName: 'Max',
|
||||
LastName: 'Verstappen',
|
||||
},
|
||||
'44': {
|
||||
RacingNumber: '44',
|
||||
BroadcastName: 'L HAMILTON',
|
||||
Tla: 'HAM',
|
||||
TeamName: 'Ferrari',
|
||||
TeamColour: 'e8002d',
|
||||
FirstName: 'Lewis',
|
||||
LastName: 'Hamilton',
|
||||
},
|
||||
},
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: {
|
||||
AirTemp: 20,
|
||||
TrackTemp: 30,
|
||||
Humidity: 50,
|
||||
WindSpeed: 1,
|
||||
WindDir: 0,
|
||||
Rainfall: false,
|
||||
},
|
||||
Session: {
|
||||
MeetingName: 'Monaco',
|
||||
CircuitName: 'Monaco',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
},
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 10,
|
||||
TotalLaps: 78,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
}
|
||||
|
||||
const podium: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7205,
|
||||
gap_to_leader: 3.2,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
function renderHero(overrides: Partial<ComponentProps<typeof CommandCenterHero>> = {}) {
|
||||
const props: ComponentProps<typeof CommandCenterHero> = {
|
||||
state: 'upcoming',
|
||||
now: new Date('2025-05-24T12:00:00Z'),
|
||||
accent: '#d61a3e',
|
||||
liveActive: false,
|
||||
liveData: null,
|
||||
focusMeeting,
|
||||
focusKind: 'current',
|
||||
sessions: weekendSessions,
|
||||
currentSession: null,
|
||||
nextSession: weekendSessions[0].session,
|
||||
lastRaceName: '',
|
||||
lastRacePodium: [],
|
||||
nextMeeting: null,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return render(<CommandCenterHero {...props} />)
|
||||
}
|
||||
|
||||
describe('CommandCenterHero', () => {
|
||||
it('renders live timing link and top three in live state', () => {
|
||||
renderHero({
|
||||
state: 'live',
|
||||
liveActive: true,
|
||||
liveData,
|
||||
currentSession: weekendSessions[0].session,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
|
||||
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
|
||||
expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders countdown and schedule strip in upcoming state', () => {
|
||||
renderHero({
|
||||
state: 'upcoming',
|
||||
nextSession: weekendSessions[0].session,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
|
||||
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
|
||||
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
|
||||
})
|
||||
|
||||
it('renders last race podium and next GP countdown in between state', () => {
|
||||
const nextMeeting: Meeting = {
|
||||
...focusMeeting,
|
||||
meeting_key: 1301,
|
||||
meeting_name: 'Canada',
|
||||
country_code: 'CAN',
|
||||
date_start: '2025-06-06T00:00:00+00:00',
|
||||
date_end: '2025-06-08T00:00:00+00:00',
|
||||
}
|
||||
|
||||
renderHero({
|
||||
state: 'between',
|
||||
lastRaceName: 'Monaco',
|
||||
lastRacePodium: podium,
|
||||
nextMeeting,
|
||||
now: new Date('2025-06-01T12:00:00Z'),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
|
||||
expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
|
||||
expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
|
||||
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
|
||||
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
|
||||
})
|
||||
})
|
||||
@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
|
||||
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
|
||||
],
|
||||
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
|
||||
TeamRadio: [],
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 21,
|
||||
TotalLaps: 78,
|
||||
@@ -220,7 +221,7 @@ describe('track status mapping', () => {
|
||||
describe('live qualifying display', () => {
|
||||
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
|
||||
rows(22),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('SQ1')
|
||||
@@ -232,7 +233,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q1')
|
||||
@@ -241,7 +242,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20, 5),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q2')
|
||||
@@ -251,13 +252,13 @@ describe('live qualifying display', () => {
|
||||
it('shows no cutoff for race sessions or Q3', () => {
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
|
||||
rows(20),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
|
||||
rows(10),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
|
||||
118
frontend/src/test/telemetry-trace-chart.test.tsx
Normal file
118
frontend/src/test/telemetry-trace-chart.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
TelemetryTraceChart,
|
||||
type TelemetryTraceSeries,
|
||||
} from '../components/charts/TelemetryTraceChart'
|
||||
|
||||
function makeSeries(label: string, color: string, speeds: number[]): TelemetryTraceSeries {
|
||||
return {
|
||||
label,
|
||||
color,
|
||||
samples: speeds.map((speed, i) => ({
|
||||
speed,
|
||||
throttle: (i * 25) % 101,
|
||||
brake: i % 2 === 0 ? 100 : 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const VER = makeSeries('VER', '#2563eb', [280, 310, 190, 90, 240])
|
||||
const LEC = makeSeries('LEC', '#dc2626', [275, 305, 200, 95, 235])
|
||||
|
||||
describe('TelemetryTraceChart', () => {
|
||||
it('renders one polyline per series per channel', () => {
|
||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
||||
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
|
||||
expect(lines).toHaveLength(2 * 3)
|
||||
for (const channel of ['speed', 'throttle', 'brake']) {
|
||||
expect(
|
||||
container.querySelectorAll(`polyline.telemetry-trace-line[data-channel="${channel}"]`),
|
||||
).toHaveLength(2)
|
||||
}
|
||||
expect(
|
||||
container.querySelectorAll('polyline.telemetry-trace-line[data-series="VER"]'),
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('respects the channels prop', () => {
|
||||
const { container } = render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
|
||||
expect(container.querySelectorAll('polyline.telemetry-trace-line')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('.telemetry-trace-panel')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders an empty state for no series', () => {
|
||||
render(<TelemetryTraceChart series={[]} />)
|
||||
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty state when a series has no samples', () => {
|
||||
render(<TelemetryTraceChart series={[makeSeries('VER', '#2563eb', [])]} />)
|
||||
expect(screen.getByText('No telemetry data')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('survives a single sample and mismatched series lengths', () => {
|
||||
const short = makeSeries('VER', '#2563eb', [300])
|
||||
const long = makeSeries('LEC', '#dc2626', [280, 290, 300])
|
||||
const { container } = render(<TelemetryTraceChart series={[short, long]} />)
|
||||
// Clamped to the shortest series: every polyline has exactly one point.
|
||||
const lines = container.querySelectorAll('polyline.telemetry-trace-line')
|
||||
expect(lines).toHaveLength(6)
|
||||
for (const line of lines) {
|
||||
expect(line.getAttribute('points')!.trim().split(' ')).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps throttle values to 0-100 within the panel', () => {
|
||||
const wild: TelemetryTraceSeries = {
|
||||
label: 'VER',
|
||||
color: '#2563eb',
|
||||
samples: [
|
||||
{ speed: 100, throttle: 150, brake: 0 },
|
||||
{ speed: 100, throttle: -20, brake: 0 },
|
||||
{ speed: 100, throttle: 50, brake: 0 },
|
||||
],
|
||||
}
|
||||
const { container } = render(
|
||||
<TelemetryTraceChart series={[wild]} channels={['throttle']} height={110} />,
|
||||
)
|
||||
const line = container.querySelector('polyline.telemetry-trace-line[data-channel="throttle"]')!
|
||||
const ys = line
|
||||
.getAttribute('points')!
|
||||
.split(' ')
|
||||
.map((p) => Number(p.split(',')[1]))
|
||||
// Panel occupies y 0..110 with 6px padding; clamped values pin to the edges.
|
||||
expect(ys[0]).toBeCloseTo(6, 1) // 150% -> 100% -> panel top
|
||||
expect(ys[1]).toBeCloseTo(104, 1) // -20% -> 0% -> panel bottom
|
||||
expect(ys[2]).toBeCloseTo(55, 1) // 50% -> middle
|
||||
})
|
||||
|
||||
it('shows the speed axis max in km/h', () => {
|
||||
render(<TelemetryTraceChart series={[VER]} channels={['speed']} />)
|
||||
expect(screen.getByText('310 km/h')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists every series in the legend with a color swatch', () => {
|
||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
||||
const legend = container.querySelector('.telemetry-trace-legend')!
|
||||
expect(legend.textContent).toContain('VER')
|
||||
expect(legend.textContent).toContain('LEC')
|
||||
expect(legend.querySelectorAll('.telemetry-trace-swatch')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a crosshair and per-driver readout on hover, hides on leave', () => {
|
||||
const { container } = render(<TelemetryTraceChart series={[VER, LEC]} />)
|
||||
const svg = container.querySelector('svg.telemetry-trace-svg')!
|
||||
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseMove(svg, { clientX: 0, clientY: 10 })
|
||||
expect(screen.getByTestId('telemetry-trace-crosshair')).toBeInTheDocument()
|
||||
const readout = screen.getByTestId('telemetry-trace-readout')
|
||||
expect(readout.textContent).toContain('VER')
|
||||
expect(readout.textContent).toContain('LEC')
|
||||
expect(readout.textContent).toContain('km/h')
|
||||
|
||||
fireEvent.mouseLeave(svg)
|
||||
expect(screen.queryByTestId('telemetry-trace-crosshair')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
149
frontend/src/test/tyre-stint-timeline.test.tsx
Normal file
149
frontend/src/test/tyre-stint-timeline.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TyreStintTimeline, type StintTimelineRow } from '../components/charts/TyreStintTimeline'
|
||||
import { StrategyView } from '../components/StrategyView'
|
||||
import type { EnrichedResult, Stint, PitStop } from '../types'
|
||||
|
||||
const sampleRows: StintTimelineRow[] = [
|
||||
{
|
||||
label: 'VER',
|
||||
color: '#3671C6',
|
||||
stints: [
|
||||
{ compound: 'MEDIUM', lapStart: 1, lapEnd: 30 },
|
||||
{ compound: 'SOFT', lapStart: 31, lapEnd: 78 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'HAM',
|
||||
color: '#E8002D',
|
||||
stints: [{ compound: 'SOFT', lapStart: 1, lapEnd: 18 }],
|
||||
},
|
||||
]
|
||||
|
||||
describe('TyreStintTimeline', () => {
|
||||
it('renders one rect per stint', () => {
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
||||
)
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('maps compounds to color classes', () => {
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
||||
)
|
||||
const bars = container.querySelectorAll('.stint-timeline__bar')
|
||||
expect(bars[0]).toHaveClass('tyre-medium')
|
||||
expect(bars[1]).toHaveClass('tyre-soft')
|
||||
expect(bars[2]).toHaveClass('tyre-soft')
|
||||
})
|
||||
|
||||
it('shows native title with compound, lap range, and stint length', () => {
|
||||
const { container } = render(
|
||||
<TyreStintTimeline rows={sampleRows} totalLaps={78} />,
|
||||
)
|
||||
const titles = [...container.querySelectorAll('title')].map((t) => t.textContent)
|
||||
expect(titles).toContain('Medium · L1–30 · 30 laps')
|
||||
expect(titles).toContain('Soft · L31–78 · 48 laps')
|
||||
expect(titles).toContain('Soft · L1–18 · 18 laps')
|
||||
})
|
||||
|
||||
it('renders lap-axis ticks every 10 laps', () => {
|
||||
render(<TyreStintTimeline rows={sampleRows} totalLaps={78} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
expect(screen.getByText('20')).toBeInTheDocument()
|
||||
expect(screen.getByText('70')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows legend only for used compounds', () => {
|
||||
render(<TyreStintTimeline rows={sampleRows} totalLaps={78} />)
|
||||
expect(screen.getByTestId('legend-soft')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('legend-medium')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('legend-hard')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('legend-wet')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Soft')).toBeInTheDocument()
|
||||
expect(screen.getByText('Medium')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when rows are empty', () => {
|
||||
render(<TyreStintTimeline rows={[]} totalLaps={50} />)
|
||||
expect(screen.getByTestId('stint-timeline-empty')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No stint data/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
const results: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
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: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'E8002D',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: null,
|
||||
gap_to_leader: 5.1,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
const stints: Stint[] = [
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 1,
|
||||
meeting_key: 1229,
|
||||
stint_number: 1,
|
||||
compound: 'MEDIUM',
|
||||
lap_start: 1,
|
||||
lap_end: 30,
|
||||
tyre_age_at_start: 0,
|
||||
},
|
||||
{
|
||||
session_key: 9472,
|
||||
driver_number: 44,
|
||||
meeting_key: 1229,
|
||||
stint_number: 1,
|
||||
compound: 'SOFT',
|
||||
lap_start: 1,
|
||||
lap_end: 18,
|
||||
tyre_age_at_start: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const pitStops: PitStop[] = []
|
||||
|
||||
describe('StrategyView integration', () => {
|
||||
it('renders timeline when stints exist', () => {
|
||||
const { container } = render(
|
||||
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />,
|
||||
)
|
||||
expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('stint-timeline')).toBeInTheDocument()
|
||||
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.stint-timeline__bar')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user