mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the authoritative #72 Weekend Context API. - Replace the invented frontend WeekendContext with the exact backend contract (temporal_state, previous/focus/next meetings, previous_completed/active/next/ default_analysis sessions with availability). Every valid canonical payload now maps to a designed state via a total resolveViewState; a well-formed response can never fall through to the limited-data placeholder. - Make the canonical read the single source of truth: useWeekendContext no longer fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary championship + news reads run, and only once the canonical context resolves. - Fix the Prepare/analysis flow: /preview is a stable alias that renders the preparation surface (PreSessionView) instead of redirecting back to the same between-races screen. - One primary navigation system per breakpoint: the mobile top-bar links are hidden so the bottom bar is the sole primary nav, and Admin is moved out of every Primary landmark into an operator-utilities toolbar. - Add Vitest coverage for the contract mapping, every temporal state, loading/ error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav hierarchy; add hermetic Playwright journeys (seeded + injected canonical payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire the stale Command Center specs/snapshots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
67
frontend/src/test/Nav.test.tsx
Normal file
67
frontend/src/test/Nav.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
} from '@tanstack/react-router'
|
||||
import { Nav } from '../components/Nav'
|
||||
|
||||
function renderNav() {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Nav />
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
})
|
||||
const stub = (p: string, id: string) =>
|
||||
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([
|
||||
stub('/', 'home'),
|
||||
stub('/championship', 'championship'),
|
||||
stub('/briefing', 'briefing'),
|
||||
stub('/explore', 'explore'),
|
||||
stub('/admin', 'admin'),
|
||||
]),
|
||||
history: createMemoryHistory({ initialEntries: ['/'] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
|
||||
|
||||
describe('Nav — one primary navigation system per breakpoint', () => {
|
||||
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
|
||||
})
|
||||
|
||||
it('every Primary landmark contains all four destinations', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||
for (const nav of primaries) {
|
||||
for (const label of DESTINATIONS) {
|
||||
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
|
||||
renderNav()
|
||||
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
|
||||
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
|
||||
for (const nav of primaries) {
|
||||
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
|
||||
}
|
||||
// Admin lives in the operator utilities toolbar.
|
||||
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
|
||||
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
309
frontend/src/test/WeekendPage.test.tsx
Normal file
309
frontend/src/test/WeekendPage.test.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createMemoryHistory,
|
||||
} from '@tanstack/react-router'
|
||||
import { WeekendPage } from '../pages/WeekendPage'
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
ContextAvailability,
|
||||
ContextSession,
|
||||
Meeting,
|
||||
RaceHub,
|
||||
Session,
|
||||
TemporalState,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchWeekendContext: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchNews: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchResults: vi.fn(),
|
||||
fetchStartingGrid: vi.fn(),
|
||||
fetchTrackOutline: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchWeekendContext,
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchRaceHub,
|
||||
fetchSeasons,
|
||||
fetchMeetings,
|
||||
fetchSessions,
|
||||
fetchResults,
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
|
||||
const mockContext = vi.mocked(fetchWeekendContext)
|
||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockNews = vi.mocked(fetchNews)
|
||||
const mockRaceHub = vi.mocked(fetchRaceHub)
|
||||
const mockSeasons = vi.mocked(fetchSeasons)
|
||||
const mockMeetings = vi.mocked(fetchMeetings)
|
||||
const mockSessions = vi.mocked(fetchSessions)
|
||||
const mockResults = vi.mocked(fetchResults)
|
||||
const mockGrid = vi.mocked(fetchStartingGrid)
|
||||
const mockTrack = vi.mocked(fetchTrackOutline)
|
||||
|
||||
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
|
||||
return {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'complete',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||
return {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'British Grand Prix',
|
||||
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||
location: 'Silverstone',
|
||||
country_name: 'United Kingdom',
|
||||
country_code: 'GBR',
|
||||
country_flag: '',
|
||||
circuit_key: 2,
|
||||
circuit_short_name: 'Silverstone',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
session_key: 11,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-05T14:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
gmt_offset: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||
return {
|
||||
session: session(),
|
||||
meeting: meeting(),
|
||||
availability: availability(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||
return {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
championship_round: 5,
|
||||
total_championship_rounds: 24,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 5,
|
||||
total_rounds: 24,
|
||||
rounds_left: 19,
|
||||
last_race: 'British GP',
|
||||
round_labels: [],
|
||||
drivers: [
|
||||
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
const raceHub = {
|
||||
session_key: 11,
|
||||
results: [
|
||||
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
|
||||
],
|
||||
} as unknown as RaceHub
|
||||
|
||||
function renderAt(path: string) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Outlet />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => <WeekendPage /> })
|
||||
const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: () => <WeekendPage preview />,
|
||||
})
|
||||
const raceHubRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/race-hub',
|
||||
validateSearch: (s: Record<string, unknown>) => {
|
||||
const sk = Number(s.session_key)
|
||||
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
|
||||
},
|
||||
component: () => <div data-testid="race-hub-stub" />,
|
||||
})
|
||||
const stub = (p: string, id: string) =>
|
||||
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
|
||||
|
||||
const router = createRouter({
|
||||
routeTree: rootRoute.addChildren([
|
||||
homeRoute,
|
||||
previewRoute,
|
||||
raceHubRoute,
|
||||
stub('/live', 'live-stub'),
|
||||
stub('/explore', 'explore-stub'),
|
||||
stub('/championship', 'championship-stub'),
|
||||
stub('/briefing', 'briefing-stub'),
|
||||
]),
|
||||
history: createMemoryHistory({ initialEntries: [path] }),
|
||||
})
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('WeekendPage canonical contract rendering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
|
||||
mockHub.mockResolvedValue(hub)
|
||||
mockNews.mockResolvedValue([])
|
||||
mockRaceHub.mockResolvedValue(raceHub)
|
||||
mockSeasons.mockResolvedValue([2026])
|
||||
mockMeetings.mockResolvedValue([meeting()])
|
||||
mockSessions.mockResolvedValue([session()])
|
||||
mockResults.mockResolvedValue([])
|
||||
mockGrid.mockResolvedValue([])
|
||||
mockTrack.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows the loading surface before the canonical context resolves', async () => {
|
||||
mockContext.mockReturnValue(new Promise(() => {}))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows an explicit error surface when the canonical endpoint fails', async () => {
|
||||
mockContext.mockRejectedValue(new Error('API 500: boom'))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
|
||||
})
|
||||
|
||||
const stateCases: Array<[TemporalState, string]> = [
|
||||
['between_weekends', 'weekend-between-races'],
|
||||
['post_weekend', 'weekend-between-races'],
|
||||
['season_complete', 'weekend-between-races'],
|
||||
['pre_session', 'weekend-pre-session'],
|
||||
['between_sessions', 'weekend-between-sessions'],
|
||||
['session_settling', 'weekend-between-sessions'],
|
||||
['session_live', 'weekend-live'],
|
||||
]
|
||||
|
||||
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
temporal_state: temporal,
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
active_session: temporal === 'session_live'
|
||||
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
|
||||
: undefined,
|
||||
focus_meeting: meeting(),
|
||||
}),
|
||||
)
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
|
||||
expect(screen.getByTestId(testid)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
|
||||
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
}),
|
||||
)
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
|
||||
const story = screen.getByTestId('wk-explore-race-story')
|
||||
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
|
||||
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
|
||||
})
|
||||
|
||||
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
|
||||
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
|
||||
// Only supplementary championship + news reads are allowed; no season /
|
||||
// meetings / sessions / live fan-out from the Weekend home hook.
|
||||
expect(mockSeasons).not.toHaveBeenCalled()
|
||||
expect(mockSessions).not.toHaveBeenCalled()
|
||||
// fetchMeetings may still be reached only through the folded preview surface,
|
||||
// which is not mounted in the between-races state.
|
||||
expect(mockMeetings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supplementary reads stay dormant while the canonical context is pending', async () => {
|
||||
mockContext.mockReturnValue(new Promise(() => {}))
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
|
||||
expect(mockHub).not.toHaveBeenCalled()
|
||||
expect(mockNews).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the /preview alias renders the preparation surface instead of looping back', async () => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
temporal_state: 'between_weekends',
|
||||
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
|
||||
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
|
||||
}),
|
||||
)
|
||||
renderAt('/preview')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
|
||||
// The between-races surface must NOT be what /preview renders.
|
||||
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
223
frontend/src/test/weekendContext.test.ts
Normal file
223
frontend/src/test/weekendContext.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
analysisSessionKey,
|
||||
briefingItems,
|
||||
championshipImpact,
|
||||
hasLocalAnalysis,
|
||||
meetingIdentity,
|
||||
podiumFromResults,
|
||||
resolveViewState,
|
||||
} from '../lib/weekendContext'
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
ContextAvailability,
|
||||
ContextSession,
|
||||
EnrichedResult,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
Session,
|
||||
TemporalState,
|
||||
WeekendContext,
|
||||
} from '../types'
|
||||
|
||||
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
|
||||
return {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'complete',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function meeting(overrides: Partial<Meeting> = {}): Meeting {
|
||||
return {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'British Grand Prix',
|
||||
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
|
||||
location: 'Silverstone',
|
||||
country_name: 'United Kingdom',
|
||||
country_code: 'GBR',
|
||||
country_flag: '',
|
||||
circuit_key: 2,
|
||||
circuit_short_name: 'Silverstone',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function session(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
session_key: 11,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-05T14:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
gmt_offset: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
|
||||
return {
|
||||
session: session(),
|
||||
meeting: meeting(),
|
||||
availability: availability(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
|
||||
return {
|
||||
season: 2026,
|
||||
temporal_state: 'between_weekends',
|
||||
championship_round: 1,
|
||||
total_championship_rounds: 24,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveViewState', () => {
|
||||
const cases: Array<[TemporalState, string]> = [
|
||||
['no_season', 'no_season'],
|
||||
['between_weekends', 'between_weekends'],
|
||||
['pre_session', 'pre_session'],
|
||||
['session_live', 'session_live'],
|
||||
['session_settling', 'session_settling'],
|
||||
['between_sessions', 'between_sessions'],
|
||||
['post_weekend', 'post_weekend'],
|
||||
['season_complete', 'season_complete'],
|
||||
]
|
||||
|
||||
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
|
||||
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
|
||||
})
|
||||
|
||||
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
|
||||
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
|
||||
})
|
||||
})
|
||||
|
||||
describe('analysisSessionKey', () => {
|
||||
it('prefers the default analysis session', () => {
|
||||
const ctx = context({
|
||||
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBe(32)
|
||||
})
|
||||
|
||||
it('falls back to the previous completed session', () => {
|
||||
const ctx = context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBe(11)
|
||||
})
|
||||
|
||||
it('returns undefined when there is no analysable session', () => {
|
||||
expect(analysisSessionKey(context())).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a zero/synthetic session key', () => {
|
||||
const ctx = context({
|
||||
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
|
||||
})
|
||||
expect(analysisSessionKey(ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLocalAnalysis', () => {
|
||||
it('is true for complete or partial local analysis', () => {
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for pending/not_applicable/absent', () => {
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
|
||||
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
|
||||
expect(hasLocalAnalysis(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meetingIdentity', () => {
|
||||
it('derives a short name and tolerates partial fields', () => {
|
||||
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
|
||||
expect(id?.short_name).toBe('Monaco')
|
||||
expect(id?.circuit_short_name).toBe('Monaco')
|
||||
})
|
||||
|
||||
it('returns undefined for an absent meeting', () => {
|
||||
expect(meetingIdentity(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops empty date strings', () => {
|
||||
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
|
||||
expect(id?.date_start).toBeUndefined()
|
||||
expect(id?.date_end).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('championshipImpact', () => {
|
||||
const hub: ChampionshipHub = {
|
||||
season: 2026,
|
||||
round: 2,
|
||||
total_rounds: 24,
|
||||
rounds_left: 22,
|
||||
last_race: 'British GP',
|
||||
round_labels: ['R1', 'R2'],
|
||||
drivers: [
|
||||
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
|
||||
],
|
||||
teams: [],
|
||||
}
|
||||
|
||||
it('maps top-3 movers with deltas and a note', () => {
|
||||
const impact = championshipImpact(hub)
|
||||
expect(impact?.leaders).toHaveLength(2)
|
||||
expect(impact?.leaders[0].delta).toBe(25)
|
||||
expect(impact?.note).toContain('British GP')
|
||||
})
|
||||
|
||||
it('returns undefined for an empty hub', () => {
|
||||
expect(championshipImpact(undefined)).toBeUndefined()
|
||||
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('briefingItems', () => {
|
||||
it('caps at three items and maps fields', () => {
|
||||
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
source: 'src',
|
||||
title: `Item ${i}`,
|
||||
url: `https://x/${i}`,
|
||||
fetched_at: '2026-07-06T00:00:00Z',
|
||||
category: 'news',
|
||||
og_image_url: 'img',
|
||||
}))
|
||||
const items = briefingItems(news)
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('podiumFromResults', () => {
|
||||
it('sorts by position, caps at three, and formats gaps', () => {
|
||||
const results: EnrichedResult[] = [
|
||||
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
|
||||
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
|
||||
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
|
||||
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
|
||||
]
|
||||
const podium = podiumFromResults(results)
|
||||
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
|
||||
expect(podium[1].gap).toBe('+5.123')
|
||||
expect(podium[2].gap).toBe('+10.5')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user