diff --git a/frontend/src/components/live/LiveHandoff.tsx b/frontend/src/components/live/LiveHandoff.tsx index b166dce..dca9d4f 100644 --- a/frontend/src/components/live/LiveHandoff.tsx +++ b/frontend/src/components/live/LiveHandoff.tsx @@ -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) diff --git a/frontend/src/components/weekend/BetweenSessionsView.tsx b/frontend/src/components/weekend/BetweenSessionsView.tsx index d92635b..f84fb52 100644 --- a/frontend/src/components/weekend/BetweenSessionsView.tsx +++ b/frontend/src/components/weekend/BetweenSessionsView.tsx @@ -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 ( diff --git a/frontend/src/lib/liveState.ts b/frontend/src/lib/liveState.ts index 4d9f7d6..54c6804 100644 --- a/frontend/src/lib/liveState.ts +++ b/frontend/src/lib/liveState.ts @@ -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) { diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index e7f8223..0250d8c 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -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 (
@@ -321,7 +323,7 @@ export function LiveTimingPage() { {(phase === 'settling' || phase === 'inactive') && ( diff --git a/frontend/src/test/LiveHandoff.test.tsx b/frontend/src/test/LiveHandoff.test.tsx index 5639161..ba019c6 100644 --- a/frontend/src/test/LiveHandoff.test.tsx +++ b/frontend/src/test/LiveHandoff.test.tsx @@ -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( { expect(screen.getByTestId('live-handoff-snapshot')).toHaveTextContent('VER') }) + it('links/labels/readiness follow archive-only previous, not an older ready default', () => { + render( + , + ) + + 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( { { 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 () => { diff --git a/frontend/src/test/liveState.test.ts b/frontend/src/test/liveState.test.ts index 2ae27ae..6d05edd 100644 --- a/frontend/src/test/liveState.test.ts +++ b/frontend/src/test/liveState.test.ts @@ -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') + }) +}) diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index fb46235..2e586a2 100644 --- a/tests/live-timing.spec.ts +++ b/tests/live-timing.spec.ts @@ -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) }) }) diff --git a/tests/visual/__snapshots__/desktop/live-active.png b/tests/visual/__snapshots__/desktop/live-active.png new file mode 100644 index 0000000..d8cf6e3 Binary files /dev/null and b/tests/visual/__snapshots__/desktop/live-active.png differ diff --git a/tests/visual/__snapshots__/desktop/live.png b/tests/visual/__snapshots__/desktop/live.png index abdc444..92be1d7 100644 Binary files a/tests/visual/__snapshots__/desktop/live.png and b/tests/visual/__snapshots__/desktop/live.png differ diff --git a/tests/visual/__snapshots__/mobile/live-active.png b/tests/visual/__snapshots__/mobile/live-active.png new file mode 100644 index 0000000..1bcd051 Binary files /dev/null and b/tests/visual/__snapshots__/mobile/live-active.png differ diff --git a/tests/visual/__snapshots__/mobile/live.png b/tests/visual/__snapshots__/mobile/live.png index f734957..846a5a2 100644 Binary files a/tests/visual/__snapshots__/mobile/live.png and b/tests/visual/__snapshots__/mobile/live.png differ diff --git a/tests/visual/__snapshots__/tablet/live-active.png b/tests/visual/__snapshots__/tablet/live-active.png new file mode 100644 index 0000000..5994f7a Binary files /dev/null and b/tests/visual/__snapshots__/tablet/live-active.png differ diff --git a/tests/visual/__snapshots__/tablet/live.png b/tests/visual/__snapshots__/tablet/live.png index 251f884..c4887dd 100644 Binary files a/tests/visual/__snapshots__/tablet/live.png and b/tests/visual/__snapshots__/tablet/live.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index ddd0ebc..84b7bae 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -68,6 +68,156 @@ export async function gotoLiveInactiveReady(page: Page): Promise { // 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 { + 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 = {}, + ) => ({ + 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) } diff --git a/tests/visual/live-active.spec.ts b/tests/visual/live-active.spec.ts new file mode 100644 index 0000000..3ba2422 --- /dev/null +++ b/tests/visual/live-active.spec.ts @@ -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') + }) +})