Merge phase-1 (radio ticker, tyre-deg panel, hero) into event-rail branch

This commit is contained in:
2026-07-04 00:08:24 -04:00
37 changed files with 4117 additions and 339 deletions

View File

@@ -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()
})

View File

@@ -201,6 +201,7 @@ describe('TimingTower', () => {
CircuitName: 'Silverstone',
SessionType: 'Sprint Qualifying',
SessionName: 'Sprint Qualifying',
Path: '',
}}
/>,
)

View 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()
})
})

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { TyreDegPanel } from '../components/live/TyreDegPanel'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData, LiveTyreData } from '../types'
function makeRow(
number: string,
position: number,
tla: string,
driver: Partial<LiveDriverData> = {},
tyre: Partial<LiveTyreData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: position === 1 ? '' : '+5.0',
GapToLeader: '',
LastLapTime: '1:30.000',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
Tyre: { Compound: 'MEDIUM', New: false, Age: 5, ...tyre } as LiveTyreData,
}
}
function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
return [
makeRow('1', 1, 'VER', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
makeRow('4', 2, 'NOR', { NumberOfLaps: lap, LastLapTime: lastLapTime }),
]
}
describe('TyreDegPanel', () => {
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('VER')
expect(panel).toHaveTextContent('M +5')
expect(panel).toHaveTextContent('warming up')
})
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
const { rerender } = render(
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
)
// Five completed laps at +0.1s/lap after the first observed snapshot.
for (let lap = 2; lap <= 6; lap++) {
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
}
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).toHaveTextContent('+0.10s/lap')
expect(panel).not.toHaveTextContent('warming up')
// VER pits from P1 with NOR +5.0 behind: NOR gets past -> rejoin ~P2.
expect(panel).toHaveTextContent('→ ~P2')
})
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
const panel = screen.getByTestId('tyredeg-panel')
expect(panel).not.toHaveTextContent('~P')
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
})
it('limits rows to the top ten plus pinned drivers', () => {
const rows = Array.from({ length: 15 }, (_, index) =>
makeRow(String(index + 1), index + 1, `D${index + 1}`),
)
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
expect(screen.getByText('D14')).toBeInTheDocument()
expect(screen.queryByText('D12')).not.toBeInTheDocument()
})
it('renders nothing without rows', () => {
render(<TyreDegPanel rows={[]} sessionType="Race" pinned={[]} />)
expect(screen.queryByTestId('tyredeg-panel')).not.toBeInTheDocument()
})
})

View 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()
})
})

View 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')
})
})

View 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')
})
})

View File

@@ -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()

View 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()
})
})

View 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 · L130 · 30 laps')
expect(titles).toContain('Soft · L3178 · 48 laps')
expect(titles).toContain('Soft · L118 · 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)
})
})

View File

@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest'
import {
DEG_SLOPE_THRESHOLD,
MIN_CLEAN_LAPS,
PIT_LOSS_SECONDS,
cleanStintSamples,
degradationModel,
estimatePitRejoin,
formatSlope,
parseLapTimeSeconds,
recordStintSamples,
stintInputFromRow,
} from '../lib/tyredeg'
import type { StintHistoryMap, StintLapInput, StintSample } from '../lib/tyredeg'
import type { LiveTimingRow } from '../lib/live'
import type { LiveDriverData } from '../types'
function makeInput(overrides: Partial<StintLapInput> = {}): StintLapInput {
return {
racingNumber: '1',
lapNumber: 1,
lastLapTime: '1:30.000',
compound: 'MEDIUM',
tyreAge: 1,
inPit: false,
pitOut: false,
...overrides,
}
}
/** Feed a sequence of snapshots for one driver through the accumulator. */
function accumulate(snapshots: Array<Partial<StintLapInput>>): StintHistoryMap {
let history: StintHistoryMap = {}
for (const snapshot of snapshots) {
history = recordStintSamples(history, [makeInput(snapshot)])
}
return history
}
function makeRow(
number: string,
position: number,
tla: string,
driver: Partial<LiveDriverData> = {},
): LiveTimingRow {
return {
RacingNumber: number,
Position: position,
Driver: {
RacingNumber: number,
Position: position,
Interval: '',
GapToLeader: '',
LastLapTime: '1:30.000',
NumberOfLaps: 10,
InPit: false,
PitOut: false,
Retired: false,
...driver,
} as LiveDriverData,
Info: {
RacingNumber: number,
BroadcastName: '',
Tla: tla,
TeamName: '',
TeamColour: '3671c6',
FirstName: '',
LastName: '',
},
}
}
describe('parseLapTimeSeconds', () => {
it('parses minute-form and plain-second lap times', () => {
expect(parseLapTimeSeconds('1:23.456')).toBeCloseTo(83.456)
expect(parseLapTimeSeconds('83.456')).toBeCloseTo(83.456)
})
it('rejects empty, garbage, and non-positive values', () => {
expect(parseLapTimeSeconds('')).toBeNull()
expect(parseLapTimeSeconds(undefined)).toBeNull()
expect(parseLapTimeSeconds(null)).toBeNull()
expect(parseLapTimeSeconds('LAP 12')).toBeNull()
expect(parseLapTimeSeconds('-1:30.000')).toBeNull()
})
})
describe('recordStintSamples', () => {
it('accumulates one sample per completed lap across snapshots', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
{ lapNumber: 2, lastLapTime: '1:30.100' }, // repeated snapshot, same lap
{ lapNumber: 3, lastLapTime: '1:30.200' },
])
expect(history['1'].samples).toEqual([
{ lap: 2, seconds: 90.1 },
{ lap: 3, seconds: 90.2 },
])
})
it('does not record a lap from the very first snapshot seen', () => {
const history = accumulate([{ lapNumber: 5, lastLapTime: '1:29.000' }])
expect(history['1'].samples).toEqual([])
})
it('replaces (not duplicates) a sample when the lap time is corrected', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.500' },
{ lapNumber: 2, lastLapTime: '1:30.400' }, // late correction for lap 2
])
expect(history['1'].samples).toEqual([{ lap: 2, seconds: 90.4 }])
})
it('resets the stint on compound change and skips the next lap', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000', compound: 'SOFT', tyreAge: 1 },
{ lapNumber: 2, lastLapTime: '1:30.100', compound: 'SOFT', tyreAge: 2 },
{ lapNumber: 3, lastLapTime: '1:30.200', compound: 'SOFT', tyreAge: 3 },
// Boxed for hards: old samples dropped, out-lap (lap 4) excluded.
{ lapNumber: 4, lastLapTime: '1:52.000', compound: 'HARD', tyreAge: 0 },
{ lapNumber: 5, lastLapTime: '1:31.000', compound: 'HARD', tyreAge: 1 },
{ lapNumber: 6, lastLapTime: '1:31.100', compound: 'HARD', tyreAge: 2 },
])
expect(history['1'].samples).toEqual([
{ lap: 5, seconds: 91.0 },
{ lap: 6, seconds: 91.1 },
])
})
it('resets on a fresh set of the same compound (tyre age drops)', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000', tyreAge: 10 },
{ lapNumber: 2, lastLapTime: '1:30.100', tyreAge: 11 },
{ lapNumber: 3, lastLapTime: '1:50.000', tyreAge: 0 }, // new mediums
{ lapNumber: 4, lastLapTime: '1:30.500', tyreAge: 1 },
])
expect(history['1'].samples).toEqual([{ lap: 4, seconds: 90.5 }])
})
it('excludes laps completed in the pit lane or on pit exit', () => {
const history = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
{ lapNumber: 3, lastLapTime: '1:48.000', inPit: true }, // in-lap
{ lapNumber: 4, lastLapTime: '1:45.000', pitOut: true, tyreAge: 0 }, // out-lap
{ lapNumber: 5, lastLapTime: '1:30.300', tyreAge: 1 },
])
expect(history['1'].samples).toEqual([{ lap: 5, seconds: 90.3 }])
})
it('prunes drivers missing from the snapshot and does not mutate input', () => {
const initial = accumulate([
{ lapNumber: 1, lastLapTime: '1:30.000' },
{ lapNumber: 2, lastLapTime: '1:30.100' },
])
const next = recordStintSamples(initial, [makeInput({ racingNumber: '44' })])
expect(next['1']).toBeUndefined()
expect(next['44']).toBeDefined()
expect(initial['1'].samples).toHaveLength(1)
})
})
describe('degradationModel', () => {
const series = (times: number[], startLap = 1): StintSample[] =>
times.map((seconds, index) => ({ lap: startLap + index, seconds }))
it('fits the slope of a known linear series', () => {
const model = degradationModel(series([90.0, 90.1, 90.2, 90.3, 90.4]))
expect(model).not.toBeNull()
expect(model!.slope).toBeCloseTo(0.1, 5)
expect(model!.trend).toBe('degrading')
})
it('classifies improving and stable stints', () => {
expect(degradationModel(series([91.0, 90.8, 90.6, 90.4]))!.trend).toBe('improving')
expect(degradationModel(series([90.0, 90.01, 90.0, 90.02, 90.01]))!.trend).toBe('stable')
// A slope clearly under the threshold stays stable.
expect(DEG_SLOPE_THRESHOLD).toBeGreaterThan(0.03)
expect(degradationModel(series([90.0, 90.03, 90.06, 90.09, 90.12]))!.trend).toBe('stable')
})
it('excludes outliers more than ~5s off the stint median', () => {
// Clean laps follow +0.1s/lap; the 97.5 (traffic/spin) must not skew the fit.
const samples = series([90.0, 90.1, 97.5, 90.3, 90.4, 90.5])
expect(cleanStintSamples(samples)).toHaveLength(5)
const model = degradationModel(samples)
expect(model!.samples).toHaveLength(5)
expect(model!.slope).toBeCloseTo(0.1, 5)
})
it('returns null with fewer than MIN_CLEAN_LAPS clean laps', () => {
expect(degradationModel(series([90.0, 90.1, 90.2]))).toBeNull()
// 4 raw laps but one outlier -> only 3 clean -> still warming up
expect(degradationModel(series([90.0, 90.1, 90.2, 99.0]))).toBeNull()
expect(MIN_CLEAN_LAPS).toBe(4)
})
})
describe('formatSlope', () => {
it('renders signed seconds-per-lap', () => {
expect(formatSlope(0.083)).toBe('+0.08s/lap')
expect(formatSlope(-0.125)).toBe('0.13s/lap')
})
})
describe('estimatePitRejoin', () => {
const ladder = [
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+5.0' }),
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
makeRow('14', 4, 'ALO', { Interval: '+15.0' }),
makeRow('16', 5, 'LEC', { Interval: '+20.0' }),
]
it('projects the rejoin slot between the right cars', () => {
// VER pits from P1: NOR (5s) and HAM (15s cumulative) get past;
// ALO would be 30s back -> stays behind. Rejoin ~P3.
const estimate = estimatePitRejoin(ladder, '1')
expect(estimate).toEqual({
rejoinPosition: 3,
positionsLost: 2,
aheadCode: 'HAM',
behindCode: 'ALO',
})
})
it('keeps position when the cars behind are further than the pit loss', () => {
const estimate = estimatePitRejoin(ladder, '14')
// LEC is 20s behind ALO — inside the 22s window, so he gets past.
expect(estimate!.rejoinPosition).toBe(5)
expect(estimate!.aheadCode).toBe('LEC')
expect(estimate!.behindCode).toBeNull()
const wide = estimatePitRejoin(
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: `+${PIT_LOSS_SECONDS + 3}.0` })],
'1',
)
expect(wide!.rejoinPosition).toBe(1)
expect(wide!.positionsLost).toBe(0)
expect(wide!.behindCode).toBe('NOR')
})
it('stops the ladder walk at lapped cars and skips pitted/retired ones', () => {
const estimate = estimatePitRejoin(
[
makeRow('1', 1, 'VER'),
makeRow('4', 2, 'NOR', { Interval: '+5.0', InPit: true }), // mid-stop, excluded
makeRow('44', 3, 'HAM', { Interval: '+10.0' }),
makeRow('16', 4, 'LEC', { Interval: '1L' }), // lapped — walk ends here
makeRow('14', 5, 'ALO', { Interval: '+2.0' }),
],
'1',
)
expect(estimate!.positionsLost).toBe(1) // only HAM gets past
expect(estimate!.rejoinPosition).toBe(2)
expect(estimate!.aheadCode).toBe('HAM')
expect(estimate!.behindCode).toBe('LEC')
})
it('returns null for unknown drivers', () => {
expect(estimatePitRejoin(ladder, '99')).toBeNull()
})
})
describe('stintInputFromRow', () => {
it('maps tower rows into accumulator inputs', () => {
const row = makeRow('4', 2, 'NOR', { NumberOfLaps: 12, LastLapTime: '1:29.900' })
row.Tyre = { Compound: 'HARD', New: false, Age: 7 }
expect(stintInputFromRow(row)).toEqual({
racingNumber: '4',
lapNumber: 12,
lastLapTime: '1:29.900',
compound: 'HARD',
tyreAge: 7,
inPit: false,
pitOut: false,
})
})
})