mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Compare commits
6 Commits
feat/issue
...
c475011c49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c475011c49 | ||
|
|
5a6323d3b4 | ||
|
|
ed3b8cf628 | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 |
@@ -1,6 +1,17 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState } from '../api'
|
||||
import { isLiveSessionActive } from '../lib/live'
|
||||
|
||||
export function Nav() {
|
||||
const { data: liveState } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const liveActive = isLiveSessionActive(liveState)
|
||||
|
||||
return (
|
||||
<nav className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
@@ -10,8 +21,16 @@ export function Nav() {
|
||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||
Command
|
||||
</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
|
||||
{liveActive && <span className="sr-only"> session active</span>}
|
||||
</Link>
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
|
||||
@@ -26,6 +26,12 @@ const CHART_PR = 48
|
||||
const CHART_PT = 8
|
||||
const CHART_PB = 20
|
||||
|
||||
interface ChartTiming {
|
||||
tMin: number
|
||||
tMax: number
|
||||
tRange: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
results: EnrichedResult[]
|
||||
@@ -42,6 +48,48 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position samples are recorded for the entire session, including the pre-race
|
||||
* grid period. Use the winning driver's laps to define the race window so
|
||||
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||
*/
|
||||
function raceChartTiming(
|
||||
positions: PositionSample[],
|
||||
laps: Lap[],
|
||||
results: EnrichedResult[],
|
||||
): ChartTiming | null {
|
||||
const positionTimes = positions
|
||||
.map((position) => new Date(position.date).getTime())
|
||||
.filter(Number.isFinite)
|
||||
if (positionTimes.length === 0) return null
|
||||
|
||||
const fallbackMin = Math.min(...positionTimes)
|
||||
const fallbackMax = Math.max(...positionTimes)
|
||||
const fallback = {
|
||||
tMin: fallbackMin,
|
||||
tMax: fallbackMax,
|
||||
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||
}
|
||||
|
||||
const winner = results.find((result) => result.position === 1)
|
||||
if (!winner) return fallback
|
||||
|
||||
const winnerLaps = laps
|
||||
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||
.filter((lap) => Number.isFinite(lap.start))
|
||||
.sort((a, b) => a.lap_number - b.lap_number)
|
||||
if (winnerLaps.length === 0) return fallback
|
||||
|
||||
const firstLap = winnerLaps[0]
|
||||
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||
if (tMax <= firstLap.start) return fallback
|
||||
|
||||
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const {
|
||||
results,
|
||||
@@ -69,14 +117,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||
|
||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||
const hasChartData = hasPositions && allTimes.length > 0
|
||||
const chartTiming = useMemo(() => {
|
||||
if (!hasChartData) return null
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
||||
}, [allTimes, hasChartData])
|
||||
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||
const hasChartData = hasPositions && chartTiming !== null
|
||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
||||
@@ -237,12 +279,15 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time)) continue
|
||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||
byDriver.get(p.driver_number)!.push({
|
||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||
t: normaliseTime(time),
|
||||
pos: p.position,
|
||||
})
|
||||
}
|
||||
@@ -306,12 +351,15 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const lapTicks: { lap: number; t: number }[] = []
|
||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||
|
||||
for (const lap of winnerLaps) {
|
||||
for (let i = 0; i < winnerLaps.length; i++) {
|
||||
const lap = winnerLaps[i]
|
||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||
if (t >= 0 && t <= 1) {
|
||||
lapTicks.push({ lap: lap.lap_number, t })
|
||||
}
|
||||
const lapStart = new Date(lap.date_start).getTime()
|
||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||
? lapStart + lap.lap_duration * 1000
|
||||
: nextLapStart
|
||||
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +417,10 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
clearChapterSelection()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
if (rect.width <= 0) return
|
||||
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||
// before comparing with the fixed chart margins and plot width.
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||
setScrubTime(t)
|
||||
}
|
||||
@@ -491,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
/>
|
||||
|
||||
{driverPits.map((p, i) => {
|
||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||
if (t < 0 || t > 1) return null
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||
const t = normaliseTime(time)
|
||||
const pos = getInterpPos(samples, t)
|
||||
if (pos === null) return null
|
||||
return (
|
||||
@@ -546,6 +598,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
width={plotW}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
data-testid="position-chart-interaction"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) {
|
||||
|
||||
@@ -80,7 +80,9 @@ export function RaceControlFeed({ messages, driverInfo }: Props) {
|
||||
<section className="live-rc panel-glass">
|
||||
<div className="sec-header sticky-header">
|
||||
<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>
|
||||
{latest.length === 0 ? (
|
||||
<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 (
|
||||
<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.Flag
|
||||
? <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">
|
||||
{display.advanceCount && <span>{display.advanceCount} advance</span>}
|
||||
{atRiskLabel && <span>{atRiskLabel}</span>}
|
||||
<span>
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
{display.isRace && (
|
||||
<span data-testid="live-lap-counter">
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
)}
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, Fragment } from 'react'
|
||||
import { useState, useMemo, Fragment } from 'react'
|
||||
import { teamColor } from '../../utils'
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import type { LiveSessionMeta, LiveStintData } from '../../types'
|
||||
import type { LiveTimingRow } from '../../lib/live'
|
||||
import {
|
||||
bestLapGaps,
|
||||
driverCode,
|
||||
liveSessionDisplay,
|
||||
positionDelta,
|
||||
@@ -62,6 +63,10 @@ export function TimingTower({
|
||||
const isQuali = sessionDisplay.isQualifying || !isRace
|
||||
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 (
|
||||
<div className="scroll-x">
|
||||
<table className="data-table live-tower" style={{ minWidth: 760 }}>
|
||||
@@ -71,9 +76,9 @@ export function TimingTower({
|
||||
{isRace && <th>Δ</th>}
|
||||
<th>Driver</th>
|
||||
<th>Tyre</th>
|
||||
<th>Last Lap</th>
|
||||
<th
|
||||
className="interactive"
|
||||
<th className={isRace ? undefined : 'hide-mobile'}>Last Lap</th>
|
||||
<th
|
||||
className="interactive"
|
||||
onClick={() => setGapMode(g => g === 'interval' ? 'leader' : 'interval')}
|
||||
title="Click to toggle Gap vs Interval"
|
||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||
@@ -81,10 +86,10 @@ export function TimingTower({
|
||||
{gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'}
|
||||
</th>
|
||||
{isRace && <th>Trend</th>}
|
||||
{isQuali && <th>S1</th>}
|
||||
{isQuali && <th>S2</th>}
|
||||
{isQuali && <th>S3</th>}
|
||||
<th className="hide-mobile">Best</th>
|
||||
{isQuali && <th className="hide-mobile">S1</th>}
|
||||
{isQuali && <th className="hide-mobile">S2</th>}
|
||||
{isQuali && <th className="hide-mobile">S3</th>}
|
||||
<th className={isRace ? 'hide-mobile' : undefined}>Best</th>
|
||||
{isRace && <th className="hide-mobile r">Laps</th>}
|
||||
<th className="r"></th>
|
||||
</tr>
|
||||
@@ -102,7 +107,15 @@ export function TimingTower({
|
||||
driver.Cutoff
|
||||
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 =
|
||||
gapMode === 'interval' && isRace && row.Position > 1
|
||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||
@@ -153,7 +166,10 @@ export function TimingTower({
|
||||
<td>
|
||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||
</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 || '-'}
|
||||
</td>
|
||||
<td className="mono">
|
||||
@@ -171,11 +187,14 @@ export function TimingTower({
|
||||
</td>
|
||||
)}
|
||||
|
||||
{isQuali && <td>{renderSector(0)}</td>}
|
||||
{isQuali && <td>{renderSector(1)}</td>}
|
||||
{isQuali && <td>{renderSector(2)}</td>}
|
||||
{isQuali && <td className="hide-mobile">{renderSector(0)}</td>}
|
||||
{isQuali && <td className="hide-mobile">{renderSector(1)}</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 || '-'}
|
||||
</td>
|
||||
|
||||
@@ -208,6 +227,16 @@ export function TimingTower({
|
||||
<div className="mono">{driver.NumberOfLaps || 0}</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 && (
|
||||
<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) {
|
||||
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>({})
|
||||
|
||||
// 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)))
|
||||
}, [rows])
|
||||
|
||||
const isRace = isRaceSession(sessionType)
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
|
||||
@@ -268,6 +268,63 @@ export function driverCode(row: LiveTimingRow): string {
|
||||
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 {
|
||||
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.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 {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -209,6 +209,32 @@ describe('TimingTower', () => {
|
||||
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', () => {
|
||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
|
||||
@@ -153,6 +153,29 @@ describe('LiveTimingPage archive mode', () => {
|
||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the live session for a red-flag paused snapshot instead of the inactive empty state', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
data: {
|
||||
...archivedSnapshot,
|
||||
SessionStatus: 'Inactive',
|
||||
TrackStatus: '2',
|
||||
Clock: '00:03:27',
|
||||
Session: {
|
||||
MeetingName: 'Belgian Grand Prix',
|
||||
CircuitName: 'Spa-Francorchamps',
|
||||
SessionType: 'Practice',
|
||||
SessionName: 'Practice 2',
|
||||
Path: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-empty')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
|
||||
@@ -179,6 +179,57 @@ describe('RaceStoryCanvas replay map', () => {
|
||||
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses lap timing rather than pre-race position samples for the chart scale', () => {
|
||||
const laps = Array.from({ length: 20 }, (_, index) => ({
|
||||
session_key: 99,
|
||||
driver_number: 1,
|
||||
meeting_key: 1,
|
||||
lap_number: index + 1,
|
||||
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
|
||||
lap_duration: 60,
|
||||
is_pit_out_lap: false,
|
||||
}))
|
||||
|
||||
renderCanvas({
|
||||
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
|
||||
laps,
|
||||
positions: [
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
|
||||
const lapTenLabel = screen.getByText('L10')
|
||||
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
|
||||
// The pre-race position is retained as the starting grid at the left edge.
|
||||
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
|
||||
})
|
||||
|
||||
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
|
||||
renderCanvas()
|
||||
|
||||
const svg = document.querySelector('.rs-position-chart-svg')!
|
||||
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 360,
|
||||
top: 0,
|
||||
right: 1280,
|
||||
bottom: 360,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
const pointerMove = new Event('pointermove', { bubbles: true })
|
||||
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
|
||||
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
|
||||
|
||||
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
|
||||
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
|
||||
})
|
||||
|
||||
it('syncs active chapter highlight when a chapter card is clicked', async () => {
|
||||
renderCanvas()
|
||||
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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={[]} />)
|
||||
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 }))
|
||||
// Practice/qualifying starts collapsed so the Timing Tower stays above the fold.
|
||||
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', () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bestLapGaps,
|
||||
compoundClass,
|
||||
compoundLetter,
|
||||
extrapolateClock,
|
||||
isLiveSessionActive,
|
||||
latestRaceControl,
|
||||
liveSessionDisplay,
|
||||
loadPinnedDrivers,
|
||||
mergeVisibleSectors,
|
||||
parseLapTimeSeconds,
|
||||
positionDeltaClass,
|
||||
parseLiveStateEvent,
|
||||
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', () => {
|
||||
it('holds S1 and S2 through temporary blanks while a flying lap is active', () => {
|
||||
const first = [timingRow('4', 1, {
|
||||
|
||||
@@ -307,6 +307,28 @@ func TestSessionStatusIsActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatusIsTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{"Finished", true},
|
||||
{"Finalised", true},
|
||||
{"Finalized", true},
|
||||
{"Ends", true},
|
||||
{"Aborted", true},
|
||||
{"Started", false},
|
||||
{"Resumed", false},
|
||||
{"Inactive", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := live.SessionStatusIsTerminal(tt.status); got != tt.want {
|
||||
t.Errorf("SessionStatusIsTerminal(%q) = %v, want %v", tt.status, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||
state := live.NewState()
|
||||
data := json.RawMessage(`{
|
||||
|
||||
@@ -186,6 +186,19 @@ func SessionStatusIsActive(status string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// SessionStatusIsTerminal reports whether a raw F1 live timing SessionStatus
|
||||
// value represents a session that has ended and will not resume. A temporarily
|
||||
// inactive session (e.g. a red-flag pause reported as "Inactive") is neither
|
||||
// active nor terminal.
|
||||
func SessionStatusIsTerminal(status string) bool {
|
||||
switch normalizeSessionStatus(status) {
|
||||
case "finished", "finalised", "finalized", "ends", "aborted":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSessionStatus(status string) string {
|
||||
out := make([]rune, 0, len(status))
|
||||
for _, r := range status {
|
||||
|
||||
@@ -134,7 +134,7 @@ func (h *SSEHub) applySnapshot(data live.LiveStreamData, now time.Time) liveStat
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||
if snapshotIsLive(data) {
|
||||
h.isLive = true
|
||||
h.activeSnapshot = &data
|
||||
if data.PositionUpdated && len(data.Positions) > 0 {
|
||||
@@ -208,6 +208,40 @@ func (h *SSEHub) stateLocked() liveStatePayload {
|
||||
return payload
|
||||
}
|
||||
|
||||
// snapshotIsLive reports whether the current live-timing snapshot represents an
|
||||
// ongoing session that should be surfaced as live.
|
||||
//
|
||||
// An actively running session (Started/Resumed) is always live. A non-terminal
|
||||
// but temporarily inactive session — e.g. a red-flag pause where SessionStatus
|
||||
// drops to "Inactive" while the session is still in progress — is also live when
|
||||
// the snapshot itself carries live evidence: a session clock with time remaining
|
||||
// plus session metadata. This keeps the paused track state visible instead of
|
||||
// collapsing to the inactive empty state.
|
||||
//
|
||||
// Terminal sessions (Finished/Finalised/Ends/Aborted) are never live. A generic
|
||||
// inactive/no-session stale snapshot is not live either: the predicate keys off
|
||||
// the current snapshot's clock and session, so old metadata alone is not enough.
|
||||
func snapshotIsLive(data live.LiveStreamData) bool {
|
||||
if live.SessionStatusIsActive(data.SessionStatus) {
|
||||
return true
|
||||
}
|
||||
if live.SessionStatusIsTerminal(data.SessionStatus) {
|
||||
return false
|
||||
}
|
||||
return clockHasTimeRemaining(data.Clock) && data.Session.SessionName != ""
|
||||
}
|
||||
|
||||
// clockHasTimeRemaining reports whether an "HH:MM:SS" session clock has any time
|
||||
// left. Empty or all-zero clocks (a spent or absent session) return false.
|
||||
func clockHasTimeRemaining(clock string) bool {
|
||||
for _, r := range clock {
|
||||
if r >= '1' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasLiveSnapshotData(data live.LiveStreamData) bool {
|
||||
return len(data.Drivers) > 0 ||
|
||||
len(data.DriverInfo) > 0 ||
|
||||
|
||||
@@ -51,6 +51,101 @@ func TestSSEHubArchivesTerminalSessionSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotIsLive(t *testing.T) {
|
||||
redFlag := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
TrackStatus: "2",
|
||||
Clock: "00:03:27",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
data live.LiveStreamData
|
||||
want bool
|
||||
}{
|
||||
{"started", live.LiveStreamData{SessionStatus: "Started"}, true},
|
||||
{"resumed", live.LiveStreamData{SessionStatus: "Resumed"}, true},
|
||||
{"red flag inactive with clock and session", redFlag, true},
|
||||
{"terminal finished with clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.SessionStatus = "Finished"
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive spent clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Clock = "00:00:00"
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive no clock", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Clock = ""
|
||||
return d
|
||||
}(), false},
|
||||
{"inactive no session metadata", func() live.LiveStreamData {
|
||||
d := redFlag
|
||||
d.Session = live.LiveSessionMeta{}
|
||||
return d
|
||||
}(), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := snapshotIsLive(tt.data); got != tt.want {
|
||||
t.Errorf("%s: snapshotIsLive = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHubKeepsRedFlagSessionLive(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
redFlag := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
TrackStatus: "2",
|
||||
Clock: "00:03:27",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2", SessionType: "Practice"},
|
||||
SnapshotUpdated: true,
|
||||
}
|
||||
state := hub.applySnapshot(redFlag, now)
|
||||
|
||||
if !state.IsLive {
|
||||
t.Fatal("red-flag paused session should report is_live=true")
|
||||
}
|
||||
if state.Data == nil {
|
||||
t.Fatal("red-flag paused session must still send the active snapshot")
|
||||
}
|
||||
if state.Data.TrackStatus != "2" {
|
||||
t.Fatalf("track status = %q, want the paused state \"2\"", state.Data.TrackStatus)
|
||||
}
|
||||
if state.LastSnapshot != nil {
|
||||
t.Fatalf("red-flag session should not be archived: %+v", state.LastSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHubStaleInactiveSnapshotNotLive(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 26, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
// Inactive snapshot with session metadata but no remaining clock: this is a
|
||||
// stale/no-live-evidence snapshot and must not be surfaced as live.
|
||||
stale := live.LiveStreamData{
|
||||
SessionStatus: "Inactive",
|
||||
Clock: "00:00:00",
|
||||
Session: live.LiveSessionMeta{MeetingName: "Belgian Grand Prix", SessionName: "Practice 2"},
|
||||
SnapshotUpdated: true,
|
||||
}
|
||||
state := hub.applySnapshot(stale, now)
|
||||
|
||||
if state.IsLive {
|
||||
t.Fatal("stale inactive snapshot should report is_live=false")
|
||||
}
|
||||
if state.Data != nil {
|
||||
t.Fatalf("stale inactive snapshot leaked into active data: %+v", state.Data)
|
||||
}
|
||||
if state.LastSnapshot == nil {
|
||||
t.Fatal("stale inactive snapshot with metadata should be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLiveStateKeepsArchiveOutOfActiveData(t *testing.T) {
|
||||
hub := newSSEHub()
|
||||
now := time.Date(2026, 7, 4, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -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.beforeEach(async ({ page }) => {
|
||||
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('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
||||
await page.goto('/live')
|
||||
|
||||
Reference in New Issue
Block a user