From 01d291507da9c8ebe99755d1177c8041e31e8713 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sun, 12 Jul 2026 20:43:51 -0400 Subject: [PATCH] fix(#76): correct frontend availability, retry, and live recovery semantics Make response freshness React Query-safe, retry the failed Weekend/Preview/Briefing resources with busy gating, preserve distinct embedded Preview notices, and clear fatal Live errors once SSE supplies usable timing. Co-authored-by: Cursor --- .../src/components/weekend/PreSessionView.tsx | 13 ++- frontend/src/hooks/useWeekendContext.ts | 42 ++++++-- frontend/src/lib/availability.ts | 13 +++ frontend/src/lib/fetch.ts | 31 ++++++ frontend/src/lib/queryClient.ts | 16 +++ frontend/src/main.tsx | 13 +-- frontend/src/pages/BriefingPage.tsx | 36 +++++-- frontend/src/pages/LiveTimingPage.tsx | 9 +- frontend/src/pages/RacePreviewPage.tsx | 53 +++++++++- frontend/src/pages/WeekendPage.tsx | 18 +++- frontend/src/test/BriefingPage.test.tsx | 31 ++++++ frontend/src/test/LiveTimingPage.test.tsx | 94 +++++++++++++++++- frontend/src/test/RacePreviewPage.test.tsx | 78 ++++++++++++++- frontend/src/test/WeekendPage.test.tsx | 99 +++++++++++++++++-- frontend/src/test/availability.test.ts | 90 +++++++++++++++++ tests/issue-76-availability.spec.ts | 62 +++++++++--- tests/live-timing.spec.ts | 24 +++++ 17 files changed, 659 insertions(+), 63 deletions(-) create mode 100644 frontend/src/lib/queryClient.ts diff --git a/frontend/src/components/weekend/PreSessionView.tsx b/frontend/src/components/weekend/PreSessionView.tsx index 04a5746..1374a73 100644 --- a/frontend/src/components/weekend/PreSessionView.tsx +++ b/frontend/src/components/weekend/PreSessionView.tsx @@ -4,6 +4,7 @@ import type { WeekendContext } from '../../types' import { RacePreviewPage } from '../../pages/RacePreviewPage' import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared' import { meetingIdentity } from '../../lib/weekendContext' +import type { DataAvailability } from '../../lib/availability' /** * PreSessionView folds the race preview surface into the Weekend home so there is @@ -14,7 +15,16 @@ import { meetingIdentity } from '../../lib/weekendContext' * Canonical meeting/session identity is passed into Preview so the nested surface * cannot independently re-resolve a different current weekend. */ -export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) { +export function PreSessionView({ + context, + now, + shellAvailability = null, +}: { + context: WeekendContext + now: Date + /** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */ + shellAvailability?: DataAvailability | null +}) { const meeting = context.next_meeting ?? context.focus_meeting const identity = meetingIdentity(meeting) const next = context.next_session?.session @@ -52,6 +62,7 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now: embedded meeting={meeting} season={context.season ?? meeting?.year} + shellAvailability={shellAvailability} /> diff --git a/frontend/src/hooks/useWeekendContext.ts b/frontend/src/hooks/useWeekendContext.ts index 8c86c2a..ce8a912 100644 --- a/frontend/src/hooks/useWeekendContext.ts +++ b/frontend/src/hooks/useWeekendContext.ts @@ -25,9 +25,9 @@ export interface UseWeekendContextResult { /** True when championship or news supplements failed while context succeeded. */ supplementsLimited: boolean now: Date - /** Refetch the canonical weekend-context read. */ + /** Refetch canonical context and/or failed supplements as appropriate. */ refetch: () => void - /** True while a canonical refetch is in flight. */ + /** True while a relevant refetch is in flight. */ isFetching: boolean } @@ -88,15 +88,42 @@ export function useWeekendContext(): UseWeekendContextResult { [canonical], ) + // Keep Limited visible while a failed supplement is refetching so Retry can + // expose disabled/aria-busy. React Query v5 clears isError during that refetch. + const supplementDegraded = (q: { + isError: boolean + isFetching: boolean + isFetched: boolean + data: unknown + }) => q.isError || (q.isFetching && q.isFetched && q.data == null) + const supplementsLimited = canonicalReady && - ((championshipQuery.isError && !championshipQuery.isFetching) || - (newsQuery.isError && !newsQuery.isFetching)) + (supplementDegraded(championshipQuery) || supplementDegraded(newsQuery)) let loadState: WeekendLoadState = 'loading' if (contextQuery.isError) loadState = 'error' else if (canonicalReady) loadState = 'ready' + const refetch = () => { + if (!canonicalReady) { + if (!contextQuery.isFetching) void contextQuery.refetch() + return + } + // Freshness notice Retry refreshes the canonical context. + if (availabilityNotice && !contextQuery.isFetching) { + void contextQuery.refetch() + } + // Limited Retry must hit the failed supplements — not merely context. + // Gate on degraded (error or in-flight post-error refetch), not isError alone. + if (championshipQuery.isError && !championshipQuery.isFetching) { + void championshipQuery.refetch() + } + if (newsQuery.isError && !newsQuery.isFetching) { + void newsQuery.refetch() + } + } + return { context: canonical, loadState, @@ -106,9 +133,8 @@ export function useWeekendContext(): UseWeekendContextResult { availabilityNotice, supplementsLimited, now: nowDate, - refetch: () => { - if (!contextQuery.isFetching) void contextQuery.refetch() - }, - isFetching: contextQuery.isFetching, + refetch, + isFetching: + contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching, } } diff --git a/frontend/src/lib/availability.ts b/frontend/src/lib/availability.ts index af33a95..6431aa8 100644 --- a/frontend/src/lib/availability.ts +++ b/frontend/src/lib/availability.ts @@ -74,3 +74,16 @@ export function weekendContextNotice( } return null } + +/** + * Embedded Preview should disclose its own freshness unless the Weekend shell + * already shows an equivalent notice (same DataAvailability kind). + */ +export function shouldShowEmbeddedNotice( + notice: DataAvailability | null | undefined, + shellNotice: DataAvailability | null | undefined, +): boolean { + if (!notice) return false + if (!shellNotice) return true + return notice !== shellNotice +} diff --git a/frontend/src/lib/fetch.ts b/frontend/src/lib/fetch.ts index 8b94698..a20bc2b 100644 --- a/frontend/src/lib/fetch.ts +++ b/frontend/src/lib/fetch.ts @@ -1,5 +1,7 @@ /** Bounded fetch helpers for primary-route resilience. */ +import { replaceEqualDeep } from '@tanstack/react-query' + export const DEFAULT_FETCH_TIMEOUT_MS = 15_000 /** CORS-readable success provenance headers from the Go API. */ @@ -55,6 +57,35 @@ export function rememberResponseAvailability( responseAvailability.set(data, meta) } +/** Drop availability metadata from a payload (e.g. after a header-less refetch). */ +export function clearResponseAvailability(data: object): void { + responseAvailability.delete(data) +} + +/** + * React Query structural sharing that keeps header availability in sync. + * + * Default `replaceEqualDeep` reuses the previous object when JSON is equal, which + * would leave stale WeakMap metadata attached after a stale→fresh (or fresh→partial) + * refetch of an identical body. Always re-bind the latest fetch's metadata onto the + * object that lands in the query cache. + */ +export function availabilityAwareStructuralSharing( + oldData: T | undefined, + newData: T, +): T { + const meta = getResponseAvailability(newData) + const shared = replaceEqualDeep(oldData, newData) as T + if (shared !== null && typeof shared === 'object') { + if (meta) { + rememberResponseAvailability(shared as object, meta) + } else { + clearResponseAvailability(shared as object) + } + } + return shared +} + export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network' export class ApiError extends Error { diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..b94637e --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,16 @@ +import { QueryClient } from '@tanstack/react-query' +import { availabilityAwareStructuralSharing } from './fetch' + +/** Shared QueryClient defaults — availability-aware structural sharing for header metadata. */ +export function createAppQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + // Explicit Retry on RouteState — avoid automatic retry storms. + retry: false, + structuralSharing: availabilityAwareStructuralSharing, + }, + }, + }) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8c8519c..9ee4f5d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,19 +1,12 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider } from '@tanstack/react-router' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' +import { createAppQueryClient } from './lib/queryClient' import { router } from './router' import './styles/app.css' -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - // Explicit Retry on RouteState — avoid automatic retry storms. - retry: false, - }, - }, -}) +const queryClient = createAppQueryClient() ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/BriefingPage.tsx b/frontend/src/pages/BriefingPage.tsx index 2700e1e..346466f 100644 --- a/frontend/src/pages/BriefingPage.tsx +++ b/frontend/src/pages/BriefingPage.tsx @@ -517,12 +517,35 @@ export function BriefingPage() { const hasDigest = meetings.length > 0 const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0 + const supplementsFetching = + meetingsQuery.isFetching || hubQuery.isFetching || seasonsQuery.isFetching + // React Query v5 clears isError while a post-error refetch is in flight + // (status → pending). Keep Limited mounted so Retry can show aria-busy. + const supplementDegraded = (q: { + isError: boolean + isFetching: boolean + isFetched: boolean + data: unknown + }) => q.isError || (q.isFetching && q.isFetched && q.data == null) const supplementsLimited = !isLoading && !isError && - ((meetingsQuery.isError && !meetingsQuery.isFetching) || - (hubQuery.isError && !hubQuery.isFetching) || - (seasonsQuery.isError && !seasonsQuery.isFetching)) + (supplementDegraded(meetingsQuery) || + supplementDegraded(hubQuery) || + supplementDegraded(seasonsQuery)) + const supplementsRetrying = supplementsLimited && supplementsFetching + + const retrySupplements = () => { + if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) { + void meetingsQuery.refetch() + } + if (supplementDegraded(hubQuery) && !hubQuery.isFetching) { + void hubQuery.refetch() + } + if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) { + void seasonsQuery.refetch() + } + } const newsAvailability = noticeFromResponse(allNews, { includeLocal: true }) const hubAvailability = noticeFromResponse(hub, { includeLocal: false }) @@ -560,11 +583,8 @@ export function BriefingPage() { { - if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch() - if (hubQuery.isError && !hubQuery.isFetching) void hubQuery.refetch() - if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch() - }} + onRetry={retrySupplements} + retrying={supplementsRetrying} testId="briefing-data-notice" /> )} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index cc6c0ca..042bc33 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -304,9 +304,14 @@ export function LiveTimingPage() { // feed-health truth rather than "Connection lost" + "Feed healthy". const feedHealth = effectiveFeedHealth(streamStatus, phase) + // A usable SSE/stream snapshot must not coexist with the fatal initial-state + // error — gate once live or retained archive data arrives. + const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot) + const showInitialError = isError && !hasUsableStreamData + return (
- {isError && ( + {showInitialError && ( )} - {phase === 'connecting' && (isLoading || !liveStateFetched) && ( + {phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && ( Date.now()) @@ -398,6 +409,17 @@ export function RacePreviewPage({ noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ?? noticeFromResponse(priorResultsQuery.data, { includeLocal: false }) + const showFreshnessNotice = + Boolean(dataNotice) && + (!embedded || shouldShowEmbeddedNotice(dataNotice, shellAvailability)) + + const sessionsFailed = sessionsQuery.isError + const sessionsRetrying = sessionsQuery.isFetching + + const retrySessions = () => { + if (!sessionsQuery.isFetching) void sessionsQuery.refetch() + } + if (identityLoading) { return (
@@ -455,8 +477,7 @@ export function RacePreviewPage({ data-meeting-key={previewMeeting.meeting_key} data-embedded={embedded ? 'true' : undefined} > - {/* Weekend shell owns availability disclosure when Preview is embedded. */} - {!embedded && dataNotice && ( + {showFreshnessNotice && dataNotice && ( )} + {sessionsFailed && ( + + )} + {/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */} {!embedded && ( )} + {/* Embedded: still surface recovered schedule once sessions load. */} + {embedded && sessions.length > 0 && ( +
+ {sessions.map((session) => ( +
+ {session.session_name} + + {formatSessionScheduleTime(session.date_start)} + +
+ ))} +
+ )} +
+ return } switch (view) { @@ -56,7 +58,7 @@ function renderState(view: WeekendViewState, args: RenderArgs) { case 'session_live': return case 'pre_session': - return + return default: return } @@ -144,6 +146,7 @@ export function WeekendPage({ } const view = resolveViewState(context) + const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null) return (
- {renderState(view, { context, now, championship, briefing, preview })} + {renderState(view, { + context, + now, + championship, + briefing, + preview, + shellAvailability, + })}
) } diff --git a/frontend/src/test/BriefingPage.test.tsx b/frontend/src/test/BriefingPage.test.tsx index 56e9aa0..43d2713 100644 --- a/frontend/src/test/BriefingPage.test.tsx +++ b/frontend/src/test/BriefingPage.test.tsx @@ -219,5 +219,36 @@ describe('BriefingPage digest layout', () => { }) expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument() expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument() + + let meetingsCalls = 1 + let hubCalls = 1 + let resolveMeetings!: (value: Meeting[]) => void + let resolveHub!: (value: ChampionshipHub) => void + mockFetchSeasonMeetings.mockImplementation( + () => + new Promise((resolve) => { + meetingsCalls += 1 + resolveMeetings = resolve + }), + ) + mockFetchHub.mockImplementation( + () => + new Promise((resolve) => { + hubCalls += 1 + resolveHub = resolve + }), + ) + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Retrying…' })).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Retrying…' })).toHaveAttribute('aria-busy', 'true') + fireEvent.click(screen.getByRole('button', { name: 'Retrying…' })) + expect(meetingsCalls).toBe(2) + expect(hubCalls).toBe(2) + + resolveMeetings([bahrain, monaco]) + resolveHub(hub) + await waitFor(() => expect(screen.queryByTestId('briefing-data-notice')).not.toBeInTheDocument()) + expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument() }) }) diff --git a/frontend/src/test/LiveTimingPage.test.tsx b/frontend/src/test/LiveTimingPage.test.tsx index be0a8ac..e9848f6 100644 --- a/frontend/src/test/LiveTimingPage.test.tsx +++ b/frontend/src/test/LiveTimingPage.test.tsx @@ -356,7 +356,7 @@ describe('LiveTimingPage', () => { expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument() }) - it('shows the inactive weekend context when nothing is live or retained', async () => { + it('shows inactive handoff when nothing is live or retained', async () => { renderPage( { is_live: false, data: null }, { @@ -392,4 +392,96 @@ describe('LiveTimingPage', () => { expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'), ) }) + + it('clears fatal initial error once SSE supplies a live snapshot', async () => { + type Listener = (event: { data: string }) => void + class RecoveringEventSource { + onopen: (() => void) | null = null + onerror: (() => void) | null = null + private listeners = new Map() + static latest: RecoveringEventSource | null = null + constructor() { + RecoveringEventSource.latest = this + setTimeout(() => this.onopen?.(), 0) + } + addEventListener(type: string, listener: Listener) { + const list = this.listeners.get(type) ?? [] + list.push(listener) + this.listeners.set(type, list) + } + emit(type: string, data: unknown) { + for (const listener of this.listeners.get(type) ?? []) { + listener({ data: JSON.stringify(data) }) + } + } + close() {} + } + + Object.defineProperty(window, 'EventSource', { + value: RecoveringEventSource, + writable: true, + configurable: true, + }) + + mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable')) + mockFetchLiveTrackOutline.mockResolvedValue({ + circuit_key: 1, + points: [], + bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, + }) + mockFetchWeekendContext.mockResolvedValue({ + temporal_state: 'no_season', + championship_round: 0, + total_championship_rounds: 0, + }) + + render( + + + , + ) + + expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument() + expect(screen.getByText(/Live timing unavailable/i)).toBeInTheDocument() + + await act(async () => { + RecoveringEventSource.latest?.emit('snapshot', { + is_live: true, + data: raceSnapshot, + }) + }) + + await waitFor(() => expect(screen.getByText('Timing Tower')).toBeInTheDocument()) + expect(screen.queryByTestId('live-initial-error')).not.toBeInTheDocument() + expect(screen.getByTestId('live-page')).toHaveAttribute('data-phase', 'live') + expect(screen.queryByText(/Live timing unavailable/i)).not.toBeInTheDocument() + }) + + it('keeps bounded initial error + Retry when no stream data arrives', async () => { + mockFetchLiveState.mockRejectedValue(new Error('API 503: live state unavailable')) + mockFetchLiveTrackOutline.mockResolvedValue({ + circuit_key: 1, + points: [], + bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, + }) + mockFetchWeekendContext.mockResolvedValue({ + temporal_state: 'no_season', + championship_round: 0, + total_championship_rounds: 0, + }) + + render( + + + , + ) + + expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/test/RacePreviewPage.test.tsx b/frontend/src/test/RacePreviewPage.test.tsx index 55e0a8c..4a5949d 100644 --- a/frontend/src/test/RacePreviewPage.test.tsx +++ b/frontend/src/test/RacePreviewPage.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router' +import { rememberResponseAvailability } from '../lib/fetch' import { RacePreviewPage } from '../pages/RacePreviewPage' import { ApiError } from '../lib/fetch' import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types' @@ -175,7 +176,12 @@ const outline: TrackOutline = { bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 }, } -function renderPage(props?: { meeting?: Meeting; season?: number; embedded?: boolean }) { +function renderPage(props?: { + meeting?: Meeting + season?: number + embedded?: boolean + shellAvailability?: 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' | null +}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const Page = () => const rootRoute = createRootRoute({ @@ -288,7 +294,7 @@ describe('RacePreviewPage', () => { return [] }) - renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true }) + renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true, shellAvailability: 'partial' }) await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument()) expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100') @@ -298,10 +304,76 @@ describe('RacePreviewPage', () => { expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything()) expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything()) expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument() - // Weekend shell owns availability disclosure — no stacked Preview notice. + }) + + it('dedupes an equivalent shell notice but shows distinct Preview freshness', async () => { + mockFetchSessions.mockImplementation(async (meetingKey: number) => { + if (meetingKey === 100) return sessions + if (meetingKey === 90) return [priorRaceSession] + return [] + }) + mockFetchMeetings.mockImplementation(async (year: number) => { + if (year === 2098) return [priorMeeting] + return [] + }) + + const staleHub = { ...hub } + rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' }) + mockFetchChampionshipHub.mockResolvedValue(staleHub) + + const distinct = renderPage({ + meeting: upcomingMeeting, + season: 2099, + embedded: true, + shellAvailability: 'partial', + }) + await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i)) + distinct.unmount() + + const partialHub = { ...hub } + rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' }) + mockFetchChampionshipHub.mockResolvedValue(partialHub) + + renderPage({ + meeting: upcomingMeeting, + season: 2099, + embedded: true, + shellAvailability: 'partial', + }) + await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument()) expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument() }) + it('shows sanitized sessions failure with Retry that refetches once', async () => { + let sessionCalls = 0 + mockFetchSessions.mockImplementation(async (meetingKey: number) => { + if (meetingKey !== 100) return [priorRaceSession] + sessionCalls += 1 + if (sessionCalls === 1) { + throw new ApiError('http', 'API 500: sessions boom', { status: 500 }) + } + return sessions + }) + mockFetchMeetings.mockImplementation(async (year: number) => { + if (year === 2098) return [priorMeeting] + return [] + }) + + renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true }) + + await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument()) + expect(screen.getByTestId('preview-sessions-error')).not.toHaveTextContent(/API 500|sessions boom/i) + expect(screen.getByTestId('preview-page')).toBeInTheDocument() + + const retry = screen.getByTestId('preview-sessions-retry') + fireEvent.click(retry) + fireEvent.click(retry) + + await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')) + expect(sessionCalls).toBe(2) + expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument() + }) + it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => { mockFetchSeasons.mockRejectedValue( new ApiError('http', 'API 500: Internal Server Error', { status: 500 }), diff --git a/frontend/src/test/WeekendPage.test.tsx b/frontend/src/test/WeekendPage.test.tsx index 101935d..27e262a 100644 --- a/frontend/src/test/WeekendPage.test.tsx +++ b/frontend/src/test/WeekendPage.test.tsx @@ -50,6 +50,7 @@ import { fetchStartingGrid, fetchTrackOutline, } from '../api' +import { rememberResponseAvailability } from '../lib/fetch' const mockContext = vi.mocked(fetchWeekendContext) const mockHub = vi.mocked(fetchChampionshipHub) @@ -432,19 +433,99 @@ describe('WeekendPage canonical contract rendering', () => { }), }), ) - // Force identity path by omitting meeting prop simulation: sessions for supplements fail, - // but shell stays. Actually with canonical meeting, identity never errors — force - // championship/track failures instead, and separately test standalone raw error below. - mockSessions.mockRejectedValue(new Error('API 500: sessions boom')) - mockHub.mockRejectedValue(new Error('API 429: rate limited')) + let sessionCalls = 0 + mockSessions.mockImplementation(async (meetingKey: number) => { + if (meetingKey !== 2) return [] + sessionCalls += 1 + if (sessionCalls === 1) throw new Error('API 500: sessions boom') + return [ + session({ + session_key: 21, + session_name: 'Practice 1', + meeting_key: 2, + date_start: '2026-07-24T09:00:00Z', + }), + ] + }) renderAt('/') await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument()) expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix') - expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument() - // Nested preview still mounts with canonical meeting; section errors are sanitized. - await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument()) - expect(screen.queryByText(/API 500|API 429|sessions boom/i)).not.toBeInTheDocument() + await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument()) + expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('preview-sessions-retry')) + await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('Practice 1')) + expect(sessionCalls).toBe(2) + expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument() + }) + + it('Limited Retry refetches failed championship/news supplements once', async () => { + mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' })) + let hubCalls = 0 + let newsCalls = 0 + mockHub.mockImplementation(async () => { + hubCalls += 1 + if (hubCalls === 1) throw new Error('API 503: hub') + return hub + }) + mockNews.mockImplementation(async () => { + newsCalls += 1 + if (newsCalls === 1) throw new Error('API 503: news') + return [] + }) + + renderAt('/') + await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i)) + const retry = screen.getByTestId('weekend-data-notice').querySelector('button')! + expect(retry).toBeEnabled() + fireEvent.click(retry) + fireEvent.click(retry) + + await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument()) + expect(hubCalls).toBe(2) + expect(newsCalls).toBe(2) + // Canonical context is not the failed resource — only one initial fetch. + expect(mockContext).toHaveBeenCalledTimes(1) + }) + + it('pre_session keeps shell Partial while disclosing distinct Preview stale', async () => { + const next = meeting({ + meeting_key: 2, + meeting_name: 'Hungarian Grand Prix', + date_start: '2026-07-24T09:00:00Z', + }) + mockContext.mockResolvedValue( + context({ + temporal_state: 'pre_session', + next_meeting: next, + focus_meeting: next, + next_session: ctxSession({ + session: session({ + session_key: 21, + session_name: 'Practice 1', + meeting_key: 2, + date_start: '2026-07-24T09:00:00Z', + }), + meeting: next, + availability: availability({ freshness: 'partial', local_analysis: 'partial' }), + }), + }), + ) + mockSessions.mockResolvedValue([ + session({ + session_key: 21, + session_name: 'Practice 1', + meeting_key: 2, + date_start: '2026-07-24T09:00:00Z', + }), + ]) + rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' }) + mockHub.mockResolvedValue(hub) + + renderAt('/') + await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)) + await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i)) }) it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => { diff --git a/frontend/src/test/availability.test.ts b/frontend/src/test/availability.test.ts index d5615af..b639b4b 100644 --- a/frontend/src/test/availability.test.ts +++ b/frontend/src/test/availability.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { QueryClient, QueryObserver } from '@tanstack/react-query' import { apiFetch, + availabilityAwareStructuralSharing, clearApiFetchInflight, DATA_FRESHNESS_HEADER, DATA_SOURCE_HEADER, @@ -10,6 +12,7 @@ import { import { noticeFromFreshness, noticeFromResponse, + shouldShowEmbeddedNotice, weekendContextNotice, } from '../lib/availability' import type { WeekendContext } from '../types' @@ -85,6 +88,13 @@ describe('response availability metadata', () => { expect(noticeFromResponse(hub)).toBe('stale') }) + it('dedupes only equivalent embedded Preview notices', () => { + expect(shouldShowEmbeddedNotice('stale', 'partial')).toBe(true) + expect(shouldShowEmbeddedNotice('partial', 'partial')).toBe(false) + expect(shouldShowEmbeddedNotice('stale', null)).toBe(true) + expect(shouldShowEmbeddedNotice(null, 'partial')).toBe(false) + }) + it('derives Weekend Context notices from typed session freshness, skipping routine local', () => { const context: WeekendContext = { season: 2026, @@ -128,4 +138,84 @@ describe('response availability metadata', () => { } expect(weekendContextNotice(localOnly)).toBeNull() }) + + it('updates metadata when structural sharing reuses an equal JSON object', () => { + const body = { season: 2026, drivers: [{ points: 1 }] } + const first = structuredClone(body) + const second = structuredClone(body) + rememberResponseAvailability(first, { source: 'openf1', freshness: 'stale' }) + rememberResponseAvailability(second, { source: 'openf1', freshness: 'fresh' }) + + const shared = availabilityAwareStructuralSharing(first, second) + expect(shared).toBe(first) + expect(getResponseAvailability(shared)).toEqual({ source: 'openf1', freshness: 'fresh' }) + expect(noticeFromResponse(shared)).toBeNull() + + const third = structuredClone(body) + rememberResponseAvailability(third, { source: 'openf1', freshness: 'partial' }) + const sharedAgain = availabilityAwareStructuralSharing(shared, third) + expect(sharedAgain).toBe(first) + expect(noticeFromResponse(sharedAgain)).toBe('partial') + }) + + it('QueryObserver stale→fresh and fresh→partial refetches update metadata and notices', async () => { + const body = { season: 2026, drivers: [{ points: 42 }] } + const sequence: Array<'stale' | 'fresh' | 'partial'> = ['stale', 'fresh', 'partial'] + let call = 0 + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + const freshness = sequence[Math.min(call, sequence.length - 1)] + call += 1 + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + [DATA_SOURCE_HEADER]: 'openf1', + [DATA_FRESHNESS_HEADER]: freshness, + }, + }) + }), + ) + + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + structuralSharing: availabilityAwareStructuralSharing, + }, + }, + }) + const observer = new QueryObserver(client, { + queryKey: ['availability-observer'], + queryFn: () => apiFetch('/api/v1/championship/hub'), + staleTime: 0, + }) + + const notices: Array = [] + const unsub = observer.subscribe((result) => { + if (result.data) notices.push(noticeFromResponse(result.data)) + }) + + await observer.refetch() + expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('stale') + expect(notices[notices.length - 1]).toBe('stale') + + await observer.refetch() + expect(observer.getCurrentResult().data).toEqual(body) + expect(getResponseAvailability(observer.getCurrentResult().data!)).toEqual({ + source: 'openf1', + freshness: 'fresh', + }) + expect(noticeFromResponse(observer.getCurrentResult().data)).toBeNull() + expect(notices[notices.length - 1]).toBeNull() + + await observer.refetch() + expect(noticeFromResponse(observer.getCurrentResult().data)).toBe('partial') + expect(notices[notices.length - 1]).toBe('partial') + expect(call).toBe(3) + + unsub() + client.clear() + }) }) diff --git a/tests/issue-76-availability.spec.ts b/tests/issue-76-availability.spec.ts index 99cc456..ae58da3 100644 --- a/tests/issue-76-availability.spec.ts +++ b/tests/issue-76-availability.spec.ts @@ -184,33 +184,66 @@ async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recov } test.describe('Issue #76 availability / Preview resilience evidence', () => { - test('Weekend pre-session keeps shell usable on Preview supplement failure and recovers', async ({ + test('Weekend pre-session recovers Preview sessions via Retry and keeps distinct notices', async ({ page, }, testInfo) => { - await stubPreSession(page, { failSessions: true, recover: true }) + let sessionCalls = 0 + let allowSessions = false + await stubPreSession(page) + await page.unroute('**/api/v1/sessions**') + await page.route('**/api/v1/sessions**', async (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get('meeting_key') === '2') { + sessionCalls += 1 + } + if (!allowSessions) { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'API 500: forced sessions failure' }), + }) + return + } + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { + session_key: 21, + session_name: 'Practice 1', + session_type: 'Practice', + meeting_key: 2, + date_start: '2026-07-24T09:00:00Z', + date_end: '2026-07-24T10:00:00Z', + gmt_offset: '', + }, + ]), + }) + }) + await page.setViewportSize({ width: 390, height: 844 }) await page.goto('/') await expect(page.getByTestId('weekend-pre-session')).toBeVisible() await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix') await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i) - // Weekend shell owns disclosure — embedded Preview must not stack a second notice. - await expect(page.getByTestId('preview-data-notice')).toHaveCount(0) + // Shell Partial + Preview championship Stale are distinct — both may show. + await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i) await expect(page.getByTestId('preview-page')).toBeVisible() await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2') - // No raw HTTP jargon in the primary Weekend child. + await expect(page.getByTestId('preview-sessions-error')).toBeVisible() await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i) - // Title fight remains usable from the shared championship supplement. await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER') - // Recovery: guarded Retry on the shell notice re-runs context; sessions auto-recover - // on React Query retry (recover stub). Shell stays usable either way. - const retry = page.getByTestId('weekend-data-notice').getByRole('button', { name: 'Retry' }) + const failedCalls = sessionCalls + expect(failedCalls).toBeGreaterThanOrEqual(1) + + allowSessions = true + const retry = page.getByTestId('preview-sessions-retry') await expect(retry).toBeEnabled() await retry.click() - await expect(page.getByTestId('weekend-pre-session')).toBeVisible() - await expect(page.getByTestId('preview-page')).toBeVisible() - await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER') + await expect(page.getByTestId('preview-schedule')).toContainText('Practice 1') + await expect(page.getByTestId('preview-sessions-error')).toHaveCount(0) + expect(sessionCalls).toBe(failedCalls + 1) await page.screenshot({ path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`), @@ -222,9 +255,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => { path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`), fullPage: true, }) - // Desktop evidence must also keep a single Partial notice vocabulary. await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i) - await expect(page.getByTestId('preview-data-notice')).toHaveCount(0) + await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i) }) test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => { @@ -343,6 +375,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => { await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i) await expect(page.getByText('Paddock briefing sample')).toBeVisible() await expect(page.locator('body')).not.toContainText(/API 503/i) + const retry = page.getByTestId('briefing-data-notice').getByRole('button', { name: 'Retry' }) + await expect(retry).toBeEnabled() await page.screenshot({ path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`), fullPage: true, diff --git a/tests/live-timing.spec.ts b/tests/live-timing.spec.ts index 2e586a2..ae8a6e7 100644 --- a/tests/live-timing.spec.ts +++ b/tests/live-timing.spec.ts @@ -452,4 +452,28 @@ test.describe('Live Timing (no session)', () => { await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i) await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i) }) + + test('clears fatal initial error once SSE supplies a live snapshot', async ({ page }) => { + await page.route('**/api/v1/live/state', (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'API 503: live state unavailable' }), + }), + ) + await page.route('**/api/v1/live/stream', (route) => + route.fulfill({ + contentType: 'text/event-stream', + headers: { 'Cache-Control': 'no-cache' }, + body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`, + }), + ) + + await page.goto('/live') + await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('live-initial-error')).toHaveCount(0) + await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0) + await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/) + await expect(page.locator('.live-tower')).toContainText('VER') + }) })