mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(race-story): polish chapter strip, controls, graph, and map UX (#68)
Consolidate the Race Story section into a cohesive timeline surface: custom chapter-strip scrolling with active-card sync, unified segmented playback controls, chapter bands on the position graph with decimated axes and de-collided labels, map toggle gated by an on-mount replay/outline probe, and shared empty-state cards. Adds vitest coverage and visual snapshots. Note: replay/outline queries now probe on mount (not only when Map opens) so the toggle can be hidden before users hit a dead panel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -68,6 +68,25 @@ describe('ChapterStrip', () => {
|
||||
expect(screen.getByText('L12–L15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty-state card when there are no chapters', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChapterStrip
|
||||
chapters={[]}
|
||||
scrubTime={null}
|
||||
tMin={tMin}
|
||||
tRange={tRange}
|
||||
tourActive={false}
|
||||
tourChapterIndex={null}
|
||||
onChapterClick={vi.fn()}
|
||||
onTourToggle={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights the active chapter from scrub time', () => {
|
||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
||||
renderStrip({ scrubTime: scrub })
|
||||
|
||||
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchReplayFrames: vi.fn(),
|
||||
@@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = {
|
||||
session_key: 99,
|
||||
interval_ms: 5000,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
|
||||
frames: [
|
||||
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
|
||||
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
||||
],
|
||||
}
|
||||
|
||||
const chapters: Chapter[] = [
|
||||
{
|
||||
kind: 'start',
|
||||
title: 'Start',
|
||||
headline: 'Lights out',
|
||||
start_lap: 1,
|
||||
end_lap: 1,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
end_time: '2025-05-25T13:01:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
{
|
||||
kind: 'safety_car',
|
||||
title: 'Safety Car',
|
||||
headline: 'Incident brings out the Safety Car',
|
||||
start_lap: 2,
|
||||
end_lap: 2,
|
||||
start_time: '2025-05-25T13:05:00Z',
|
||||
end_time: '2025-05-25T13:05:00Z',
|
||||
driver_numbers: [],
|
||||
},
|
||||
]
|
||||
|
||||
const raceHub: RaceHub = {
|
||||
source: 'local',
|
||||
session_key: 99,
|
||||
@@ -103,14 +129,14 @@ const raceHub: RaceHub = {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
chapters,
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
function renderCanvas(overrides: Partial<RaceHub> = {}) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RaceStoryCanvas data={raceHub} />
|
||||
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
@@ -122,14 +148,53 @@ describe('RaceStoryCanvas replay map', () => {
|
||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||
})
|
||||
|
||||
it('fetches replay frames lazily when the map panel opens', async () => {
|
||||
it('probes replay frames on mount and opens the map when data is available', async () => {
|
||||
renderCanvas()
|
||||
|
||||
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
||||
|
||||
const mapToggle = await screen.findByTestId('replay-map-toggle')
|
||||
fireEvent.click(mapToggle)
|
||||
|
||||
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the map toggle when replay frames are unavailable', async () => {
|
||||
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
|
||||
renderCanvas()
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('uses full-width chart layout when the map is closed', async () => {
|
||||
renderCanvas()
|
||||
|
||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
||||
const chart = screen.getByTestId('position-chart')
|
||||
expect(chart).toHaveClass('rs-chart-container--full')
|
||||
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
|
||||
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('syncs active chapter highlight when a chapter card is clicked', async () => {
|
||||
renderCanvas()
|
||||
|
||||
fireEvent.click(screen.getByTestId('chapter-card-1'))
|
||||
await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active'))
|
||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||
})
|
||||
|
||||
it('renders the empty-state card when positions are unavailable', () => {
|
||||
renderCanvas({
|
||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||
positions: [],
|
||||
chapters: [],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,9 +44,10 @@ const results: EnrichedResult[] = [
|
||||
]
|
||||
|
||||
describe('ReplayTrackMap', () => {
|
||||
it('renders an empty state when replay frames are missing', () => {
|
||||
it('renders an empty-state card when replay frames are missing', () => {
|
||||
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
||||
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
|
||||
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders car labels from result metadata', () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
activeChapterIndex,
|
||||
chapterBandFill,
|
||||
chapterKindLabel,
|
||||
chapterLapRange,
|
||||
chapterStartScrub,
|
||||
chapterTourDurations,
|
||||
deCollideYPositions,
|
||||
decimatedPositionLabels,
|
||||
} from '../lib/chapters'
|
||||
import type { Chapter } from '../types'
|
||||
|
||||
@@ -60,4 +63,27 @@ describe('chapters lib', () => {
|
||||
it('splits 90s evenly across chapters', () => {
|
||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
||||
})
|
||||
|
||||
it('decimates position axis labels', () => {
|
||||
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
|
||||
})
|
||||
|
||||
it('returns chapter band fills by kind', () => {
|
||||
expect(chapterBandFill('safety_car')).toContain('rgba')
|
||||
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
|
||||
})
|
||||
|
||||
it('de-collides overlapping label y positions', () => {
|
||||
const adjusted = deCollideYPositions(
|
||||
[
|
||||
{ key: 'a', y: 10 },
|
||||
{ key: 'b', y: 12 },
|
||||
{ key: 'c', y: 30 },
|
||||
],
|
||||
12,
|
||||
)
|
||||
expect(adjusted.get('a')).toBe(10)
|
||||
expect(adjusted.get('b')).toBe(22)
|
||||
expect(adjusted.get('c')).toBe(34)
|
||||
})
|
||||
})
|
||||
|
||||
33
frontend/src/test/replayMap.test.ts
Normal file
33
frontend/src/test/replayMap.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||
import type { ReplayFramesResponse, TrackOutline } from '../types'
|
||||
|
||||
const outline: TrackOutline = {
|
||||
circuit_key: 1,
|
||||
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
|
||||
points: [{ x: 0, y: 0 }],
|
||||
}
|
||||
|
||||
const replay: ReplayFramesResponse = {
|
||||
session_key: 1,
|
||||
interval_ms: 5000,
|
||||
start_time: '2025-05-25T13:00:00Z',
|
||||
frames: [
|
||||
{ t: 0, cars: {} },
|
||||
{ t: 5000, cars: {} },
|
||||
],
|
||||
}
|
||||
|
||||
describe('isReplayMapAvailable', () => {
|
||||
it('returns true when frames and outline are present', () => {
|
||||
expect(isReplayMapAvailable(replay, outline, false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when frames are sparse', () => {
|
||||
expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on query error', () => {
|
||||
expect(isReplayMapAvailable(replay, outline, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user