mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(#87): [Live] Correct FP/Q timing truth, hierarchy, and mobile navigation
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
@@ -1,6 +1,17 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLiveState } from '../api'
|
||||||
|
import { isLiveSessionActive } from '../lib/live'
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
|
const { data: liveState } = useQuery({
|
||||||
|
queryKey: ['live-state'],
|
||||||
|
queryFn: fetchLiveState,
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
const liveActive = isLiveSessionActive(liveState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-nav">
|
<nav className="app-nav">
|
||||||
<Link to="/" className="nav-logo">
|
<Link to="/" className="nav-logo">
|
||||||
@@ -10,8 +21,16 @@ export function Nav() {
|
|||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
Command
|
Command
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
<Link
|
||||||
|
to="/live"
|
||||||
|
className={liveActive ? 'nav-live nav-live-on' : 'nav-live'}
|
||||||
|
activeProps={{ className: 'active' }}
|
||||||
|
data-testid="nav-live"
|
||||||
|
data-live-active={liveActive ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{liveActive && <span className="nav-live-dot" aria-hidden="true" />}
|
||||||
Live
|
Live
|
||||||
|
{liveActive && <span className="sr-only"> session active</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
<section className="live-rc panel-glass">
|
<section className="live-rc panel-glass">
|
||||||
<div className="sec-header sticky-header">
|
<div className="sec-header sticky-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Race Control</span>
|
||||||
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
|
<span className="sec-meta" data-testid="rc-timezone">
|
||||||
|
{messages.length > 0 ? `${messages.length} messages · ` : ''}times UTC
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{latest.length === 0 ? (
|
{latest.length === 0 ? (
|
||||||
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
<div className="missing-notice">No race control messages in the current live snapshot.</div>
|
||||||
@@ -102,7 +104,7 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`live-rc-row${flashClass}`} key={key}>
|
<div className={`live-rc-row${flashClass}`} key={key}>
|
||||||
<span className="rc-time">{message.Time || '--:--'}</span>
|
<span className="rc-time" title="UTC">{message.Time || '--:--'}</span>
|
||||||
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
|
||||||
{message.Flag
|
{message.Flag
|
||||||
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ export function SessionBanner({ isLive, isArchive = false, snapshot, rows, conne
|
|||||||
<div className="live-banner-meta">
|
<div className="live-banner-meta">
|
||||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||||
<span>
|
{display.isRace && (
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
<span data-testid="live-lap-counter">
|
||||||
</span>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, Fragment } from 'react'
|
import { useState, useMemo, Fragment } from 'react'
|
||||||
import { teamColor } from '../../utils'
|
import { teamColor } from '../../utils'
|
||||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
driverCode,
|
driverCode,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
positionDelta,
|
positionDelta,
|
||||||
@@ -62,6 +63,10 @@ export function TimingTower({
|
|||||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||||
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0)
|
||||||
|
|
||||||
|
// Practice/qualifying: derive a display-only gap to P1 from valid best laps
|
||||||
|
// when the upstream feed omits GapToLeader. Empty for races (feed is truth).
|
||||||
|
const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scroll-x">
|
<div className="scroll-x">
|
||||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||||
@@ -71,9 +76,9 @@ export function TimingTower({
|
|||||||
{isRace && <th>Δ</th>}
|
{isRace && <th>Δ</th>}
|
||||||
<th>Driver</th>
|
<th>Driver</th>
|
||||||
<th>Tyre</th>
|
<th>Tyre</th>
|
||||||
<th>Last Lap</th>
|
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
||||||
<th
|
<th
|
||||||
className="interactive"
|
className="interactive"
|
||||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||||
title="Click to toggle Gap vs Interval"
|
title="Click to toggle Gap vs Interval"
|
||||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||||
@@ -81,10 +86,10 @@ export function TimingTower({
|
|||||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||||
</th>
|
</th>
|
||||||
{isRace && <th>Trend</th>}
|
{isRace && <th>Trend</th>}
|
||||||
{isQuali && <th>S1</th>}
|
{isQuali && <th className="hide-mobile">S1</th>}
|
||||||
{isQuali && <th>S2</th>}
|
{isQuali && <th className="hide-mobile">S2</th>}
|
||||||
{isQuali && <th>S3</th>}
|
{isQuali && <th className="hide-mobile">S3</th>}
|
||||||
<th className="hide-mobile">Best</th>
|
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
||||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||||
<th className="r"></th>
|
<th className="r"></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,7 +107,15 @@ export function TimingTower({
|
|||||||
driver.Cutoff
|
driver.Cutoff
|
||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||||
|
|
||||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
let gapText: string
|
||||||
|
if (isRace) {
|
||||||
|
gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||||
|
} else if (driver.GapToLeader) {
|
||||||
|
gapText = driver.GapToLeader
|
||||||
|
} else {
|
||||||
|
const computed = practiceGaps[row.RacingNumber]
|
||||||
|
gapText = computed ? (computed.isLeader ? '—' : computed.gap) : ''
|
||||||
|
}
|
||||||
const intervalAnnotation =
|
const intervalAnnotation =
|
||||||
gapMode === 'interval' && isRace && row.Position > 1
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
@@ -153,7 +166,10 @@ export function TimingTower({
|
|||||||
<td>
|
<td>
|
||||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
<td className={[
|
||||||
|
isRace ? '' : 'hide-mobile',
|
||||||
|
driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono',
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
{driver.LastLapTime || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">
|
<td className="mono">
|
||||||
@@ -171,11 +187,14 @@ export function TimingTower({
|
|||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isQuali && <td>{renderSector(0)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
||||||
{isQuali && <td>{renderSector(1)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(1)}</td>}
|
||||||
{isQuali && <td>{renderSector(2)}</td>}
|
{isQuali && <td className="hide-mobile">{renderSector(2)}</td>}
|
||||||
|
|
||||||
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
|
<td className={[
|
||||||
|
isRace ? 'hide-mobile' : '',
|
||||||
|
driver.BestLapOB ? 'mono lap-ob' : 'mono',
|
||||||
|
].filter(Boolean).join(' ')}>
|
||||||
{driver.BestLapTime || '-'}
|
{driver.BestLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -208,6 +227,16 @@ export function TimingTower({
|
|||||||
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
<div className="mono">{driver.NumberOfLaps || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
|
||||||
|
<div data-testid="expanded-sectors">
|
||||||
|
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SECTORS</div>
|
||||||
|
<div className="mono" style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
{renderSector(0)}
|
||||||
|
{renderSector(1)}
|
||||||
|
{renderSector(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{driver.SpeedTrap && (
|
{driver.SpeedTrap && (
|
||||||
<div>
|
<div>
|
||||||
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
<div className="mono" style={{ color: 'var(--text-3)', fontSize: '10px', marginBottom: '4px' }}>SPEED TRAP</div>
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const isRace = isRaceSession(sessionType)
|
||||||
|
// In practice/qualifying deg trends are secondary — collapse by default so
|
||||||
|
// the Timing Tower stays above the fold. Races keep it open.
|
||||||
|
const [collapsed, setCollapsed] = useState(!isRace)
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||||
@@ -61,8 +64,6 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
||||||
}, [rows])
|
}, [rows])
|
||||||
|
|
||||||
const isRace = isRaceSession(sessionType)
|
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
rows.filter(
|
rows.filter(
|
||||||
|
|||||||
@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
|
|||||||
return row.Info?.Tla || row.RacingNumber
|
return row.Info?.Tla || row.RacingNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an F1 lap-time string ("1:45.944", "45.944") into total seconds.
|
||||||
|
* Returns null for empty/invalid values so callers never invent a gap.
|
||||||
|
*/
|
||||||
|
export function parseLapTimeSeconds(value: string | null | undefined): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
const match = value.trim().match(/^(?:(\d+):)?([0-5]?\d(?:\.\d+)?)$/)
|
||||||
|
if (!match) return null
|
||||||
|
const minutes = match[1] ? Number(match[1]) : 0
|
||||||
|
const seconds = Number(match[2])
|
||||||
|
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
|
||||||
|
return minutes * 60 + seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestLapGap {
|
||||||
|
isLeader: boolean
|
||||||
|
gap: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only gap-to-P1 for practice/qualifying, derived from each driver's
|
||||||
|
* valid best lap. Only drivers with a parseable best lap get an entry, and the
|
||||||
|
* fastest is flagged as the leader. Never fabricates a gap from a missing or
|
||||||
|
* invalid lap — the upstream interval remains the source of truth for races.
|
||||||
|
*/
|
||||||
|
export function bestLapGaps(rows: ReadonlyArray<LiveTimingRow>): Record<string, BestLapGap> {
|
||||||
|
let leaderNumber = ''
|
||||||
|
let best = Infinity
|
||||||
|
for (const row of rows) {
|
||||||
|
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||||
|
if (seconds === null) continue
|
||||||
|
if (seconds < best) {
|
||||||
|
best = seconds
|
||||||
|
leaderNumber = row.RacingNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, BestLapGap> = {}
|
||||||
|
if (!Number.isFinite(best)) return out
|
||||||
|
for (const row of rows) {
|
||||||
|
const seconds = parseLapTimeSeconds(row.Driver.BestLapTime)
|
||||||
|
if (seconds === null) continue
|
||||||
|
out[row.RacingNumber] =
|
||||||
|
row.RacingNumber === leaderNumber
|
||||||
|
? { isLeader: true, gap: '' }
|
||||||
|
: { isLeader: false, gap: `+${(seconds - best).toFixed(3)}` }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the live feed reports an in-progress session with timing data. */
|
||||||
|
export function isLiveSessionActive(
|
||||||
|
state: { is_live?: boolean; data?: unknown } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(state?.is_live && state.data)
|
||||||
|
}
|
||||||
|
|
||||||
export function trackStatusLabel(status: string): string {
|
export function trackStatusLabel(status: string): string {
|
||||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* Live nav gets a pulsing marker only while a session is on air. */
|
||||||
|
.nav-live { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.nav-links a.nav-live-on { color: var(--text); font-weight: 600; }
|
||||||
|
.nav-live-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--red);
|
||||||
|
box-shadow: 0 0 0 0 rgba(225, 6, 0, 0.4);
|
||||||
|
animation: pulse-live 1.6s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-utility {
|
.nav-utility {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -209,6 +209,32 @@ describe('TimingTower', () => {
|
|||||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('derives a gap to P1 from best laps when the practice feed omits GapToLeader', () => {
|
||||||
|
const practiceRows = [
|
||||||
|
makeRow('1', 1, 'VER', { BestLapTime: '1:45.944', GapToLeader: '' }),
|
||||||
|
makeRow('4', 2, 'NOR', { BestLapTime: '1:46.134', GapToLeader: '' }),
|
||||||
|
makeRow('16', 3, 'LEC', { BestLapTime: '', GapToLeader: '' }),
|
||||||
|
]
|
||||||
|
render(
|
||||||
|
<TimingTower
|
||||||
|
rows={practiceRows}
|
||||||
|
session={{
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa',
|
||||||
|
SessionType: 'Practice',
|
||||||
|
SessionName: 'Practice 2',
|
||||||
|
Path: '',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
// Leader shows a clear leader marker, not a fabricated gap.
|
||||||
|
expect(screen.getByText('VER').closest('tr')).toHaveTextContent('—')
|
||||||
|
expect(screen.getByText('+0.190')).toBeInTheDocument()
|
||||||
|
// A driver without a valid best lap gets no invented gap.
|
||||||
|
const lecRow = screen.getByText('LEC').closest('tr')!
|
||||||
|
expect(lecRow).not.toHaveTextContent('+')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
|
|||||||
61
frontend/src/test/SessionBanner.test.tsx
Normal file
61
frontend/src/test/SessionBanner.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
|
import type { LiveStreamData } from '../types'
|
||||||
|
|
||||||
|
function makeSnapshot(sessionType: string, sessionName: string): LiveStreamData {
|
||||||
|
return {
|
||||||
|
Drivers: {},
|
||||||
|
DriverInfo: {},
|
||||||
|
Tyres: {},
|
||||||
|
RCMessages: [],
|
||||||
|
Weather: { AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false },
|
||||||
|
Session: {
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa-Francorchamps',
|
||||||
|
SessionType: sessionType,
|
||||||
|
SessionName: sessionName,
|
||||||
|
Path: '',
|
||||||
|
},
|
||||||
|
TeamRadio: [],
|
||||||
|
TrackStatus: '1',
|
||||||
|
CurrentLap: 0,
|
||||||
|
TotalLaps: 0,
|
||||||
|
Clock: '00:45:00',
|
||||||
|
ClockRefTime: '',
|
||||||
|
ClockExtrapolating: false,
|
||||||
|
Stints: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SessionBanner', () => {
|
||||||
|
it('never renders a race lap counter for a practice session', () => {
|
||||||
|
render(
|
||||||
|
<SessionBanner
|
||||||
|
isLive
|
||||||
|
snapshot={makeSnapshot('Practice', 'Practice 2')}
|
||||||
|
rows={[]}
|
||||||
|
connection="connected"
|
||||||
|
now={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.queryByTestId('live-lap-counter')).not.toBeInTheDocument()
|
||||||
|
// Session identity and clock stay intact.
|
||||||
|
expect(screen.getByText('Belgian Grand Prix')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('live-clock')).toHaveTextContent('00:45:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the lap counter for a race session', () => {
|
||||||
|
const snapshot = { ...makeSnapshot('Race', 'Race'), CurrentLap: 12, TotalLaps: 44 }
|
||||||
|
render(
|
||||||
|
<SessionBanner
|
||||||
|
isLive
|
||||||
|
snapshot={snapshot}
|
||||||
|
rows={[]}
|
||||||
|
connection="connected"
|
||||||
|
now={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('live-lap-counter')).toHaveTextContent('L12/44')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -81,14 +81,20 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).toHaveTextContent('→ ~P2')
|
expect(panel).toHaveTextContent('→ ~P2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the rejoin estimate outside race sessions and collapses on toggle', () => {
|
it('collapses by default outside race sessions and hides the rejoin estimate when expanded', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Qualifying" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
const panel = screen.getByTestId('tyredeg-panel')
|
||||||
expect(panel).not.toHaveTextContent('~P')
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
||||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
|
||||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts expanded during a race', () => {
|
||||||
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
|
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('limits rows to the top ten plus pinned drivers', () => {
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
bestLapGaps,
|
||||||
compoundClass,
|
compoundClass,
|
||||||
compoundLetter,
|
compoundLetter,
|
||||||
extrapolateClock,
|
extrapolateClock,
|
||||||
|
isLiveSessionActive,
|
||||||
latestRaceControl,
|
latestRaceControl,
|
||||||
liveSessionDisplay,
|
liveSessionDisplay,
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
mergeVisibleSectors,
|
mergeVisibleSectors,
|
||||||
|
parseLapTimeSeconds,
|
||||||
positionDeltaClass,
|
positionDeltaClass,
|
||||||
parseLiveStateEvent,
|
parseLiveStateEvent,
|
||||||
rcFlagClass,
|
rcFlagClass,
|
||||||
@@ -279,6 +282,43 @@ describe('live qualifying display', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('practice/qualifying computed gaps', () => {
|
||||||
|
it('parses lap-time strings into seconds and rejects invalid input', () => {
|
||||||
|
expect(parseLapTimeSeconds('1:45.944')).toBeCloseTo(105.944, 3)
|
||||||
|
expect(parseLapTimeSeconds('45.944')).toBeCloseTo(45.944, 3)
|
||||||
|
expect(parseLapTimeSeconds('')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds(undefined)).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('-')).toBeNull()
|
||||||
|
expect(parseLapTimeSeconds('nope')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives a gap to P1 from valid best laps only, flagging the leader', () => {
|
||||||
|
const gaps = bestLapGaps([
|
||||||
|
timingRow('1', 1, { BestLapTime: '1:45.944' }),
|
||||||
|
timingRow('4', 2, { BestLapTime: '1:46.134' }),
|
||||||
|
timingRow('16', 3, { BestLapTime: '' }),
|
||||||
|
])
|
||||||
|
expect(gaps['1']).toEqual({ isLeader: true, gap: '' })
|
||||||
|
expect(gaps['4']).toEqual({ isLeader: false, gap: '+0.190' })
|
||||||
|
// No valid best lap → no fabricated gap.
|
||||||
|
expect(gaps['16']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no gaps when nobody has set a lap', () => {
|
||||||
|
expect(bestLapGaps([timingRow('1', 1, { BestLapTime: '' })])).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('live session activity', () => {
|
||||||
|
it('is active only when the feed reports a live session with data', () => {
|
||||||
|
expect(isLiveSessionActive({ is_live: true, data: snapshot })).toBe(true)
|
||||||
|
expect(isLiveSessionActive({ is_live: true, data: null })).toBe(false)
|
||||||
|
expect(isLiveSessionActive({ is_live: false, data: snapshot })).toBe(false)
|
||||||
|
expect(isLiveSessionActive(null)).toBe(false)
|
||||||
|
expect(isLiveSessionActive(undefined)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('visible sector display', () => {
|
describe('visible sector display', () => {
|
||||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||||
const first = [timingRow('4', 1, {
|
const first = [timingRow('4', 1, {
|
||||||
|
|||||||
@@ -147,6 +147,58 @@ const sprintQualifyingSnapshot = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Practice: the feed sets best laps but no GapToLeader/Interval, and there is
|
||||||
|
// no race lap-total concept. Exercises computed gaps, the non-race banner, the
|
||||||
|
// collapsed-by-default tyre panel, and the 390px core-field layout.
|
||||||
|
const practiceSnapshot = {
|
||||||
|
is_live: true,
|
||||||
|
data: {
|
||||||
|
...raceSnapshot.data,
|
||||||
|
Drivers: Object.fromEntries(
|
||||||
|
Array.from({ length: 10 }, (_, index) => {
|
||||||
|
const num = String(index + 1)
|
||||||
|
return [
|
||||||
|
num,
|
||||||
|
driver(num, index + 1, '', '', {
|
||||||
|
// Distinct, increasing best laps; P1 fastest, +0.200s per position.
|
||||||
|
BestLapTime: `1:${(45.9 + index * 0.2).toFixed(3).padStart(6, '0')}`,
|
||||||
|
LastLapTime: '1:46.500',
|
||||||
|
NumberOfLaps: 12,
|
||||||
|
Sectors: index === 0
|
||||||
|
? [
|
||||||
|
{ Value: '28.500', PersonalFastest: true, OverallFastest: false },
|
||||||
|
{ Value: '52.100', PersonalFastest: false, OverallFastest: false },
|
||||||
|
{ Value: '25.344', PersonalFastest: false, OverallFastest: false },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
DriverInfo: Object.fromEntries(
|
||||||
|
Array.from({ length: 10 }, (_, index) => {
|
||||||
|
const num = String(index + 1)
|
||||||
|
return [num, info(num, `D${index + 1}`, 'Driver', String(index + 1), 'Test Team', index % 2 ? 'FF8000' : '27F4D2')]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Tyres: Object.fromEntries(
|
||||||
|
Array.from({ length: 10 }, (_, index) => [String(index + 1), { Compound: 'SOFT', New: false, Age: index % 4 }]),
|
||||||
|
),
|
||||||
|
Session: {
|
||||||
|
MeetingName: 'Belgian Grand Prix',
|
||||||
|
CircuitName: 'Spa-Francorchamps',
|
||||||
|
SessionType: 'Practice',
|
||||||
|
SessionName: 'Practice 2',
|
||||||
|
},
|
||||||
|
TrackStatus: '1',
|
||||||
|
CurrentLap: 0,
|
||||||
|
TotalLaps: 0,
|
||||||
|
Clock: '00:45:00',
|
||||||
|
ClockRefTime: '2026-07-17T13:00:00Z',
|
||||||
|
ClockExtrapolating: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Live Timing (mocked snapshot)', () => {
|
test.describe('Live Timing (mocked snapshot)', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route('**/api/v1/live/state', (route) =>
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
@@ -233,6 +285,94 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test.describe('Live Timing (mocked Practice)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', (route) =>
|
||||||
|
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
|
||||||
|
)
|
||||||
|
await page.goto('/live')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('derives a gap to P1 from best laps and marks the leader', async ({ page }) => {
|
||||||
|
const tower = page.locator('.live-tower')
|
||||||
|
await expect(tower).toBeVisible()
|
||||||
|
// P1 shows a leader marker, not a fabricated gap.
|
||||||
|
await expect(tower.locator('tbody tr').first()).toContainText('—')
|
||||||
|
// P2 shows the computed +0.200 delta.
|
||||||
|
await expect(tower).toContainText('+0.200')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits the race lap counter and keeps the session clock for practice', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('live-lap-counter')).toHaveCount(0)
|
||||||
|
await expect(page.getByTestId('live-clock')).toContainText('00:45:00')
|
||||||
|
await expect(page.locator('.live-banner')).not.toContainText('L-/-')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collapses the tyre panel by default so the tower is above the fold', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('tyredeg-panel')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('tyredeg-row')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.live-tower')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('labels Race Control times as UTC', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('rc-timezone')).toContainText('UTC')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marks Live as active in the primary navigation', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('nav-live')).toHaveAttribute('data-live-active', 'true')
|
||||||
|
await expect(page.getByTestId('nav-live').locator('.nav-live-dot')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Live Timing (390px practice viewport)', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } })
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(practiceSnapshot) }),
|
||||||
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', (route) =>
|
||||||
|
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' }),
|
||||||
|
)
|
||||||
|
await page.goto('/live')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows the five core fields with no horizontal overflow', async ({ page }) => {
|
||||||
|
const tower = page.locator('.live-tower')
|
||||||
|
await expect(tower).toBeVisible()
|
||||||
|
|
||||||
|
// Core comparison fields are present in the header.
|
||||||
|
await expect(tower.locator('thead')).toContainText('Pos')
|
||||||
|
await expect(tower.locator('thead')).toContainText('Driver')
|
||||||
|
await expect(tower.locator('thead')).toContainText('Tyre')
|
||||||
|
await expect(tower.locator('thead')).toContainText('Best')
|
||||||
|
await expect(tower.locator('thead')).toContainText('Gap to P1')
|
||||||
|
|
||||||
|
// Sectors are hidden from the initial mobile tower (reachable via expand).
|
||||||
|
const firstSector = tower.locator('thead th', { hasText: 'S1' })
|
||||||
|
await expect(firstSector).toBeHidden()
|
||||||
|
|
||||||
|
// The document must not scroll horizontally.
|
||||||
|
const overflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
expect(overflow).toBeLessThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exposes sectors through the row detail interaction', async ({ page }) => {
|
||||||
|
// Sector columns are not in the initial mobile tower...
|
||||||
|
await expect(page.locator('.live-tower thead th', { hasText: 'S1' })).toBeHidden()
|
||||||
|
// ...but the P1 row's sectors are reachable by expanding the row.
|
||||||
|
await page.locator('.live-tower tbody tr', { hasText: 'D1' }).first().click()
|
||||||
|
const sectors = page.getByTestId('expanded-sectors')
|
||||||
|
await expect(sectors).toBeVisible()
|
||||||
|
await expect(sectors).toContainText('28.500')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test.describe('Live Timing (no session)', () => {
|
test.describe('Live Timing (no session)', () => {
|
||||||
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|||||||
Reference in New Issue
Block a user