mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Upgrade live timing page for race weekends
- Track status flag banner (green/yellow/SC/VSC/red, defensive mapping) - Weather strip: air/track temp, humidity, wind with compass, rain badge - Gap trend sparklines from a per-driver interval ring buffer - Battle detection: consecutive cars within 1.0s chained and highlighted - Stint history column with compound sequence per driver - Pinnable drivers (max 3) with focus cards, persisted to localStorage Pure logic lives in lib/gapHistory.ts and lib/battles.ts with unit tests; 137 frontend tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
21
frontend/src/components/live/BattleChips.tsx
Normal file
21
frontend/src/components/live/BattleChips.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Battle } from '../../lib/battles'
|
||||
import { battleLabel } from '../../lib/battles'
|
||||
|
||||
interface Props {
|
||||
battles: Battle[]
|
||||
}
|
||||
|
||||
export function BattleChips({ battles }: Props) {
|
||||
if (battles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="battle-chips" data-testid="battle-chips">
|
||||
<span className="battle-chips-label">Battles</span>
|
||||
{battles.map((battle) => (
|
||||
<span className="battle-chip mono" key={battle.drivers.map((d) => d.racingNumber).join('-')}>
|
||||
{battleLabel(battle)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/live/GapSparkline.tsx
Normal file
44
frontend/src/components/live/GapSparkline.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { gapTrend, sparklinePoints } from '../../lib/gapHistory'
|
||||
|
||||
interface Props {
|
||||
samples: number[] | undefined
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function GapSparkline({ samples, width = 56, height = 14 }: Props) {
|
||||
if (!samples || samples.length < 2) {
|
||||
return <span className="gap-spark gap-spark-empty">·</span>
|
||||
}
|
||||
|
||||
const trend = gapTrend(samples)
|
||||
const points = sparklinePoints(samples, width, height)
|
||||
|
||||
return (
|
||||
<span className={`gap-spark trend-${trend ?? 'steady'}`} data-testid="gap-spark">
|
||||
<svg
|
||||
className="gap-spark-svg"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{trend === 'closing' && (
|
||||
<span className="trend-arrow trend-arrow-closing" title="Gap closing">▼</span>
|
||||
)}
|
||||
{trend === 'opening' && (
|
||||
<span className="trend-arrow trend-arrow-opening" title="Gap opening">▲</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/live/PinnedDrivers.tsx
Normal file
63
frontend/src/components/live/PinnedDrivers.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { teamColor } from '../../utils'
|
||||
import type { LiveTimingRow } from '../../lib/live'
|
||||
import { driverCode, tyreClass, tyreLabel } from '../../lib/live'
|
||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||
import { GapSparkline } from './GapSparkline'
|
||||
|
||||
interface Props {
|
||||
rows: LiveTimingRow[]
|
||||
history: GapHistoryMap
|
||||
pinned: string[]
|
||||
onToggle: (racingNumber: string) => void
|
||||
}
|
||||
|
||||
export function PinnedDrivers({ rows, history, pinned, onToggle }: Props) {
|
||||
if (pinned.length === 0) return null
|
||||
|
||||
const rowsByNumber = new Map(rows.map((row) => [row.RacingNumber, row]))
|
||||
|
||||
return (
|
||||
<div className="pinned-strip" data-testid="pinned-strip">
|
||||
{pinned.map((number) => {
|
||||
const row = rowsByNumber.get(number)
|
||||
if (!row) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pinned-card pinned-card-missing"
|
||||
key={number}
|
||||
onClick={() => onToggle(number)}
|
||||
title="Unpin driver"
|
||||
>
|
||||
<span className="drv-code">#{number}</span>
|
||||
<span className="pinned-nodata">no data</span>
|
||||
<span className="pinned-unpin" aria-hidden="true">×</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const driver = row.Driver
|
||||
const gap = driver.Interval || driver.GapToLeader || '-'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pinned-card"
|
||||
key={number}
|
||||
onClick={() => onToggle(number)}
|
||||
title="Unpin driver"
|
||||
>
|
||||
<span className="pinned-pos mono">P{row.Position}</span>
|
||||
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||
<span className="drv-code">{driverCode(row)}</span>
|
||||
<span className={`tyre-badge ${tyreClass(row.Tyre)}`}>{tyreLabel(row.Tyre)}</span>
|
||||
<span className="pinned-gap mono">{gap}</span>
|
||||
<GapSparkline samples={history[number]} />
|
||||
{driver.InPit && <span className="badge badge-pit">PIT</span>}
|
||||
{driver.Retired && <span className="badge badge-out">RET</span>}
|
||||
<span className="pinned-unpin" aria-hidden="true">×</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LiveStreamData } from '../../types'
|
||||
import { extrapolateClock, trackStatusClass, trackStatusLabel } from '../../lib/live'
|
||||
import { extrapolateClock } from '../../lib/live'
|
||||
import { WeatherStrip } from './WeatherStrip'
|
||||
|
||||
interface Props {
|
||||
isLive: boolean
|
||||
@@ -11,9 +12,6 @@ interface Props {
|
||||
export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
|
||||
const session = snapshot.Session
|
||||
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||
const status = snapshot.TrackStatus ? trackStatusLabel(snapshot.TrackStatus) : ''
|
||||
const weather = snapshot.Weather
|
||||
const hasWeather = weather && (weather.AirTemp > 0 || weather.TrackTemp > 0)
|
||||
|
||||
return (
|
||||
<section className="live-banner">
|
||||
@@ -28,7 +26,6 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="live-banner-meta">
|
||||
{status && <span className={`track-status ${trackStatusClass(snapshot.TrackStatus)}`}>{status}</span>}
|
||||
<span className="mono">
|
||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||
</span>
|
||||
@@ -36,15 +33,7 @@ export function SessionBanner({ isLive, snapshot, connection, now }: Props) {
|
||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasWeather && (
|
||||
<div className="live-weather-strip">
|
||||
<span>{weather.AirTemp.toFixed(0)}° air</span>
|
||||
<span>{weather.TrackTemp.toFixed(0)}° track</span>
|
||||
{weather.Humidity > 0 && <span>{weather.Humidity.toFixed(0)}% humidity</span>}
|
||||
{weather.WindSpeed > 0 && <span>{weather.WindSpeed.toFixed(1)} m/s</span>}
|
||||
{weather.Rainfall && <span className="badge badge-wet">WET</span>}
|
||||
</div>
|
||||
)}
|
||||
<WeatherStrip weather={snapshot.Weather} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
29
frontend/src/components/live/StintHistory.tsx
Normal file
29
frontend/src/components/live/StintHistory.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { LiveStintData } from '../../types'
|
||||
import { compoundClass, compoundLetter } from '../../lib/live'
|
||||
|
||||
interface Props {
|
||||
stints: LiveStintData[] | undefined
|
||||
}
|
||||
|
||||
export function StintHistory({ stints }: Props) {
|
||||
if (!stints || stints.length === 0) {
|
||||
return <span className="stint-empty">-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="stint-seq" data-testid="stint-seq">
|
||||
{stints.map((stint, index) => (
|
||||
<span className="stint-item" key={index}>
|
||||
{index > 0 && <span className="stint-arrow">›</span>}
|
||||
<span
|
||||
className={`stint-dot ${compoundClass(stint.Compound)}`}
|
||||
title={`${stint.Compound || 'Unknown'}${stint.New ? ' (new)' : ''} · ${stint.Laps} laps`}
|
||||
>
|
||||
{compoundLetter(stint.Compound)}
|
||||
</span>
|
||||
{stint.Laps > 0 && <span className="stint-laps">{stint.Laps}</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { teamColor } from '../../utils'
|
||||
import type { LiveStreamData } from '../../types'
|
||||
import type { LiveStintData } from '../../types'
|
||||
import type { LiveTimingRow } from '../../lib/live'
|
||||
import {
|
||||
driverCode,
|
||||
positionDelta,
|
||||
positionDeltaClass,
|
||||
sortLiveTimingRows,
|
||||
tyreClass,
|
||||
tyreLabel,
|
||||
} from '../../lib/live'
|
||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||
import { GapSparkline } from './GapSparkline'
|
||||
import { StintHistory } from './StintHistory'
|
||||
|
||||
interface Props {
|
||||
snapshot: LiveStreamData
|
||||
rows: LiveTimingRow[]
|
||||
stints?: Record<string, LiveStintData[]>
|
||||
history?: GapHistoryMap
|
||||
battleNumbers?: Set<string>
|
||||
pinned?: string[]
|
||||
onTogglePin?: (racingNumber: string) => void
|
||||
}
|
||||
|
||||
function posClass(pos: number): string {
|
||||
@@ -20,9 +28,14 @@ function posClass(pos: number): string {
|
||||
return 'pos-n'
|
||||
}
|
||||
|
||||
export function TimingTower({ snapshot }: Props) {
|
||||
const rows = sortLiveTimingRows(snapshot)
|
||||
|
||||
export function TimingTower({
|
||||
rows,
|
||||
stints,
|
||||
history,
|
||||
battleNumbers,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: Props) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="missing-notice">
|
||||
@@ -33,7 +46,7 @@ export function TimingTower({ snapshot }: Props) {
|
||||
|
||||
return (
|
||||
<div className="scroll-x">
|
||||
<table className="data-table live-tower" style={{ minWidth: 480 }}>
|
||||
<table className="data-table live-tower" style={{ minWidth: 620 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pos</th>
|
||||
@@ -42,7 +55,9 @@ export function TimingTower({ snapshot }: Props) {
|
||||
<th>Tyre</th>
|
||||
<th>Last Lap</th>
|
||||
<th>Gap</th>
|
||||
<th>Trend</th>
|
||||
<th className="hide-mobile">Best</th>
|
||||
<th className="hide-mobile">Stints</th>
|
||||
<th className="hide-mobile r">Laps</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -51,6 +66,8 @@ export function TimingTower({ snapshot }: Props) {
|
||||
const driver = row.Driver
|
||||
const delta = positionDelta(driver)
|
||||
const deltaClass = positionDeltaClass(driver)
|
||||
const isPinned = pinned?.includes(row.RacingNumber) ?? false
|
||||
const inBattle = battleNumbers?.has(row.RacingNumber) ?? false
|
||||
return (
|
||||
<tr
|
||||
key={row.RacingNumber}
|
||||
@@ -58,7 +75,12 @@ export function TimingTower({ snapshot }: Props) {
|
||||
driver.InPit ? 'in-pit' : '',
|
||||
driver.PitOut ? 'pit-out' : '',
|
||||
driver.Retired ? 'retired' : '',
|
||||
inBattle ? 'battle-row' : '',
|
||||
isPinned ? 'pinned-row' : '',
|
||||
onTogglePin ? 'pinnable' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={onTogglePin ? () => onTogglePin(row.RacingNumber) : undefined}
|
||||
title={onTogglePin ? (isPinned ? 'Click to unpin' : 'Click to pin (max 3)') : undefined}
|
||||
>
|
||||
<td className={`mono ${posClass(row.Position)}`}>{row.Position}</td>
|
||||
<td className={`pos-delta${deltaClass ? ` ${deltaClass}` : ''}`}>{delta}</td>
|
||||
@@ -67,6 +89,7 @@ export function TimingTower({ snapshot }: Props) {
|
||||
<div className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||
<span className="drv-code">{driverCode(row)}</span>
|
||||
<span className="drv-num">{row.RacingNumber}</span>
|
||||
{isPinned && <span className="pin-mark" title="Pinned">◈</span>}
|
||||
{driver.InPit && <span className="badge badge-pit">PIT</span>}
|
||||
{driver.PitOut && !driver.InPit && <span className="badge badge-pit">OUT</span>}
|
||||
{driver.Retired && <span className="badge badge-out">RET</span>}
|
||||
@@ -82,9 +105,15 @@ export function TimingTower({ snapshot }: Props) {
|
||||
{driver.LastLapTime || '-'}
|
||||
</td>
|
||||
<td className="mono">{driver.GapToLeader || driver.Interval || '-'}</td>
|
||||
<td className="spark-cell">
|
||||
<GapSparkline samples={history?.[row.RacingNumber]} />
|
||||
</td>
|
||||
<td className={`hide-mobile ${driver.BestLapOB ? 'mono lap-ob' : 'mono'}`}>
|
||||
{driver.BestLapTime || '-'}
|
||||
</td>
|
||||
<td className="hide-mobile">
|
||||
<StintHistory stints={stints?.[row.RacingNumber]} />
|
||||
</td>
|
||||
<td className="hide-mobile mono r">{driver.NumberOfLaps || '-'}</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
17
frontend/src/components/live/TrackStatusBanner.tsx
Normal file
17
frontend/src/components/live/TrackStatusBanner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { trackStatusInfo } from '../../lib/live'
|
||||
|
||||
interface Props {
|
||||
status: string | null | undefined
|
||||
}
|
||||
|
||||
export function TrackStatusBanner({ status }: Props) {
|
||||
if (!status) return null
|
||||
const info = trackStatusInfo(status)
|
||||
|
||||
return (
|
||||
<div className={`track-banner track-banner-${info.key}`} role="status" data-testid="track-banner">
|
||||
<span className="track-banner-label">{info.label}</span>
|
||||
{info.detail && <span className="track-banner-detail">{info.detail}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/live/WeatherStrip.tsx
Normal file
51
frontend/src/components/live/WeatherStrip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { LiveWeatherData } from '../../types'
|
||||
import { windDirectionLabel } from '../../lib/live'
|
||||
|
||||
interface Props {
|
||||
weather: LiveWeatherData | null | undefined
|
||||
}
|
||||
|
||||
export function WeatherStrip({ weather }: Props) {
|
||||
if (!weather) return null
|
||||
const hasData =
|
||||
weather.AirTemp > 0 ||
|
||||
weather.TrackTemp > 0 ||
|
||||
weather.Humidity > 0 ||
|
||||
weather.WindSpeed > 0 ||
|
||||
weather.Rainfall
|
||||
if (!hasData) return null
|
||||
|
||||
const windDir = windDirectionLabel(weather.WindDir)
|
||||
|
||||
return (
|
||||
<div className="live-weather-strip" data-testid="weather-strip">
|
||||
{weather.AirTemp > 0 && (
|
||||
<span className="weather-item">
|
||||
<span className="weather-k">air</span>
|
||||
<span className="weather-v">{weather.AirTemp.toFixed(0)}°C</span>
|
||||
</span>
|
||||
)}
|
||||
{weather.TrackTemp > 0 && (
|
||||
<span className="weather-item">
|
||||
<span className="weather-k">track</span>
|
||||
<span className="weather-v">{weather.TrackTemp.toFixed(0)}°C</span>
|
||||
</span>
|
||||
)}
|
||||
{weather.Humidity > 0 && (
|
||||
<span className="weather-item">
|
||||
<span className="weather-k">hum</span>
|
||||
<span className="weather-v">{weather.Humidity.toFixed(0)}%</span>
|
||||
</span>
|
||||
)}
|
||||
{weather.WindSpeed > 0 && (
|
||||
<span className="weather-item">
|
||||
<span className="weather-k">wind</span>
|
||||
<span className="weather-v">
|
||||
{weather.WindSpeed.toFixed(1)} m/s{windDir ? ` ${windDir}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{weather.Rainfall && <span className="badge badge-wet">RAIN</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
frontend/src/lib/battles.ts
Normal file
107
frontend/src/lib/battles.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Battle detection for the live timing tower.
|
||||
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||
|
||||
import type { LiveTimingRow } from './live'
|
||||
import { driverCode } from './live'
|
||||
import { parseIntervalSeconds } from './gapHistory'
|
||||
|
||||
export const BATTLE_THRESHOLD_SECONDS = 1.0
|
||||
|
||||
export interface BattleDriver {
|
||||
racingNumber: string
|
||||
code: string
|
||||
position: number
|
||||
/** Interval to the car ahead within the group; null for the group leader. */
|
||||
gapToAhead: number | null
|
||||
}
|
||||
|
||||
export interface Battle {
|
||||
drivers: BattleDriver[]
|
||||
/** Tightest car-to-car interval within the group. */
|
||||
minGap: number
|
||||
}
|
||||
|
||||
/** Battles are only meaningful in race-type sessions (GP race, sprint). */
|
||||
export function isRaceSession(sessionType: string | null | undefined): boolean {
|
||||
if (!sessionType) return false
|
||||
const type = sessionType.toLowerCase()
|
||||
return type.includes('race') || type.includes('sprint')
|
||||
}
|
||||
|
||||
function isEligible(row: LiveTimingRow): boolean {
|
||||
const driver = row.Driver
|
||||
return Boolean(driver) && !driver.InPit && !driver.Retired && row.Position > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan position-sorted tower rows and group consecutive cars racing within
|
||||
* `threshold` seconds of the car ahead. Cars in the pits or retired break
|
||||
* the chain, as do lapped/unparsable intervals.
|
||||
*/
|
||||
export function detectBattles(
|
||||
rows: ReadonlyArray<LiveTimingRow>,
|
||||
sessionType: string | null | undefined,
|
||||
threshold = BATTLE_THRESHOLD_SECONDS,
|
||||
): Battle[] {
|
||||
if (!isRaceSession(sessionType) || rows.length < 2) return []
|
||||
|
||||
const battles: Battle[] = []
|
||||
let current: BattleDriver[] | null = null
|
||||
let minGap = Number.POSITIVE_INFINITY
|
||||
|
||||
const flush = () => {
|
||||
if (current && current.length >= 2) {
|
||||
battles.push({ drivers: current, minGap })
|
||||
}
|
||||
current = null
|
||||
minGap = Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const ahead = rows[i - 1]
|
||||
const row = rows[i]
|
||||
const gap = parseIntervalSeconds(row.Driver?.Interval)
|
||||
|
||||
const inBattle =
|
||||
gap !== null && gap >= 0 && gap <= threshold && isEligible(ahead) && isEligible(row)
|
||||
|
||||
if (!inBattle) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = [toBattleDriver(ahead, null)]
|
||||
}
|
||||
current.push(toBattleDriver(row, gap))
|
||||
if (gap < minGap) minGap = gap
|
||||
}
|
||||
flush()
|
||||
|
||||
return battles
|
||||
}
|
||||
|
||||
function toBattleDriver(row: LiveTimingRow, gapToAhead: number | null): BattleDriver {
|
||||
return {
|
||||
racingNumber: row.RacingNumber,
|
||||
code: driverCode(row),
|
||||
position: row.Position,
|
||||
gapToAhead,
|
||||
}
|
||||
}
|
||||
|
||||
/** Chip label, e.g. "VER ⚔ NOR +0.4" or "VER ⚔ NOR ⚔ PIA +0.3". */
|
||||
export function battleLabel(battle: Battle): string {
|
||||
const codes = battle.drivers.map((driver) => driver.code).join(' ⚔ ')
|
||||
const gap = Number.isFinite(battle.minGap) ? ` +${battle.minGap.toFixed(1)}` : ''
|
||||
return `${codes}${gap}`
|
||||
}
|
||||
|
||||
/** Racing numbers involved in any battle, for tower row highlighting. */
|
||||
export function battleNumbers(battles: ReadonlyArray<Battle>): Set<string> {
|
||||
const numbers = new Set<string>()
|
||||
for (const battle of battles) {
|
||||
for (const driver of battle.drivers) numbers.add(driver.racingNumber)
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
108
frontend/src/lib/gapHistory.ts
Normal file
108
frontend/src/lib/gapHistory.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// Client-side gap/interval history tracking for the live timing tower.
|
||||
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||
|
||||
export const MAX_GAP_SAMPLES = 40
|
||||
|
||||
/** Per-driver ring buffer of interval samples (seconds), keyed by racing number. */
|
||||
export type GapHistoryMap = Record<string, number[]>
|
||||
|
||||
export type GapTrend = 'closing' | 'opening' | 'steady'
|
||||
|
||||
/**
|
||||
* Parse an F1 live-timing interval/gap string into seconds.
|
||||
* Handles "+1.234", "1.234", "+1:05.678" (minute form) and rejects
|
||||
* lapped/leader markers: "", "LAP 12", "1L", "+1 LAP", "2 LAPS", etc.
|
||||
*/
|
||||
export function parseIntervalSeconds(raw: string | null | undefined): number | null {
|
||||
if (!raw) return null
|
||||
const text = raw.trim()
|
||||
if (!text) return null
|
||||
// Lapped / leader markers are never numeric gaps.
|
||||
if (/lap/i.test(text) || /^\+?\d+\s*L$/i.test(text)) return null
|
||||
|
||||
const match = text.match(/^([+-])?(?:(\d+):)?(\d+(?:\.\d+)?)$/)
|
||||
if (!match) return null
|
||||
|
||||
const sign = match[1] === '-' ? -1 : 1
|
||||
const minutes = match[2] ? Number(match[2]) : 0
|
||||
const seconds = Number(match[3])
|
||||
if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) return null
|
||||
|
||||
return sign * (minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
export interface GapSampleInput {
|
||||
racingNumber: string
|
||||
interval: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one snapshot's worth of interval samples.
|
||||
* Returns a new map (input is not mutated). Drivers missing from `rows`
|
||||
* are pruned; unparsable intervals keep the existing buffer untouched.
|
||||
*/
|
||||
export function recordGapSamples(
|
||||
history: GapHistoryMap,
|
||||
rows: ReadonlyArray<GapSampleInput>,
|
||||
maxSamples = MAX_GAP_SAMPLES,
|
||||
): GapHistoryMap {
|
||||
const next: GapHistoryMap = {}
|
||||
for (const row of rows) {
|
||||
if (!row.racingNumber) continue
|
||||
const existing = history[row.racingNumber] ?? []
|
||||
const value = parseIntervalSeconds(row.interval)
|
||||
if (value === null) {
|
||||
if (existing.length > 0) next[row.racingNumber] = existing
|
||||
continue
|
||||
}
|
||||
const samples = [...existing, value]
|
||||
next[row.racingNumber] = samples.length > maxSamples ? samples.slice(samples.length - maxSamples) : samples
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the recent trend of a gap buffer: is the driver closing on the
|
||||
* car ahead, dropping back, or holding steady? Compares the mean of the
|
||||
* older half vs the newer half of the most recent samples.
|
||||
*/
|
||||
export function gapTrend(samples: ReadonlyArray<number>, window = 10, threshold = 0.1): GapTrend | null {
|
||||
if (!samples || samples.length < 3) return null
|
||||
const recent = samples.slice(-window)
|
||||
const mid = Math.floor(recent.length / 2)
|
||||
const older = recent.slice(0, mid)
|
||||
const newer = recent.slice(mid)
|
||||
if (older.length === 0 || newer.length === 0) return null
|
||||
|
||||
const mean = (xs: ReadonlyArray<number>) => xs.reduce((a, b) => a + b, 0) / xs.length
|
||||
const delta = mean(newer) - mean(older)
|
||||
if (delta <= -threshold) return 'closing'
|
||||
if (delta >= threshold) return 'opening'
|
||||
return 'steady'
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SVG polyline points for a sparkline of the samples, fitted to
|
||||
* width x height with a small vertical inset. Flat data draws a mid line.
|
||||
*/
|
||||
export function sparklinePoints(
|
||||
samples: ReadonlyArray<number>,
|
||||
width: number,
|
||||
height: number,
|
||||
inset = 1.5,
|
||||
): string {
|
||||
if (!samples || samples.length < 2) return ''
|
||||
const min = Math.min(...samples)
|
||||
const max = Math.max(...samples)
|
||||
const span = max - min
|
||||
const usable = height - inset * 2
|
||||
const step = width / (samples.length - 1)
|
||||
|
||||
return samples
|
||||
.map((value, index) => {
|
||||
const x = index * step
|
||||
const y = span === 0 ? height / 2 : inset + (1 - (value - min) / span) * usable
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
@@ -21,6 +21,35 @@ const TRACK_STATUS_LABELS: Record<string, string> = {
|
||||
'4': 'SC',
|
||||
'5': 'RED',
|
||||
'6': 'VSC',
|
||||
'7': 'VSC ENDING',
|
||||
}
|
||||
|
||||
export interface TrackStatusInfo {
|
||||
key: 'green' | 'yellow' | 'sc' | 'vsc' | 'red' | 'unknown'
|
||||
label: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
// Raw F1 SignalR TrackStatus.Status values (see internal/live/types.go):
|
||||
// "1"=all clear, "2"=yellow, "4"=safety car, "5"=red, "6"=VSC, "7"=VSC ending.
|
||||
// "3" has not been observed in the feed; unknown values fall through to a
|
||||
// neutral display so a new encoding never breaks the banner.
|
||||
const TRACK_STATUS_INFO: Record<string, TrackStatusInfo> = {
|
||||
'1': { key: 'green', label: 'TRACK CLEAR', detail: 'Green flag — racing' },
|
||||
'2': { key: 'yellow', label: 'YELLOW FLAG', detail: 'Caution on track' },
|
||||
'4': { key: 'sc', label: 'SAFETY CAR', detail: 'Safety car deployed' },
|
||||
'5': { key: 'red', label: 'RED FLAG', detail: 'Session stopped' },
|
||||
'6': { key: 'vsc', label: 'VIRTUAL SAFETY CAR', detail: 'VSC deployed' },
|
||||
'7': { key: 'vsc', label: 'VSC ENDING', detail: 'Virtual safety car ending' },
|
||||
}
|
||||
|
||||
export function trackStatusInfo(status: string | null | undefined): TrackStatusInfo {
|
||||
if (status && TRACK_STATUS_INFO[status]) return TRACK_STATUS_INFO[status]
|
||||
return {
|
||||
key: 'unknown',
|
||||
label: status ? `TRACK STATUS ${status}` : 'TRACK STATUS UNKNOWN',
|
||||
detail: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLiveStateEvent(data: string): LiveStateResponse | null {
|
||||
@@ -90,10 +119,6 @@ export function trackStatusLabel(status: string): string {
|
||||
return TRACK_STATUS_LABELS[status] || status || 'UNKNOWN'
|
||||
}
|
||||
|
||||
export function trackStatusClass(status: string): string {
|
||||
return `track-${trackStatusLabel(status).toLowerCase()}`
|
||||
}
|
||||
|
||||
export function positionDelta(driver: LiveDriverData): string {
|
||||
if (!driver.PrevPosition || !driver.Position || driver.PrevPosition === driver.Position) return ''
|
||||
return driver.PrevPosition > driver.Position ? '▲' : '▼'
|
||||
@@ -132,10 +157,68 @@ export function tyreLabel(tyre: LiveTyreData | undefined): string {
|
||||
return `${compound} +${tyre.Age || 0}`
|
||||
}
|
||||
|
||||
export function compoundClass(compound: string | null | undefined): string {
|
||||
if (!compound) return 'tyre-unknown'
|
||||
const normalized = compound.toLowerCase()
|
||||
return `tyre-${normalized === 'intermediate' ? 'inter' : normalized}`
|
||||
}
|
||||
|
||||
export function compoundLetter(compound: string | null | undefined): string {
|
||||
return compound?.charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
export function tyreClass(tyre: LiveTyreData | undefined): string {
|
||||
if (!tyre?.Compound) return 'tyre-unknown'
|
||||
const compound = tyre.Compound.toLowerCase()
|
||||
return `tyre-${compound === 'intermediate' ? 'inter' : compound}`
|
||||
return compoundClass(tyre?.Compound)
|
||||
}
|
||||
|
||||
export function windDirectionLabel(degrees: number | null | undefined): string {
|
||||
if (degrees == null || !Number.isFinite(degrees)) return ''
|
||||
const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8
|
||||
return points[index]
|
||||
}
|
||||
|
||||
export const MAX_PINNED_DRIVERS = 3
|
||||
|
||||
/**
|
||||
* Toggle a driver pin. Unpins if already pinned; otherwise appends,
|
||||
* dropping the oldest pin when at capacity so clicking always works.
|
||||
*/
|
||||
export function togglePin(pins: ReadonlyArray<string>, racingNumber: string, max = MAX_PINNED_DRIVERS): string[] {
|
||||
if (!racingNumber) return [...pins]
|
||||
if (pins.includes(racingNumber)) return pins.filter((pin) => pin !== racingNumber)
|
||||
const next = [...pins, racingNumber]
|
||||
return next.length > max ? next.slice(next.length - max) : next
|
||||
}
|
||||
|
||||
const PINS_STORAGE_KEY = 'box-box.live.pins'
|
||||
|
||||
export function loadPinnedDrivers(storage: Pick<Storage, 'getItem'> | null = safeStorage()): string[] {
|
||||
try {
|
||||
const raw = storage?.getItem(PINS_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((pin): pin is string => typeof pin === 'string').slice(0, MAX_PINNED_DRIVERS)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function savePinnedDrivers(pins: ReadonlyArray<string>, storage: Pick<Storage, 'setItem'> | null = safeStorage()): void {
|
||||
try {
|
||||
storage?.setItem(PINS_STORAGE_KEY, JSON.stringify(pins))
|
||||
} catch {
|
||||
// storage unavailable (private mode, SSR) — pins just won't persist
|
||||
}
|
||||
}
|
||||
|
||||
function safeStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window !== 'undefined' ? window.localStorage : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function latestRaceControl(messages: LiveRCMessage[], limit = 10): LiveRCMessage[] {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LiveStreamData } from '../types'
|
||||
import { parseLiveStateEvent } from '../lib/live'
|
||||
import {
|
||||
loadPinnedDrivers,
|
||||
parseLiveStateEvent,
|
||||
savePinnedDrivers,
|
||||
sortLiveTimingRows,
|
||||
togglePin,
|
||||
} from '../lib/live'
|
||||
import type { GapHistoryMap } from '../lib/gapHistory'
|
||||
import { recordGapSamples } from '../lib/gapHistory'
|
||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||
import { SessionBanner } from '../components/live/SessionBanner'
|
||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||
import { TimingTower } from '../components/live/TimingTower'
|
||||
import { BattleChips } from '../components/live/BattleChips'
|
||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
|
||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
@@ -14,6 +26,8 @@ export function LiveTimingPage() {
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
const [gapHistory, setGapHistory] = useState<GapHistoryMap>({})
|
||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
@@ -68,6 +82,33 @@ export function LiveTimingPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||
|
||||
// One interval sample per received snapshot, ring-buffered per driver.
|
||||
useEffect(() => {
|
||||
if (rows.length === 0) return
|
||||
setGapHistory((prev) =>
|
||||
recordGapSamples(
|
||||
prev,
|
||||
rows.map((row) => ({ racingNumber: row.RacingNumber, interval: row.Driver.Interval || '' })),
|
||||
),
|
||||
)
|
||||
}, [rows])
|
||||
|
||||
useEffect(() => {
|
||||
savePinnedDrivers(pinned)
|
||||
}, [pinned])
|
||||
|
||||
const battles = useMemo(
|
||||
() => detectBattles(rows, snapshot?.Session?.SessionType),
|
||||
[rows, snapshot?.Session?.SessionType],
|
||||
)
|
||||
const inBattle = useMemo(() => battleNumbers(battles), [battles])
|
||||
|
||||
const handleTogglePin = (racingNumber: string) => {
|
||||
setPinned((prev) => togglePin(prev, racingNumber))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page live-page" data-testid="live-page">
|
||||
{isError && (
|
||||
@@ -101,12 +142,23 @@ export function LiveTimingPage() {
|
||||
{snapshot && (
|
||||
<>
|
||||
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} />
|
||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||
<div className="live-columns">
|
||||
<div className="live-tower-col">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Timing Tower</span>
|
||||
{pinned.length > 0 && <span className="sec-meta">{pinned.length}/3 pinned</span>}
|
||||
</div>
|
||||
<TimingTower snapshot={snapshot} />
|
||||
<BattleChips battles={battles} />
|
||||
<TimingTower
|
||||
rows={rows}
|
||||
stints={snapshot.Stints}
|
||||
history={gapHistory}
|
||||
battleNumbers={inBattle}
|
||||
pinned={pinned}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
</div>
|
||||
<div className="live-rc-col">
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} />
|
||||
|
||||
@@ -661,6 +661,178 @@ a { color: inherit; text-decoration: none; }
|
||||
.tyre-wet { background: var(--tyre-wet); color: #fff; }
|
||||
.tyre-unknown { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--border-2); }
|
||||
|
||||
/* ── Track status banner ── */
|
||||
.track-banner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s4);
|
||||
width: 100%;
|
||||
padding: var(--s3) var(--s5);
|
||||
margin-bottom: var(--s5);
|
||||
border: 1px solid var(--border-2);
|
||||
border-left-width: 3px;
|
||||
border-radius: 2px;
|
||||
font-family: var(--f-mono);
|
||||
}
|
||||
|
||||
.track-banner-label {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-banner-detail {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-banner-green { background: rgba(57,199,58,.08); border-color: rgba(57,199,58,.3); color: var(--green); }
|
||||
.track-banner-yellow { background: rgba(255,214,0,.12); border-color: rgba(255,214,0,.4); color: var(--yellow); }
|
||||
.track-banner-sc { background: rgba(255,152,0,.12); border-color: rgba(255,152,0,.4); color: #ffb84d; }
|
||||
.track-banner-vsc { background: rgba(194,120,255,.12); border-color: rgba(194,120,255,.4); color: var(--purple); }
|
||||
.track-banner-red { background: rgba(225,6,0,.14); border-color: rgba(225,6,0,.5); color: #ff6b6b; animation: track-banner-pulse 1.6s ease-in-out infinite; }
|
||||
.track-banner-unknown { background: var(--surface-2); border-color: var(--border-2); color: var(--text-2); }
|
||||
|
||||
@keyframes track-banner-pulse {
|
||||
0%, 100% { background: rgba(225,6,0,.14); }
|
||||
50% { background: rgba(225,6,0,.26); }
|
||||
}
|
||||
|
||||
/* ── Weather strip items ── */
|
||||
.weather-item {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.weather-k { color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; }
|
||||
.weather-v { color: var(--text-2); font-family: var(--f-mono); font-size: 11px; }
|
||||
|
||||
/* ── Gap trend sparkline ── */
|
||||
.gap-spark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
color: var(--text-3);
|
||||
line-height: 1;
|
||||
}
|
||||
.gap-spark-empty { color: var(--text-3); }
|
||||
.gap-spark-svg { display: block; }
|
||||
.gap-spark.trend-closing { color: var(--green); }
|
||||
.gap-spark.trend-opening { color: #ff6b6b; }
|
||||
.gap-spark.trend-steady { color: var(--text-3); }
|
||||
|
||||
.trend-arrow { font-size: 8px; line-height: 1; }
|
||||
.trend-arrow-closing { color: var(--green); }
|
||||
.trend-arrow-opening { color: #ff6b6b; }
|
||||
|
||||
.spark-cell { line-height: 0; }
|
||||
|
||||
/* ── Battle highlighting ── */
|
||||
.live-tower tr.battle-row td { background: rgba(255,214,0,.045); }
|
||||
.live-tower tr.battle-row td:first-child { box-shadow: inset 2px 0 0 rgba(255,214,0,.55); }
|
||||
|
||||
.battle-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.battle-chips-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.battle-chip {
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,214,0,.08);
|
||||
border: 1px solid rgba(255,214,0,.25);
|
||||
color: var(--yellow);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Pinned drivers ── */
|
||||
.live-tower tr.pinnable { cursor: pointer; }
|
||||
.live-tower tr.pinned-row td { background: rgba(0,128,255,.08); }
|
||||
.live-tower tr.pinned-row td:first-child { box-shadow: inset 2px 0 0 rgba(0,128,255,.55); }
|
||||
.live-tower tr.battle-row.pinned-row td:first-child {
|
||||
box-shadow: inset 2px 0 0 rgba(0,128,255,.55), inset 4px 0 0 rgba(255,214,0,.55);
|
||||
}
|
||||
|
||||
.pin-mark { color: #66aaff; font-size: 10px; line-height: 1; }
|
||||
|
||||
.pinned-strip {
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.pinned-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s2) var(--s4);
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(0,128,255,.26);
|
||||
border-radius: 2px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
.pinned-card:hover { background: var(--surface-h); border-color: rgba(0,128,255,.45); }
|
||||
.pinned-card .drv-bar { height: 16px; }
|
||||
|
||||
.pinned-pos { font-size: 11px; color: var(--text-2); }
|
||||
.pinned-gap { font-size: 11px; color: var(--text-2); min-width: 44px; text-align: right; }
|
||||
.pinned-nodata { font-size: 10px; color: var(--text-3); font-family: var(--f-mono); }
|
||||
.pinned-unpin { color: var(--text-3); font-size: 12px; line-height: 1; }
|
||||
.pinned-card:hover .pinned-unpin { color: #ff6b6b; }
|
||||
.pinned-card-missing { border-color: var(--border-2); opacity: 0.75; }
|
||||
|
||||
/* ── Stint history ── */
|
||||
.stint-seq {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stint-item { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.stint-arrow { color: var(--text-3); font-size: 9px; margin-right: 2px; }
|
||||
|
||||
.stint-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
font-family: var(--f-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stint-laps {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.stint-empty { color: var(--text-3); font-family: var(--f-mono); }
|
||||
|
||||
.live-rc { margin-bottom: var(--s7); }
|
||||
|
||||
.live-rc-list {
|
||||
@@ -2470,6 +2642,22 @@ a { color: inherit; text-decoration: none; }
|
||||
.live-weather-strip { gap: var(--s3); }
|
||||
.live-rc-scroll { max-height: 220px; }
|
||||
|
||||
.track-banner { padding: var(--s2) var(--s4); }
|
||||
.track-banner-label { font-size: 12px; }
|
||||
.track-banner-detail { display: none; }
|
||||
|
||||
.battle-chips,
|
||||
.pinned-strip {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: var(--s2);
|
||||
}
|
||||
.battle-chips::-webkit-scrollbar,
|
||||
.pinned-strip::-webkit-scrollbar { display: none; }
|
||||
.pinned-card { flex-shrink: 0; }
|
||||
|
||||
.dataset-strip {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
|
||||
221
frontend/src/test/LiveComponents.test.tsx
Normal file
221
frontend/src/test/LiveComponents.test.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||
import { WeatherStrip } from '../components/live/WeatherStrip'
|
||||
import { StintHistory } from '../components/live/StintHistory'
|
||||
import { BattleChips } from '../components/live/BattleChips'
|
||||
import { GapSparkline } from '../components/live/GapSparkline'
|
||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { TimingTower } from '../components/live/TimingTower'
|
||||
import { detectBattles } from '../lib/battles'
|
||||
import type { LiveTimingRow } from '../lib/live'
|
||||
import type { LiveDriverData, LiveWeatherData } from '../types'
|
||||
|
||||
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:14.000',
|
||||
BestLapTime: '1:13.500',
|
||||
NumberOfLaps: 10,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
...driver,
|
||||
} as LiveDriverData,
|
||||
Info: {
|
||||
RacingNumber: number,
|
||||
BroadcastName: '',
|
||||
Tla: tla,
|
||||
TeamName: '',
|
||||
TeamColour: '3671c6',
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('TrackStatusBanner', () => {
|
||||
it('renders a safety car banner for status 4', () => {
|
||||
render(<TrackStatusBanner status="4" />)
|
||||
const banner = screen.getByTestId('track-banner')
|
||||
expect(banner).toHaveClass('track-banner-sc')
|
||||
expect(banner).toHaveTextContent('SAFETY CAR')
|
||||
})
|
||||
|
||||
it('renders a neutral banner for unknown statuses', () => {
|
||||
render(<TrackStatusBanner status="42" />)
|
||||
const banner = screen.getByTestId('track-banner')
|
||||
expect(banner).toHaveClass('track-banner-unknown')
|
||||
expect(banner).toHaveTextContent('TRACK STATUS 42')
|
||||
})
|
||||
|
||||
it('renders nothing without a status', () => {
|
||||
render(<TrackStatusBanner status="" />)
|
||||
expect(screen.queryByTestId('track-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WeatherStrip', () => {
|
||||
const weather: LiveWeatherData = {
|
||||
AirTemp: 21.4,
|
||||
TrackTemp: 34.8,
|
||||
Humidity: 58,
|
||||
WindSpeed: 2.3,
|
||||
WindDir: 180,
|
||||
Rainfall: true,
|
||||
}
|
||||
|
||||
it('renders all populated weather fields', () => {
|
||||
render(<WeatherStrip weather={weather} />)
|
||||
const strip = screen.getByTestId('weather-strip')
|
||||
expect(strip).toHaveTextContent('21°C')
|
||||
expect(strip).toHaveTextContent('35°C')
|
||||
expect(strip).toHaveTextContent('58%')
|
||||
expect(strip).toHaveTextContent('2.3 m/s S')
|
||||
expect(strip).toHaveTextContent('RAIN')
|
||||
})
|
||||
|
||||
it('hides gracefully when the payload is empty', () => {
|
||||
render(
|
||||
<WeatherStrip
|
||||
weather={{ AirTemp: 0, TrackTemp: 0, Humidity: 0, WindSpeed: 0, WindDir: 0, Rainfall: false }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides when weather is missing entirely', () => {
|
||||
render(<WeatherStrip weather={undefined} />)
|
||||
expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StintHistory', () => {
|
||||
it('renders the compound sequence with lap counts', () => {
|
||||
render(
|
||||
<StintHistory
|
||||
stints={[
|
||||
{ Compound: 'MEDIUM', New: true, Laps: 12 },
|
||||
{ Compound: 'HARD', New: false, Laps: 20 },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
const seq = screen.getByTestId('stint-seq')
|
||||
expect(seq).toHaveTextContent('M')
|
||||
expect(seq).toHaveTextContent('12')
|
||||
expect(seq).toHaveTextContent('H')
|
||||
expect(seq).toHaveTextContent('20')
|
||||
})
|
||||
|
||||
it('renders a dash without stint data', () => {
|
||||
render(<StintHistory stints={undefined} />)
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GapSparkline', () => {
|
||||
it('shows a placeholder with too few samples', () => {
|
||||
render(<GapSparkline samples={[1.0]} />)
|
||||
expect(screen.queryByTestId('gap-spark')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a closing indicator when the gap shrinks', () => {
|
||||
render(<GapSparkline samples={[2.0, 1.8, 1.5, 1.2, 0.9, 0.6]} />)
|
||||
expect(screen.getByTestId('gap-spark')).toHaveClass('trend-closing')
|
||||
expect(screen.getByTitle('Gap closing')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BattleChips', () => {
|
||||
it('renders chip labels for detected battles', () => {
|
||||
const battles = detectBattles(
|
||||
[makeRow('1', 1, 'VER'), makeRow('4', 2, 'NOR', { Interval: '+0.4' })],
|
||||
'Race',
|
||||
)
|
||||
render(<BattleChips battles={battles} />)
|
||||
expect(screen.getByTestId('battle-chips')).toHaveTextContent('VER ⚔ NOR +0.4')
|
||||
})
|
||||
|
||||
it('renders nothing when there are no battles', () => {
|
||||
render(<BattleChips battles={[]} />)
|
||||
expect(screen.queryByTestId('battle-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TimingTower', () => {
|
||||
const rows = [
|
||||
makeRow('1', 1, 'VER'),
|
||||
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
|
||||
makeRow('16', 3, 'LEC', { Interval: '+3.2', GapToLeader: '+3.6' }),
|
||||
]
|
||||
|
||||
it('highlights battle rows and marks pinned drivers', () => {
|
||||
render(
|
||||
<TimingTower
|
||||
rows={rows}
|
||||
battleNumbers={new Set(['1', '4'])}
|
||||
pinned={['16']}
|
||||
onTogglePin={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('VER').closest('tr')).toHaveClass('battle-row')
|
||||
expect(screen.getByText('NOR').closest('tr')).toHaveClass('battle-row')
|
||||
const lecRow = screen.getByText('LEC').closest('tr')
|
||||
expect(lecRow).not.toHaveClass('battle-row')
|
||||
expect(lecRow).toHaveClass('pinned-row')
|
||||
})
|
||||
|
||||
it('toggles a pin when a row is clicked', () => {
|
||||
const onTogglePin = vi.fn()
|
||||
render(<TimingTower rows={rows} onTogglePin={onTogglePin} />)
|
||||
fireEvent.click(screen.getByText('NOR').closest('tr')!)
|
||||
expect(onTogglePin).toHaveBeenCalledWith('4')
|
||||
})
|
||||
|
||||
it('shows an empty notice without rows', () => {
|
||||
render(<TimingTower rows={[]} />)
|
||||
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PinnedDrivers', () => {
|
||||
it('renders pinned cards with gap and unpins on click', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(
|
||||
<PinnedDrivers
|
||||
rows={[makeRow('4', 2, 'NOR', { Interval: '+0.4' })]}
|
||||
history={{ '4': [1.0, 0.8, 0.6, 0.4] }}
|
||||
pinned={['4']}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
const strip = screen.getByTestId('pinned-strip')
|
||||
expect(strip).toHaveTextContent('NOR')
|
||||
expect(strip).toHaveTextContent('+0.4')
|
||||
fireEvent.click(screen.getByText('NOR').closest('button')!)
|
||||
expect(onToggle).toHaveBeenCalledWith('4')
|
||||
})
|
||||
|
||||
it('renders a fallback card when the driver is missing from the feed', () => {
|
||||
render(
|
||||
<PinnedDrivers rows={[]} history={{}} pinned={['44']} onToggle={() => {}} />,
|
||||
)
|
||||
expect(screen.getByTestId('pinned-strip')).toHaveTextContent('no data')
|
||||
})
|
||||
|
||||
it('renders nothing without pins', () => {
|
||||
render(<PinnedDrivers rows={[]} history={{}} pinned={[]} onToggle={() => {}} />)
|
||||
expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
118
frontend/src/test/battles.test.ts
Normal file
118
frontend/src/test/battles.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { battleLabel, battleNumbers, detectBattles, isRaceSession } from '../lib/battles'
|
||||
import type { LiveTimingRow } from '../lib/live'
|
||||
import type { LiveDriverData } from '../types'
|
||||
|
||||
function row(
|
||||
number: string,
|
||||
position: number,
|
||||
interval: string,
|
||||
tla: string,
|
||||
overrides: Partial<LiveDriverData> = {},
|
||||
): LiveTimingRow {
|
||||
return {
|
||||
RacingNumber: number,
|
||||
Position: position,
|
||||
Driver: {
|
||||
RacingNumber: number,
|
||||
Position: position,
|
||||
Interval: interval,
|
||||
GapToLeader: interval,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
...overrides,
|
||||
} as LiveDriverData,
|
||||
Info: {
|
||||
RacingNumber: number,
|
||||
BroadcastName: '',
|
||||
Tla: tla,
|
||||
TeamName: '',
|
||||
TeamColour: '',
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('isRaceSession', () => {
|
||||
it('only treats race and sprint sessions as races', () => {
|
||||
expect(isRaceSession('Race')).toBe(true)
|
||||
expect(isRaceSession('Sprint')).toBe(true)
|
||||
expect(isRaceSession('Qualifying')).toBe(false)
|
||||
expect(isRaceSession('Practice')).toBe(false)
|
||||
expect(isRaceSession('')).toBe(false)
|
||||
expect(isRaceSession(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectBattles', () => {
|
||||
const rows = [
|
||||
row('1', 1, '', 'VER'),
|
||||
row('4', 2, '+0.4', 'NOR'),
|
||||
row('81', 3, '+0.8', 'PIA'),
|
||||
row('16', 4, '+5.0', 'LEC'),
|
||||
row('44', 5, '+0.9', 'HAM'),
|
||||
row('63', 6, '+12.2', 'RUS'),
|
||||
]
|
||||
|
||||
it('groups consecutive cars within 1.0s in race sessions', () => {
|
||||
const battles = detectBattles(rows, 'Race')
|
||||
expect(battles).toHaveLength(2)
|
||||
expect(battles[0].drivers.map((d) => d.code)).toEqual(['VER', 'NOR', 'PIA'])
|
||||
expect(battles[0].minGap).toBeCloseTo(0.4)
|
||||
expect(battles[1].drivers.map((d) => d.code)).toEqual(['LEC', 'HAM'])
|
||||
expect(battles[1].minGap).toBeCloseTo(0.9)
|
||||
})
|
||||
|
||||
it('returns nothing outside race sessions', () => {
|
||||
expect(detectBattles(rows, 'Qualifying')).toEqual([])
|
||||
expect(detectBattles(rows, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes cars in the pits or retired', () => {
|
||||
const pitted = [
|
||||
row('1', 1, '', 'VER'),
|
||||
row('4', 2, '+0.4', 'NOR', { InPit: true }),
|
||||
row('81', 3, '+0.6', 'PIA'),
|
||||
row('16', 4, '+8.0', 'LEC'),
|
||||
row('44', 5, '+0.5', 'HAM', { Retired: true }),
|
||||
]
|
||||
const battles = detectBattles(pitted, 'Race')
|
||||
expect(battles).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores lapped and missing intervals', () => {
|
||||
const lapped = [
|
||||
row('1', 1, '', 'VER'),
|
||||
row('4', 2, '1L', 'NOR'),
|
||||
row('81', 3, '', 'PIA'),
|
||||
]
|
||||
expect(detectBattles(lapped, 'Race')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles empty and single-row input', () => {
|
||||
expect(detectBattles([], 'Race')).toEqual([])
|
||||
expect(detectBattles([row('1', 1, '', 'VER')], 'Race')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('battleLabel / battleNumbers', () => {
|
||||
it('formats a two-car chip label', () => {
|
||||
const battles = detectBattles([row('1', 1, '', 'VER'), row('4', 2, '+0.4', 'NOR')], 'Race')
|
||||
expect(battleLabel(battles[0])).toBe('VER ⚔ NOR +0.4')
|
||||
})
|
||||
|
||||
it('collects racing numbers across all battles', () => {
|
||||
const battles = detectBattles(
|
||||
[
|
||||
row('1', 1, '', 'VER'),
|
||||
row('4', 2, '+0.4', 'NOR'),
|
||||
row('16', 3, '+9.0', 'LEC'),
|
||||
row('44', 4, '+0.7', 'HAM'),
|
||||
],
|
||||
'Race',
|
||||
)
|
||||
expect(battleNumbers(battles)).toEqual(new Set(['1', '4', '16', '44']))
|
||||
})
|
||||
})
|
||||
117
frontend/src/test/gapHistory.test.ts
Normal file
117
frontend/src/test/gapHistory.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_GAP_SAMPLES,
|
||||
gapTrend,
|
||||
parseIntervalSeconds,
|
||||
recordGapSamples,
|
||||
sparklinePoints,
|
||||
} from '../lib/gapHistory'
|
||||
import type { GapHistoryMap } from '../lib/gapHistory'
|
||||
|
||||
describe('parseIntervalSeconds', () => {
|
||||
it('parses signed and unsigned decimal gaps', () => {
|
||||
expect(parseIntervalSeconds('+1.234')).toBe(1.234)
|
||||
expect(parseIntervalSeconds('1.234')).toBe(1.234)
|
||||
expect(parseIntervalSeconds('+0.4')).toBe(0.4)
|
||||
expect(parseIntervalSeconds('12')).toBe(12)
|
||||
expect(parseIntervalSeconds('-0.5')).toBe(-0.5)
|
||||
})
|
||||
|
||||
it('parses minute-form gaps', () => {
|
||||
expect(parseIntervalSeconds('+1:05.234')).toBeCloseTo(65.234)
|
||||
})
|
||||
|
||||
it('rejects leader, lapped, and garbage markers', () => {
|
||||
expect(parseIntervalSeconds('')).toBeNull()
|
||||
expect(parseIntervalSeconds(' ')).toBeNull()
|
||||
expect(parseIntervalSeconds(undefined)).toBeNull()
|
||||
expect(parseIntervalSeconds(null)).toBeNull()
|
||||
expect(parseIntervalSeconds('LAP 12')).toBeNull()
|
||||
expect(parseIntervalSeconds('1L')).toBeNull()
|
||||
expect(parseIntervalSeconds('+1 LAP')).toBeNull()
|
||||
expect(parseIntervalSeconds('2 LAPS')).toBeNull()
|
||||
expect(parseIntervalSeconds('abc')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordGapSamples', () => {
|
||||
it('appends one sample per snapshot and does not mutate the input', () => {
|
||||
const initial = { '1': [1.0] }
|
||||
const next = recordGapSamples(initial, [
|
||||
{ racingNumber: '1', interval: '+0.9' },
|
||||
{ racingNumber: '4', interval: '+1.5' },
|
||||
])
|
||||
expect(next['1']).toEqual([1.0, 0.9])
|
||||
expect(next['4']).toEqual([1.5])
|
||||
expect(initial['1']).toEqual([1.0])
|
||||
})
|
||||
|
||||
it('keeps existing history when the interval is unparsable', () => {
|
||||
const next = recordGapSamples({ '1': [1.2, 1.1] }, [{ racingNumber: '1', interval: '1L' }])
|
||||
expect(next['1']).toEqual([1.2, 1.1])
|
||||
})
|
||||
|
||||
it('prunes drivers missing from the snapshot', () => {
|
||||
const next = recordGapSamples({ '99': [3.0] }, [{ racingNumber: '1', interval: '+0.5' }])
|
||||
expect(next['99']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('caps the ring buffer', () => {
|
||||
let history: GapHistoryMap = {}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
history = recordGapSamples(history, [{ racingNumber: '1', interval: `+${i}.0` }])
|
||||
}
|
||||
expect(history['1']).toHaveLength(MAX_GAP_SAMPLES)
|
||||
expect(history['1'][MAX_GAP_SAMPLES - 1]).toBe(49)
|
||||
expect(history['1'][0]).toBe(50 - MAX_GAP_SAMPLES)
|
||||
})
|
||||
|
||||
it('respects a custom cap', () => {
|
||||
const next = recordGapSamples({ '1': [1, 2, 3] }, [{ racingNumber: '1', interval: '+4.0' }], 3)
|
||||
expect(next['1']).toEqual([2, 3, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gapTrend', () => {
|
||||
it('detects a closing gap', () => {
|
||||
expect(gapTrend([2.0, 1.8, 1.6, 1.4, 1.2, 1.0])).toBe('closing')
|
||||
})
|
||||
|
||||
it('detects an opening gap', () => {
|
||||
expect(gapTrend([1.0, 1.2, 1.4, 1.6, 1.8, 2.0])).toBe('opening')
|
||||
})
|
||||
|
||||
it('reports steady within the threshold', () => {
|
||||
expect(gapTrend([1.0, 1.02, 0.98, 1.01, 1.0, 0.99])).toBe('steady')
|
||||
})
|
||||
|
||||
it('needs at least three samples', () => {
|
||||
expect(gapTrend([])).toBeNull()
|
||||
expect(gapTrend([1.0])).toBeNull()
|
||||
expect(gapTrend([1.0, 0.5])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sparklinePoints', () => {
|
||||
it('returns empty for insufficient samples', () => {
|
||||
expect(sparklinePoints([], 56, 14)).toBe('')
|
||||
expect(sparklinePoints([1.0], 56, 14)).toBe('')
|
||||
})
|
||||
|
||||
it('spans the full width and inverts the y axis (smaller gap = lower)', () => {
|
||||
const points = sparklinePoints([0, 10], 56, 14).split(' ')
|
||||
expect(points).toHaveLength(2)
|
||||
const [x1, y1] = points[0].split(',').map(Number)
|
||||
const [x2, y2] = points[1].split(',').map(Number)
|
||||
expect(x1).toBe(0)
|
||||
expect(x2).toBe(56)
|
||||
expect(y1).toBeGreaterThan(y2) // larger gap plots higher (smaller y)
|
||||
})
|
||||
|
||||
it('draws a mid line for flat data', () => {
|
||||
const points = sparklinePoints([1, 1, 1], 56, 14).split(' ')
|
||||
for (const point of points) {
|
||||
expect(Number(point.split(',')[1])).toBe(7)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compoundClass,
|
||||
compoundLetter,
|
||||
extrapolateClock,
|
||||
latestRaceControl,
|
||||
loadPinnedDrivers,
|
||||
positionDeltaClass,
|
||||
parseLiveStateEvent,
|
||||
rcFlagClass,
|
||||
savePinnedDrivers,
|
||||
sortLiveTimingRows,
|
||||
togglePin,
|
||||
trackStatusInfo,
|
||||
trackStatusLabel,
|
||||
tyreClass,
|
||||
tyreLabel,
|
||||
windDirectionLabel,
|
||||
} from '../lib/live'
|
||||
import type { LiveStreamData } from '../types'
|
||||
|
||||
@@ -137,3 +144,70 @@ describe('live transforms', () => {
|
||||
expect(extrapolateClock('01:20:00', '2026-05-25T12:00:00Z', true, Date.parse('2026-05-25T12:00:30Z'))).toBe('01:19:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('track status mapping', () => {
|
||||
it('maps all known raw statuses to banner info', () => {
|
||||
expect(trackStatusInfo('1')).toMatchObject({ key: 'green', label: 'TRACK CLEAR' })
|
||||
expect(trackStatusInfo('2')).toMatchObject({ key: 'yellow', label: 'YELLOW FLAG' })
|
||||
expect(trackStatusInfo('4')).toMatchObject({ key: 'sc', label: 'SAFETY CAR' })
|
||||
expect(trackStatusInfo('5')).toMatchObject({ key: 'red', label: 'RED FLAG' })
|
||||
expect(trackStatusInfo('6')).toMatchObject({ key: 'vsc', label: 'VIRTUAL SAFETY CAR' })
|
||||
expect(trackStatusInfo('7')).toMatchObject({ key: 'vsc', label: 'VSC ENDING' })
|
||||
})
|
||||
|
||||
it('falls back to a neutral display for unknown values', () => {
|
||||
expect(trackStatusInfo('9')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS 9' })
|
||||
expect(trackStatusInfo('')).toMatchObject({ key: 'unknown', label: 'TRACK STATUS UNKNOWN' })
|
||||
expect(trackStatusInfo(undefined)).toMatchObject({ key: 'unknown' })
|
||||
})
|
||||
|
||||
it('keeps the compact label helper defensive', () => {
|
||||
expect(trackStatusLabel('7')).toBe('VSC ENDING')
|
||||
expect(trackStatusLabel('99')).toBe('99')
|
||||
expect(trackStatusLabel('')).toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('weather and stint helpers', () => {
|
||||
it('maps wind direction degrees to compass points', () => {
|
||||
expect(windDirectionLabel(0)).toBe('N')
|
||||
expect(windDirectionLabel(90)).toBe('E')
|
||||
expect(windDirectionLabel(180)).toBe('S')
|
||||
expect(windDirectionLabel(315)).toBe('NW')
|
||||
expect(windDirectionLabel(359)).toBe('N')
|
||||
expect(windDirectionLabel(null)).toBe('')
|
||||
})
|
||||
|
||||
it('maps stint compounds to classes and letters', () => {
|
||||
expect(compoundClass('SOFT')).toBe('tyre-soft')
|
||||
expect(compoundClass('INTERMEDIATE')).toBe('tyre-inter')
|
||||
expect(compoundClass('')).toBe('tyre-unknown')
|
||||
expect(compoundLetter('MEDIUM')).toBe('M')
|
||||
expect(compoundLetter(undefined)).toBe('?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pinned drivers', () => {
|
||||
it('toggles pins with a max of three, dropping the oldest', () => {
|
||||
expect(togglePin([], '1')).toEqual(['1'])
|
||||
expect(togglePin(['1'], '1')).toEqual([])
|
||||
expect(togglePin(['1', '4'], '16')).toEqual(['1', '4', '16'])
|
||||
expect(togglePin(['1', '4', '16'], '44')).toEqual(['4', '16', '44'])
|
||||
expect(togglePin(['1', '4', '16'], '4')).toEqual(['1', '16'])
|
||||
})
|
||||
|
||||
it('round-trips pins through storage and survives corrupt data', () => {
|
||||
const store = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
}
|
||||
savePinnedDrivers(['1', '44'], storage)
|
||||
expect(loadPinnedDrivers(storage)).toEqual(['1', '44'])
|
||||
|
||||
store.set('box-box.live.pins', 'not json {{')
|
||||
expect(loadPinnedDrivers(storage)).toEqual([])
|
||||
store.set('box-box.live.pins', '{"nope":true}')
|
||||
expect(loadPinnedDrivers(storage)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user