import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent } 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' import { parseWeekendSearch } from '../lib/routeSearch' vi.mock('../api', () => ({ fetchWeekendContext: vi.fn(), fetchChampionshipHub: vi.fn(), fetchNews: vi.fn(), fetchRaceHub: vi.fn(), fetchWeekend: 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, fetchWeekend, 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 mockWeekend = vi.mocked(fetchWeekend) 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 { return { schedule: 'available', live_transport: 'unknown', live_session: 'inactive', archive: 'unavailable', local_analysis: 'complete', freshness: 'fresh', limitations: [], ...overrides, } } function meeting(overrides: Partial = {}): 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 { 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 { return { session: session(), meeting: meeting(), availability: availability(), ...overrides, } } function context(overrides: Partial = {}): 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: () => ( ), }) const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', validateSearch: (s: Record) => parseWeekendSearch(s), component: function HomeRoute() { const { meeting_key, session_key } = homeRoute.useSearch() return }, }) const previewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/preview', component: () => , }) const raceHubRoute = createRoute({ getParentRoute: () => rootRoute, path: '/race-hub', validateSearch: (s: Record) => { const sk = Number(s.session_key) return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {} }, component: () =>
, }) const stub = (p: string, id: string) => createRoute({ getParentRoute: () => rootRoute, path: p, component: () =>
}) 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() } 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) mockWeekend.mockResolvedValue({ source: 'local', meeting_key: 1, meeting: meeting(), sessions: [ { session: session({ session_key: 11, session_name: 'Race' }), source: 'local', datasets: {}, }, ], default_session_key: 11, }) 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()) // Sanitized — never leak raw HTTP status/body into the Weekend surface. expect(screen.getByTestId('weekend-error')).not.toHaveTextContent(/API 500|boom/i) expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() }) it('retries the canonical read and recovers into a temporal state', async () => { let calls = 0 mockContext.mockImplementation(async () => { calls += 1 if (calls === 1) throw new Error('API 503: unavailable') return context({ temporal_state: 'between_weekends' }) }) renderAt('/') await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument()) const retry = screen.getByRole('button', { name: 'Retry' }) fireEvent.click(retry) await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument()) expect(calls).toBe(2) expect(screen.getByTestId('weekend-page')).toHaveAttribute( 'data-temporal-state', 'between_weekends', ) }) it('failed canonical read creates zero seasons/meetings/weekend/live-state fanout', async () => { mockContext.mockRejectedValue(new Error('API 503: unavailable')) renderAt('/') await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument()) expect(mockSeasons).not.toHaveBeenCalled() expect(mockMeetings).not.toHaveBeenCalled() expect(mockWeekend).not.toHaveBeenCalled() expect(mockHub).not.toHaveBeenCalled() expect(mockNews).not.toHaveBeenCalled() }) 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() }) it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => { mockContext.mockResolvedValue( context({ temporal_state: 'season_complete', previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }), default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }), }), ) renderAt('/?meeting_key=1&session_key=11') await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument()) expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1') expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11') await waitFor(() => expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'), ) await waitFor(() => expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'), ) const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i }) expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11')) expect(mockWeekend).toHaveBeenCalledWith(1) }) })