mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Distinguish FIA feed drops from true session end via terminal SessionStatus, consume canonical /api/v1/weekend-context (post-#72 rebase), poll until analysis-ready, and restore missing live-state styles plus transition/E2E/visual coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
206
frontend/src/test/LiveHandoff.test.tsx
Normal file
206
frontend/src/test/LiveHandoff.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LiveHandoff, analysisIsReady } from '../components/live/LiveHandoff'
|
||||
import type { LiveTimingRow } from '../lib/live'
|
||||
import type { ContextSession, WeekendContext } from '../types'
|
||||
|
||||
function session(key: number, name: string, type = name): ContextSession['session'] {
|
||||
return {
|
||||
session_key: key,
|
||||
session_name: name,
|
||||
session_type: type,
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-05T14:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
gmt_offset: '',
|
||||
}
|
||||
}
|
||||
|
||||
function contextSession(
|
||||
key: number,
|
||||
name: string,
|
||||
localAnalysis: string,
|
||||
): ContextSession {
|
||||
return {
|
||||
session: session(key, name),
|
||||
availability: {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: localAnalysis,
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const baseContext: WeekendContext = {
|
||||
temporal_state: 'session_settling',
|
||||
focus_meeting: {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'British Grand Prix',
|
||||
meeting_official_name: 'British Grand Prix',
|
||||
location: 'Silverstone',
|
||||
country_name: 'UK',
|
||||
country_code: 'GB',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Silverstone',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
},
|
||||
championship_round: 1,
|
||||
total_championship_rounds: 1,
|
||||
}
|
||||
|
||||
const rows: LiveTimingRow[] = [
|
||||
{ RacingNumber: '1', Position: 1, Driver: { RacingNumber: '1', Position: 1 } as never, Info: { Tla: 'VER' } as never },
|
||||
{ RacingNumber: '4', Position: 2, Driver: { RacingNumber: '4', Position: 2 } as never, Info: { Tla: 'NOR' } as never },
|
||||
]
|
||||
|
||||
describe('analysisIsReady', () => {
|
||||
it('is true only when local analysis is complete', () => {
|
||||
expect(analysisIsReady(contextSession(11, 'Race', 'complete'))).toBe(true)
|
||||
expect(analysisIsReady(contextSession(11, 'Race', 'pending'))).toBe(false)
|
||||
expect(analysisIsReady(contextSession(11, 'Race', 'partial'))).toBe(false)
|
||||
expect(analysisIsReady(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LiveHandoff settling', () => {
|
||||
it('shows SESSION SETTLING with a pending analysis action while ingesting', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'pending') }}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('live-settling')).toBeInTheDocument()
|
||||
expect(screen.getByText('SESSION SETTLING')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-handoff-captured')).toHaveTextContent('Final feed snapshot captured')
|
||||
const action = screen.getByTestId('live-handoff-analysis')
|
||||
expect(action).toHaveAttribute('href', '/race-hub?session_key=11')
|
||||
expect(action).toHaveAttribute('data-ready', 'false')
|
||||
expect(action).toHaveTextContent('Open Race analysis')
|
||||
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||
// Provisional final order from the retained snapshot.
|
||||
expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER')
|
||||
})
|
||||
|
||||
it('flips to analysis-ready once local ingestion completes', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'complete') }}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive={false}
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const action = screen.getByTestId('live-handoff-analysis')
|
||||
expect(action).toHaveAttribute('data-ready', 'true')
|
||||
expect(action).toHaveTextContent(/full timing, strategy & story ready/i)
|
||||
})
|
||||
|
||||
it('never renders live/connected chrome or a countdown in settling', () => {
|
||||
const { container } = render(
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'complete') }}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-clock')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.live-session-flag-live')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses non-settling readiness copy when inactive and analysis is still ingesting', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="inactive"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'pending') }}
|
||||
rows={[]}
|
||||
hasArchive={false}
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const action = screen.getByTestId('live-handoff-analysis')
|
||||
expect(action).toHaveAttribute('data-ready', 'false')
|
||||
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||
expect(action).not.toHaveTextContent(/^Settling/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LiveHandoff inactive', () => {
|
||||
it('uses shared next + recap context instead of a telemetry-offline dead end', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="inactive"
|
||||
transport="connected"
|
||||
context={{
|
||||
...baseContext,
|
||||
temporal_state: 'between_sessions',
|
||||
default_analysis_session: contextSession(11, 'Practice 1', 'complete'),
|
||||
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||
previous_completed_session: contextSession(10, 'Practice 2', 'complete'),
|
||||
}}
|
||||
rows={[]}
|
||||
hasArchive={false}
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('live-inactive')).toBeInTheDocument()
|
||||
expect(screen.getByText('NO LIVE SESSION')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Qualifying')
|
||||
expect(screen.getByTestId('live-handoff-recap')).toHaveTextContent('Practice 2')
|
||||
// No provisional-order snapshot when inactive.
|
||||
expect(screen.queryByTestId('live-handoff-snapshot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not duplicate the recap card when previous equals the analysis target', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="inactive"
|
||||
transport="connected"
|
||||
context={{
|
||||
...baseContext,
|
||||
default_analysis_session: contextSession(11, 'Race', 'complete'),
|
||||
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||
}}
|
||||
rows={[]}
|
||||
hasArchive={false}
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('live-handoff-recap')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to Weekend when the context has nothing to offer', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="inactive"
|
||||
transport="error"
|
||||
context={{ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }}
|
||||
rows={[]}
|
||||
hasArchive={false}
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('live-handoff-fallback')).toHaveTextContent('Weekend')
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,34 @@
|
||||
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 { LiveTimingPage } from '../pages/LiveTimingPage'
|
||||
import type { LiveStateResponse, LiveStreamData } from '../types'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage'
|
||||
import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchLiveTrackOutline: vi.fn(),
|
||||
fetchWeekendContext: vi.fn(),
|
||||
}))
|
||||
|
||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
||||
import { fetchLiveState, fetchLiveTrackOutline, fetchWeekendContext } from '../api'
|
||||
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
||||
const mockFetchWeekendContext = vi.mocked(fetchWeekendContext)
|
||||
|
||||
// A MockEventSource that opens on construct but never emits a snapshot, so the
|
||||
// initial /api/v1/live/state query drives the rendered phase.
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor() {
|
||||
setTimeout(() => this.onopen?.(), 0)
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const archivedSnapshot: LiveStreamData = {
|
||||
const raceSnapshot: LiveStreamData = {
|
||||
Drivers: {
|
||||
'1': {
|
||||
RacingNumber: '1',
|
||||
@@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = {
|
||||
LastName: 'Verstappen',
|
||||
},
|
||||
},
|
||||
Tyres: {
|
||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
||||
},
|
||||
Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } },
|
||||
Telemetry: {},
|
||||
RCMessages: [],
|
||||
Weather: {
|
||||
AirTemp: 22,
|
||||
TrackTemp: 41,
|
||||
Humidity: 58,
|
||||
WindSpeed: 3,
|
||||
WindDir: 180,
|
||||
Rainfall: false,
|
||||
},
|
||||
Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false },
|
||||
Session: {
|
||||
MeetingName: 'Testonia Grand Prix',
|
||||
CircuitName: 'Testring',
|
||||
@@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = {
|
||||
Path: '',
|
||||
},
|
||||
TeamRadio: [],
|
||||
SessionStatus: 'Finished',
|
||||
SessionStatus: 'Started',
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 57,
|
||||
CurrentLap: 30,
|
||||
TotalLaps: 57,
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
@@ -94,17 +87,66 @@ const archivedSnapshot: LiveStreamData = {
|
||||
Stints: {},
|
||||
}
|
||||
|
||||
function renderPage(response: LiveStateResponse) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
function weekendContext(localAnalysis: string): WeekendContext {
|
||||
return {
|
||||
temporal_state: 'session_settling',
|
||||
focus_meeting: {
|
||||
meeting_key: 1,
|
||||
meeting_name: 'Testonia Grand Prix',
|
||||
meeting_official_name: 'Testonia Grand Prix',
|
||||
location: 'Testring',
|
||||
country_name: 'Testonia',
|
||||
country_code: 'TS',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Testring',
|
||||
date_start: '2026-07-03T09:00:00Z',
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
},
|
||||
default_analysis_session: {
|
||||
session: {
|
||||
session_key: 99,
|
||||
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: '',
|
||||
},
|
||||
availability: {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'available',
|
||||
local_analysis: localAnalysis,
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
championship_round: 1,
|
||||
total_championship_rounds: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
response: LiveStateResponse,
|
||||
context?: WeekendContext,
|
||||
options: { setWeekendContext?: boolean } = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mockFetchLiveState.mockResolvedValue(response)
|
||||
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||
circuit_key: 1,
|
||||
points: [],
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
})
|
||||
|
||||
// Allow callers to pre-configure the weekend-context mock (e.g. pending→complete
|
||||
// polling) without this helper overwriting the chain.
|
||||
if (options.setWeekendContext !== false) {
|
||||
mockFetchWeekendContext.mockResolvedValue(
|
||||
context ?? { temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 },
|
||||
)
|
||||
}
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LiveTimingPage />
|
||||
@@ -112,7 +154,7 @@ function renderPage(response: LiveStateResponse) {
|
||||
)
|
||||
}
|
||||
|
||||
describe('LiveTimingPage archive mode', () => {
|
||||
describe('LiveTimingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'EventSource', {
|
||||
@@ -122,45 +164,162 @@ describe('LiveTimingPage archive mode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps archived snapshots behind the View Last Session action', async () => {
|
||||
renderPage({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: archivedSnapshot,
|
||||
last_positions: {
|
||||
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the live timing tower for an active session', async () => {
|
||||
renderPage({ is_live: true, data: raceSnapshot })
|
||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||
expect(screen.getByTestId('live-session-flag')).toHaveTextContent('LIVE SESSION')
|
||||
expect(screen.getByTestId('live-feed-health')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('enters the settling handoff (not archive) when the session ends cleanly', async () => {
|
||||
renderPage(
|
||||
{
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||
},
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
weekendContext('pending'),
|
||||
)
|
||||
|
||||
const settling = await screen.findByTestId('live-settling')
|
||||
expect(settling).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
|
||||
// Canonical default-analysis session drives the primary action target.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||
'href',
|
||||
'/race-hub?session_key=99',
|
||||
),
|
||||
)
|
||||
// The timing tower is not shown while settling — it is a handoff surface.
|
||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flips settling → analysis-ready when polling sees ingestion complete', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
// First fetch: pending. Subsequent polls: complete.
|
||||
mockFetchWeekendContext
|
||||
.mockResolvedValueOnce(weekendContext('pending'))
|
||||
.mockResolvedValue(weekendContext('complete'))
|
||||
|
||||
renderPage(
|
||||
{
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||
},
|
||||
undefined,
|
||||
{ setWeekendContext: false },
|
||||
)
|
||||
|
||||
const action = await screen.findByTestId('live-handoff-analysis')
|
||||
expect(action).toHaveAttribute('data-ready', 'false')
|
||||
expect(action).toHaveTextContent(/analysis will fill in as data ingests/i)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||
})
|
||||
|
||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||
)
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveTextContent(
|
||||
/full timing, strategy & story ready/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('retains the last snapshot with a disconnected warning (not settling) on a feed drop', async () => {
|
||||
// is_live=false but the retained snapshot has a NON-terminal SessionStatus:
|
||||
// the FIA feed dropped while the session was still active.
|
||||
renderPage(
|
||||
{
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: { ...raceSnapshot, SessionStatus: 'Started' },
|
||||
last_snapshot_at: '2026-07-05T15:30:00Z',
|
||||
},
|
||||
weekendContext('pending'),
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('live-disconnected-strip')).toHaveTextContent(
|
||||
/showing the last live data/i,
|
||||
)
|
||||
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||
// The retained live tower stays visible; we never fell into settling/archive.
|
||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the settled snapshot behind an explicit read-only archive action', async () => {
|
||||
renderPage(
|
||||
{
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||
last_positions: { '1': { x: 10, y: 20, z: 0, status: 'OnTrack' } },
|
||||
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||
},
|
||||
weekendContext('complete'),
|
||||
)
|
||||
|
||||
// Settling first — the tower is hidden until the user opens the archive.
|
||||
await screen.findByTestId('live-settling')
|
||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /view last session/i }))
|
||||
fireEvent.click(screen.getByTestId('live-handoff-archive'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
|
||||
)
|
||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
// Archive presents its read-only, timestamped chrome — never LIVE.
|
||||
expect(screen.getByText('archive')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the archive action when no snapshot is retained', async () => {
|
||||
renderPage({ is_live: false, data: null })
|
||||
it('shows the inactive weekend context when nothing is live or retained', async () => {
|
||||
renderPage(
|
||||
{ is_live: false, data: null },
|
||||
{
|
||||
temporal_state: 'between_weekends',
|
||||
next_session: {
|
||||
session: {
|
||||
session_key: 21,
|
||||
session_name: 'Practice 1',
|
||||
session_type: 'Practice 1',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-17T09:00:00Z',
|
||||
date_end: '2026-07-17T10:00:00Z',
|
||||
gmt_offset: '',
|
||||
},
|
||||
availability: {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'not_applicable',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
championship_round: 2,
|
||||
total_championship_rounds: 24,
|
||||
},
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('temporarily omits the track map while live GPS is unavailable', async () => {
|
||||
renderPage({
|
||||
is_live: true,
|
||||
data: { ...archivedSnapshot, SessionStatus: 'Started' },
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Track Map')).not.toBeInTheDocument()
|
||||
expect(mockFetchLiveTrackOutline).not.toHaveBeenCalled()
|
||||
expect(await screen.findByTestId('live-inactive')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
169
frontend/src/test/liveState.test.ts
Normal file
169
frontend/src/test/liveState.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allowsLiveInterpretations,
|
||||
deriveLivePhase,
|
||||
isReadOnlyPhase,
|
||||
rendersSnapshot,
|
||||
terminalSessionStatus,
|
||||
type LiveStateInputs,
|
||||
} from '../lib/liveState'
|
||||
|
||||
const base: LiveStateInputs = {
|
||||
transport: 'connecting',
|
||||
isLive: false,
|
||||
hasActiveSnapshot: false,
|
||||
hasArchive: false,
|
||||
archiveMode: false,
|
||||
sessionEndedCleanly: false,
|
||||
wasLive: false,
|
||||
stateLoaded: false,
|
||||
}
|
||||
|
||||
describe('terminalSessionStatus', () => {
|
||||
it('recognizes genuine session-end statuses', () => {
|
||||
for (const s of ['Finished', 'Finalised', 'Finalized', 'ENDED', 'Aborted']) {
|
||||
expect(terminalSessionStatus(s)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats still-running / unknown statuses as non-terminal', () => {
|
||||
for (const s of ['Started', 'Resumed', 'Inactive', '', undefined, null]) {
|
||||
expect(terminalSessionStatus(s)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveLivePhase transitions', () => {
|
||||
it('connecting: cold start with the feed still opening', () => {
|
||||
expect(deriveLivePhase({ ...base, transport: 'connecting' })).toBe('connecting')
|
||||
})
|
||||
|
||||
it('connecting → live once a session snapshot arrives', () => {
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: true,
|
||||
hasActiveSnapshot: true,
|
||||
}),
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('live → disconnected when the SSE transport drops mid-session', () => {
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'disconnected',
|
||||
isLive: true,
|
||||
hasActiveSnapshot: true,
|
||||
wasLive: true,
|
||||
}),
|
||||
).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('live → settling when the session ends with a terminal status', () => {
|
||||
// Feed reports is_live=false, transport healthy, retained snapshot carries a
|
||||
// terminal SessionStatus → the session genuinely finished.
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: false,
|
||||
hasArchive: true,
|
||||
sessionEndedCleanly: true,
|
||||
wasLive: true,
|
||||
}),
|
||||
).toBe('settling')
|
||||
})
|
||||
|
||||
it('live → disconnected (NOT settling) when the FIA feed drops but status is non-terminal', () => {
|
||||
// This is the P0 case: server deactivate() moved a still-active snapshot to
|
||||
// last_snapshot with is_live=false but a non-terminal SessionStatus. The
|
||||
// browser transport is healthy. We must warn + retain, never settle/archive.
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: false,
|
||||
hasArchive: true,
|
||||
sessionEndedCleanly: false,
|
||||
wasLive: true,
|
||||
}),
|
||||
).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('a retained non-terminal snapshot on cold load is disconnected, not settling', () => {
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: false,
|
||||
hasArchive: true,
|
||||
sessionEndedCleanly: false,
|
||||
wasLive: false,
|
||||
}),
|
||||
).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('settling → archive when the user explicitly opens the read-only snapshot', () => {
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: false,
|
||||
hasArchive: true,
|
||||
sessionEndedCleanly: true,
|
||||
archiveMode: true,
|
||||
}),
|
||||
).toBe('archive')
|
||||
})
|
||||
|
||||
it('archive mode wins over an active session (user-chosen read-only view)', () => {
|
||||
expect(
|
||||
deriveLivePhase({
|
||||
...base,
|
||||
transport: 'connected',
|
||||
isLive: true,
|
||||
hasActiveSnapshot: true,
|
||||
hasArchive: true,
|
||||
archiveMode: true,
|
||||
}),
|
||||
).toBe('archive')
|
||||
})
|
||||
|
||||
it('inactive when there is no session and nothing retained', () => {
|
||||
expect(deriveLivePhase({ ...base, transport: 'connected', stateLoaded: true })).toBe('inactive')
|
||||
})
|
||||
|
||||
it('leaves connecting for inactive once the initial state resolves, even mid-handshake', () => {
|
||||
// connecting → inactive: the SSE transport handshake is still pending
|
||||
// (transport === "connecting") but the initial live-state query resolved as
|
||||
// idle, so we must not spin on "connecting…" forever.
|
||||
expect(deriveLivePhase({ ...base, transport: 'connecting', stateLoaded: true })).toBe('inactive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('phase capability helpers', () => {
|
||||
it('renders the snapshot surface only for live/disconnected/archive', () => {
|
||||
expect(rendersSnapshot('live')).toBe(true)
|
||||
expect(rendersSnapshot('disconnected')).toBe(true)
|
||||
expect(rendersSnapshot('archive')).toBe(true)
|
||||
expect(rendersSnapshot('settling')).toBe(false)
|
||||
expect(rendersSnapshot('inactive')).toBe(false)
|
||||
expect(rendersSnapshot('connecting')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows live-only interpretations only while the session is moving', () => {
|
||||
expect(allowsLiveInterpretations('live')).toBe(true)
|
||||
expect(allowsLiveInterpretations('disconnected')).toBe(true)
|
||||
// A frozen archive frame must never present live-only interpretations.
|
||||
expect(allowsLiveInterpretations('archive')).toBe(false)
|
||||
expect(allowsLiveInterpretations('settling')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks archive as the read-only phase', () => {
|
||||
expect(isReadOnlyPhase('archive')).toBe(true)
|
||||
expect(isReadOnlyPhase('live')).toBe(false)
|
||||
expect(isReadOnlyPhase('disconnected')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user