mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Merge pull request #38 from AmanTahiliani/feat/issue-14-session-aware-command-center-hero
Session-aware Command Center hero (#14)
This commit is contained in:
@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
|
||||
fetchSessions: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
fetchLiveState,
|
||||
fetchChampionshipHub,
|
||||
fetchRaceHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
@@ -102,6 +115,48 @@ describe('CommandCenterPage', () => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
|
||||
mockFetchSessions.mockResolvedValue([])
|
||||
mockFetchChampionshipHub.mockResolvedValue({
|
||||
season: 2025,
|
||||
round: 1,
|
||||
total_rounds: 1,
|
||||
rounds_left: 0,
|
||||
last_race: 'Monaco',
|
||||
round_labels: ['R1'],
|
||||
drivers: [],
|
||||
teams: [],
|
||||
})
|
||||
mockFetchRaceHub.mockResolvedValue({
|
||||
source: 'local',
|
||||
session_key: 9472,
|
||||
datasets: fullDatasets,
|
||||
results: [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
],
|
||||
starting_grid: [],
|
||||
drivers: [],
|
||||
stints: [],
|
||||
pit_stops: [],
|
||||
positions: [],
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no seasons are ingested', async () => {
|
||||
@@ -131,11 +186,10 @@ describe('CommandCenterPage', () => {
|
||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
|
||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
|
||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||
})
|
||||
|
||||
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
|
||||
@@ -181,9 +235,9 @@ describe('CommandCenterPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
123
frontend/src/test/hero.test.ts
Normal file
123
frontend/src/test/hero.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { heroState, classifySessionStatus } from '../lib/hero'
|
||||
import { currentAndNextSession } from '../lib/schedule'
|
||||
import type { Session } from '../types'
|
||||
|
||||
const session = (overrides: Partial<Session> = {}): Session => ({
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('heroState', () => {
|
||||
it('returns live when the live snapshot is active', () => {
|
||||
const now = new Date('2025-05-24T12:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: true,
|
||||
currentSession: null,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('returns live when a session is in progress', () => {
|
||||
const now = new Date('2025-05-25T14:00:00Z')
|
||||
const sessions = [session()]
|
||||
const { current } = currentAndNextSession(sessions, now)
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('returns upcoming during a focus weekend gap before the next session', () => {
|
||||
const now = new Date('2025-05-24T12:00:00Z')
|
||||
const sessions = [
|
||||
session({
|
||||
session_key: 1,
|
||||
session_name: 'FP1',
|
||||
date_start: '2025-05-23T10:00:00+00:00',
|
||||
date_end: '2025-05-23T11:00:00+00:00',
|
||||
}),
|
||||
session({
|
||||
session_key: 2,
|
||||
session_name: 'Race',
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
}),
|
||||
]
|
||||
const { current, next } = currentAndNextSession(sessions, now)
|
||||
expect(current).toBeNull()
|
||||
expect(next?.session_key).toBe(2)
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('upcoming')
|
||||
})
|
||||
|
||||
it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
|
||||
const now = new Date('2025-05-25T16:00:00Z')
|
||||
const sessions = [session()]
|
||||
const { current } = currentAndNextSession(sessions, now)
|
||||
expect(current).toBeNull()
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: current,
|
||||
focusKind: 'current',
|
||||
}),
|
||||
).toBe('upcoming')
|
||||
})
|
||||
|
||||
it('returns between before the next grand prix weekend', () => {
|
||||
const now = new Date('2025-06-01T12:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: null,
|
||||
focusKind: 'next',
|
||||
}),
|
||||
).toBe('between')
|
||||
})
|
||||
|
||||
it('returns between after the season when only a recent meeting remains', () => {
|
||||
const now = new Date('2026-01-01T00:00:00Z')
|
||||
expect(
|
||||
heroState({
|
||||
now,
|
||||
liveActive: false,
|
||||
currentSession: null,
|
||||
focusKind: 'recent',
|
||||
}),
|
||||
).toBe('between')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifySessionStatus', () => {
|
||||
it('marks a session as done immediately after it ends', () => {
|
||||
const now = new Date('2025-05-25T15:00:00Z')
|
||||
expect(classifySessionStatus(session(), now)).toBe('done')
|
||||
})
|
||||
|
||||
it('marks a session as live during its window', () => {
|
||||
const now = new Date('2025-05-25T14:00:00Z')
|
||||
expect(classifySessionStatus(session(), now)).toBe('live')
|
||||
})
|
||||
})
|
||||
257
frontend/src/test/hero.test.tsx
Normal file
257
frontend/src/test/hero.test.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({
|
||||
to,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
to: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
'data-testid'?: string
|
||||
}) => (
|
||||
<a href={to} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
||||
|
||||
const focusMeeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T00:00:00+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const weekendSessions: WeekendSession[] = [
|
||||
{
|
||||
session: {
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1229,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
source: 'local',
|
||||
datasets: {} as WeekendSession['datasets'],
|
||||
},
|
||||
]
|
||||
|
||||
const liveData: LiveStreamData = {
|
||||
Drivers: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
Position: 1,
|
||||
PrevPosition: 1,
|
||||
GapToLeader: '',
|
||||
Interval: '',
|
||||
LastLapTime: '1:14.000',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:13.500',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 10,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
},
|
||||
'44': {
|
||||
RacingNumber: '44',
|
||||
Position: 2,
|
||||
PrevPosition: 2,
|
||||
GapToLeader: '+1.2',
|
||||
Interval: '+1.2',
|
||||
LastLapTime: '1:14.200',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:13.700',
|
||||
BestLapPB: false,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 10,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 10,
|
||||
SpeedTrap: '',
|
||||
Sectors: [],
|
||||
},
|
||||
},
|
||||
DriverInfo: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
BroadcastName: 'M VERSTAPPEN',
|
||||
Tla: 'VER',
|
||||
TeamName: 'Red Bull Racing',
|
||||
TeamColour: '3671c6',
|
||||
FirstName: 'Max',
|
||||
LastName: 'Verstappen',
|
||||
},
|
||||
'44': {
|
||||
RacingNumber: '44',
|
||||
BroadcastName: 'L HAMILTON',
|
||||
Tla: 'HAM',
|
||||
TeamName: 'Ferrari',
|
||||
TeamColour: 'e8002d',
|
||||
FirstName: 'Lewis',
|
||||
LastName: 'Hamilton',
|
||||
},
|
||||
},
|
||||
Tyres: {},
|
||||
RCMessages: [],
|
||||
Weather: {
|
||||
AirTemp: 20,
|
||||
TrackTemp: 30,
|
||||
Humidity: 50,
|
||||
WindSpeed: 1,
|
||||
WindDir: 0,
|
||||
Rainfall: false,
|
||||
},
|
||||
Session: {
|
||||
MeetingName: 'Monaco',
|
||||
CircuitName: 'Monaco',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
},
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 10,
|
||||
TotalLaps: 78,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Stints: {},
|
||||
}
|
||||
|
||||
const podium: EnrichedResult[] = [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
{
|
||||
driver_number: 44,
|
||||
position: 2,
|
||||
name_acronym: 'HAM',
|
||||
full_name: 'Lewis Hamilton',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7205,
|
||||
gap_to_leader: 3.2,
|
||||
number_of_laps: 78,
|
||||
points: 18,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
]
|
||||
|
||||
function renderHero(overrides: Partial<ComponentProps<typeof CommandCenterHero>> = {}) {
|
||||
const props: ComponentProps<typeof CommandCenterHero> = {
|
||||
state: 'upcoming',
|
||||
now: new Date('2025-05-24T12:00:00Z'),
|
||||
accent: '#d61a3e',
|
||||
liveActive: false,
|
||||
liveData: null,
|
||||
focusMeeting,
|
||||
focusKind: 'current',
|
||||
sessions: weekendSessions,
|
||||
currentSession: null,
|
||||
nextSession: weekendSessions[0].session,
|
||||
lastRaceName: '',
|
||||
lastRacePodium: [],
|
||||
nextMeeting: null,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return render(<CommandCenterHero {...props} />)
|
||||
}
|
||||
|
||||
describe('CommandCenterHero', () => {
|
||||
it('renders live timing link and top three in live state', () => {
|
||||
renderHero({
|
||||
state: 'live',
|
||||
liveActive: true,
|
||||
liveData,
|
||||
currentSession: weekendSessions[0].session,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
|
||||
expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
|
||||
expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders countdown and schedule strip in upcoming state', () => {
|
||||
renderHero({
|
||||
state: 'upcoming',
|
||||
nextSession: weekendSessions[0].session,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
|
||||
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
|
||||
expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
|
||||
})
|
||||
|
||||
it('renders last race podium and next GP countdown in between state', () => {
|
||||
const nextMeeting: Meeting = {
|
||||
...focusMeeting,
|
||||
meeting_key: 1301,
|
||||
meeting_name: 'Canada',
|
||||
country_code: 'CAN',
|
||||
date_start: '2025-06-06T00:00:00+00:00',
|
||||
date_end: '2025-06-08T00:00:00+00:00',
|
||||
}
|
||||
|
||||
renderHero({
|
||||
state: 'between',
|
||||
lastRaceName: 'Monaco',
|
||||
lastRacePodium: podium,
|
||||
nextMeeting,
|
||||
now: new Date('2025-06-01T12:00:00Z'),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
|
||||
expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
|
||||
expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
|
||||
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
|
||||
expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user