mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(#11): "What just happened" synthesized event rail
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
72
frontend/src/components/live/EventRail.tsx
Normal file
72
frontend/src/components/live/EventRail.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||
import {
|
||||
Flag,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
OctagonX,
|
||||
Swords,
|
||||
Timer,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { LiveDriverInfo } from '../../types'
|
||||
import type { LiveEvent, LiveEventKind } from '../../lib/events'
|
||||
import { teamColor } from '../../utils'
|
||||
import '../../styles/event-rail.css'
|
||||
|
||||
interface Props {
|
||||
events: LiveEvent[]
|
||||
driverInfo?: Record<string, LiveDriverInfo>
|
||||
}
|
||||
|
||||
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
|
||||
overtake: Swords,
|
||||
'position-gain': TrendingUp,
|
||||
'position-loss': TrendingDown,
|
||||
'pit-in': Wrench,
|
||||
'pit-out': LogOut,
|
||||
'personal-best': Timer,
|
||||
'fastest-lap': Zap,
|
||||
retirement: OctagonX,
|
||||
'track-status': Flag,
|
||||
'race-control': Megaphone,
|
||||
}
|
||||
|
||||
export function EventRail({ events, driverInfo }: Props) {
|
||||
const [listRef] = useAutoAnimate<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<section className="event-rail panel-glass" data-testid="event-rail">
|
||||
<div className="sec-header sticky-header">
|
||||
<span className="sec-title">What Just Happened</span>
|
||||
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<div className="missing-notice">Events will appear here as the session unfolds.</div>
|
||||
) : (
|
||||
<div className="event-rail-list" ref={listRef}>
|
||||
{events.map((event) => {
|
||||
const Icon = KIND_ICONS[event.kind] ?? Megaphone
|
||||
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
|
||||
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
|
||||
return (
|
||||
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
|
||||
<span className="event-rail-icon" aria-hidden="true">
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<div className="event-rail-body">
|
||||
<span className="event-rail-headline">{event.headline}</span>
|
||||
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
|
||||
</div>
|
||||
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
318
frontend/src/lib/events.ts
Normal file
318
frontend/src/lib/events.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
// "What just happened" event synthesis for the live page.
|
||||
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||
// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots
|
||||
// in, typed events out; the page owns the buffer, this module owns the diffing.
|
||||
|
||||
import type {
|
||||
LiveDriverData,
|
||||
LiveRCMessage,
|
||||
LiveSessionMeta,
|
||||
LiveStreamData,
|
||||
} from '../types'
|
||||
|
||||
export const MAX_EVENTS = 30
|
||||
|
||||
export type LiveEventKind =
|
||||
| 'overtake'
|
||||
| 'position-gain'
|
||||
| 'position-loss'
|
||||
| 'pit-in'
|
||||
| 'pit-out'
|
||||
| 'personal-best'
|
||||
| 'fastest-lap'
|
||||
| 'retirement'
|
||||
| 'track-status'
|
||||
| 'race-control'
|
||||
|
||||
export interface LiveEvent {
|
||||
id: string
|
||||
kind: LiveEventKind
|
||||
lap: number
|
||||
timestamp: string
|
||||
headline: string
|
||||
detail?: string
|
||||
racingNumbers: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the running session, used to reset the event buffer when a new
|
||||
* session starts. LiveSessionMeta carries no numeric session key, so the
|
||||
* meeting + session name/type triple stands in for one.
|
||||
*/
|
||||
export function sessionSignature(session: LiveSessionMeta | null | undefined): string {
|
||||
if (!session) return ''
|
||||
return [session.MeetingName, session.SessionName, session.SessionType].join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend freshly diffed events onto the newest-first buffer, dropping any
|
||||
* whose id is already present (re-renders and replayed diffs must not
|
||||
* duplicate) and evicting the oldest events beyond `cap`.
|
||||
*/
|
||||
export function appendEvents(
|
||||
buffer: ReadonlyArray<LiveEvent>,
|
||||
incoming: ReadonlyArray<LiveEvent>,
|
||||
cap = MAX_EVENTS,
|
||||
): LiveEvent[] {
|
||||
const seen = new Set(buffer.map((event) => event.id))
|
||||
const fresh = incoming.filter((event) => {
|
||||
if (seen.has(event.id)) return false
|
||||
seen.add(event.id)
|
||||
return true
|
||||
})
|
||||
if (fresh.length === 0) return [...buffer]
|
||||
// Within one diff the array is oldest-first; reverse so the last-produced
|
||||
// event ends up at the top of the newest-first rail.
|
||||
return [...fresh.reverse(), ...buffer].slice(0, cap)
|
||||
}
|
||||
|
||||
const TRACK_STATUS_HEADLINES: Record<string, string> = {
|
||||
'1': 'Green flag — track clear',
|
||||
'2': 'Yellow flag',
|
||||
'4': 'Safety Car deployed',
|
||||
'5': 'Red flag — session stopped',
|
||||
'6': 'Virtual Safety Car deployed',
|
||||
'7': 'Virtual Safety Car ending',
|
||||
}
|
||||
|
||||
const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs'])
|
||||
|
||||
/**
|
||||
* Diff two successive live snapshots into a chronological list of synthesized
|
||||
* events. Callers are expected to feed genuinely consecutive snapshots of the
|
||||
* same session; diffing across a session boundary is the caller's job to
|
||||
* prevent (see sessionSignature).
|
||||
*/
|
||||
export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] {
|
||||
if (!prev || !next || prev === next) return []
|
||||
|
||||
const events: LiveEvent[] = []
|
||||
const lap = next.CurrentLap || 0
|
||||
const timestamp = next.Clock || ''
|
||||
const code = (racingNumber: string): string =>
|
||||
next.DriverInfo?.[racingNumber]?.Tla ||
|
||||
prev.DriverInfo?.[racingNumber]?.Tla ||
|
||||
`#${racingNumber}`
|
||||
|
||||
diffTrackStatus(prev, next, lap, timestamp, events)
|
||||
diffRaceControl(prev, next, events)
|
||||
|
||||
const prevDrivers = prev.Drivers ?? {}
|
||||
const nextDrivers = next.Drivers ?? {}
|
||||
const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number])
|
||||
|
||||
// Racing numbers whose position loss is already explained by an overtake
|
||||
// event, so we don't also report a generic "drops to Pn".
|
||||
const overtakenNumbers = new Set<string>()
|
||||
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
|
||||
if (!before.Retired && after.Retired) {
|
||||
events.push({
|
||||
id: `ret:${number}`,
|
||||
kind: 'retirement',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} retires`,
|
||||
detail: before.Position > 0 ? `Was running P${before.Position}` : undefined,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
if (!before.InPit && after.InPit && !after.Retired) {
|
||||
const fromPosition = before.Position > 0 ? ` from P${before.Position}` : ''
|
||||
events.push({
|
||||
id: `pit-in:${number}:l${lap}`,
|
||||
kind: 'pit-in',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} pits${fromPosition}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
if (before.InPit && !after.InPit && !after.Retired) {
|
||||
const compound = next.Tyres?.[number]?.Compound || ''
|
||||
const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1)
|
||||
const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean)
|
||||
const rejoined = after.Position > 0 ? ` P${after.Position}` : ''
|
||||
events.push({
|
||||
id: `pit-out:${number}:l${lap}`,
|
||||
kind: 'pit-out',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
diffBestLap(number, before, after, code, lap, timestamp, events)
|
||||
}
|
||||
|
||||
// Position gains, with overtake attribution via position swaps.
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
if (before.Position <= 0 || after.Position <= 0) continue
|
||||
if (after.Position >= before.Position) continue
|
||||
if (after.InPit || after.Retired) continue
|
||||
|
||||
const overtaken = numbers.find((other) => {
|
||||
if (other === number) return false
|
||||
const otherBefore = prevDrivers[other]
|
||||
const otherAfter = nextDrivers[other]
|
||||
return (
|
||||
otherBefore.Position === after.Position &&
|
||||
otherAfter.Position > otherBefore.Position &&
|
||||
!otherAfter.InPit &&
|
||||
!otherAfter.Retired
|
||||
)
|
||||
})
|
||||
|
||||
if (overtaken) {
|
||||
overtakenNumbers.add(overtaken)
|
||||
events.push({
|
||||
id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`,
|
||||
kind: 'overtake',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`,
|
||||
detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined,
|
||||
racingNumbers: [number, overtaken],
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||
kind: 'position-gain',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Position losses not already explained by an overtake or a pit visit.
|
||||
for (const number of numbers) {
|
||||
const before = prevDrivers[number]
|
||||
const after = nextDrivers[number]
|
||||
if (before.Position <= 0 || after.Position <= 0) continue
|
||||
if (after.Position <= before.Position) continue
|
||||
if (overtakenNumbers.has(number)) continue
|
||||
if (after.InPit || after.PitOut || after.Retired || before.InPit) continue
|
||||
|
||||
events.push({
|
||||
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||
kind: 'position-loss',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function diffTrackStatus(
|
||||
prev: LiveStreamData,
|
||||
next: LiveStreamData,
|
||||
lap: number,
|
||||
timestamp: string,
|
||||
events: LiveEvent[],
|
||||
): void {
|
||||
if (!prev.TrackStatus || !next.TrackStatus) return
|
||||
if (prev.TrackStatus === next.TrackStatus) return
|
||||
events.push({
|
||||
id: `track:${next.TrackStatus}:l${lap}`,
|
||||
kind: 'track-status',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`,
|
||||
racingNumbers: [],
|
||||
})
|
||||
}
|
||||
|
||||
function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void {
|
||||
const prevMessages = prev.RCMessages ?? []
|
||||
const seen = new Set(prevMessages.map(rcMessageKey))
|
||||
for (const message of next.RCMessages ?? []) {
|
||||
if (!message.Message || seen.has(rcMessageKey(message))) continue
|
||||
if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue
|
||||
events.push({
|
||||
id: `rc:${rcMessageKey(message)}`,
|
||||
kind: 'race-control',
|
||||
lap: message.Lap || 0,
|
||||
timestamp: message.Time || '',
|
||||
headline: humanizeRaceControl(message.Message),
|
||||
racingNumbers: carNumbersIn(message.Message),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function diffBestLap(
|
||||
number: string,
|
||||
before: LiveDriverData,
|
||||
after: LiveDriverData,
|
||||
code: (racingNumber: string) => string,
|
||||
lap: number,
|
||||
timestamp: string,
|
||||
events: LiveEvent[],
|
||||
): void {
|
||||
if (!after.LastLapTime) return
|
||||
const isNewLap = after.LastLapTime !== before.LastLapTime
|
||||
|
||||
if (after.LastLapOB && (isNewLap || !before.LastLapOB)) {
|
||||
events.push({
|
||||
id: `ob:${number}:${after.LastLapTime}`,
|
||||
kind: 'fastest-lap',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) {
|
||||
events.push({
|
||||
id: `pb:${number}:${after.LastLapTime}`,
|
||||
kind: 'personal-best',
|
||||
lap,
|
||||
timestamp,
|
||||
headline: `${code(number)} sets a personal best — ${after.LastLapTime}`,
|
||||
racingNumbers: [number],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function rcMessageKey(message: LiveRCMessage): string {
|
||||
return `${message.Time}:${message.Message}`
|
||||
}
|
||||
|
||||
/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */
|
||||
export function humanizeRaceControl(message: string): string {
|
||||
const lower = message.trim().toLowerCase()
|
||||
if (!lower) return ''
|
||||
const sentence = lower.charAt(0).toUpperCase() + lower.slice(1)
|
||||
return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase())
|
||||
}
|
||||
|
||||
function carNumbersIn(message: string): string[] {
|
||||
const numbers: string[] = []
|
||||
for (const match of message.matchAll(/CAR (\d+)/gi)) {
|
||||
if (!numbers.includes(match[1])) numbers.push(match[1])
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const rem10 = n % 10
|
||||
const rem100 = n % 100
|
||||
if (rem10 === 1 && rem100 !== 11) return `${n}st`
|
||||
if (rem10 === 2 && rem100 !== 12) return `${n}nd`
|
||||
if (rem10 === 3 && rem100 !== 13) return `${n}rd`
|
||||
return `${n}th`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import type { LivePosition, LiveStreamData } from '../types'
|
||||
@@ -15,12 +15,15 @@ import type { VisibleSectorState } from '../lib/live'
|
||||
import type { GapHistoryMap } from '../lib/gapHistory'
|
||||
import { recordGapSamples } from '../lib/gapHistory'
|
||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||
import type { LiveEvent } from '../lib/events'
|
||||
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||
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'
|
||||
import { EventRail } from '../components/live/EventRail'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { Radio } from 'lucide-react'
|
||||
|
||||
@@ -35,6 +38,9 @@ export function LiveTimingPage() {
|
||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
||||
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||
const sessionSigRef = useRef('')
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
@@ -112,6 +118,23 @@ export function LiveTimingPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Synthesize "what just happened" events from successive snapshots; the
|
||||
// buffer resets whenever a different session starts streaming.
|
||||
useEffect(() => {
|
||||
if (!snapshot) return
|
||||
const signature = sessionSignature(snapshot.Session)
|
||||
const prev = prevSnapshotRef.current
|
||||
prevSnapshotRef.current = snapshot
|
||||
if (signature !== sessionSigRef.current) {
|
||||
sessionSigRef.current = signature
|
||||
setEvents([])
|
||||
return
|
||||
}
|
||||
if (!prev || prev === snapshot) return
|
||||
const fresh = diffSnapshots(prev, snapshot)
|
||||
if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh))
|
||||
}, [snapshot])
|
||||
|
||||
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -212,6 +235,7 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
<div className="live-rc-col">
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
67
frontend/src/styles/event-rail.css
Normal file
67
frontend/src/styles/event-rail.css
Normal file
@@ -0,0 +1,67 @@
|
||||
/* ── "What just happened" event rail (live page) ── */
|
||||
|
||||
.event-rail {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
.event-rail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding-top: var(--s4);
|
||||
}
|
||||
|
||||
.event-rail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s4);
|
||||
padding: var(--s3) var(--s4);
|
||||
border-left: 3px solid var(--border-2);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.event-rail-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-2);
|
||||
padding-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-kind-fastest-lap .event-rail-icon { color: var(--purple); }
|
||||
.event-kind-personal-best .event-rail-icon { color: var(--green); }
|
||||
.event-kind-overtake .event-rail-icon,
|
||||
.event-kind-position-gain .event-rail-icon { color: var(--green); }
|
||||
.event-kind-position-loss .event-rail-icon { color: var(--yellow); }
|
||||
.event-kind-retirement .event-rail-icon,
|
||||
.event-kind-track-status .event-rail-icon { color: var(--red); }
|
||||
.event-kind-pit-in .event-rail-icon,
|
||||
.event-kind-pit-out .event-rail-icon { color: var(--blue); }
|
||||
|
||||
.event-rail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-rail-headline {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-rail-detail {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.event-rail-lap {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
266
frontend/src/test/events.test.ts
Normal file
266
frontend/src/test/events.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_EVENTS,
|
||||
appendEvents,
|
||||
diffSnapshots,
|
||||
humanizeRaceControl,
|
||||
sessionSignature,
|
||||
} from '../lib/events'
|
||||
import type { LiveEvent } from '../lib/events'
|
||||
import type {
|
||||
LiveDriverData,
|
||||
LiveDriverInfo,
|
||||
LiveRCMessage,
|
||||
LiveStreamData,
|
||||
} from '../types'
|
||||
|
||||
function driver(number: string, position: number, overrides: Partial<LiveDriverData> = {}): LiveDriverData {
|
||||
return {
|
||||
RacingNumber: number,
|
||||
Position: position,
|
||||
PrevPosition: position,
|
||||
GapToLeader: '',
|
||||
Interval: '',
|
||||
LastLapTime: '',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 0,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function info(number: string, tla: string): LiveDriverInfo {
|
||||
return {
|
||||
RacingNumber: number,
|
||||
BroadcastName: tla,
|
||||
Tla: tla,
|
||||
TeamName: '',
|
||||
TeamColour: '3671C6',
|
||||
FirstName: '',
|
||||
LastName: '',
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
drivers: Record<string, LiveDriverData>,
|
||||
overrides: Partial<LiveStreamData> = {},
|
||||
): LiveStreamData {
|
||||
return {
|
||||
Drivers: drivers,
|
||||
DriverInfo: {
|
||||
'1': info('1', 'VER'),
|
||||
'4': info('4', 'NOR'),
|
||||
'44': info('44', 'HAM'),
|
||||
},
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: {} as LiveStreamData['Weather'],
|
||||
Session: {
|
||||
MeetingName: 'Test Grand Prix',
|
||||
CircuitName: 'Test Circuit',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
},
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 12,
|
||||
TotalLaps: 57,
|
||||
Clock: '01:23:45',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function rc(message: string, category: string, overrides: Partial<LiveRCMessage> = {}): LiveRCMessage {
|
||||
return { Time: '2026-07-03T14:00:00Z', Category: category, Flag: '', Message: message, Lap: 12, ...overrides }
|
||||
}
|
||||
|
||||
describe('diffSnapshots', () => {
|
||||
it('returns no events for identical snapshots', () => {
|
||||
const prev = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 1), '44': driver('44', 2) })
|
||||
expect(diffSnapshots(prev, next)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an overtake when two drivers swap positions', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('overtake')
|
||||
expect(events[0].headline).toBe('VER overtakes HAM for P2')
|
||||
expect(events[0].racingNumbers).toEqual(['1', '44'])
|
||||
expect(events[0].lap).toBe(12)
|
||||
})
|
||||
|
||||
it('reports a generic gain when the displaced driver pitted instead', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 5, { InPit: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
const gain = events.find((event) => event.kind === 'position-gain')
|
||||
expect(gain?.headline).toBe('VER moves up to P2 (from P3)')
|
||||
// HAM's drop is covered by the pit-in event, not a position-loss one.
|
||||
expect(events.some((event) => event.kind === 'position-loss')).toBe(false)
|
||||
expect(events.some((event) => event.kind === 'pit-in')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a position loss not explained by an overtake or pit stop', () => {
|
||||
const prev = snapshot({ '44': driver('44', 3) })
|
||||
const next = snapshot({ '44': driver('44', 6) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('position-loss')
|
||||
expect(events[0].headline).toBe('HAM drops to P6 (from P3)')
|
||||
})
|
||||
|
||||
it('reports pit entry with the position pitted from', () => {
|
||||
const prev = snapshot({ '4': driver('4', 3) })
|
||||
const next = snapshot({ '4': driver('4', 3, { InPit: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('pit-in')
|
||||
expect(events[0].headline).toBe('NOR pits from P3')
|
||||
expect(events[0].racingNumbers).toEqual(['4'])
|
||||
})
|
||||
|
||||
it('reports pit exit with rejoin position, compound, and stop count', () => {
|
||||
const prev = snapshot({ '4': driver('4', 3, { InPit: true }) })
|
||||
const next = snapshot(
|
||||
{ '4': driver('4', 6, { PitOut: true }) },
|
||||
{
|
||||
Tyres: { '4': { Compound: 'MEDIUM', New: true, Age: 0 } },
|
||||
Stints: {
|
||||
'4': [
|
||||
{ Compound: 'SOFT', New: true, Laps: 12 },
|
||||
{ Compound: 'HARD', New: true, Laps: 20 },
|
||||
{ Compound: 'MEDIUM', New: true, Laps: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('pit-out')
|
||||
expect(events[0].headline).toBe('NOR rejoins P6 (MEDIUM, 2nd stop)')
|
||||
})
|
||||
|
||||
it('reports personal and overall best laps once per lap time', () => {
|
||||
const prev = snapshot({
|
||||
'1': driver('1', 1, { LastLapTime: '1:24.500' }),
|
||||
'4': driver('4', 2, { LastLapTime: '1:25.100' }),
|
||||
})
|
||||
const next = snapshot({
|
||||
'1': driver('1', 1, { LastLapTime: '1:23.456', LastLapPB: true, LastLapOB: true }),
|
||||
'4': driver('4', 2, { LastLapTime: '1:24.900', LastLapPB: true }),
|
||||
})
|
||||
const events = diffSnapshots(prev, next)
|
||||
const fastest = events.find((event) => event.kind === 'fastest-lap')
|
||||
const personal = events.find((event) => event.kind === 'personal-best')
|
||||
expect(fastest?.headline).toBe('VER sets the fastest lap — 1:23.456')
|
||||
expect(personal?.headline).toBe('NOR sets a personal best — 1:24.900')
|
||||
// A repeat diff of the same state produces nothing new.
|
||||
expect(diffSnapshots(next, snapshot(next.Drivers))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a retirement with the last running position', () => {
|
||||
const prev = snapshot({ '44': driver('44', 5) })
|
||||
const next = snapshot({ '44': driver('44', 5, { Retired: true }) })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('retirement')
|
||||
expect(events[0].headline).toBe('HAM retires')
|
||||
expect(events[0].detail).toBe('Was running P5')
|
||||
})
|
||||
|
||||
it('reports track status transitions as human sentences', () => {
|
||||
const prev = snapshot({ '1': driver('1', 1) })
|
||||
const next = snapshot({ '1': driver('1', 1) }, { TrackStatus: '4' })
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('track-status')
|
||||
expect(events[0].headline).toBe('Safety Car deployed')
|
||||
})
|
||||
|
||||
it('reports new Flag/SafetyCar/Drs race-control messages and skips other categories', () => {
|
||||
const existing = rc('YELLOW IN TRACK SECTOR 7', 'Flag', { Time: '2026-07-03T13:00:00Z' })
|
||||
const prev = snapshot({ '1': driver('1', 1) }, { RCMessages: [existing] })
|
||||
const next = snapshot({ '1': driver('1', 1) }, {
|
||||
RCMessages: [
|
||||
existing,
|
||||
rc('DRS ENABLED', 'Drs'),
|
||||
rc('FIA STEWARDS: CAR 4 (NOR) UNDER INVESTIGATION', 'Other'),
|
||||
],
|
||||
})
|
||||
const events = diffSnapshots(prev, next)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].kind).toBe('race-control')
|
||||
expect(events[0].headline).toBe('DRS enabled')
|
||||
})
|
||||
|
||||
it('produces stable ids across repeated diffs of the same snapshots', () => {
|
||||
const prev = snapshot({ '1': driver('1', 3), '44': driver('44', 2) })
|
||||
const next = snapshot({ '1': driver('1', 2), '44': driver('44', 3) })
|
||||
const first = diffSnapshots(prev, next)
|
||||
const second = diffSnapshots(prev, next)
|
||||
expect(first.map((event) => event.id)).toEqual(second.map((event) => event.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanizeRaceControl', () => {
|
||||
it('sentence-cases while preserving acronyms', () => {
|
||||
expect(humanizeRaceControl('SAFETY CAR DEPLOYED')).toBe('Safety car deployed')
|
||||
expect(humanizeRaceControl('DRS ENABLED')).toBe('DRS enabled')
|
||||
expect(humanizeRaceControl('VSC DEPLOYED')).toBe('VSC deployed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendEvents', () => {
|
||||
const event = (id: string): LiveEvent => ({
|
||||
id,
|
||||
kind: 'race-control',
|
||||
lap: 1,
|
||||
timestamp: '',
|
||||
headline: id,
|
||||
racingNumbers: [],
|
||||
})
|
||||
|
||||
it('prepends new events newest-first and dedups by id', () => {
|
||||
const buffer = appendEvents([event('a')], [event('a'), event('b'), event('c')])
|
||||
expect(buffer.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
|
||||
// Re-appending the same diff output changes nothing.
|
||||
const again = appendEvents(buffer, [event('b'), event('c')])
|
||||
expect(again.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('evicts the oldest events beyond the cap', () => {
|
||||
let buffer: LiveEvent[] = []
|
||||
for (let index = 0; index < MAX_EVENTS + 5; index += 1) {
|
||||
buffer = appendEvents(buffer, [event(`event-${index}`)])
|
||||
}
|
||||
expect(buffer).toHaveLength(MAX_EVENTS)
|
||||
expect(buffer[0].id).toBe(`event-${MAX_EVENTS + 4}`)
|
||||
expect(buffer[buffer.length - 1].id).toBe('event-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionSignature', () => {
|
||||
it('changes when the session changes, enabling buffer resets', () => {
|
||||
const race = snapshot({}).Session
|
||||
expect(sessionSignature(race)).toBe('Test Grand Prix|Race|Race')
|
||||
expect(sessionSignature({ ...race, SessionName: 'Qualifying' })).not.toBe(sessionSignature(race))
|
||||
expect(sessionSignature(null)).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user