From b5d87775a66724d99c2b0eac47fd14bf49db00ae Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 02:19:53 -0400 Subject: [PATCH] Add Race Hub analytics visuals --- .../17-phase-9-navigation-data-api.md | 52 +++++ documentations/refactor/README.md | 7 +- ...claude-phase-8-analytics-visuals-prompt.md | 88 -------- ...rsor-phase-9-navigation-data-api-prompt.md | 89 ++++++++ .../src/components/PositionEvolutionView.tsx | 173 ++++++++++++++- frontend/src/components/StrategyView.tsx | 175 ++++++++++++++- frontend/src/pages/RaceHubPage.tsx | 4 + .../src/test/PositionEvolutionView.test.tsx | 200 ++++++++++++++++++ frontend/src/test/StrategyView.test.tsx | 134 ++++++++++++ tests/race-hub.spec.ts | 12 +- 10 files changed, 826 insertions(+), 108 deletions(-) create mode 100644 documentations/refactor/17-phase-9-navigation-data-api.md delete mode 100644 documentations/refactor/claude-phase-8-analytics-visuals-prompt.md create mode 100644 documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md create mode 100644 frontend/src/test/PositionEvolutionView.test.tsx create mode 100644 frontend/src/test/StrategyView.test.tsx diff --git a/documentations/refactor/17-phase-9-navigation-data-api.md b/documentations/refactor/17-phase-9-navigation-data-api.md new file mode 100644 index 0000000..27c29aa --- /dev/null +++ b/documentations/refactor/17-phase-9-navigation-data-api.md @@ -0,0 +1,52 @@ +# Phase 9 Navigation Data API + +## Purpose + +Race Hub now has useful local-first session views, but it still depends on a +manual `session_key`. Phase 9 should make the backend expose enough local +navigation data for the Web UI to become race-weekend-first: season calendar, +meeting detail, sessions, and ingestion coverage. + +This is a backend/read-model slice for Cursor. Keep the React redesign for the +following phase. + +## Scope + +Add local-first Web API endpoints/read models for: + +- seasons or available years in the domain database; +- meetings for a year; +- one meeting/weekend with its sessions; +- per-session dataset coverage using the same dataset vocabulary as Race Hub; +- a sensible "latest available" or "default session" helper if it can be done + without guessing from remote API data. + +## Backend Work + +Expected changes: + +- add query-layer read models in `internal/query` for calendar/weekend data; +- add store reads if existing methods are insufficient; +- add HTTP handlers in `internal/web`; +- keep responses local-first and deterministic; +- expose empty but well-shaped responses when the database has no ingested + meetings; +- add offline tests using temporary SQLite databases. + +## Guardrails + +- Do not fetch OpenF1 from these read endpoints. +- Do not make React depend on OpenF1 directly. +- Do not start frontend navigation implementation in this phase. +- Keep endpoint names stable and boring; this is app infrastructure, not a + product copywriting exercise. +- Keep the existing Race Hub API working unchanged. + +## Acceptance Criteria + +- Web API can list ingested years and meetings. +- Web API can return a meeting/weekend with sessions. +- Each session includes dataset coverage needed to guide users into Race Hub. +- Empty database behavior is explicit and tested. +- Focused Go tests pass. +- Existing frontend unit/build/e2e checks still pass. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 7a48099..c990198 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -66,8 +66,11 @@ not implementation tickets yet. - [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend slice for turning the newly available analytics datasets into useful Race Hub views. -- [Claude Phase 8 Prompt](claude-phase-8-analytics-visuals-prompt.md): current - handoff prompt for the next Claude frontend phase. +- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend + slice for local-first season/weekend/session navigation so users do not need + raw session keys. +- [Cursor Phase 9 Prompt](cursor-phase-9-navigation-data-api-prompt.md): + current handoff prompt for the next Cursor backend phase. ## External References diff --git a/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md b/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md deleted file mode 100644 index cac2222..0000000 --- a/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md +++ /dev/null @@ -1,88 +0,0 @@ -# Prompt For Claude: Phase 8 Analytics Visuals - -You are the frontend/UI engineer for Phase 8 of `box-box`. Please keep context -usage low: do not read the whole refactor docs folder. Start with the files -listed below and only open more if you are blocked. - -## Goal - -Turn the Race Hub Strategy and Position tabs from placeholders into real views -powered by the local-first `/api/v1/race-hub` response. - -## Read First - -Open only these first: - -- `frontend/src/pages/RaceHubPage.tsx` -- `frontend/src/components/StrategyView.tsx` -- `frontend/src/components/PositionEvolutionView.tsx` -- `frontend/src/types.ts` -- `tests/race-hub.spec.ts` -- `scripts/seed-e2e-db/main.go` - -Optional, only if you need design guidance: - -- `documentations/refactor/16-phase-8-analytics-visuals.md` -- `documentations/refactor/06-visual-design-direction.md` - -## Current Backend Contract - -`RaceHub` already includes these arrays: - -- `stints` -- `pit_stops` -- `positions` -- `race_control` -- `weather` -- `laps` - -Dataset availability is still reported under `datasets`. - -Seeded e2e sessions: - -- `9472`: has core data plus analytics data. -- `9000`: has core data only, so missing-data states must still render. - -## Work To Do - -1. Update `RaceHubPage.tsx` to pass analytics arrays into the Strategy and - Position components. -2. Replace `"Strategy chart: not yet implemented."` with a real strategy view: - per-driver stint bars, compound labels/colors, lap ranges, and pit context. -3. Replace `"Position evolution chart: not yet implemented."` with a real - position view: per-driver progression from `positions`, plus grid/finish - context when available. -4. Preserve honest missing-data states for sessions without analytics. -5. Update tests so they assert real analytics UI for session `9472`, not - placeholder text. - -## Design Constraints - -- Keep it dense, technical, and F1-native. -- Use SVG/CSS for this first slice unless a dependency is truly necessary. -- Team color identifies drivers; compound color identifies tyre data. -- Avoid generic dashboard card sludge, decorative gradients, and fake runtime - mock data. -- Keep mobile/iPad usable. - -## Verification - -Run: - -```bash -cd frontend && npm test -- --run -cd frontend && npm run build -npm run test:e2e -``` - -The root e2e command starts a seeded local database and local web/API servers. -It should not need OpenF1 network access. - -## Report Back - -Summarize: - -- files changed; -- UI behavior added; -- tests run and results; -- follow-up risks or refinements. diff --git a/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md b/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md new file mode 100644 index 0000000..dec3425 --- /dev/null +++ b/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md @@ -0,0 +1,89 @@ +# Prompt For Cursor: Phase 9 Navigation Data API + +You are working in the `box-box` repository as the backend engineer for Phase +9. Please keep this phase focused: add local-first navigation APIs so the +frontend can later stop requiring users to know raw `session_key` values. + +## Read First + +Open these files first: + +- `documentations/refactor/17-phase-9-navigation-data-api.md` +- `internal/query/racehub.go` +- `internal/web/racehub.go` +- `internal/web/server.go` +- `internal/store/store.go` +- `internal/store/models.go` +- `internal/store/store_test.go` +- `scripts/seed-e2e-db/main.go` + +Only open older planning docs if you need context. + +## Goal + +Implement local-first Web API read models for season/weekend/session +navigation. These endpoints must read from the SQLite domain database only. +They must not fetch OpenF1 on demand. + +## Suggested API Shape + +Use boring, stable names unless the codebase suggests a better convention: + +- `GET /api/v1/seasons` + - returns years available in the local domain DB. +- `GET /api/v1/meetings?year=2025` + - returns locally ingested meetings for that year. +- `GET /api/v1/weekend?meeting_key=1229` + - returns meeting metadata, sessions, and per-session dataset coverage. + +Dataset coverage should reuse the Race Hub dataset vocabulary where practical: + +- meeting +- session +- drivers +- results +- starting_grid +- stints +- pit_stops +- positions +- race_control +- weather +- laps + +## Implementation Notes + +- Add query-layer structs/methods in `internal/query`; keep HTTP handlers thin. +- Add store read methods only where needed. +- Empty DB should return valid empty arrays, not 500s. +- Missing meeting should return a clear 404 from the web handler. +- Add tests against temp SQLite databases. +- If you touch the e2e seed, keep session `9472` as full data and `9000` as + core-only data. + +## Do Not Do + +- Do not build the React navigation UI yet. +- Do not add remote OpenF1 calls to these endpoints. +- Do not change the existing Race Hub response shape. +- Do not add live timing persistence in this phase. + +## Verification + +Run: + +```bash +go test ./internal/store/... ./internal/query/... ./internal/web/... +go build -o /private/tmp/box-box ./cmd/main.go +cd frontend && npm test -- --run +cd frontend && npm run build +npm run test:e2e +``` + +## Report Back + +Summarize: + +- files changed; +- endpoint shapes added; +- tests run and results; +- follow-up risks or frontend handoff notes. diff --git a/frontend/src/components/PositionEvolutionView.tsx b/frontend/src/components/PositionEvolutionView.tsx index f04c180..bcd7b10 100644 --- a/frontend/src/components/PositionEvolutionView.tsx +++ b/frontend/src/components/PositionEvolutionView.tsx @@ -1,20 +1,22 @@ -import type { EnrichedResult, EnrichedGrid } from '../types' +import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types' import { gridDelta, gridDeltaClass } from '../utils' interface Props { results: EnrichedResult[] grid: EnrichedGrid[] + positions: PositionSample[] + laps: Lap[] hasPositions: boolean } -export function PositionEvolutionView({ results, grid, hasPositions }: Props) { +export function PositionEvolutionView({ results, grid, positions, laps: _laps, hasPositions }: Props) { if (!hasPositions) { return (
- Lap-by-lap positions not available. The backend does not - yet expose position samples in /api/v1/race-hub. Evolution - charts require per-driver position per lap. + Lap-by-lap positions not available. This session does not + have ingested position samples in /api/v1/race-hub. Evolution + charts require per-driver position samples over time.
{results.length > 0 && grid.length > 0 && ( @@ -73,8 +75,165 @@ export function PositionEvolutionView({ results, grid, hasPositions }: Props) { ) } - // Placeholder for when position samples are available + // Build time-indexed position series per driver + const allTimes = [...new Set(positions.map((p) => p.date))].sort() + + if (allTimes.length === 0) { + return
No position samples in this dataset.
+ } + + const tMin = new Date(allTimes[0]).getTime() + const tMax = new Date(allTimes[allTimes.length - 1]).getTime() + const tRange = Math.max(tMax - tMin, 1) + + const byDriver = new Map>() + for (const p of positions) { + if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, []) + byDriver.get(p.driver_number)!.push({ + t: (new Date(p.date).getTime() - tMin) / tRange, + pos: p.position, + }) + } + for (const samples of byDriver.values()) { + samples.sort((a, b) => a.t - b.t) + } + + const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) + const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour])) + const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym])) + + const W = 640 + const H = 180 + const PL = 40 + const PR = 48 // right margin for driver labels + const PT = 8 + const PB = 8 + const plotW = W - PL - PR + const plotH = H - PT - PB + + const toX = (t: number) => PL + t * plotW + const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH + return ( -
Position evolution chart: not yet implemented.
+
+
+ + {/* Horizontal grid lines + P# labels */} + {Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => ( + + + + P{pos} + + + ))} + + {/* Driver lines */} + {Array.from(byDriver.entries()).map(([dNum, samples]) => { + const colour = colorByDriver.get(dNum) + const color = colour ? `#${colour}` : '#888' + const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ') + const last = samples[samples.length - 1] + + return ( + + + {samples.map((s, i) => ( + + ))} + {last && ( + + {acronymByDriver.get(dNum) ?? dNum} + + )} + + ) + })} + +
+ + {/* Grid → Finish table below chart for context */} + {results.length > 0 && grid.length > 0 && ( + <> +
+ Grid → Finish + net positions +
+ + + + + + + + + + + {results.map((r) => { + const gridPos = + grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 + return ( + + + + + + + ) + })} + +
DriverGridFinishΔ
+ + {r.name_acronym || r.driver_number} + + + {gridPos || '—'} + {r.position} + + {gridDelta(r.position, gridPos)} + +
+ + )} +
) } diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx index 6a60c5f..4676634 100644 --- a/frontend/src/components/StrategyView.tsx +++ b/frontend/src/components/StrategyView.tsx @@ -1,16 +1,41 @@ -import type { EnrichedResult } from '../types' +import type { EnrichedResult, Stint, PitStop } from '../types' + +const COMPOUND_COLORS: Record = { + SOFT: '#e8002d', + MEDIUM: '#ffd600', + HARD: '#e8e8e4', + INTERMEDIATE: '#39b54a', + WET: '#0067ff', +} + +function compoundColor(c: string): string { + return COMPOUND_COLORS[c.toUpperCase()] ?? '#666' +} + +function compoundInitial(c: string): string { + const abbr: Record = { + SOFT: 'S', + MEDIUM: 'M', + HARD: 'H', + INTERMEDIATE: 'I', + WET: 'W', + } + return abbr[c.toUpperCase()] ?? c[0] ?? '?' +} interface Props { results: EnrichedResult[] + stints: Stint[] + pit_stops: PitStop[] hasStints: boolean } -export function StrategyView({ results, hasStints }: Props) { +export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { if (!hasStints) { return (
- Stints not available. The backend does not yet expose + Stints not available. This session does not have ingested tyre compound and stint ranges in /api/v1/race-hub. Strategy charts require per-driver stints: compound, lap_start, lap_end.
@@ -59,8 +84,148 @@ export function StrategyView({ results, hasStints }: Props) { ) } - // Placeholder for when stints data is available + const sortedDrivers = [...results].sort((a, b) => a.position - b.position) + const totalLaps = Math.max( + ...stints.map((s) => s.lap_end), + ...results.map((r) => r.number_of_laps), + 1 + ) + + const SVG_W = 640 + const LEFT = 48 + const RIGHT = 12 + const ROW_H = 28 + const BAR_H = 14 + const BAR_Y = 7 + const BAR_W = SVG_W - LEFT - RIGHT + const SVG_H = sortedDrivers.length * ROW_H + 8 + + const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W + const stintW = (s: Stint) => + Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W) + + const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter( + (c) => c in COMPOUND_COLORS + ) + return ( -
Strategy chart: not yet implemented.
+
+
+ + {sortedDrivers.map((driver, i) => { + const rowY = i * ROW_H + const color = driver.team_colour ? `#${driver.team_colour}` : '#888' + const dStints = stints.filter((s) => s.driver_number === driver.driver_number) + const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number) + + return ( + + + {driver.name_acronym} + + + {dStints.map((stint, si) => { + const x = lapX(stint.lap_start) + const w = stintW(stint) + const fill = compoundColor(stint.compound) + return ( + + + {w > 18 && ( + + {compoundInitial(stint.compound)} + + )} + + ) + })} + + {dPits.map((pit, pi) => { + const x = lapX(pit.lap_number) + return ( + + ) + })} + + ) + })} + +
+ +
+ {usedCompounds.map((c) => ( + + + {c.charAt(0) + c.slice(1).toLowerCase()} + + ))} + {pit_stops.length > 0 && ( + + + Pit stop + + )} + + {totalLaps} laps + +
+
) } diff --git a/frontend/src/pages/RaceHubPage.tsx b/frontend/src/pages/RaceHubPage.tsx index 59f7fb9..0ab46dc 100644 --- a/frontend/src/pages/RaceHubPage.tsx +++ b/frontend/src/pages/RaceHubPage.tsx @@ -123,6 +123,8 @@ export function RaceHubPage({ sessionKey }: Props) {
@@ -136,6 +138,8 @@ export function RaceHubPage({ sessionKey }: Props) { diff --git a/frontend/src/test/PositionEvolutionView.test.tsx b/frontend/src/test/PositionEvolutionView.test.tsx new file mode 100644 index 0000000..0fba545 --- /dev/null +++ b/frontend/src/test/PositionEvolutionView.test.tsx @@ -0,0 +1,200 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PositionEvolutionView } from '../components/PositionEvolutionView' +import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types' + +const results: 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: null, + 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: null, + gap_to_leader: 5.1, + number_of_laps: 78, + points: 18, + session_key: 9472, + meeting_key: 1229, + }, +] + +const grid: EnrichedGrid[] = [ + { + driver_number: 1, + position: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull Racing', + team_colour: '3671C6', + session_key: 9472, + meeting_key: 1229, + lap_duration: 71.234, + }, + { + driver_number: 44, + position: 2, + name_acronym: 'HAM', + full_name: 'Lewis Hamilton', + team_name: 'Ferrari', + team_colour: 'E8002D', + session_key: 9472, + meeting_key: 1229, + lap_duration: 71.456, + }, +] + +const positions: PositionSample[] = [ + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + date: '2025-05-25T13:05:00+00:00', + position: 1, + }, + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + date: '2025-05-25T13:10:00+00:00', + position: 1, + }, + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + date: '2025-05-25T13:05:00+00:00', + position: 2, + }, +] + +const laps: Lap[] = [ + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + lap_number: 1, + date_start: '2025-05-25T13:00:00+00:00', + lap_duration: 75.1, + is_pit_out_lap: false, + }, +] + +describe('PositionEvolutionView — positions available', () => { + it('renders the position chart container', () => { + const { container } = render( + + ) + expect(container.querySelector('[data-testid="position-chart"]')).toBeInTheDocument() + }) + + it('renders an SVG chart', () => { + const { container } = render( + + ) + expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelectorAll('polyline').length).toBeGreaterThan(0) + }) + + it('does not show the missing-data notice', () => { + render( + + ) + expect(screen.queryByText(/Lap-by-lap positions not available/i)).not.toBeInTheDocument() + }) + + it('renders Grid → Finish table below chart', () => { + render( + + ) + expect(screen.getByText('Grid → Finish')).toBeInTheDocument() + }) +}) + +describe('PositionEvolutionView — positions missing', () => { + it('shows the missing-data notice', () => { + render( + + ) + expect(screen.getByText(/Lap-by-lap positions not available/i)).toBeInTheDocument() + }) + + it('falls back to grid → finish table when both results and grid exist', () => { + render( + + ) + expect(screen.getByText('Grid → Finish')).toBeInTheDocument() + expect(screen.getByText('VER')).toBeInTheDocument() + expect(screen.getByText('HAM')).toBeInTheDocument() + }) + + it('does not render the position chart', () => { + const { container } = render( + + ) + expect(container.querySelector('[data-testid="position-chart"]')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/StrategyView.test.tsx b/frontend/src/test/StrategyView.test.tsx new file mode 100644 index 0000000..03b35f1 --- /dev/null +++ b/frontend/src/test/StrategyView.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { StrategyView } from '../components/StrategyView' +import type { EnrichedResult, Stint, PitStop } from '../types' + +const results: 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: null, + 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: null, + gap_to_leader: 5.1, + number_of_laps: 78, + points: 18, + session_key: 9472, + meeting_key: 1229, + }, +] + +const stints: Stint[] = [ + { + session_key: 9472, + driver_number: 1, + meeting_key: 1229, + stint_number: 1, + compound: 'MEDIUM', + lap_start: 1, + lap_end: 30, + tyre_age_at_start: 0, + }, + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + stint_number: 1, + compound: 'SOFT', + lap_start: 1, + lap_end: 18, + tyre_age_at_start: 0, + }, +] + +const pitStops: PitStop[] = [ + { + session_key: 9472, + driver_number: 44, + meeting_key: 1229, + lap_number: 19, + date: '2025-05-25T14:00:00+00:00', + pit_duration: 2.4, + lane_duration: 0, + stop_duration: 2.4, + }, +] + +describe('StrategyView — stints available', () => { + it('renders the strategy chart container', () => { + const { container } = render( + + ) + expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument() + }) + + it('renders driver acronyms as SVG text', () => { + render( + + ) + expect(screen.getByText('VER')).toBeInTheDocument() + expect(screen.getByText('HAM')).toBeInTheDocument() + }) + + it('renders an SVG stint chart', () => { + const { container } = render( + + ) + expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelectorAll('rect').length).toBeGreaterThan(0) + }) + + it('does not show the stints-unavailable notice', () => { + render( + + ) + expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument() + }) +}) + +describe('StrategyView — stints missing', () => { + it('shows the missing-data notice', () => { + render( + + ) + expect(screen.getByText(/Stints not available/i)).toBeInTheDocument() + }) + + it('falls back to laps-completed table', () => { + render( + + ) + expect(screen.getByText('VER')).toBeInTheDocument() + expect(screen.getByText('HAM')).toBeInTheDocument() + expect(screen.getAllByText('78').length).toBe(2) + }) + + it('does not render the strategy chart', () => { + const { container } = render( + + ) + expect(container.querySelector('[data-testid="strategy-chart"]')).not.toBeInTheDocument() + }) +}) diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index 16e0ab2..f4c3d97 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -12,19 +12,19 @@ test.describe('Race Hub', () => { await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible() }) - test('strategy tab shows chart placeholder when stints are available', async ({ page }) => { + test('strategy tab renders stint chart when stints are available', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.getByRole('tab', { name: 'Strategy' }).click() - await expect(page.getByText('Strategy chart: not yet implemented.')).toBeVisible() + await expect(page.locator('[data-testid="strategy-chart"]')).toBeVisible() await expect(page.getByText('Stints not available.')).not.toBeVisible() }) - test('positions tab shows chart placeholder when positions are available', async ({ page }) => { + test('positions tab renders position chart when positions are available', async ({ page }) => { await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.getByRole('tab', { name: 'Positions' }).click() - await expect(page.getByText('Position evolution chart: not yet implemented.')).toBeVisible() + await expect(page.locator('[data-testid="position-chart"]')).toBeVisible() await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible() }) @@ -33,7 +33,7 @@ test.describe('Race Hub', () => { await page.getByRole('tab', { name: 'Strategy' }).click() await expect(page.getByText('Stints not available.')).toBeVisible() - await expect(page.getByText('Strategy chart: not yet implemented.')).not.toBeVisible() + await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() }) test('positions tab shows missing notice when positions are unavailable', async ({ page }) => { @@ -41,6 +41,6 @@ test.describe('Race Hub', () => { await page.getByRole('tab', { name: 'Positions' }).click() await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() - await expect(page.getByText('Position evolution chart: not yet implemented.')).not.toBeVisible() + await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible() }) })