fix(#74): settle on just-finished session and coherent feed health
Prefer previous_completed_session for Live settling link/poll/readiness so an older ready default cannot short-circuit handoff; show reconnecting when phase is disconnected even if browser SSE stays open; refresh Live visuals and add active responsive snapshots against the Weekend shell. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -27,6 +27,11 @@ function formatCapturedAt(capturedAt: string | null | undefined): string {
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function sessionKeyOf(session: ContextSession | undefined): number | undefined {
|
||||
const key = session?.session.session_key
|
||||
return key && key > 0 ? key : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully ingested analysis (local_analysis === complete). Stricter than
|
||||
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
|
||||
@@ -36,6 +41,51 @@ export function analysisIsReady(session: ContextSession | undefined): boolean {
|
||||
return session?.availability?.local_analysis === 'complete'
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysis target for the Live handoff surface.
|
||||
*
|
||||
* Settling must follow the just-finished session (`previous_completed_session`).
|
||||
* The canonical backend can mark that session archive-complete while leaving
|
||||
* `default_analysis_session` on an older already-ingested practice/qualifying —
|
||||
* preferring default here would link/poll/label the wrong race.
|
||||
*
|
||||
* Inactive keeps the shared default-first preference via analysisSessionKey.
|
||||
*/
|
||||
export function handoffAnalysisSession(
|
||||
context: WeekendContext | undefined,
|
||||
phase: 'settling' | 'inactive',
|
||||
): ContextSession | undefined {
|
||||
if (!context) return undefined
|
||||
if (phase === 'settling') {
|
||||
if (sessionKeyOf(context.previous_completed_session)) {
|
||||
return context.previous_completed_session
|
||||
}
|
||||
return sessionKeyOf(context.default_analysis_session)
|
||||
? context.default_analysis_session
|
||||
: undefined
|
||||
}
|
||||
const key = analysisSessionKey(context)
|
||||
if (!key) return undefined
|
||||
if (context.default_analysis_session?.session.session_key === key) {
|
||||
return context.default_analysis_session
|
||||
}
|
||||
return context.previous_completed_session
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep polling weekend-context while the just-finished session (or, absent
|
||||
* that, the default analysis session) is still ingesting. An older ready
|
||||
* default must not stop the settle→ready transition.
|
||||
*/
|
||||
export function shouldPollHandoffAnalysis(context: WeekendContext | undefined): boolean {
|
||||
if (!context) return true
|
||||
const previous = context.previous_completed_session
|
||||
if (sessionKeyOf(previous) && !analysisIsReady(previous)) return true
|
||||
const fallback = context.default_analysis_session
|
||||
if (sessionKeyOf(fallback) && !analysisIsReady(fallback)) return true
|
||||
return !sessionKeyOf(previous) && !sessionKeyOf(fallback)
|
||||
}
|
||||
|
||||
export function LiveHandoff({
|
||||
phase,
|
||||
transport,
|
||||
@@ -54,14 +104,8 @@ export function LiveHandoff({
|
||||
const title = focusName || activeName || 'Live Timing'
|
||||
const topRows = rows.slice(0, 3)
|
||||
|
||||
// Shared analysisSessionKey prefers default_analysis_session, then previous.
|
||||
const analysisKey = context ? analysisSessionKey(context) : undefined
|
||||
const analysis =
|
||||
!context || !analysisKey
|
||||
? undefined
|
||||
: context.default_analysis_session?.session.session_key === analysisKey
|
||||
? context.default_analysis_session
|
||||
: context.previous_completed_session
|
||||
const analysis = handoffAnalysisSession(context, phase)
|
||||
const analysisKey = sessionKeyOf(analysis)
|
||||
const analysisName = analysis?.session.session_name || 'session'
|
||||
const analysisReady = analysisIsReady(analysis)
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ export function BetweenSessionsView({
|
||||
const previous = context.previous_completed_session
|
||||
const previousName = previous?.session.session_name ?? 'Last session'
|
||||
const next = context.next_session?.session
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
// Settling recap must open the just-finished session, not an older default.
|
||||
const previousKey = previous?.session.session_key
|
||||
const analysisKey =
|
||||
settling && previousKey && previousKey > 0 ? previousKey : analysisSessionKey(context)
|
||||
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||
|
||||
return (
|
||||
|
||||
@@ -138,6 +138,21 @@ export function isReadOnlyPhase(phase: LivePhase): boolean {
|
||||
return phase === 'archive'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-facing feed health. Browser SSE can stay `connected` after an upstream
|
||||
* FIA drop leaves us in `disconnected` with a retained non-terminal snapshot —
|
||||
* present that as reconnecting, never "Feed healthy" beside "Connection lost".
|
||||
*/
|
||||
export function effectiveFeedHealth(
|
||||
transport: TransportHealth,
|
||||
phase: LivePhase,
|
||||
): TransportHealth {
|
||||
if (phase === 'disconnected' && !transportDown(transport)) {
|
||||
return 'disconnected'
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
/** Short, human transport-health label — always secondary to session state. */
|
||||
export function feedHealthLabel(transport: TransportHealth): string {
|
||||
switch (transport) {
|
||||
|
||||
@@ -20,11 +20,12 @@ import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||
import {
|
||||
allowsLiveInterpretations,
|
||||
deriveLivePhase,
|
||||
effectiveFeedHealth,
|
||||
rendersSnapshot,
|
||||
terminalSessionStatus,
|
||||
} from '../lib/liveState'
|
||||
import type { TransportHealth } from '../lib/liveState'
|
||||
import { analysisIsReady } from '../components/live/LiveHandoff'
|
||||
import { shouldPollHandoffAnalysis } from '../components/live/LiveHandoff'
|
||||
import { SessionBanner } from '../components/live/SessionBanner'
|
||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||
import { TimingTower } from '../components/live/TimingTower'
|
||||
@@ -112,11 +113,9 @@ export function LiveTimingPage() {
|
||||
enabled: !isLive,
|
||||
staleTime: WEEKEND_CONTEXT_STALE_MS,
|
||||
refetchInterval: (query) => {
|
||||
const ctx = query.state.data
|
||||
if (!ctx) return WEEKEND_CONTEXT_POLL_MS
|
||||
// Once the default-analysis session is fully ingested there is nothing
|
||||
// left to wait for; stop polling.
|
||||
return analysisIsReady(ctx.default_analysis_session) ? false : WEEKEND_CONTEXT_POLL_MS
|
||||
// Poll until the just-finished previous_completed_session is ready — an
|
||||
// older already-complete default_analysis_session must not stop us.
|
||||
return shouldPollHandoffAnalysis(query.state.data) ? WEEKEND_CONTEXT_POLL_MS : false
|
||||
},
|
||||
refetchIntervalInBackground: false,
|
||||
})
|
||||
@@ -290,6 +289,9 @@ export function LiveTimingPage() {
|
||||
: 'Archived live timing snapshot'
|
||||
|
||||
const showLiveInterpretations = allowsLiveInterpretations(phase)
|
||||
// Upstream FIA loss can leave the browser SSE open; present one coherent
|
||||
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
||||
|
||||
return (
|
||||
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||
@@ -321,7 +323,7 @@ export function LiveTimingPage() {
|
||||
{(phase === 'settling' || phase === 'inactive') && (
|
||||
<LiveHandoff
|
||||
phase={phase}
|
||||
transport={streamStatus}
|
||||
transport={feedHealth}
|
||||
context={weekendContext}
|
||||
rows={settlingRows}
|
||||
capturedAt={archiveSnapshotAt}
|
||||
@@ -336,7 +338,7 @@ export function LiveTimingPage() {
|
||||
phase={phase}
|
||||
snapshot={snapshot}
|
||||
rows={rows}
|
||||
transport={streamStatus}
|
||||
transport={feedHealth}
|
||||
now={now}
|
||||
capturedAt={phase === 'archive' ? archiveSnapshotAt : null}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LiveHandoff, analysisIsReady } from '../components/live/LiveHandoff'
|
||||
import {
|
||||
LiveHandoff,
|
||||
analysisIsReady,
|
||||
handoffAnalysisSession,
|
||||
shouldPollHandoffAnalysis,
|
||||
} from '../components/live/LiveHandoff'
|
||||
import type { LiveTimingRow } from '../lib/live'
|
||||
import type { ContextSession, WeekendContext } from '../types'
|
||||
|
||||
@@ -54,6 +59,18 @@ const baseContext: WeekendContext = {
|
||||
total_championship_rounds: 1,
|
||||
}
|
||||
|
||||
/** Canonical contract: archive-only just-finished Race vs older ready Practice. */
|
||||
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||
return {
|
||||
...baseContext,
|
||||
// Older already-ingested session remains the default analysis target.
|
||||
default_analysis_session: contextSession(10, 'Practice 1', 'complete'),
|
||||
// Just-finished Race is archive-complete but not yet analysis-ready.
|
||||
previous_completed_session: contextSession(99, 'Race', previousAnalysis),
|
||||
next_session: contextSession(12, 'Qualifying', 'not_applicable'),
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -68,13 +85,51 @@ describe('analysisIsReady', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoffAnalysisSession', () => {
|
||||
it('settling prefers previous_completed_session over an older default', () => {
|
||||
const ctx = archiveOnlySettlingContext('pending')
|
||||
const analysis = handoffAnalysisSession(ctx, 'settling')
|
||||
expect(analysis?.session.session_key).toBe(99)
|
||||
expect(analysis?.session.session_name).toBe('Race')
|
||||
expect(analysisIsReady(analysis)).toBe(false)
|
||||
})
|
||||
|
||||
it('inactive prefers default_analysis_session (shared analysisSessionKey)', () => {
|
||||
const ctx = archiveOnlySettlingContext('pending')
|
||||
const analysis = handoffAnalysisSession(ctx, 'inactive')
|
||||
expect(analysis?.session.session_key).toBe(10)
|
||||
expect(analysis?.session.session_name).toBe('Practice 1')
|
||||
})
|
||||
|
||||
it('settling falls back to default when previous is absent', () => {
|
||||
const ctx = {
|
||||
...baseContext,
|
||||
default_analysis_session: contextSession(11, 'Race', 'pending'),
|
||||
}
|
||||
expect(handoffAnalysisSession(ctx, 'settling')?.session.session_key).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPollHandoffAnalysis', () => {
|
||||
it('keeps polling when previous is pending even if default is already complete', () => {
|
||||
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('pending'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stops polling once the just-finished previous session is ready', () => {
|
||||
expect(shouldPollHandoffAnalysis(archiveOnlySettlingContext('complete'))).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') }}
|
||||
context={{
|
||||
...baseContext,
|
||||
previous_completed_session: contextSession(11, 'Race', 'pending'),
|
||||
}}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive
|
||||
@@ -94,12 +149,37 @@ describe('LiveHandoff settling', () => {
|
||||
expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER')
|
||||
})
|
||||
|
||||
it('links/labels/readiness follow archive-only previous, not an older ready default', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={archiveOnlySettlingContext('pending')}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive
|
||||
onViewArchive={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const action = screen.getByTestId('live-handoff-analysis')
|
||||
expect(action).toHaveAttribute('href', '/race-hub?session_key=99')
|
||||
expect(action).toHaveAttribute('data-ready', 'false')
|
||||
expect(action).toHaveTextContent('Open Race analysis')
|
||||
expect(action).toHaveTextContent(/Settling — analysis will fill in as data ingests/i)
|
||||
expect(action).not.toHaveTextContent('Practice 1')
|
||||
expect(action).not.toHaveTextContent(/full timing, strategy & story ready/i)
|
||||
})
|
||||
|
||||
it('flips to analysis-ready once local ingestion completes', () => {
|
||||
render(
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'complete') }}
|
||||
context={{
|
||||
...baseContext,
|
||||
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||
}}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive={false}
|
||||
@@ -116,7 +196,10 @@ describe('LiveHandoff settling', () => {
|
||||
<LiveHandoff
|
||||
phase="settling"
|
||||
transport="connected"
|
||||
context={{ ...baseContext, default_analysis_session: contextSession(11, 'Race', 'complete') }}
|
||||
context={{
|
||||
...baseContext,
|
||||
previous_completed_session: contextSession(11, 'Race', 'complete'),
|
||||
}}
|
||||
rows={rows}
|
||||
capturedAt="2026-07-05T16:02:00Z"
|
||||
hasArchive
|
||||
|
||||
@@ -103,7 +103,7 @@ function weekendContext(localAnalysis: string): WeekendContext {
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
},
|
||||
default_analysis_session: {
|
||||
previous_completed_session: {
|
||||
session: {
|
||||
session_key: 99,
|
||||
session_name: 'Race',
|
||||
@@ -128,6 +128,33 @@ function weekendContext(localAnalysis: string): WeekendContext {
|
||||
}
|
||||
}
|
||||
|
||||
/** Just-finished archive-only Race + older already-ready Practice default. */
|
||||
function archiveOnlySettlingContext(previousAnalysis: string): WeekendContext {
|
||||
return {
|
||||
...weekendContext(previousAnalysis),
|
||||
default_analysis_session: {
|
||||
session: {
|
||||
session_key: 10,
|
||||
session_name: 'Practice 1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-04T12:00:00Z',
|
||||
date_end: '2026-07-04T13:00:00Z',
|
||||
gmt_offset: '',
|
||||
},
|
||||
availability: {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'available',
|
||||
local_analysis: 'complete',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
response: LiveStateResponse,
|
||||
context?: WeekendContext,
|
||||
@@ -191,7 +218,7 @@ describe('LiveTimingPage', () => {
|
||||
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.
|
||||
// Just-finished previous_completed_session drives the primary action target.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||
'href',
|
||||
@@ -202,6 +229,44 @@ describe('LiveTimingPage', () => {
|
||||
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('settles against archive-only previous, not an older ready default_analysis_session', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
mockFetchWeekendContext
|
||||
.mockResolvedValueOnce(archiveOnlySettlingContext('pending'))
|
||||
.mockResolvedValue(archiveOnlySettlingContext('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('href', '/race-hub?session_key=99')
|
||||
expect(action).toHaveAttribute('data-ready', 'false')
|
||||
expect(action).toHaveTextContent('Open Race analysis')
|
||||
expect(action).not.toHaveTextContent('Practice 1')
|
||||
|
||||
// Older default is already complete — polling must continue for previous.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(WEEKEND_CONTEXT_POLL_MS + 500)
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute('data-ready', 'true'),
|
||||
)
|
||||
expect(screen.getByTestId('live-handoff-analysis')).toHaveAttribute(
|
||||
'href',
|
||||
'/race-hub?session_key=99',
|
||||
)
|
||||
expect(mockFetchWeekendContext.mock.calls.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('flips settling → analysis-ready when polling sees ingestion complete', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
// First fetch: pending. Subsequent polls: complete.
|
||||
@@ -257,6 +322,11 @@ describe('LiveTimingPage', () => {
|
||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-settling')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('live-archive-strip')).not.toBeInTheDocument()
|
||||
// SSE may still be open (MockEventSource opens) — health must not say healthy.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('live-feed-health')).toHaveTextContent(/reconnecting/i),
|
||||
)
|
||||
expect(screen.getByTestId('live-feed-health')).not.toHaveTextContent(/feed healthy/i)
|
||||
})
|
||||
|
||||
it('keeps the settled snapshot behind an explicit read-only archive action', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allowsLiveInterpretations,
|
||||
deriveLivePhase,
|
||||
effectiveFeedHealth,
|
||||
isReadOnlyPhase,
|
||||
rendersSnapshot,
|
||||
terminalSessionStatus,
|
||||
@@ -167,3 +168,16 @@ describe('phase capability helpers', () => {
|
||||
expect(isReadOnlyPhase('disconnected')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveFeedHealth', () => {
|
||||
it('downgrades a still-open SSE to reconnecting while phase is disconnected', () => {
|
||||
expect(effectiveFeedHealth('connected', 'disconnected')).toBe('disconnected')
|
||||
expect(effectiveFeedHealth('connecting', 'disconnected')).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('preserves transport when the session phase is live or settling', () => {
|
||||
expect(effectiveFeedHealth('connected', 'live')).toBe('connected')
|
||||
expect(effectiveFeedHealth('connected', 'settling')).toBe('connected')
|
||||
expect(effectiveFeedHealth('error', 'disconnected')).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -256,7 +256,8 @@ const weekendContext = (localAnalysis: string) => ({
|
||||
date_end: '2026-07-05T16:00:00Z',
|
||||
year: 2026,
|
||||
},
|
||||
default_analysis_session: {
|
||||
// Just-finished session lives in previous_completed_session (canonical contract).
|
||||
previous_completed_session: {
|
||||
session: {
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
@@ -280,6 +281,31 @@ const weekendContext = (localAnalysis: string) => ({
|
||||
total_championship_rounds: 1,
|
||||
})
|
||||
|
||||
/** Archive-only just-finished Race + older already-ready Practice default. */
|
||||
const archiveOnlySettlingContext = (previousAnalysis: string) => ({
|
||||
...weekendContext(previousAnalysis),
|
||||
default_analysis_session: {
|
||||
session: {
|
||||
session_key: 9001,
|
||||
session_name: 'Practice 1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 1,
|
||||
date_start: '2026-07-04T12:00:00Z',
|
||||
date_end: '2026-07-04T13:00:00Z',
|
||||
gmt_offset: '',
|
||||
},
|
||||
availability: {
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'available',
|
||||
local_analysis: 'complete',
|
||||
freshness: 'fresh',
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const heartbeatStream = (route: import('@playwright/test').Route) =>
|
||||
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' })
|
||||
|
||||
@@ -353,9 +379,40 @@ test.describe('Live Timing (no session)', () => {
|
||||
await expect(analysis).toContainText(/analysis will fill in as data ingests/i)
|
||||
})
|
||||
|
||||
test('settling targets archive-only previous_completed_session over older default', async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
is_live: false,
|
||||
data: null,
|
||||
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/weekend-context', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(archiveOnlySettlingContext('pending')),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||
const analysis = page.getByTestId('live-handoff-analysis')
|
||||
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
|
||||
await expect(analysis).toHaveAttribute('data-ready', 'false')
|
||||
await expect(analysis).toContainText('Open Race analysis')
|
||||
await expect(analysis).not.toContainText('Practice 1')
|
||||
})
|
||||
|
||||
test('retains the last live snapshot with a disconnected warning on a feed drop', async ({ page }) => {
|
||||
// is_live=false but SessionStatus is still "Started": the FIA feed dropped
|
||||
// mid-session. The page must warn + retain the live tower, never settle.
|
||||
// Keep the browser SSE open via a long-lived stream so transport stays connected
|
||||
// while phase is disconnected — health must still say Reconnecting.
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
@@ -370,7 +427,21 @@ test.describe('Live Timing (no session)', () => {
|
||||
await page.route('**/api/v1/weekend-context', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('pending')) }),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||
await page.route('**/api/v1/live/stream', async (route) => {
|
||||
const body = [
|
||||
'event: heartbeat',
|
||||
'data: {}',
|
||||
'',
|
||||
'event: heartbeat',
|
||||
'data: {}',
|
||||
'',
|
||||
].join('\n')
|
||||
await route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||
@@ -378,5 +449,7 @@ test.describe('Live Timing (no session)', () => {
|
||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||
await expect(page.getByTestId('live-settling')).toHaveCount(0)
|
||||
await expect(page.getByTestId('live-archive-strip')).toHaveCount(0)
|
||||
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
|
||||
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
|
||||
})
|
||||
})
|
||||
|
||||
BIN
tests/visual/__snapshots__/desktop/live-active.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
BIN
tests/visual/__snapshots__/mobile/live-active.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
BIN
tests/visual/__snapshots__/tablet/live-active.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 45 KiB |
@@ -68,6 +68,156 @@ export async function gotoLiveInactiveReady(page: Page): Promise<void> {
|
||||
// With BOXBOX_DISABLE_LIVE=1 the feed is silent, so the page settles into the
|
||||
// inactive weekend-context handoff sourced from /api/v1/weekend-context.
|
||||
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||
// Integrated #73 shell — stale Command/Live/Race Hub baselines must not pass.
|
||||
await expect(page.getByRole('navigation')).toContainText('Weekend')
|
||||
await expect(page.getByRole('navigation')).not.toContainText('Command')
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
/** Deterministic active Live hierarchy (mocked snapshot + sticky SSE). */
|
||||
export async function gotoLiveActiveReady(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.clear()
|
||||
class StickyEventSource {
|
||||
onopen: ((ev: Event) => void) | null = null
|
||||
onerror: ((ev: Event) => void) | null = null
|
||||
constructor(_url: string | URL) {
|
||||
queueMicrotask(() => this.onopen?.(new Event('open')))
|
||||
}
|
||||
addEventListener(_type: string, _listener: EventListenerOrEventListenerObject) {}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, 'EventSource', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: StickyEventSource,
|
||||
})
|
||||
})
|
||||
|
||||
const driver = (
|
||||
num: string,
|
||||
pos: number,
|
||||
interval: string,
|
||||
gap: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) => ({
|
||||
RacingNumber: num,
|
||||
Position: pos,
|
||||
PrevPosition: pos,
|
||||
GapToLeader: gap,
|
||||
Interval: interval,
|
||||
LastLapTime: '1:21.345',
|
||||
LastLapPB: false,
|
||||
LastLapOB: false,
|
||||
BestLapTime: '1:20.987',
|
||||
BestLapPB: true,
|
||||
BestLapOB: false,
|
||||
BestLapNum: 22,
|
||||
InPit: false,
|
||||
PitOut: false,
|
||||
Retired: false,
|
||||
KnockedOut: false,
|
||||
Cutoff: false,
|
||||
OnFlyingLap: false,
|
||||
NumberOfLaps: 30,
|
||||
SpeedTrap: '312',
|
||||
Sectors: [],
|
||||
...overrides,
|
||||
})
|
||||
const info = (num: string, tla: string, first: string, last: string, team: string, colour: string) => ({
|
||||
RacingNumber: num,
|
||||
BroadcastName: `${first[0]} ${last.toUpperCase()}`,
|
||||
Tla: tla,
|
||||
TeamName: team,
|
||||
TeamColour: colour,
|
||||
FirstName: first,
|
||||
LastName: last,
|
||||
})
|
||||
|
||||
const liveState = {
|
||||
is_live: true,
|
||||
data: {
|
||||
Drivers: {
|
||||
'1': driver('1', 1, '', ''),
|
||||
'4': driver('4', 2, '+0.523', '+0.523'),
|
||||
'44': driver('44', 3, '+3.214', '+3.737'),
|
||||
'63': driver('63', 4, '+12.001', '+15.738', { InPit: true }),
|
||||
},
|
||||
DriverInfo: {
|
||||
'1': info('1', 'VER', 'Max', 'Verstappen', 'Red Bull Racing', '3671C6'),
|
||||
'4': info('4', 'NOR', 'Lando', 'Norris', 'McLaren', 'FF8000'),
|
||||
'44': info('44', 'HAM', 'Lewis', 'Hamilton', 'Ferrari', 'E80020'),
|
||||
'63': info('63', 'RUS', 'George', 'Russell', 'Mercedes', '27F4D2'),
|
||||
},
|
||||
Tyres: {
|
||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
||||
'4': { Compound: 'MEDIUM', New: false, Age: 8 },
|
||||
'44': { Compound: 'MEDIUM', New: true, Age: 3 },
|
||||
'63': { Compound: 'HARD', New: true, Age: 0 },
|
||||
},
|
||||
Stints: {
|
||||
'1': [
|
||||
{ Compound: 'MEDIUM', New: true, Laps: 18 },
|
||||
{ Compound: 'HARD', New: false, Laps: 12 },
|
||||
],
|
||||
'4': [
|
||||
{ Compound: 'SOFT', New: true, Laps: 14 },
|
||||
{ Compound: 'MEDIUM', New: true, Laps: 16 },
|
||||
],
|
||||
},
|
||||
RCMessages: [
|
||||
{
|
||||
Time: '2026-07-03T14:05:00Z',
|
||||
Category: 'Flag',
|
||||
Flag: 'YELLOW',
|
||||
Message: 'YELLOW IN SECTOR 2',
|
||||
Lap: 29,
|
||||
},
|
||||
],
|
||||
Weather: {
|
||||
AirTemp: 22.5,
|
||||
TrackTemp: 41.3,
|
||||
Humidity: 58,
|
||||
WindSpeed: 3.4,
|
||||
WindDir: 180,
|
||||
Rainfall: false,
|
||||
},
|
||||
Session: {
|
||||
MeetingName: 'Testonia Grand Prix',
|
||||
CircuitName: 'Testring',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '',
|
||||
},
|
||||
TeamRadio: [],
|
||||
SessionStatus: 'Started',
|
||||
TrackStatus: '2',
|
||||
CurrentLap: 30,
|
||||
TotalLaps: 57,
|
||||
// Fixed empty clock → "--:--:--" (no live extrapolation drift).
|
||||
Clock: '',
|
||||
ClockRefTime: '',
|
||||
ClockExtrapolating: false,
|
||||
Telemetry: {},
|
||||
},
|
||||
}
|
||||
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(liveState) }),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
body: 'event: heartbeat\ndata: {}\n\n',
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'live')
|
||||
await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION')
|
||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||
await expect(page.getByRole('navigation')).toContainText('Weekend')
|
||||
await expect(page.getByRole('navigation')).not.toContainText('Command')
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
|
||||
9
tests/visual/live-active.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { test } from '@playwright/test'
|
||||
import { gotoLiveActiveReady, screenshotPage } from './helpers'
|
||||
|
||||
test.describe('Live active visual regression', () => {
|
||||
test('live-active', async ({ page }) => {
|
||||
await gotoLiveActiveReady(page)
|
||||
await screenshotPage(page, 'live-active')
|
||||
})
|
||||
})
|
||||