diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 71e67ed..be335e0 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -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;
diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx
new file mode 100644
index 0000000..b8425d4
--- /dev/null
+++ b/frontend/src/test/LiveComponents.test.tsx
@@ -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
= {},
+): 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()
+ 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()
+ 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()
+ 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()
+ 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(
+ ,
+ )
+ expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
+ })
+
+ it('hides when weather is missing entirely', () => {
+ render()
+ expect(screen.queryByTestId('weather-strip')).not.toBeInTheDocument()
+ })
+})
+
+describe('StintHistory', () => {
+ it('renders the compound sequence with lap counts', () => {
+ render(
+ ,
+ )
+ 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()
+ expect(screen.getByText('-')).toBeInTheDocument()
+ })
+})
+
+describe('GapSparkline', () => {
+ it('shows a placeholder with too few samples', () => {
+ render()
+ expect(screen.queryByTestId('gap-spark')).not.toBeInTheDocument()
+ })
+
+ it('renders a closing indicator when the gap shrinks', () => {
+ render()
+ 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()
+ expect(screen.getByTestId('battle-chips')).toHaveTextContent('VER ⚔ NOR +0.4')
+ })
+
+ it('renders nothing when there are no battles', () => {
+ render()
+ 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(
+ {}}
+ />,
+ )
+ 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()
+ fireEvent.click(screen.getByText('NOR').closest('tr')!)
+ expect(onTogglePin).toHaveBeenCalledWith('4')
+ })
+
+ it('shows an empty notice without rows', () => {
+ render()
+ 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(
+ ,
+ )
+ 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(
+ {}} />,
+ )
+ expect(screen.getByTestId('pinned-strip')).toHaveTextContent('no data')
+ })
+
+ it('renders nothing without pins', () => {
+ render( {}} />)
+ expect(screen.queryByTestId('pinned-strip')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/battles.test.ts b/frontend/src/test/battles.test.ts
new file mode 100644
index 0000000..b956d87
--- /dev/null
+++ b/frontend/src/test/battles.test.ts
@@ -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 = {},
+): 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']))
+ })
+})
diff --git a/frontend/src/test/gapHistory.test.ts b/frontend/src/test/gapHistory.test.ts
new file mode 100644
index 0000000..b66e927
--- /dev/null
+++ b/frontend/src/test/gapHistory.test.ts
@@ -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)
+ }
+ })
+})
diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts
index 78c7a52..e3a94bf 100644
--- a/frontend/src/test/live.test.ts
+++ b/frontend/src/test/live.test.ts
@@ -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()
+ 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([])
+ })
+})