mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add Race Hub analytics visuals
This commit is contained in:
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal file
52
documentations/refactor/17-phase-9-navigation-data-api.md
Normal file
@@ -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.
|
||||||
@@ -66,8 +66,11 @@ not implementation tickets yet.
|
|||||||
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
|
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
|
||||||
slice for turning the newly available analytics datasets into useful Race Hub
|
slice for turning the newly available analytics datasets into useful Race Hub
|
||||||
views.
|
views.
|
||||||
- [Claude Phase 8 Prompt](claude-phase-8-analytics-visuals-prompt.md): current
|
- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend
|
||||||
handoff prompt for the next Claude frontend phase.
|
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
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { EnrichedResult, EnrichedGrid } from '../types'
|
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
|
||||||
import { gridDelta, gridDeltaClass } from '../utils'
|
import { gridDelta, gridDeltaClass } from '../utils'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
results: EnrichedResult[]
|
results: EnrichedResult[]
|
||||||
grid: EnrichedGrid[]
|
grid: EnrichedGrid[]
|
||||||
|
positions: PositionSample[]
|
||||||
|
laps: Lap[]
|
||||||
hasPositions: boolean
|
hasPositions: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PositionEvolutionView({ results, grid, hasPositions }: Props) {
|
export function PositionEvolutionView({ results, grid, positions, laps: _laps, hasPositions }: Props) {
|
||||||
if (!hasPositions) {
|
if (!hasPositions) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="analysis-notice">
|
<div className="analysis-notice">
|
||||||
<strong>Lap-by-lap positions not available.</strong> The backend does not
|
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||||
yet expose position samples in <code>/api/v1/race-hub</code>. Evolution
|
have ingested position samples in <code>/api/v1/race-hub</code>. Evolution
|
||||||
charts require per-driver position per lap.
|
charts require per-driver position samples over time.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && grid.length > 0 && (
|
{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 <div className="missing-notice">No position samples in this dataset.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number, Array<{ t: number; pos: number }>>()
|
||||||
|
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 (
|
return (
|
||||||
<div className="missing-notice">Position evolution chart: not yet implemented.</div>
|
<div data-testid="position-chart">
|
||||||
|
<div className="scroll-x">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||||
|
role="img"
|
||||||
|
aria-label="Position evolution chart"
|
||||||
|
>
|
||||||
|
{/* Horizontal grid lines + P# labels */}
|
||||||
|
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||||
|
<g key={pos}>
|
||||||
|
<line
|
||||||
|
x1={PL}
|
||||||
|
x2={W - PR}
|
||||||
|
y1={toY(pos)}
|
||||||
|
y2={toY(pos)}
|
||||||
|
stroke="var(--border)"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PL - 4}
|
||||||
|
y={toY(pos) + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="var(--text-3)"
|
||||||
|
fontSize={8}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
>
|
||||||
|
P{pos}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<g key={dNum}>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{samples.map((s, i) => (
|
||||||
|
<circle key={i} cx={toX(s.t)} cy={toY(s.pos)} r={3} fill={color} />
|
||||||
|
))}
|
||||||
|
{last && (
|
||||||
|
<text
|
||||||
|
x={toX(last.t) + 6}
|
||||||
|
y={toY(last.pos) + 4}
|
||||||
|
fill={color}
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
fontWeight={700}
|
||||||
|
>
|
||||||
|
{acronymByDriver.get(dNum) ?? dNum}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid → Finish table below chart for context */}
|
||||||
|
{results.length > 0 && grid.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
|
||||||
|
<span className="sec-title">Grid → Finish</span>
|
||||||
|
<span className="sec-meta">net positions</span>
|
||||||
|
</div>
|
||||||
|
<table className="data-table" style={{ maxWidth: 360 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th className="c">Grid</th>
|
||||||
|
<th className="c">Finish</th>
|
||||||
|
<th className="r">Δ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((r) => {
|
||||||
|
const gridPos =
|
||||||
|
grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
|
return (
|
||||||
|
<tr key={r.driver_number}>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--f-mono)',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name_acronym || r.driver_number}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="c mono" style={{ color: 'var(--text-3)' }}>
|
||||||
|
{gridPos || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="c mono">{r.position}</td>
|
||||||
|
<td className="r">
|
||||||
|
<span className={gridDeltaClass(r.position, gridPos)}>
|
||||||
|
{gridDelta(r.position, gridPos)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
import type { EnrichedResult } from '../types'
|
import type { EnrichedResult, Stint, PitStop } from '../types'
|
||||||
|
|
||||||
|
const COMPOUND_COLORS: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
SOFT: 'S',
|
||||||
|
MEDIUM: 'M',
|
||||||
|
HARD: 'H',
|
||||||
|
INTERMEDIATE: 'I',
|
||||||
|
WET: 'W',
|
||||||
|
}
|
||||||
|
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
results: EnrichedResult[]
|
results: EnrichedResult[]
|
||||||
|
stints: Stint[]
|
||||||
|
pit_stops: PitStop[]
|
||||||
hasStints: boolean
|
hasStints: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StrategyView({ results, hasStints }: Props) {
|
export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
||||||
if (!hasStints) {
|
if (!hasStints) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="analysis-notice">
|
<div className="analysis-notice">
|
||||||
<strong>Stints not available.</strong> The backend does not yet expose
|
<strong>Stints not available.</strong> This session does not have ingested
|
||||||
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
|
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
|
||||||
charts require per-driver stints: compound, lap_start, lap_end.
|
charts require per-driver stints: compound, lap_start, lap_end.
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="missing-notice">Strategy chart: not yet implemented.</div>
|
<div data-testid="strategy-chart">
|
||||||
|
<div className="scroll-x">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||||
|
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
|
||||||
|
role="img"
|
||||||
|
aria-label="Race strategy stint chart"
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
|
||||||
|
<text
|
||||||
|
x={LEFT - 5}
|
||||||
|
y={BAR_Y + BAR_H / 2 + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fill={color}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
fontWeight={700}
|
||||||
|
fontSize={10}
|
||||||
|
>
|
||||||
|
{driver.name_acronym}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{dStints.map((stint, si) => {
|
||||||
|
const x = lapX(stint.lap_start)
|
||||||
|
const w = stintW(stint)
|
||||||
|
const fill = compoundColor(stint.compound)
|
||||||
|
return (
|
||||||
|
<g key={si}>
|
||||||
|
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
|
||||||
|
{w > 18 && (
|
||||||
|
<text
|
||||||
|
x={x + w / 2}
|
||||||
|
y={BAR_Y + BAR_H / 2 + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#111"
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
|
fontWeight={700}
|
||||||
|
fontSize={8}
|
||||||
|
>
|
||||||
|
{compoundInitial(stint.compound)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{dPits.map((pit, pi) => {
|
||||||
|
const x = lapX(pit.lap_number)
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={pi}
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={BAR_Y - 3}
|
||||||
|
y2={BAR_Y + BAR_H + 3}
|
||||||
|
stroke="var(--text)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--s4)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginTop: 'var(--s4)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-3)',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{usedCompounds.map((c) => (
|
||||||
|
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: COMPOUND_COLORS[c],
|
||||||
|
borderRadius: 2,
|
||||||
|
display: 'inline-block',
|
||||||
|
border: c === 'HARD' ? '1px solid #555' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{c.charAt(0) + c.slice(1).toLowerCase()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{pit_stops.length > 0 && (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 12,
|
||||||
|
background: 'var(--text)',
|
||||||
|
display: 'inline-block',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Pit stop
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
|
||||||
|
{totalLaps} laps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<StrategyView
|
<StrategyView
|
||||||
results={data.results}
|
results={data.results}
|
||||||
|
stints={data.stints}
|
||||||
|
pit_stops={data.pit_stops}
|
||||||
hasStints={data.datasets['stints']?.status === 'available'}
|
hasStints={data.datasets['stints']?.status === 'available'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,6 +138,8 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<PositionEvolutionView
|
<PositionEvolutionView
|
||||||
results={data.results}
|
results={data.results}
|
||||||
grid={data.starting_grid}
|
grid={data.starting_grid}
|
||||||
|
positions={data.positions}
|
||||||
|
laps={data.laps}
|
||||||
hasPositions={data.datasets['positions']?.status === 'available'}
|
hasPositions={data.datasets['positions']?.status === 'available'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
200
frontend/src/test/PositionEvolutionView.test.tsx
Normal file
200
frontend/src/test/PositionEvolutionView.test.tsx
Normal file
@@ -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(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={positions}
|
||||||
|
laps={laps}
|
||||||
|
hasPositions={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[data-testid="position-chart"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an SVG chart', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={positions}
|
||||||
|
laps={laps}
|
||||||
|
hasPositions={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('polyline').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the missing-data notice', () => {
|
||||||
|
render(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={positions}
|
||||||
|
laps={laps}
|
||||||
|
hasPositions={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.queryByText(/Lap-by-lap positions not available/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Grid → Finish table below chart', () => {
|
||||||
|
render(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={positions}
|
||||||
|
laps={laps}
|
||||||
|
hasPositions={true}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Grid → Finish')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PositionEvolutionView — positions missing', () => {
|
||||||
|
it('shows the missing-data notice', () => {
|
||||||
|
render(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={[]}
|
||||||
|
laps={[]}
|
||||||
|
hasPositions={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={[]}
|
||||||
|
laps={[]}
|
||||||
|
hasPositions={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<PositionEvolutionView
|
||||||
|
results={results}
|
||||||
|
grid={grid}
|
||||||
|
positions={[]}
|
||||||
|
laps={[]}
|
||||||
|
hasPositions={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[data-testid="position-chart"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
134
frontend/src/test/StrategyView.test.tsx
Normal file
134
frontend/src/test/StrategyView.test.tsx
Normal file
@@ -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(
|
||||||
|
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[data-testid="strategy-chart"]')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders driver acronyms as SVG text', () => {
|
||||||
|
render(
|
||||||
|
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText('VER')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('HAM')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an SVG stint chart', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||||
|
expect(container.querySelectorAll('rect').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show the stints-unavailable notice', () => {
|
||||||
|
render(
|
||||||
|
<StrategyView results={results} stints={stints} pit_stops={pitStops} hasStints={true} />
|
||||||
|
)
|
||||||
|
expect(screen.queryByText(/Stints not available/i)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('StrategyView — stints missing', () => {
|
||||||
|
it('shows the missing-data notice', () => {
|
||||||
|
render(
|
||||||
|
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
|
||||||
|
)
|
||||||
|
expect(screen.getByText(/Stints not available/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to laps-completed table', () => {
|
||||||
|
render(
|
||||||
|
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
<StrategyView results={results} stints={[]} pit_stops={[]} hasStints={false} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[data-testid="strategy-chart"]')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -12,19 +12,19 @@ test.describe('Race Hub', () => {
|
|||||||
await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible()
|
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.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Strategy' }).click()
|
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()
|
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.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Positions' }).click()
|
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()
|
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 page.getByRole('tab', { name: 'Strategy' }).click()
|
||||||
|
|
||||||
await expect(page.getByText('Stints not available.')).toBeVisible()
|
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 }) => {
|
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 page.getByRole('tab', { name: 'Positions' }).click()
|
||||||
|
|
||||||
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user