mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import type { WeekendContext } from '../../types'
|
|||||||
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||||
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||||
import { meetingIdentity } from '../../lib/weekendContext'
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import type { DataAvailability } from '../../lib/availability'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PreSessionView folds the race preview surface into the Weekend home so there is
|
* 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
|
* Canonical meeting/session identity is passed into Preview so the nested surface
|
||||||
* cannot independently re-resolve a different current weekend.
|
* 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 meeting = context.next_meeting ?? context.focus_meeting
|
||||||
const identity = meetingIdentity(meeting)
|
const identity = meetingIdentity(meeting)
|
||||||
const next = context.next_session?.session
|
const next = context.next_session?.session
|
||||||
@@ -52,6 +62,7 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
|||||||
embedded
|
embedded
|
||||||
meeting={meeting}
|
meeting={meeting}
|
||||||
season={context.season ?? meeting?.year}
|
season={context.season ?? meeting?.year}
|
||||||
|
shellAvailability={shellAvailability}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export interface UseWeekendContextResult {
|
|||||||
/** True when championship or news supplements failed while context succeeded. */
|
/** True when championship or news supplements failed while context succeeded. */
|
||||||
supplementsLimited: boolean
|
supplementsLimited: boolean
|
||||||
now: Date
|
now: Date
|
||||||
/** Refetch the canonical weekend-context read. */
|
/** Refetch canonical context and/or failed supplements as appropriate. */
|
||||||
refetch: () => void
|
refetch: () => void
|
||||||
/** True while a canonical refetch is in flight. */
|
/** True while a relevant refetch is in flight. */
|
||||||
isFetching: boolean
|
isFetching: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,15 +88,42 @@ export function useWeekendContext(): UseWeekendContextResult {
|
|||||||
[canonical],
|
[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 =
|
const supplementsLimited =
|
||||||
canonicalReady &&
|
canonicalReady &&
|
||||||
((championshipQuery.isError && !championshipQuery.isFetching) ||
|
(supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
|
||||||
(newsQuery.isError && !newsQuery.isFetching))
|
|
||||||
|
|
||||||
let loadState: WeekendLoadState = 'loading'
|
let loadState: WeekendLoadState = 'loading'
|
||||||
if (contextQuery.isError) loadState = 'error'
|
if (contextQuery.isError) loadState = 'error'
|
||||||
else if (canonicalReady) loadState = 'ready'
|
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 {
|
return {
|
||||||
context: canonical,
|
context: canonical,
|
||||||
loadState,
|
loadState,
|
||||||
@@ -106,9 +133,8 @@ export function useWeekendContext(): UseWeekendContextResult {
|
|||||||
availabilityNotice,
|
availabilityNotice,
|
||||||
supplementsLimited,
|
supplementsLimited,
|
||||||
now: nowDate,
|
now: nowDate,
|
||||||
refetch: () => {
|
refetch,
|
||||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
isFetching:
|
||||||
},
|
contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
|
||||||
isFetching: contextQuery.isFetching,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,3 +74,16 @@ export function weekendContextNotice(
|
|||||||
}
|
}
|
||||||
return null
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Bounded fetch helpers for primary-route resilience. */
|
/** Bounded fetch helpers for primary-route resilience. */
|
||||||
|
|
||||||
|
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||||
|
|
||||||
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
/** CORS-readable success provenance headers from the Go API. */
|
/** CORS-readable success provenance headers from the Go API. */
|
||||||
@@ -55,6 +57,35 @@ export function rememberResponseAvailability(
|
|||||||
responseAvailability.set(data, meta)
|
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<T>(
|
||||||
|
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 type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|||||||
16
frontend/src/lib/queryClient.ts
Normal file
16
frontend/src/lib/queryClient.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,19 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
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 { router } from './router'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = createAppQueryClient()
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
// Explicit Retry on RouteState — avoid automatic retry storms.
|
|
||||||
retry: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -517,12 +517,35 @@ export function BriefingPage() {
|
|||||||
const hasDigest = meetings.length > 0
|
const hasDigest = meetings.length > 0
|
||||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.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 =
|
const supplementsLimited =
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
!isError &&
|
!isError &&
|
||||||
((meetingsQuery.isError && !meetingsQuery.isFetching) ||
|
(supplementDegraded(meetingsQuery) ||
|
||||||
(hubQuery.isError && !hubQuery.isFetching) ||
|
supplementDegraded(hubQuery) ||
|
||||||
(seasonsQuery.isError && !seasonsQuery.isFetching))
|
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 newsAvailability = noticeFromResponse(allNews, { includeLocal: true })
|
||||||
const hubAvailability = noticeFromResponse(hub, { includeLocal: false })
|
const hubAvailability = noticeFromResponse(hub, { includeLocal: false })
|
||||||
@@ -560,11 +583,8 @@ export function BriefingPage() {
|
|||||||
<DataNotice
|
<DataNotice
|
||||||
availability="limited"
|
availability="limited"
|
||||||
message="Weekend grouping or driver tags are limited. Articles are still available."
|
message="Weekend grouping or driver tags are limited. Articles are still available."
|
||||||
onRetry={() => {
|
onRetry={retrySupplements}
|
||||||
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
|
retrying={supplementsRetrying}
|
||||||
if (hubQuery.isError && !hubQuery.isFetching) void hubQuery.refetch()
|
|
||||||
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
|
|
||||||
}}
|
|
||||||
testId="briefing-data-notice"
|
testId="briefing-data-notice"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -304,9 +304,14 @@ export function LiveTimingPage() {
|
|||||||
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||||
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
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 (
|
return (
|
||||||
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||||
{isError && (
|
{showInitialError && (
|
||||||
<RouteState
|
<RouteState
|
||||||
kind="error"
|
kind="error"
|
||||||
title="Live timing unavailable"
|
title="Live timing unavailable"
|
||||||
@@ -334,7 +339,7 @@ export function LiveTimingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
|
{phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
|
||||||
<RouteState
|
<RouteState
|
||||||
kind="loading"
|
kind="loading"
|
||||||
title="connecting to live timing…"
|
title="connecting to live timing…"
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
fetchTrackOutline,
|
fetchTrackOutline,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { DataNotice, RouteState } from '../components/RouteState'
|
import { DataNotice, RouteState } from '../components/RouteState'
|
||||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
import {
|
||||||
|
noticeFromResponse,
|
||||||
|
noticeMessage,
|
||||||
|
shouldShowEmbeddedNotice,
|
||||||
|
type DataAvailability,
|
||||||
|
} from '../lib/availability'
|
||||||
import { userFacingError } from '../lib/fetch'
|
import { userFacingError } from '../lib/fetch'
|
||||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import {
|
import {
|
||||||
@@ -272,12 +277,18 @@ export interface RacePreviewPageProps {
|
|||||||
season?: number
|
season?: number
|
||||||
/** Embedded under Weekend — identity failures stay non-blocking. */
|
/** Embedded under Weekend — identity failures stay non-blocking. */
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
/**
|
||||||
|
* Availability already disclosed by the Weekend shell. Embedded Preview
|
||||||
|
* suppresses only an equivalent notice kind; distinct truth stays visible.
|
||||||
|
*/
|
||||||
|
shellAvailability?: DataAvailability | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RacePreviewPage({
|
export function RacePreviewPage({
|
||||||
meeting: canonicalMeeting,
|
meeting: canonicalMeeting,
|
||||||
season: canonicalSeason,
|
season: canonicalSeason,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
shellAvailability = null,
|
||||||
}: RacePreviewPageProps = {}) {
|
}: RacePreviewPageProps = {}) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
@@ -398,6 +409,17 @@ export function RacePreviewPage({
|
|||||||
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
|
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
|
||||||
noticeFromResponse(priorResultsQuery.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) {
|
if (identityLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
||||||
@@ -455,8 +477,7 @@ export function RacePreviewPage({
|
|||||||
data-meeting-key={previewMeeting.meeting_key}
|
data-meeting-key={previewMeeting.meeting_key}
|
||||||
data-embedded={embedded ? 'true' : undefined}
|
data-embedded={embedded ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
{/* Weekend shell owns availability disclosure when Preview is embedded. */}
|
{showFreshnessNotice && dataNotice && (
|
||||||
{!embedded && dataNotice && (
|
|
||||||
<DataNotice
|
<DataNotice
|
||||||
availability={dataNotice}
|
availability={dataNotice}
|
||||||
message={noticeMessage(dataNotice)}
|
message={noticeMessage(dataNotice)}
|
||||||
@@ -464,6 +485,18 @@ export function RacePreviewPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sessionsFailed && (
|
||||||
|
<RouteState
|
||||||
|
kind="error"
|
||||||
|
title="Session schedule unavailable"
|
||||||
|
error={sessionsQuery.error}
|
||||||
|
onRetry={retrySessions}
|
||||||
|
retrying={sessionsRetrying}
|
||||||
|
testId="preview-sessions-error"
|
||||||
|
retryTestId="preview-sessions-retry"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
|
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<PreviewHeader
|
<PreviewHeader
|
||||||
@@ -475,6 +508,20 @@ export function RacePreviewPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Embedded: still surface recovered schedule once sessions load. */}
|
||||||
|
{embedded && sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="preview-schedule">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{session.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">
|
||||||
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="preview-grid">
|
<div className="preview-grid">
|
||||||
<TrackOutlineCard
|
<TrackOutlineCard
|
||||||
outline={trackOutlineQuery.data}
|
outline={trackOutlineQuery.data}
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ interface RenderArgs {
|
|||||||
briefing: WeekendBriefingItem[]
|
briefing: WeekendBriefingItem[]
|
||||||
/** When true (the /preview alias), foreground the preparation surface. */
|
/** When true (the /preview alias), foreground the preparation surface. */
|
||||||
preview: boolean
|
preview: boolean
|
||||||
|
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
|
||||||
|
shellAvailability: DataAvailability | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderState(view: WeekendViewState, args: RenderArgs) {
|
function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||||
const { context, now, championship, briefing, preview } = args
|
const { context, now, championship, briefing, preview, shellAvailability } = args
|
||||||
// The /preview alias always resolves to the preparation surface as long as
|
// The /preview alias always resolves to the preparation surface as long as
|
||||||
// there is a next event to prepare for, regardless of the temporal state. This
|
// there is a next event to prepare for, regardless of the temporal state. This
|
||||||
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
||||||
// redirecting straight back to the same between-races screen.
|
// redirecting straight back to the same between-races screen.
|
||||||
if (preview && context.next_meeting) {
|
if (preview && context.next_meeting) {
|
||||||
return <PreSessionView context={context} now={now} />
|
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
@@ -56,7 +58,7 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
|||||||
case 'session_live':
|
case 'session_live':
|
||||||
return <LiveHandoffView context={context} />
|
return <LiveHandoffView context={context} />
|
||||||
case 'pre_session':
|
case 'pre_session':
|
||||||
return <PreSessionView context={context} now={now} />
|
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||||
default:
|
default:
|
||||||
return <WeekendLimited season={context.season} />
|
return <WeekendLimited season={context.season} />
|
||||||
}
|
}
|
||||||
@@ -144,6 +146,7 @@ export function WeekendPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const view = resolveViewState(context)
|
const view = resolveViewState(context)
|
||||||
|
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -164,7 +167,14 @@ export function WeekendPage({
|
|||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
retrying={isFetching}
|
retrying={isFetching}
|
||||||
/>
|
/>
|
||||||
{renderState(view, { context, now, championship, briefing, preview })}
|
{renderState(view, {
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
preview,
|
||||||
|
shellAvailability,
|
||||||
|
})}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,5 +219,36 @@ describe('BriefingPage digest layout', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('briefing-error')).not.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<Meeting[]>((resolve) => {
|
||||||
|
meetingsCalls += 1
|
||||||
|
resolveMeetings = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
mockFetchHub.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<ChampionshipHub>((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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ describe('LiveTimingPage', () => {
|
|||||||
expect(screen.queryByText('LIVE SESSION')).not.toBeInTheDocument()
|
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(
|
renderPage(
|
||||||
{ is_live: false, data: null },
|
{ is_live: false, data: null },
|
||||||
{
|
{
|
||||||
@@ -392,4 +392,96 @@ describe('LiveTimingPage', () => {
|
|||||||
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
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<string, Listener[]>()
|
||||||
|
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(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<LiveTimingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<LiveTimingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('live-initial-error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
|
||||||
|
expect(screen.queryByText('Timing Tower')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||||
import { ApiError } from '../lib/fetch'
|
import { ApiError } from '../lib/fetch'
|
||||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
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 },
|
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 queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
const Page = () => <RacePreviewPage {...props} />
|
const Page = () => <RacePreviewPage {...props} />
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
@@ -288,7 +294,7 @@ describe('RacePreviewPage', () => {
|
|||||||
return []
|
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())
|
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||||
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100')
|
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(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
|
||||||
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
|
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
|
||||||
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
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()
|
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 () => {
|
it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
|
||||||
mockFetchSeasons.mockRejectedValue(
|
mockFetchSeasons.mockRejectedValue(
|
||||||
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
|
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
fetchStartingGrid,
|
fetchStartingGrid,
|
||||||
fetchTrackOutline,
|
fetchTrackOutline,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { rememberResponseAvailability } from '../lib/fetch'
|
||||||
|
|
||||||
const mockContext = vi.mocked(fetchWeekendContext)
|
const mockContext = vi.mocked(fetchWeekendContext)
|
||||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
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,
|
let sessionCalls = 0
|
||||||
// but shell stays. Actually with canonical meeting, identity never errors — force
|
mockSessions.mockImplementation(async (meetingKey: number) => {
|
||||||
// championship/track failures instead, and separately test standalone raw error below.
|
if (meetingKey !== 2) return []
|
||||||
mockSessions.mockRejectedValue(new Error('API 500: sessions boom'))
|
sessionCalls += 1
|
||||||
mockHub.mockRejectedValue(new Error('API 429: rate limited'))
|
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('/')
|
renderAt('/')
|
||||||
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
|
||||||
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
||||||
expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument()
|
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
|
||||||
// Nested preview still mounts with canonical meeting; section errors are sanitized.
|
expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
|
||||||
expect(screen.queryByText(/API 500|API 429|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 () => {
|
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
|
availabilityAwareStructuralSharing,
|
||||||
clearApiFetchInflight,
|
clearApiFetchInflight,
|
||||||
DATA_FRESHNESS_HEADER,
|
DATA_FRESHNESS_HEADER,
|
||||||
DATA_SOURCE_HEADER,
|
DATA_SOURCE_HEADER,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
noticeFromFreshness,
|
noticeFromFreshness,
|
||||||
noticeFromResponse,
|
noticeFromResponse,
|
||||||
|
shouldShowEmbeddedNotice,
|
||||||
weekendContextNotice,
|
weekendContextNotice,
|
||||||
} from '../lib/availability'
|
} from '../lib/availability'
|
||||||
import type { WeekendContext } from '../types'
|
import type { WeekendContext } from '../types'
|
||||||
@@ -85,6 +88,13 @@ describe('response availability metadata', () => {
|
|||||||
expect(noticeFromResponse(hub)).toBe('stale')
|
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', () => {
|
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
|
||||||
const context: WeekendContext = {
|
const context: WeekendContext = {
|
||||||
season: 2026,
|
season: 2026,
|
||||||
@@ -128,4 +138,84 @@ describe('response availability metadata', () => {
|
|||||||
}
|
}
|
||||||
expect(weekendContextNotice(localOnly)).toBeNull()
|
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<typeof body>('/api/v1/championship/hub'),
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const notices: Array<string | null> = []
|
||||||
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,33 +184,66 @@ async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recov
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Issue #76 availability / Preview resilience evidence', () => {
|
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,
|
page,
|
||||||
}, testInfo) => {
|
}, 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.setViewportSize({ width: 390, height: 844 })
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
||||||
await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix')
|
await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix')
|
||||||
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
|
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
|
||||||
// Weekend shell owns disclosure — embedded Preview must not stack a second notice.
|
// Shell Partial + Preview championship Stale are distinct — both may show.
|
||||||
await expect(page.getByTestId('preview-data-notice')).toHaveCount(0)
|
await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
|
||||||
await expect(page.getByTestId('preview-page')).toBeVisible()
|
await expect(page.getByTestId('preview-page')).toBeVisible()
|
||||||
await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
|
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)
|
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')
|
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
|
||||||
|
|
||||||
// Recovery: guarded Retry on the shell notice re-runs context; sessions auto-recover
|
const failedCalls = sessionCalls
|
||||||
// on React Query retry (recover stub). Shell stays usable either way.
|
expect(failedCalls).toBeGreaterThanOrEqual(1)
|
||||||
const retry = page.getByTestId('weekend-data-notice').getByRole('button', { name: 'Retry' })
|
|
||||||
|
allowSessions = true
|
||||||
|
const retry = page.getByTestId('preview-sessions-retry')
|
||||||
await expect(retry).toBeEnabled()
|
await expect(retry).toBeEnabled()
|
||||||
await retry.click()
|
await retry.click()
|
||||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
await expect(page.getByTestId('preview-schedule')).toContainText('Practice 1')
|
||||||
await expect(page.getByTestId('preview-page')).toBeVisible()
|
await expect(page.getByTestId('preview-sessions-error')).toHaveCount(0)
|
||||||
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
|
expect(sessionCalls).toBe(failedCalls + 1)
|
||||||
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
|
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`),
|
path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
|
||||||
fullPage: true,
|
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('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) => {
|
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.getByTestId('briefing-data-notice')).toContainText(/Limited/i)
|
||||||
await expect(page.getByText('Paddock briefing sample')).toBeVisible()
|
await expect(page.getByText('Paddock briefing sample')).toBeVisible()
|
||||||
await expect(page.locator('body')).not.toContainText(/API 503/i)
|
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({
|
await page.screenshot({
|
||||||
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
|
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
|
|||||||
@@ -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')).toContainText(/reconnecting/i)
|
||||||
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user