mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56: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:
@@ -1,16 +1,18 @@
|
|||||||
import { Archive, ChevronRight, Flag, Radio } from 'lucide-react'
|
import { Archive, ChevronRight, Flag, Radio } from 'lucide-react'
|
||||||
import type { LiveTimingRow } from '../../lib/live'
|
import type { LiveTimingRow } from '../../lib/live'
|
||||||
import { driverCode } from '../../lib/live'
|
import { driverCode } from '../../lib/live'
|
||||||
import type { LiveWeekendContext } from '../../lib/weekendContext'
|
|
||||||
import type { TransportHealth } from '../../lib/liveState'
|
import type { TransportHealth } from '../../lib/liveState'
|
||||||
import { feedHealthLabel } from '../../lib/liveState'
|
import { feedHealthLabel } from '../../lib/liveState'
|
||||||
|
import type { ContextSession, WeekendContext } from '../../types'
|
||||||
|
import { analysisSessionKey } from '../../lib/weekendContext'
|
||||||
import { formatSessionScheduleTime } from '../../lib/schedule'
|
import { formatSessionScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 'settling' immediately after a session; 'inactive' when nothing is retained. */
|
/** 'settling' immediately after a session; 'inactive' when nothing is live. */
|
||||||
phase: 'settling' | 'inactive'
|
phase: 'settling' | 'inactive'
|
||||||
transport: TransportHealth
|
transport: TransportHealth
|
||||||
context: LiveWeekendContext
|
/** Canonical weekend context (issue #72). May be undefined before it loads. */
|
||||||
|
context: WeekendContext | undefined
|
||||||
/** Top rows of the final snapshot (settling only), already position-sorted. */
|
/** Top rows of the final snapshot (settling only), already position-sorted. */
|
||||||
rows: LiveTimingRow[]
|
rows: LiveTimingRow[]
|
||||||
capturedAt?: string | null
|
capturedAt?: string | null
|
||||||
@@ -25,6 +27,15 @@ function formatCapturedAt(capturedAt: string | null | undefined): string {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully ingested analysis (local_analysis === complete). Stricter than
|
||||||
|
* hasLocalAnalysis, which also treats partial as link-worthy — Live polling
|
||||||
|
* and "ready" chrome wait for the complete state.
|
||||||
|
*/
|
||||||
|
export function analysisIsReady(session: ContextSession | undefined): boolean {
|
||||||
|
return session?.availability?.local_analysis === 'complete'
|
||||||
|
}
|
||||||
|
|
||||||
export function LiveHandoff({
|
export function LiveHandoff({
|
||||||
phase,
|
phase,
|
||||||
transport,
|
transport,
|
||||||
@@ -37,12 +48,28 @@ export function LiveHandoff({
|
|||||||
const isSettling = phase === 'settling'
|
const isSettling = phase === 'settling'
|
||||||
const testid = isSettling ? 'live-settling' : 'live-inactive'
|
const testid = isSettling ? 'live-settling' : 'live-inactive'
|
||||||
const capturedLabel = formatCapturedAt(capturedAt)
|
const capturedLabel = formatCapturedAt(capturedAt)
|
||||||
const title = context.meetingName || 'Live Timing'
|
|
||||||
|
const focusName = context?.focus_meeting?.meeting_name
|
||||||
|
const activeName = context?.active_session?.meeting?.meeting_name
|
||||||
|
const title = focusName || activeName || 'Live Timing'
|
||||||
const topRows = rows.slice(0, 3)
|
const topRows = rows.slice(0, 3)
|
||||||
|
|
||||||
const analysisKey = context.analysisSessionKey
|
// Shared analysisSessionKey prefers default_analysis_session, then previous.
|
||||||
const analysisName = context.analysisSessionName || 'session'
|
const analysisKey = context ? analysisSessionKey(context) : undefined
|
||||||
const analysisReady = context.analysisReady
|
const analysis =
|
||||||
|
!context || !analysisKey
|
||||||
|
? undefined
|
||||||
|
: context.default_analysis_session?.session.session_key === analysisKey
|
||||||
|
? context.default_analysis_session
|
||||||
|
: context.previous_completed_session
|
||||||
|
const analysisName = analysis?.session.session_name || 'session'
|
||||||
|
const analysisReady = analysisIsReady(analysis)
|
||||||
|
|
||||||
|
const next = context?.next_session
|
||||||
|
const previous = context?.previous_completed_session
|
||||||
|
// Avoid a duplicate recap card when previous == the analysis target.
|
||||||
|
const showRecap =
|
||||||
|
previous && previous.session.session_key !== analysisKey && previous.session.session_key !== 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
|
<section className={`live-handoff live-handoff-${phase}`} data-testid={testid}>
|
||||||
@@ -95,6 +122,7 @@ export function LiveHandoff({
|
|||||||
className="live-handoff-action live-handoff-primary"
|
className="live-handoff-action live-handoff-primary"
|
||||||
href={`/race-hub?session_key=${analysisKey}`}
|
href={`/race-hub?session_key=${analysisKey}`}
|
||||||
data-testid="live-handoff-analysis"
|
data-testid="live-handoff-analysis"
|
||||||
|
data-ready={analysisReady ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
<span className="live-handoff-action-body">
|
<span className="live-handoff-action-body">
|
||||||
<span className="live-handoff-action-label">
|
<span className="live-handoff-action-label">
|
||||||
@@ -103,13 +131,18 @@ export function LiveHandoff({
|
|||||||
<span className="live-handoff-action-meta mono">
|
<span className="live-handoff-action-meta mono">
|
||||||
{analysisReady
|
{analysisReady
|
||||||
? 'Full timing, strategy & story ready'
|
? 'Full timing, strategy & story ready'
|
||||||
: 'Settling — analysis will fill in as data ingests'}
|
: isSettling
|
||||||
|
? 'Settling — analysis will fill in as data ingests'
|
||||||
|
: 'Analysis will fill in as data ingests'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<div className="live-handoff-action live-handoff-pending" data-testid="live-handoff-analysis-pending">
|
<div
|
||||||
|
className="live-handoff-action live-handoff-pending"
|
||||||
|
data-testid="live-handoff-analysis-pending"
|
||||||
|
>
|
||||||
<span className="live-handoff-action-body">
|
<span className="live-handoff-action-body">
|
||||||
<span className="live-handoff-action-label">Analysis not ready yet</span>
|
<span className="live-handoff-action-label">Analysis not ready yet</span>
|
||||||
<span className="live-handoff-action-meta mono">
|
<span className="live-handoff-action-meta mono">
|
||||||
@@ -119,26 +152,32 @@ export function LiveHandoff({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{context.nextSession && (
|
{next && (
|
||||||
<div className="live-handoff-action live-handoff-next" data-testid="live-handoff-next">
|
<div className="live-handoff-action live-handoff-next" data-testid="live-handoff-next">
|
||||||
<span className="live-handoff-action-body">
|
<span className="live-handoff-action-body">
|
||||||
<span className="live-handoff-action-label">Up next · {context.nextSession.name}</span>
|
<span className="live-handoff-action-label">
|
||||||
|
Up next · {next.session.session_name}
|
||||||
|
</span>
|
||||||
<span className="live-handoff-action-meta mono">
|
<span className="live-handoff-action-meta mono">
|
||||||
{formatSessionScheduleTime(context.nextSession.startsAt)}
|
{formatSessionScheduleTime(next.session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{context.previousSession && context.previousSession.sessionKey !== analysisKey && (
|
{showRecap && (
|
||||||
<a
|
<a
|
||||||
className="live-handoff-action"
|
className="live-handoff-action"
|
||||||
href={`/race-hub?session_key=${context.previousSession.sessionKey}`}
|
href={`/race-hub?session_key=${previous!.session.session_key}`}
|
||||||
data-testid="live-handoff-recap"
|
data-testid="live-handoff-recap"
|
||||||
>
|
>
|
||||||
<span className="live-handoff-action-body">
|
<span className="live-handoff-action-body">
|
||||||
<span className="live-handoff-action-label">Recap · {context.previousSession.name}</span>
|
<span className="live-handoff-action-label">
|
||||||
<span className="live-handoff-action-meta mono">Review the last completed session</span>
|
Recap · {previous!.session.session_name}
|
||||||
|
</span>
|
||||||
|
<span className="live-handoff-action-meta mono">
|
||||||
|
Review the last completed session
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</a>
|
</a>
|
||||||
@@ -155,17 +194,19 @@ export function LiveHandoff({
|
|||||||
<span className="live-handoff-action-label">
|
<span className="live-handoff-action-label">
|
||||||
<Archive size={14} /> View full timing (read-only)
|
<Archive size={14} /> View full timing (read-only)
|
||||||
</span>
|
</span>
|
||||||
<span className="live-handoff-action-meta mono">Frozen final snapshot — no live updates</span>
|
<span className="live-handoff-action-meta mono">
|
||||||
|
Frozen final snapshot — no live updates
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!analysisKey && !context.nextSession && !context.previousSession && !hasArchive && (
|
{!analysisKey && !next && !showRecap && !hasArchive && (
|
||||||
<div className="live-handoff-fallback" data-testid="live-handoff-fallback">
|
<div className="live-handoff-fallback" data-testid="live-handoff-fallback">
|
||||||
<a href="/" className="live-handoff-action">
|
<a href="/" className="live-handoff-action">
|
||||||
<span className="live-handoff-action-body">
|
<span className="live-handoff-action-body">
|
||||||
<span className="live-handoff-action-label">Command Center</span>
|
<span className="live-handoff-action-label">Weekend</span>
|
||||||
<span className="live-handoff-action-meta mono">Weekend schedule & standings</span>
|
<span className="live-handoff-action-meta mono">Weekend schedule & standings</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
|
|||||||
@@ -11,14 +11,23 @@
|
|||||||
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
|
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
|
||||||
// the page never, for example, calls a finished session "offline" or lets a
|
// the page never, for example, calls a finished session "offline" or lets a
|
||||||
// dropped socket masquerade as "archive mode".
|
// dropped socket masquerade as "archive mode".
|
||||||
|
//
|
||||||
|
// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a
|
||||||
|
// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR
|
||||||
|
// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false`
|
||||||
|
// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus
|
||||||
|
// (Finished/Ended/…) as the only trustworthy "the session really ended" signal.
|
||||||
|
// A non-terminal retained snapshot means the feed dropped, not that the session
|
||||||
|
// finished, and must render as `disconnected` (retain snapshot, warn, recover),
|
||||||
|
// never `settling`/archive.
|
||||||
|
|
||||||
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
|
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
export type LivePhase =
|
export type LivePhase =
|
||||||
| 'connecting' // cold start: no snapshot yet, still opening the feed
|
| 'connecting' // cold start: no snapshot yet, still opening the feed
|
||||||
| 'live' // a session is running and streaming
|
| 'live' // a session is running and streaming
|
||||||
| 'disconnected' // was live, transport dropped — keep the last snapshot, warn
|
| 'disconnected' // was live, transport/feed dropped — keep the last snapshot, warn
|
||||||
| 'settling' // session ended; a final snapshot is retained, analysis pending
|
| 'settling' // session truly ended; a final snapshot is retained, analysis pending
|
||||||
| 'archive' // user opened the retained snapshot as an explicit read-only view
|
| 'archive' // user opened the retained snapshot as an explicit read-only view
|
||||||
| 'inactive' // no session and nothing retained — show weekend context instead
|
| 'inactive' // no session and nothing retained — show weekend context instead
|
||||||
|
|
||||||
@@ -27,10 +36,39 @@ export interface LiveStateInputs {
|
|||||||
/** The feed reports an active session AND carries a snapshot for it. */
|
/** The feed reports an active session AND carries a snapshot for it. */
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
hasActiveSnapshot: boolean
|
hasActiveSnapshot: boolean
|
||||||
/** A final snapshot from the last session is retained. */
|
/** A retained snapshot from the last stream is held (may be a dropped feed). */
|
||||||
hasArchive: boolean
|
hasArchive: boolean
|
||||||
/** The user explicitly opened the archive as a read-only timing view. */
|
/** The user explicitly opened the archive as a read-only timing view. */
|
||||||
archiveMode: boolean
|
archiveMode: boolean
|
||||||
|
/**
|
||||||
|
* The retained snapshot ended on a *terminal* FIA SessionStatus. Only true
|
||||||
|
* here means the session genuinely finished (vs. the transport dropping).
|
||||||
|
*/
|
||||||
|
sessionEndedCleanly: boolean
|
||||||
|
/** We have observed this session live at least once in this page session. */
|
||||||
|
wasLive: boolean
|
||||||
|
/**
|
||||||
|
* The initial `/api/v1/live/state` query has resolved. `connecting` is a
|
||||||
|
* cold-start-only phase: once we know the current state (even if the SSE
|
||||||
|
* transport handshake is still pending), an idle feed is `inactive`, not a
|
||||||
|
* perpetual "connecting…" spinner.
|
||||||
|
*/
|
||||||
|
stateLoaded: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors
|
||||||
|
// the backend `terminalSessionStatus` used by the weekend-context resolver so
|
||||||
|
// the client and server agree on "finished".
|
||||||
|
const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted'])
|
||||||
|
|
||||||
|
function normalizeStatus(status: string | undefined | null): string {
|
||||||
|
if (!status) return ''
|
||||||
|
return status.replace(/[^a-zA-Z]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a raw FIA SessionStatus represents a genuinely completed session. */
|
||||||
|
export function terminalSessionStatus(status: string | undefined | null): boolean {
|
||||||
|
return TERMINAL_STATUSES.has(normalizeStatus(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transportDown(transport: TransportHealth): boolean {
|
export function transportDown(transport: TransportHealth): boolean {
|
||||||
@@ -40,10 +78,19 @@ export function transportDown(transport: TransportHealth): boolean {
|
|||||||
/**
|
/**
|
||||||
* Map transport + session + archive inputs to one UI phase. Session lifecycle
|
* Map transport + session + archive inputs to one UI phase. Session lifecycle
|
||||||
* and transport health are deliberately orthogonal: a live session with a
|
* and transport health are deliberately orthogonal: a live session with a
|
||||||
* dropped socket is `disconnected` (snapshot retained), never `archive`.
|
* dropped socket is `disconnected` (snapshot retained), never `archive` or
|
||||||
|
* `settling`.
|
||||||
*/
|
*/
|
||||||
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
|
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
|
||||||
const { transport, isLive, hasActiveSnapshot, hasArchive, archiveMode } = input
|
const {
|
||||||
|
transport,
|
||||||
|
isLive,
|
||||||
|
hasActiveSnapshot,
|
||||||
|
hasArchive,
|
||||||
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
stateLoaded,
|
||||||
|
} = input
|
||||||
|
|
||||||
// Explicit read-only archive wins — it is a user-chosen mode.
|
// Explicit read-only archive wins — it is a user-chosen mode.
|
||||||
if (archiveMode && hasArchive) return 'archive'
|
if (archiveMode && hasArchive) return 'archive'
|
||||||
@@ -54,11 +101,20 @@ export function deriveLivePhase(input: LiveStateInputs): LivePhase {
|
|||||||
return transportDown(transport) ? 'disconnected' : 'live'
|
return transportDown(transport) ? 'disconnected' : 'live'
|
||||||
}
|
}
|
||||||
|
|
||||||
// No active session but we still hold the final snapshot -> settling handoff.
|
// Feed reports no active session but a snapshot is retained. Distinguish a
|
||||||
if (hasArchive) return 'settling'
|
// genuine session end from an upstream feed drop using the *only* trustworthy
|
||||||
|
// signal: whether the retained snapshot carries a terminal SessionStatus.
|
||||||
|
if (hasArchive) {
|
||||||
|
if (sessionEndedCleanly) return 'settling'
|
||||||
|
// Non-terminal retained snapshot means the upstream feed dropped, not that
|
||||||
|
// the session finished. Hold the last frame and warn — never settle/archive.
|
||||||
|
return 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
// Nothing yet and the feed is still opening.
|
// Cold start only: still opening the feed and we have not yet learned the
|
||||||
if (transport === 'connecting') return 'connecting'
|
// current state. Once the initial state query resolves, an idle feed is
|
||||||
|
// `inactive`, not a perpetual "connecting…".
|
||||||
|
if (transport === 'connecting' && !stateLoaded) return 'connecting'
|
||||||
|
|
||||||
return 'inactive'
|
return 'inactive'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
import { fetchLiveState, fetchWeekendContext } from '../api'
|
||||||
import type { LivePosition, LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
@@ -21,10 +21,10 @@ import {
|
|||||||
allowsLiveInterpretations,
|
allowsLiveInterpretations,
|
||||||
deriveLivePhase,
|
deriveLivePhase,
|
||||||
rendersSnapshot,
|
rendersSnapshot,
|
||||||
|
terminalSessionStatus,
|
||||||
} from '../lib/liveState'
|
} from '../lib/liveState'
|
||||||
import type { TransportHealth } from '../lib/liveState'
|
import type { TransportHealth } from '../lib/liveState'
|
||||||
import { deriveWeekendContext } from '../lib/weekendContext'
|
import { analysisIsReady } from '../components/live/LiveHandoff'
|
||||||
import { pickFocusMeeting } from '../lib/schedule'
|
|
||||||
import { SessionBanner } from '../components/live/SessionBanner'
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||||
import { TimingTower } from '../components/live/TimingTower'
|
import { TimingTower } from '../components/live/TimingTower'
|
||||||
@@ -37,6 +37,10 @@ import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
|||||||
import { LiveHandoff } from '../components/live/LiveHandoff'
|
import { LiveHandoff } from '../components/live/LiveHandoff'
|
||||||
import '../styles/live-state.css'
|
import '../styles/live-state.css'
|
||||||
|
|
||||||
|
/** How often to re-check weekend-context while analysis is still ingesting. */
|
||||||
|
export const WEEKEND_CONTEXT_POLL_MS = 15_000
|
||||||
|
const WEEKEND_CONTEXT_STALE_MS = 15_000
|
||||||
|
|
||||||
export function LiveTimingPage() {
|
export function LiveTimingPage() {
|
||||||
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
|
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
@@ -55,61 +59,75 @@ export function LiveTimingPage() {
|
|||||||
const sessionSigRef = useRef('')
|
const sessionSigRef = useRef('')
|
||||||
const isLiveRef = useRef(false)
|
const isLiveRef = useRef(false)
|
||||||
const archiveModeRef = useRef(false)
|
const archiveModeRef = useRef(false)
|
||||||
|
// Whether we ever observed this session as live — used to reason about a
|
||||||
|
// dropped feed vs. a session that was never live in this page session.
|
||||||
|
const wasLiveRef = useRef(false)
|
||||||
const hasArchive = Boolean(archiveSnapshot)
|
const hasArchive = Boolean(archiveSnapshot)
|
||||||
|
|
||||||
// Transport health, active-session state, and archive mode are three
|
const {
|
||||||
// independent inputs; deriveLivePhase collapses them into one UI phase.
|
data,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
isFetched: liveStateFetched,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['live-state'],
|
||||||
|
queryFn: fetchLiveState,
|
||||||
|
staleTime: 5_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A retained snapshot is only "settled" (session genuinely finished) if it
|
||||||
|
// carries a terminal FIA SessionStatus. Otherwise the upstream feed dropped
|
||||||
|
// while the session was still active and we must stay in `disconnected`.
|
||||||
|
const sessionEndedCleanly = terminalSessionStatus(archiveSnapshot?.SessionStatus)
|
||||||
|
|
||||||
|
// Transport health, active-session state, and archive availability are
|
||||||
|
// orthogonal inputs; deriveLivePhase collapses them into one UI phase.
|
||||||
const phase = deriveLivePhase({
|
const phase = deriveLivePhase({
|
||||||
transport: streamStatus,
|
transport: streamStatus,
|
||||||
isLive,
|
isLive,
|
||||||
hasActiveSnapshot: Boolean(activeSnapshot),
|
hasActiveSnapshot: Boolean(activeSnapshot),
|
||||||
hasArchive,
|
hasArchive,
|
||||||
archiveMode,
|
archiveMode,
|
||||||
|
sessionEndedCleanly,
|
||||||
|
wasLive: wasLiveRef.current,
|
||||||
|
stateLoaded: liveStateFetched,
|
||||||
})
|
})
|
||||||
const snapshot = phase === 'archive' ? archiveSnapshot : isLive ? activeSnapshot : null
|
const snapshot =
|
||||||
|
phase === 'archive'
|
||||||
|
? archiveSnapshot
|
||||||
|
: isLive
|
||||||
|
? activeSnapshot
|
||||||
|
: phase === 'disconnected'
|
||||||
|
? archiveSnapshot
|
||||||
|
: null
|
||||||
|
|
||||||
const { data, isError, error } = useQuery({
|
// Canonical weekend context (issue #72) — the single source of truth for
|
||||||
queryKey: ['live-state'],
|
// previous/next/default-analysis identity and temporal state. Only needed
|
||||||
queryFn: fetchLiveState,
|
// when no session is streaming; keep it idle during a live session. Poll
|
||||||
staleTime: 5_000,
|
// while a completed session is still ingesting so `settling` can flip to
|
||||||
|
// analysis-ready without a manual refresh.
|
||||||
|
const contextQuery = useQuery({
|
||||||
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: fetchWeekendContext,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
})
|
})
|
||||||
|
const weekendContext = contextQuery.data
|
||||||
// Weekend context (previous/next/analysis) is only needed when no session is
|
|
||||||
// streaming; keep the queries idle during a live session.
|
|
||||||
const notLive = !isLive
|
|
||||||
const nowDate = useMemo(() => new Date(now), [now])
|
|
||||||
const seasonsQuery = useQuery({
|
|
||||||
queryKey: ['seasons'],
|
|
||||||
queryFn: fetchSeasons,
|
|
||||||
enabled: notLive,
|
|
||||||
})
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['meetings', latestSeason],
|
|
||||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
|
||||||
enabled: notLive && latestSeason != null,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
const focusMeeting = useMemo(
|
|
||||||
() => pickFocusMeeting(meetingsQuery.data ?? [], nowDate),
|
|
||||||
[meetingsQuery.data, nowDate],
|
|
||||||
)
|
|
||||||
const weekendQuery = useQuery({
|
|
||||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
|
||||||
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
|
||||||
enabled: notLive && focusMeeting != null,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
const weekendContext = useMemo(
|
|
||||||
() => deriveWeekendContext(weekendQuery.data, nowDate),
|
|
||||||
[weekendQuery.data, nowDate],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
const nextLive = data.is_live && Boolean(data.data)
|
const nextLive = data.is_live && Boolean(data.data)
|
||||||
setIsLive(nextLive)
|
setIsLive(nextLive)
|
||||||
isLiveRef.current = nextLive
|
isLiveRef.current = nextLive
|
||||||
|
if (nextLive) wasLiveRef.current = true
|
||||||
if (nextLive && data.data) {
|
if (nextLive && data.data) {
|
||||||
setActiveSnapshot(data.data)
|
setActiveSnapshot(data.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
@@ -159,6 +177,7 @@ export function LiveTimingPage() {
|
|||||||
setPositions({})
|
setPositions({})
|
||||||
}
|
}
|
||||||
isLiveRef.current = true
|
isLiveRef.current = true
|
||||||
|
wasLiveRef.current = true
|
||||||
setActiveSnapshot(state.data)
|
setActiveSnapshot(state.data)
|
||||||
setArchiveMode(false)
|
setArchiveMode(false)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
414
frontend/src/styles/live-state.css
Normal file
414
frontend/src/styles/live-state.css
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
/* ── Live → settling → analysis handoff (issue #74) ──
|
||||||
|
*
|
||||||
|
* State-transition presentation for Live: session/flag chrome that replaces
|
||||||
|
* the old connection-as-session conflation, the connection-loss warning, the
|
||||||
|
* archive read-only strip, and the settling/inactive handoff surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Session flag: primary session state. Feed health is secondary. */
|
||||||
|
.live-session-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live {
|
||||||
|
background: rgba(225, 6, 0, 0.14);
|
||||||
|
border: 1px solid rgba(225, 6, 0, 0.35);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale {
|
||||||
|
background: rgba(255, 214, 0, 0.1);
|
||||||
|
border-color: rgba(255, 214, 0, 0.35);
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.35);
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-session-flag-live.is-stale .live-session-dot {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-health {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connected {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: rgba(57, 199, 58, 0.25);
|
||||||
|
background: rgba(57, 199, 58, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-connecting {
|
||||||
|
color: var(--yellow);
|
||||||
|
border-color: rgba(255, 214, 0, 0.25);
|
||||||
|
background: rgba(255, 214, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-feed-disconnected,
|
||||||
|
.live-feed-error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: rgba(225, 6, 0, 0.24);
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-banner-archive {
|
||||||
|
border-color: rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-stamp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-readonly {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-captured {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-state-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disconnected warning: a live session whose feed dropped. Distinct from the
|
||||||
|
* blue archive strip so a dropped feed is never mistaken for a read-only
|
||||||
|
* archive. The retained tower stays visible beneath it. */
|
||||||
|
.live-status-strip.live-status-warn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-warn::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(70, 140, 255, 0.12);
|
||||||
|
color: #8bb7ff;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-exit:hover {
|
||||||
|
border-color: rgba(70, 140, 255, 0.7);
|
||||||
|
background: rgba(70, 140, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Handoff surface (settling + inactive) ── */
|
||||||
|
.live-handoff {
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 120% at 0% 0%, rgba(255, 255, 255, 0.04), transparent 55%),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling {
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive {
|
||||||
|
border-left: 3px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px var(--s3);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-settling .live-handoff-eyebrow {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-inactive .live-handoff-eyebrow {
|
||||||
|
color: var(--text-2);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-captured {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-feed {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provisional final-order snapshot chips (settling only). */
|
||||||
|
.live-handoff-snapshot {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-snapshot-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-order li span:first-child {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-code {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action cards ── */
|
||||||
|
.live-handoff-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.live-handoff-action {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action:hover {
|
||||||
|
border-color: var(--border-glow);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The primary "open analysis" action is the strongest affordance. */
|
||||||
|
.live-handoff-primary {
|
||||||
|
border-color: rgba(230, 36, 41, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.14), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-primary:hover {
|
||||||
|
border-color: rgba(230, 36, 41, 0.7);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 36, 41, 0.22), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pending-analysis states read as "waiting", not actionable links. */
|
||||||
|
.live-handoff-primary[data-ready='false'] {
|
||||||
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-pending:hover {
|
||||||
|
transform: none;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-next:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive: keep the handoff usable at tablet / mobile widths ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.live-handoff {
|
||||||
|
padding: var(--s5) var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-title {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-handoff-action-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-status-strip.live-status-archive {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { LiveTimingPage } from '../pages/LiveTimingPage'
|
import { LiveTimingPage, WEEKEND_CONTEXT_POLL_MS } from '../pages/LiveTimingPage'
|
||||||
import type { LiveStateResponse, LiveStreamData } from '../types'
|
import type { LiveStateResponse, LiveStreamData, WeekendContext } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchLiveState: vi.fn(),
|
fetchLiveState: vi.fn(),
|
||||||
fetchLiveTrackOutline: 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 mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||||
const mockFetchLiveTrackOutline = vi.mocked(fetchLiveTrackOutline)
|
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 {
|
class MockEventSource {
|
||||||
onopen: (() => void) | null = null
|
onopen: (() => void) | null = null
|
||||||
onerror: (() => void) | null = null
|
onerror: (() => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setTimeout(() => this.onopen?.(), 0)
|
setTimeout(() => this.onopen?.(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener() {}
|
addEventListener() {}
|
||||||
close() {}
|
close() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const archivedSnapshot: LiveStreamData = {
|
const raceSnapshot: LiveStreamData = {
|
||||||
Drivers: {
|
Drivers: {
|
||||||
'1': {
|
'1': {
|
||||||
RacingNumber: '1',
|
RacingNumber: '1',
|
||||||
@@ -63,19 +65,10 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
LastName: 'Verstappen',
|
LastName: 'Verstappen',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Tyres: {
|
Tyres: { '1': { Compound: 'HARD', New: false, Age: 12 } },
|
||||||
'1': { Compound: 'HARD', New: false, Age: 12 },
|
|
||||||
},
|
|
||||||
Telemetry: {},
|
Telemetry: {},
|
||||||
RCMessages: [],
|
RCMessages: [],
|
||||||
Weather: {
|
Weather: { AirTemp: 22, TrackTemp: 41, Humidity: 58, WindSpeed: 3, WindDir: 180, Rainfall: false },
|
||||||
AirTemp: 22,
|
|
||||||
TrackTemp: 41,
|
|
||||||
Humidity: 58,
|
|
||||||
WindSpeed: 3,
|
|
||||||
WindDir: 180,
|
|
||||||
Rainfall: false,
|
|
||||||
},
|
|
||||||
Session: {
|
Session: {
|
||||||
MeetingName: 'Testonia Grand Prix',
|
MeetingName: 'Testonia Grand Prix',
|
||||||
CircuitName: 'Testring',
|
CircuitName: 'Testring',
|
||||||
@@ -84,9 +77,9 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Path: '',
|
Path: '',
|
||||||
},
|
},
|
||||||
TeamRadio: [],
|
TeamRadio: [],
|
||||||
SessionStatus: 'Finished',
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '1',
|
TrackStatus: '1',
|
||||||
CurrentLap: 57,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
Clock: '',
|
Clock: '',
|
||||||
ClockRefTime: '',
|
ClockRefTime: '',
|
||||||
@@ -94,17 +87,66 @@ const archivedSnapshot: LiveStreamData = {
|
|||||||
Stints: {},
|
Stints: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(response: LiveStateResponse) {
|
function weekendContext(localAnalysis: string): WeekendContext {
|
||||||
const queryClient = new QueryClient({
|
return {
|
||||||
defaultOptions: { queries: { retry: false } },
|
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)
|
mockFetchLiveState.mockResolvedValue(response)
|
||||||
mockFetchLiveTrackOutline.mockResolvedValue({
|
mockFetchLiveTrackOutline.mockResolvedValue({
|
||||||
circuit_key: 1,
|
circuit_key: 1,
|
||||||
points: [],
|
points: [],
|
||||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
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(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LiveTimingPage />
|
<LiveTimingPage />
|
||||||
@@ -112,7 +154,7 @@ function renderPage(response: LiveStateResponse) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('LiveTimingPage archive mode', () => {
|
describe('LiveTimingPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
Object.defineProperty(window, 'EventSource', {
|
Object.defineProperty(window, 'EventSource', {
|
||||||
@@ -122,45 +164,162 @@ describe('LiveTimingPage archive mode', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps archived snapshots behind the View Last Session action', async () => {
|
afterEach(() => {
|
||||||
renderPage({
|
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,
|
is_live: false,
|
||||||
data: null,
|
data: null,
|
||||||
last_snapshot: archivedSnapshot,
|
last_snapshot: { ...raceSnapshot, SessionStatus: 'Finished' },
|
||||||
last_positions: {
|
last_snapshot_at: '2026-07-05T16:02:00Z',
|
||||||
'1': { x: 10, y: 20, z: 0, status: 'OnTrack' },
|
|
||||||
},
|
},
|
||||||
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await screen.findByTestId('live-empty')).toHaveTextContent('No live session active')
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
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(() => {
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent('Archived snapshot')
|
expect(screen.getByTestId('live-archive-strip')).toHaveTextContent(/read-only/i),
|
||||||
})
|
)
|
||||||
expect(screen.getByText('Timing Tower')).toBeInTheDocument()
|
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.getByText('archive')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show the archive action when no snapshot is retained', async () => {
|
it('shows the inactive weekend context when nothing is live or retained', async () => {
|
||||||
renderPage({ is_live: false, data: null })
|
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(await screen.findByTestId('live-inactive')).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: /view last session/i })).not.toBeInTheDocument()
|
expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
})
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -93,6 +93,7 @@ const raceSnapshot = {
|
|||||||
SessionType: 'Race',
|
SessionType: 'Race',
|
||||||
SessionName: 'Race',
|
SessionName: 'Race',
|
||||||
},
|
},
|
||||||
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '2',
|
TrackStatus: '2',
|
||||||
CurrentLap: 30,
|
CurrentLap: 30,
|
||||||
TotalLaps: 57,
|
TotalLaps: 57,
|
||||||
@@ -138,6 +139,7 @@ const sprintQualifyingSnapshot = {
|
|||||||
SessionType: 'Sprint Qualifying',
|
SessionType: 'Sprint Qualifying',
|
||||||
SessionName: 'Sprint Qualifying',
|
SessionName: 'Sprint Qualifying',
|
||||||
},
|
},
|
||||||
|
SessionStatus: 'Started',
|
||||||
TrackStatus: '1',
|
TrackStatus: '1',
|
||||||
CurrentLap: 0,
|
CurrentLap: 0,
|
||||||
TotalLaps: 0,
|
TotalLaps: 0,
|
||||||
@@ -164,6 +166,12 @@ test.describe('Live Timing (mocked snapshot)', () => {
|
|||||||
|
|
||||||
test('renders timing tower with drivers from the snapshot', async ({ page }) => {
|
test('renders timing tower with drivers from the snapshot', async ({ page }) => {
|
||||||
await expect(page.getByTestId('live-page')).toBeVisible()
|
await expect(page.getByTestId('live-page')).toBeVisible()
|
||||||
|
// The hermetic SSE mock ends after a heartbeat, so transport may drop to
|
||||||
|
// disconnected while the session snapshot remains active — both phases keep
|
||||||
|
// the LIVE SESSION chrome and timing tower.
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
|
||||||
|
await expect(page.getByTestId('live-session-flag')).toContainText('LIVE SESSION')
|
||||||
|
await expect(page.getByTestId('live-feed-health')).toBeVisible()
|
||||||
const tower = page.locator('.live-tower')
|
const tower = page.locator('.live-tower')
|
||||||
await expect(tower).toBeVisible()
|
await expect(tower).toBeVisible()
|
||||||
await expect(tower).toContainText('VER')
|
await expect(tower).toContainText('VER')
|
||||||
@@ -233,45 +241,142 @@ test.describe('Live Timing (mocked Sprint Qualifying)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const weekendContext = (localAnalysis: string) => ({
|
||||||
|
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: 9472,
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
const heartbeatStream = (route: import('@playwright/test').Route) =>
|
||||||
|
route.fulfill({ contentType: 'text/event-stream', body: 'event: heartbeat\ndata: {}\n\n' })
|
||||||
|
|
||||||
test.describe('Live Timing (no session)', () => {
|
test.describe('Live Timing (no session)', () => {
|
||||||
test('shows the empty state when the feed has no snapshot', async ({ page }) => {
|
test('shows the inactive weekend-context handoff when the feed has no snapshot', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
// The e2e server (BOXBOX_DISABLE_LIVE=1) exposes a real /api/v1/weekend-context.
|
||||||
await expect(page.getByTestId('live-page')).toContainText('No live session active')
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'inactive')
|
||||||
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders an archived snapshot only after View Last Session', async ({ page }) => {
|
test('enters settling (not archive) when a finished session is retained', async ({ page }) => {
|
||||||
await page.route('**/api/v1/live/state', (route) =>
|
await page.route('**/api/v1/live/state', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
is_live: false,
|
is_live: false,
|
||||||
data: null,
|
data: null,
|
||||||
last_snapshot: {
|
last_snapshot: { ...raceSnapshot.data, SessionStatus: 'Finished' },
|
||||||
...raceSnapshot.data,
|
last_positions: { '1': { x: 100, y: -50, z: 2, status: 'OnTrack' } },
|
||||||
SessionStatus: 'Finished',
|
|
||||||
},
|
|
||||||
last_positions: {
|
|
||||||
'1': { x: 100, y: -50, z: 2, status: 'OnTrack' },
|
|
||||||
},
|
|
||||||
last_snapshot_at: '2026-07-04T14:00:00Z',
|
last_snapshot_at: '2026-07-04T14:00:00Z',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route('**/api/v1/live/stream', (route) =>
|
await page.route('**/api/v1/weekend-context', (route) =>
|
||||||
route.fulfill({
|
route.fulfill({ contentType: 'application/json', body: JSON.stringify(weekendContext('complete')) }),
|
||||||
contentType: 'text/event-stream',
|
|
||||||
body: 'event: heartbeat\ndata: {}\n\n',
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
await page.route('**/api/v1/live/stream', heartbeatStream)
|
||||||
|
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.getByTestId('live-empty')).toContainText('No live session active')
|
await expect(page.getByTestId('live-settling')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'settling')
|
||||||
|
await expect(page.getByText('SESSION SETTLING')).toBeVisible()
|
||||||
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
await expect(page.getByText('Timing Tower')).toHaveCount(0)
|
||||||
|
// Canonical weekend-context drives the analysis target + readiness.
|
||||||
|
const analysis = page.getByTestId('live-handoff-analysis')
|
||||||
|
await expect(analysis).toHaveAttribute('href', '/race-hub?session_key=9472')
|
||||||
|
await expect(analysis).toHaveAttribute('data-ready', 'true')
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'View Last Session' }).click()
|
// Opening the read-only archive reveals the frozen tower, never LIVE chrome.
|
||||||
await expect(page.getByTestId('live-archive-strip')).toContainText('Archived snapshot')
|
await page.getByTestId('live-handoff-archive').click()
|
||||||
|
await expect(page.getByTestId('live-archive-strip')).toContainText('read-only')
|
||||||
await expect(page.getByText('Timing Tower')).toBeVisible()
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
await expect(page.locator('.live-state')).toContainText('archive')
|
await expect(page.locator('.live-state')).toContainText('archive')
|
||||||
|
await expect(page.getByText('LIVE SESSION')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('settling shows pending analysis until weekend-context reports complete', 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(weekendContext('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('data-ready', 'false')
|
||||||
|
await expect(analysis).toContainText(/analysis will fill in as data ingests/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
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.
|
||||||
|
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: 'Started' },
|
||||||
|
last_snapshot_at: '2026-07-04T13:30:00Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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.goto('/live')
|
||||||
|
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', 'disconnected')
|
||||||
|
await expect(page.getByTestId('live-disconnected-strip')).toContainText('last live data')
|
||||||
|
await expect(page.getByText('Timing Tower')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('live-settling')).toHaveCount(0)
|
||||||
|
await expect(page.getByTestId('live-archive-strip')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves live route with empty state when live is disabled', async ({ page }) => {
|
test('serves live route with the inactive handoff when live is disabled', async ({ page }) => {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
await expect(page.getByTestId('live-inactive')).toBeVisible()
|
||||||
await expect(page.getByText('No live session active')).toBeVisible()
|
await expect(page.getByText('NO LIVE SESSION')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 46 KiB |
@@ -62,10 +62,12 @@ export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
|||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoLiveEmptyReady(page: Page): Promise<void> {
|
export async function gotoLiveInactiveReady(page: Page): Promise<void> {
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
await expect(page.locator('.loading-state')).toHaveCount(0)
|
await expect(page.locator('.loading-state')).toHaveCount(0)
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
// 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()
|
||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test } from '@playwright/test'
|
|||||||
import {
|
import {
|
||||||
gotoWeekendReady,
|
gotoWeekendReady,
|
||||||
gotoDataLibraryReady,
|
gotoDataLibraryReady,
|
||||||
gotoLiveEmptyReady,
|
gotoLiveInactiveReady,
|
||||||
gotoRaceHubFutureReady,
|
gotoRaceHubFutureReady,
|
||||||
gotoRaceHubReady,
|
gotoRaceHubReady,
|
||||||
screenshotPage,
|
screenshotPage,
|
||||||
@@ -32,8 +32,8 @@ test.describe('MVP visual regression', () => {
|
|||||||
await screenshotPage(page, 'data-library')
|
await screenshotPage(page, 'data-library')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('live-empty', async ({ page }) => {
|
test('live-inactive', async ({ page }) => {
|
||||||
await gotoLiveEmptyReady(page)
|
await gotoLiveInactiveReady(page)
|
||||||
await screenshotPage(page, 'live')
|
await screenshotPage(page, 'live')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user