mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty post-session analysis for a future race. - Backend: add `default_analysis_session` to the Weekend context. It never resolves to a future session (picks the richest completed session, ties toward the later one; 0 when everything is upcoming). Existing `default_session_key` and deep links are unchanged. - Frontend default resolution prefers the most recently completed weekend (`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling back to the switcher when only upcoming sessions exist. - New `sessionState` lib maps timing + coverage to user language (upcoming/live/preparing/partial/ready/cancelled); the session rail, active sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts. - Future sessions render a purpose-built PreSessionView (expected availability + countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards. - Analysis navigation regrouped into Story / Analysis / Data & Context; every existing tab is preserved. Diagnostics (renamed from Data Status) is now a secondary action and the raw dataset strip is hidden behind an explicit toggle, so operational coverage no longer precedes fan content. - Loading/error states offer Retry and a path back to Weekend. Tests: Go query tests for future-exclusion; Vitest for default selection, future pre-session, partial state, error/retry, grouped nav, and sessionState; hermetic Playwright for bare/completed/future/return-to-Weekend; new race-hub-future visual snapshots. Seed adds a far-future session inside the Monaco meeting (kept in-meeting so Command Center focus is unaffected). Note: `default_analysis_session` is an additive field on the existing `/weekend` contract (no new endpoint), per the spec's "context contract" scope. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
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 {
|
||||
@@ -25,6 +25,9 @@ const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
|
||||
// Use a fixed clock so upcoming/completed states are deterministic in tests.
|
||||
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
meeting_name: 'Monaco Grand Prix',
|
||||
@@ -59,6 +62,17 @@ const qualSession: Session = {
|
||||
gmt_offset: '02:00:00',
|
||||
}
|
||||
|
||||
// A session scheduled far in the future relative to NOW.
|
||||
const futureSession: Session = {
|
||||
session_key: 9600,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1300,
|
||||
date_start: '2099-05-25T13:00:00+00:00',
|
||||
date_end: '2099-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
}
|
||||
|
||||
const fullDatasets: Record<string, DatasetInfo> = {
|
||||
meeting: { status: 'available', source: 'local', count: 1 },
|
||||
session: { status: 'available', source: 'local', count: 1 },
|
||||
@@ -163,6 +177,7 @@ const weekend: Weekend = {
|
||||
meeting_key: 1229,
|
||||
meeting,
|
||||
default_session_key: 9472,
|
||||
default_analysis_session: 9472,
|
||||
sessions: [
|
||||
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||
{ session: raceSession, source: 'local', datasets: fullDatasets },
|
||||
@@ -206,12 +221,18 @@ function renderRaceHub(sessionKey: number) {
|
||||
describe('RaceHubPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(NOW)
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||
mockFetchWeekend.mockResolvedValue(weekend)
|
||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||
renderRaceHub(9472)
|
||||
|
||||
@@ -239,14 +260,44 @@ describe('RaceHubPage', () => {
|
||||
|
||||
})
|
||||
|
||||
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
|
||||
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
|
||||
renderRaceHub(9472)
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
|
||||
|
||||
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||
|
||||
// Raw dataset coverage strip is hidden until explicitly requested.
|
||||
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
|
||||
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the raw dataset strip before fan-facing content', async () => {
|
||||
renderRaceHub(9472)
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
|
||||
// Overview (fan content) is present, but the raw diagnostics strip is not.
|
||||
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
|
||||
renderRaceHub(9472)
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||
// Every capability preserved
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the inline weekend switcher', async () => {
|
||||
@@ -256,4 +307,83 @@ describe('RaceHubPage', () => {
|
||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves bare /race-hub through the default analysis session (never a future one)', async () => {
|
||||
const futureMeeting: Meeting = { ...meeting, meeting_key: 1300, meeting_name: 'Future GP' }
|
||||
mockFetchLocalMeetings.mockResolvedValue([futureMeeting])
|
||||
mockFetchWeekend.mockResolvedValue({
|
||||
source: 'partial',
|
||||
meeting_key: 1300,
|
||||
meeting: futureMeeting,
|
||||
// Backend excludes the future session; falls back to the completed quali.
|
||||
default_session_key: 9600,
|
||||
default_analysis_session: 9471,
|
||||
sessions: [
|
||||
{ session: { ...qualSession, meeting_key: 1300 }, source: 'local', datasets: fullDatasets },
|
||||
{ session: futureSession, source: 'none', datasets: {} },
|
||||
],
|
||||
})
|
||||
|
||||
renderRaceHub(0)
|
||||
|
||||
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
|
||||
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
||||
})
|
||||
|
||||
it('renders a pre-session view instead of empty analysis for a future session', async () => {
|
||||
mockFetchRaceHub.mockResolvedValue({
|
||||
...raceHub,
|
||||
session_key: 9600,
|
||||
source: 'none',
|
||||
session: futureSession,
|
||||
meeting: { ...meeting, meeting_key: 1300 },
|
||||
results: [],
|
||||
starting_grid: [],
|
||||
datasets: {},
|
||||
})
|
||||
mockFetchWeekend.mockResolvedValue({
|
||||
source: 'none',
|
||||
meeting_key: 1300,
|
||||
meeting: { ...meeting, meeting_key: 1300 },
|
||||
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
|
||||
})
|
||||
|
||||
renderRaceHub(9600)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
|
||||
// No Winner analysis card for an unrun session.
|
||||
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a completed but partial session as Partial in the active state', async () => {
|
||||
mockFetchWeekend.mockResolvedValue({
|
||||
...weekend,
|
||||
sessions: [
|
||||
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
|
||||
],
|
||||
})
|
||||
|
||||
renderRaceHub(9472)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
|
||||
})
|
||||
|
||||
it('offers retry and back-to-Weekend on an error', async () => {
|
||||
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
|
||||
|
||||
renderRaceHub(9472)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
|
||||
const back = screen.getByTestId('rh-back-weekend')
|
||||
expect(back).toHaveAttribute('href', '/race-hub')
|
||||
|
||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TabBar } from '../components/TabBar'
|
||||
|
||||
describe('TabBar', () => {
|
||||
it('renders all Race Hub workspace tabs', () => {
|
||||
it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
|
||||
render(<TabBar active="overview" onChange={() => {}} />)
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||
@@ -12,7 +12,11 @@ describe('TabBar', () => {
|
||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the active tab with aria-selected', () => {
|
||||
|
||||
82
frontend/src/test/sessionState.test.ts
Normal file
82
frontend/src/test/sessionState.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sessionState, sessionStateLabel } from '../lib/sessionState'
|
||||
import type { DatasetInfo, Session, WeekendSession } from '../types'
|
||||
|
||||
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||
|
||||
function mk(
|
||||
overrides: Partial<Session>,
|
||||
source: WeekendSession['source'],
|
||||
datasets: Record<string, DatasetInfo> = {},
|
||||
): WeekendSession {
|
||||
return {
|
||||
session: {
|
||||
session_key: 1,
|
||||
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: '00:00:00',
|
||||
...overrides,
|
||||
},
|
||||
source,
|
||||
datasets,
|
||||
}
|
||||
}
|
||||
|
||||
const FULL: Record<string, DatasetInfo> = Object.fromEntries(
|
||||
[
|
||||
'meeting',
|
||||
'session',
|
||||
'drivers',
|
||||
'results',
|
||||
'starting_grid',
|
||||
'stints',
|
||||
'pit_stops',
|
||||
'positions',
|
||||
'race_control',
|
||||
'weather',
|
||||
'laps',
|
||||
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
|
||||
)
|
||||
|
||||
describe('sessionState', () => {
|
||||
it('marks a future session as upcoming', () => {
|
||||
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
|
||||
expect(sessionState(s, NOW)).toBe('upcoming')
|
||||
})
|
||||
|
||||
it('marks a running session as live', () => {
|
||||
const start = new Date(NOW.getTime() - 60_000).toISOString()
|
||||
const end = new Date(NOW.getTime() + 60_000).toISOString()
|
||||
const s = mk({ date_start: start, date_end: end }, 'partial')
|
||||
expect(sessionState(s, NOW)).toBe('live')
|
||||
})
|
||||
|
||||
it('marks a finished session with full local data as ready', () => {
|
||||
const s = mk({}, 'local', FULL)
|
||||
expect(sessionState(s, NOW)).toBe('ready')
|
||||
})
|
||||
|
||||
it('marks a finished session with no data as preparing', () => {
|
||||
const s = mk({}, 'none', {})
|
||||
expect(sessionState(s, NOW)).toBe('preparing')
|
||||
})
|
||||
|
||||
it('marks a finished session with partial data as partial', () => {
|
||||
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
|
||||
expect(sessionState(s, NOW)).toBe('partial')
|
||||
})
|
||||
|
||||
it('marks a cancelled session as cancelled', () => {
|
||||
const s = mk({}, 'cancelled')
|
||||
expect(sessionState(s, NOW)).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('uses user language labels rather than coverage counts', () => {
|
||||
expect(sessionStateLabel('ready')).toBe('Ready')
|
||||
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
|
||||
expect(sessionStateLabel('partial')).toBe('Partial')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user