mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
fix(#76): surface truthful availability across primary routes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { isTimeoutError, userFacingError } from '../lib/fetch'
|
||||
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||
|
||||
/** Weekend Context terminology for coverage / availability indicators. */
|
||||
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
|
||||
export type { DataAvailability }
|
||||
|
||||
export function availabilityLabel(kind: DataAvailability): string {
|
||||
switch (kind) {
|
||||
@@ -131,6 +131,7 @@ interface StaleNoticeProps {
|
||||
availability?: DataAvailability
|
||||
message?: string
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
testId?: string
|
||||
}
|
||||
|
||||
@@ -139,26 +140,22 @@ export function DataNotice({
|
||||
availability = 'stale',
|
||||
message,
|
||||
onRetry,
|
||||
retrying = false,
|
||||
testId = 'data-notice',
|
||||
}: StaleNoticeProps) {
|
||||
const defaultMessage =
|
||||
availability === 'stale'
|
||||
? 'Showing stale cached data. Retry to refresh.'
|
||||
: availability === 'limited'
|
||||
? 'Some optional details are unavailable. Core local data is shown.'
|
||||
: availability === 'partial'
|
||||
? 'Coverage is partial for this weekend.'
|
||||
: availability === 'archive'
|
||||
? 'Showing an archived snapshot.'
|
||||
: 'Data availability is limited.'
|
||||
|
||||
return (
|
||||
<div className="data-notice" data-testid={testId} role="status">
|
||||
<AvailabilityBadge kind={availability} />
|
||||
<span className="data-notice-text">{message ?? defaultMessage}</span>
|
||||
<span className="data-notice-text">{message ?? noticeMessage(availability)}</span>
|
||||
{onRetry && (
|
||||
<button type="button" className="route-state-retry data-notice-retry" onClick={onRetry}>
|
||||
Retry
|
||||
<button
|
||||
type="button"
|
||||
className="route-state-retry data-notice-retry"
|
||||
onClick={onRetry}
|
||||
disabled={retrying}
|
||||
aria-busy={retrying || undefined}
|
||||
>
|
||||
{retrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,13 @@ import { meetingIdentity } from '../../lib/weekendContext'
|
||||
* no separate Preview destination. It reuses the existing preview page content and
|
||||
* frames it with the next-session countdown and compact season navigation. It also
|
||||
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||
*
|
||||
* 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 }) {
|
||||
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
|
||||
const meeting = context.next_meeting ?? context.focus_meeting
|
||||
const identity = meetingIdentity(meeting)
|
||||
const next = context.next_session?.session
|
||||
const nextStart = next?.date_start ?? meeting?.date_start
|
||||
const nodes = railNodes(
|
||||
@@ -25,11 +29,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||
|
||||
{meeting && (
|
||||
{identity && (
|
||||
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={meeting.country_code} flag={meeting.country_flag} />
|
||||
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
|
||||
<Flag code={identity.country_code} flag={identity.country_flag} />
|
||||
<h1 className="wk-sessions-title">{identity.meeting_name}</h1>
|
||||
</div>
|
||||
<div className="wk-pre-countdown">
|
||||
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
|
||||
@@ -44,7 +48,11 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||
|
||||
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||
<RacePreviewPage />
|
||||
<RacePreviewPage
|
||||
embedded
|
||||
meeting={meeting}
|
||||
season={context.season ?? meeting?.year}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChampionshipRoundStrip
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
|
||||
import { weekendContextNotice, type DataAvailability } from '../lib/availability'
|
||||
import { championshipImpact, briefingItems } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendChampionshipImpact,
|
||||
@@ -19,6 +20,10 @@ export interface UseWeekendContextResult {
|
||||
championship?: WeekendChampionshipImpact
|
||||
/** Supplementary briefing items (not part of the #72 contract). */
|
||||
briefing: WeekendBriefingItem[]
|
||||
/** Non-blocking availability notice from reported context freshness/headers. */
|
||||
availabilityNotice: DataAvailability | null
|
||||
/** True when championship or news supplements failed while context succeeded. */
|
||||
supplementsLimited: boolean
|
||||
now: Date
|
||||
/** Refetch the canonical weekend-context read. */
|
||||
refetch: () => void
|
||||
@@ -78,6 +83,16 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
)
|
||||
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
|
||||
|
||||
const availabilityNotice = useMemo(
|
||||
() => (canonical ? weekendContextNotice(canonical) : null),
|
||||
[canonical],
|
||||
)
|
||||
|
||||
const supplementsLimited =
|
||||
canonicalReady &&
|
||||
((championshipQuery.isError && !championshipQuery.isFetching) ||
|
||||
(newsQuery.isError && !newsQuery.isFetching))
|
||||
|
||||
let loadState: WeekendLoadState = 'loading'
|
||||
if (contextQuery.isError) loadState = 'error'
|
||||
else if (canonicalReady) loadState = 'ready'
|
||||
@@ -88,6 +103,8 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
|
||||
championship,
|
||||
briefing,
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
now: nowDate,
|
||||
refetch: () => {
|
||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||
|
||||
76
frontend/src/lib/availability.ts
Normal file
76
frontend/src/lib/availability.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getResponseAvailability, type ResponseAvailability } from './fetch'
|
||||
import type { ContextSession, WeekendContext } from '../types'
|
||||
|
||||
/** Shared Weekend Context terminology for coverage / availability indicators. */
|
||||
export type DataAvailability = 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing'
|
||||
|
||||
/** Freshness values that warrant a non-blocking DataNotice. */
|
||||
const NOTICE_FRESHNESS = new Set<string>(['stale', 'partial', 'local', 'limited', 'archive'])
|
||||
|
||||
/**
|
||||
* Map a reported freshness string onto shared DataNotice vocabulary.
|
||||
* Returns null for unreported / routine-success values (`fresh`, `live`, …).
|
||||
* Never invents stale from React Query staleTime.
|
||||
*/
|
||||
export function noticeFromFreshness(
|
||||
freshness: string | undefined | null,
|
||||
opts?: { includeLocal?: boolean },
|
||||
): DataAvailability | null {
|
||||
if (!freshness) return null
|
||||
const includeLocal = opts?.includeLocal ?? true
|
||||
if (freshness === 'local' && !includeLocal) return null
|
||||
if (!NOTICE_FRESHNESS.has(freshness)) return null
|
||||
return freshness as DataAvailability
|
||||
}
|
||||
|
||||
/** Prefer header freshness, then optional typed payload freshness. */
|
||||
export function noticeFromResponse(
|
||||
data: unknown,
|
||||
opts?: { includeLocal?: boolean; fallbackFreshness?: string },
|
||||
): DataAvailability | null {
|
||||
const meta = getResponseAvailability(data)
|
||||
return (
|
||||
noticeFromFreshness(meta?.freshness, opts) ??
|
||||
noticeFromFreshness(opts?.fallbackFreshness, opts)
|
||||
)
|
||||
}
|
||||
|
||||
export function noticeMessage(kind: DataAvailability): string {
|
||||
switch (kind) {
|
||||
case 'stale':
|
||||
return 'Showing stale cached data. Retry to refresh.'
|
||||
case 'partial':
|
||||
return 'Coverage is partial for this view.'
|
||||
case 'local':
|
||||
return 'Showing local season data.'
|
||||
case 'limited':
|
||||
return 'Some optional details are unavailable. Core data is still shown.'
|
||||
case 'archive':
|
||||
return 'Showing an archived snapshot.'
|
||||
case 'missing':
|
||||
return 'Some expected data is missing.'
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the first notable freshness from Weekend Context session refs. */
|
||||
export function weekendContextNotice(
|
||||
context: WeekendContext,
|
||||
responseMeta?: ResponseAvailability,
|
||||
): DataAvailability | null {
|
||||
const fromHeader =
|
||||
noticeFromFreshness(responseMeta?.freshness, { includeLocal: false }) ??
|
||||
noticeFromFreshness(getResponseAvailability(context)?.freshness, { includeLocal: false })
|
||||
if (fromHeader) return fromHeader
|
||||
|
||||
const refs: Array<ContextSession | undefined> = [
|
||||
context.active_session,
|
||||
context.next_session,
|
||||
context.previous_completed_session,
|
||||
context.default_analysis_session,
|
||||
]
|
||||
for (const ref of refs) {
|
||||
const notice = noticeFromFreshness(ref?.availability.freshness, { includeLocal: false })
|
||||
if (notice) return notice
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2,6 +2,59 @@
|
||||
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/** CORS-readable success provenance headers from the Go API. */
|
||||
export const DATA_SOURCE_HEADER = 'X-BoxBox-Data-Source'
|
||||
export const DATA_FRESHNESS_HEADER = 'X-BoxBox-Data-Freshness'
|
||||
|
||||
/** Reported response source values (`openf1|local|mixed`, plus `fia` for live). */
|
||||
export type DataSourceHeader = 'openf1' | 'local' | 'mixed' | 'fia' | string
|
||||
|
||||
/** Reported freshness (`fresh|stale|local|partial`, plus live/archive/limited). */
|
||||
export type DataFreshnessHeader =
|
||||
| 'fresh'
|
||||
| 'stale'
|
||||
| 'local'
|
||||
| 'partial'
|
||||
| 'live'
|
||||
| 'archive'
|
||||
| 'limited'
|
||||
| string
|
||||
|
||||
/** Additive success-response availability metadata (never inferred from React Query). */
|
||||
export interface ResponseAvailability {
|
||||
source?: DataSourceHeader
|
||||
freshness?: DataFreshnessHeader
|
||||
}
|
||||
|
||||
const responseAvailability = new WeakMap<object, ResponseAvailability>()
|
||||
|
||||
function readHeader(headers: Headers, name: string): string | undefined {
|
||||
const value = headers.get(name)?.trim()
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function captureResponseAvailability(data: unknown, headers: Headers): void {
|
||||
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return
|
||||
const source = readHeader(headers, DATA_SOURCE_HEADER)
|
||||
const freshness = readHeader(headers, DATA_FRESHNESS_HEADER)
|
||||
if (!source && !freshness) return
|
||||
responseAvailability.set(data as object, { source, freshness })
|
||||
}
|
||||
|
||||
/** Read availability metadata captured from the last successful fetch of this payload. */
|
||||
export function getResponseAvailability(data: unknown): ResponseAvailability | undefined {
|
||||
if (data === null || (typeof data !== 'object' && typeof data !== 'function')) return undefined
|
||||
return responseAvailability.get(data as object)
|
||||
}
|
||||
|
||||
/** Test / manual helper — attach reported availability to a payload object. */
|
||||
export function rememberResponseAvailability(
|
||||
data: object,
|
||||
meta: ResponseAvailability,
|
||||
): void {
|
||||
responseAvailability.set(data, meta)
|
||||
}
|
||||
|
||||
export type ApiErrorKind = 'http' | 'timeout' | 'abort' | 'network'
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -159,7 +212,9 @@ async function rawApiFetch<T>(url: string, options: ApiFetchOptions = {}): Promi
|
||||
})
|
||||
}
|
||||
|
||||
return (await res.json()) as T
|
||||
const data = (await res.json()) as T
|
||||
captureResponseAvailability(data, res.headers)
|
||||
return data
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from '../lib/digest'
|
||||
import { stripHtml, timeAgo } from '../utils'
|
||||
import type { ArticleContent, NewsItem } from '../types'
|
||||
import { RouteState } from '../components/RouteState'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import '../styles/digest.css'
|
||||
|
||||
type Category = 'all' | 'official' | 'news' | 'video'
|
||||
@@ -516,6 +517,18 @@ export function BriefingPage() {
|
||||
const hasDigest = meetings.length > 0
|
||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||
|
||||
const supplementsLimited =
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
((meetingsQuery.isError && !meetingsQuery.isFetching) ||
|
||||
(hubQuery.isError && !hubQuery.isFetching) ||
|
||||
(seasonsQuery.isError && !seasonsQuery.isFetching))
|
||||
|
||||
const newsAvailability = noticeFromResponse(allNews, { includeLocal: true })
|
||||
const hubAvailability = noticeFromResponse(hub, { includeLocal: false })
|
||||
const meetingsAvailability = noticeFromResponse(meetings, { includeLocal: false })
|
||||
const availability = newsAvailability ?? hubAvailability ?? meetingsAvailability
|
||||
|
||||
return (
|
||||
<div className="bp-page" data-testid="briefing-page">
|
||||
<div className="bp-topbar">
|
||||
@@ -543,6 +556,30 @@ export function BriefingPage() {
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{supplementsLimited && (
|
||||
<DataNotice
|
||||
availability="limited"
|
||||
message="Weekend grouping or driver tags are limited. Articles are still available."
|
||||
onRetry={() => {
|
||||
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
if (hubQuery.isError && !hubQuery.isFetching) void hubQuery.refetch()
|
||||
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
}}
|
||||
testId="briefing-data-notice"
|
||||
/>
|
||||
)}
|
||||
{!supplementsLimited && availability && (
|
||||
<DataNotice
|
||||
availability={availability}
|
||||
message={noticeMessage(availability)}
|
||||
onRetry={() => {
|
||||
if (!isFetching) void refetch()
|
||||
}}
|
||||
retrying={isFetching}
|
||||
testId="briefing-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
<CategoryTabs
|
||||
active={activeCategory}
|
||||
counts={counts}
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { RouteState } from '../components/RouteState'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
@@ -151,16 +152,32 @@ export function ChampionshipPage() {
|
||||
)
|
||||
}
|
||||
|
||||
return <ChampionshipBody hub={hub} view={view} setView={setView} />
|
||||
const availability = noticeFromResponse(hub, { includeLocal: true })
|
||||
|
||||
return (
|
||||
<ChampionshipBody
|
||||
hub={hub}
|
||||
view={view}
|
||||
setView={setView}
|
||||
availability={availability}
|
||||
onRetry={() => {
|
||||
if (!hubQuery.isFetching) void hubQuery.refetch()
|
||||
}}
|
||||
retrying={hubQuery.isFetching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface BodyProps {
|
||||
hub: ChampionshipHub
|
||||
view: View
|
||||
setView: (v: View) => void
|
||||
availability?: ReturnType<typeof noticeFromResponse>
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
}
|
||||
|
||||
function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
function ChampionshipBody({ hub, view, setView, availability, onRetry, retrying }: BodyProps) {
|
||||
const { drivers, teams } = hub
|
||||
const leader = drivers[0]
|
||||
const remaining = hub.rounds_left * 25
|
||||
@@ -216,6 +233,15 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
|
||||
return (
|
||||
<div className="champ-page" data-testid="championship">
|
||||
{availability && (
|
||||
<DataNotice
|
||||
availability={availability}
|
||||
message={noticeMessage(availability)}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="championship-data-notice"
|
||||
/>
|
||||
)}
|
||||
<div className="champ-header">
|
||||
<div className="champ-title-row">
|
||||
<span className="champ-accent" aria-hidden="true" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { teamColor } from '../utils'
|
||||
import { countryFlag } from '../lib/gpIdentity'
|
||||
import {
|
||||
@@ -85,19 +86,25 @@ export function DriverProfilePage({ driverNumber, year }: Props) {
|
||||
return (
|
||||
<DriverProfileBody
|
||||
summary={summary}
|
||||
availability={noticeFromResponse(summary, { includeLocal: false })}
|
||||
onRetry={() => {
|
||||
if (!summaryQuery.isFetching) void summaryQuery.refetch()
|
||||
}}
|
||||
retrying={summaryQuery.isFetching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DriverProfileBody({
|
||||
summary,
|
||||
availability,
|
||||
onRetry,
|
||||
retrying,
|
||||
}: {
|
||||
summary: DriverSummary
|
||||
availability?: ReturnType<typeof noticeFromResponse>
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
}) {
|
||||
const color = teamColor(summary.team_colour)
|
||||
const deltas = gridFinishDeltas(summary.rounds)
|
||||
@@ -109,12 +116,22 @@ function DriverProfileBody({
|
||||
availability="limited"
|
||||
message="Optional remote details are unavailable. Showing local season identity and results."
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="driver-profile-limited"
|
||||
/>
|
||||
)}
|
||||
{summary.source === 'local' && summary.enrichment !== 'limited' && (
|
||||
<DataNotice availability="local" message="Loaded from local season data." testId="driver-profile-local" />
|
||||
)}
|
||||
{availability === 'stale' && summary.enrichment !== 'limited' && (
|
||||
<DataNotice
|
||||
availability="stale"
|
||||
message={noticeMessage('stale')}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="driver-profile-stale"
|
||||
/>
|
||||
)}
|
||||
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||
<div className="dp-identity">
|
||||
<div className="dp-title-row">
|
||||
|
||||
@@ -36,7 +36,8 @@ import { EventRail } from '../components/live/EventRail'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||
import { LiveHandoff } from '../components/live/LiveHandoff'
|
||||
import { RouteState } from '../components/RouteState'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { weekendContextNotice } from '../lib/availability'
|
||||
import '../styles/live-state.css'
|
||||
|
||||
/** How often to re-check weekend-context while analysis is still ingesting. */
|
||||
@@ -124,6 +125,12 @@ export function LiveTimingPage() {
|
||||
refetchIntervalInBackground: false,
|
||||
})
|
||||
const weekendContext = contextQuery.data
|
||||
// Context freshness belongs only on inactive/settling handoff — never relabel
|
||||
// active FIA timing because an optional REST supplement reported stale/limited.
|
||||
const contextAvailabilityNotice =
|
||||
(phase === 'settling' || phase === 'inactive') && weekendContext
|
||||
? weekendContextNotice(weekendContext)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
@@ -336,15 +343,23 @@ export function LiveTimingPage() {
|
||||
)}
|
||||
|
||||
{(phase === 'settling' || phase === 'inactive') && (
|
||||
<LiveHandoff
|
||||
phase={phase}
|
||||
transport={feedHealth}
|
||||
context={weekendContext}
|
||||
rows={settlingRows}
|
||||
capturedAt={archiveSnapshotAt}
|
||||
hasArchive={hasArchive}
|
||||
onViewArchive={handleViewArchive}
|
||||
/>
|
||||
<>
|
||||
{contextAvailabilityNotice && (
|
||||
<DataNotice
|
||||
availability={contextAvailabilityNotice}
|
||||
testId="live-context-data-notice"
|
||||
/>
|
||||
)}
|
||||
<LiveHandoff
|
||||
phase={phase}
|
||||
transport={feedHealth}
|
||||
context={weekendContext}
|
||||
rows={settlingRows}
|
||||
capturedAt={archiveSnapshotAt}
|
||||
hasArchive={hasArchive}
|
||||
onViewArchive={handleViewArchive}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{snapshot && rendersSnapshot(phase) && (
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from '../components/PreSessionView'
|
||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||
import { SourceBadge } from '../components/SourceBadge'
|
||||
import { RouteState } from '../components/RouteState'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { formatSessionScheduleTime, sortSessionsByStart } from '../lib/schedule'
|
||||
@@ -247,6 +248,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
const preparing = isPreparing(activeState)
|
||||
const unavailable = isUnavailable(activeState)
|
||||
const partial = isPartialAnalysis(activeState)
|
||||
// Distinct stale disclosure from response headers — skip routine local (already
|
||||
// covered by SourceBadge) and skip partial when the readiness banner is shown.
|
||||
const freshnessNotice = noticeFromResponse(data, { includeLocal: false })
|
||||
const showFreshnessNotice =
|
||||
freshnessNotice != null && !(partial && freshnessNotice === 'partial')
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||
@@ -278,6 +284,18 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFreshnessNotice && freshnessNotice && (
|
||||
<DataNotice
|
||||
availability={freshnessNotice}
|
||||
message={noticeMessage(freshnessNotice)}
|
||||
onRetry={() => {
|
||||
if (!raceHubQuery.isFetching) void raceHubQuery.refetch()
|
||||
}}
|
||||
retrying={raceHubQuery.isFetching}
|
||||
testId="race-hub-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
{switcherOpen && (
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={meetingKey}
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { DataNotice, RouteState } from '../components/RouteState'
|
||||
import { noticeFromResponse, noticeMessage } from '../lib/availability'
|
||||
import { userFacingError } from '../lib/fetch'
|
||||
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import {
|
||||
buildTitleFightContext,
|
||||
@@ -37,7 +40,7 @@ function SectionState({
|
||||
children,
|
||||
}: {
|
||||
loading?: boolean
|
||||
error?: Error | null
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyMessage?: string
|
||||
children: ReactNode
|
||||
@@ -47,8 +50,8 @@ function SectionState({
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="preview-section-state error">
|
||||
{error instanceof Error ? error.message : 'Failed to load'}
|
||||
<div className="preview-section-state error" role="status">
|
||||
{userFacingError(error)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +69,7 @@ function TrackOutlineCard({
|
||||
}: {
|
||||
outline: TrackOutline | null | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
accent: string
|
||||
}) {
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
@@ -101,7 +104,7 @@ function LastYearCard({
|
||||
}: {
|
||||
year: number | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
podium: ReturnType<typeof extractPodium>
|
||||
pole: ReturnType<typeof extractPole>
|
||||
isFirstTime: boolean
|
||||
@@ -150,7 +153,7 @@ function TitleFightCard({
|
||||
season,
|
||||
}: {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
error: unknown
|
||||
drivers: ReturnType<typeof buildTitleFightContext>
|
||||
sprintWeekend: boolean
|
||||
season: number | null
|
||||
@@ -259,7 +262,23 @@ function PreviewHeader({
|
||||
)
|
||||
}
|
||||
|
||||
export function RacePreviewPage() {
|
||||
export interface RacePreviewPageProps {
|
||||
/**
|
||||
* Canonical meeting identity from Weekend Context. When set, Preview must not
|
||||
* independently re-select another current meeting/session.
|
||||
*/
|
||||
meeting?: Meeting
|
||||
/** Season for championship supplement; defaults to meeting.year. */
|
||||
season?: number
|
||||
/** Embedded under Weekend — identity failures stay non-blocking. */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function RacePreviewPage({
|
||||
meeting: canonicalMeeting,
|
||||
season: canonicalSeason,
|
||||
embedded = false,
|
||||
}: RacePreviewPageProps = {}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -268,28 +287,34 @@ export function RacePreviewPage() {
|
||||
}, [])
|
||||
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
const hasCanonicalMeeting = canonicalMeeting != null && canonicalMeeting.meeting_key > 0
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: ({ signal }) => fetchSeasons(signal),
|
||||
enabled: !hasCanonicalMeeting,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
const latestSeason =
|
||||
canonicalSeason ??
|
||||
canonicalMeeting?.year ??
|
||||
seasonsQuery.data?.[0] ??
|
||||
null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason, 'auto'],
|
||||
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||
enabled: latestSeason != null,
|
||||
queryFn: ({ signal }) => fetchMeetings(latestSeason!, 'auto', signal),
|
||||
enabled: !hasCanonicalMeeting && latestSeason != null,
|
||||
})
|
||||
|
||||
const previewMeeting = useMemo(
|
||||
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||
[meetingsQuery.data, nowDate],
|
||||
)
|
||||
const previewMeeting = useMemo(() => {
|
||||
if (hasCanonicalMeeting) return canonicalMeeting
|
||||
return pickPreviewMeeting(meetingsQuery.data ?? [], nowDate)
|
||||
}, [hasCanonicalMeeting, canonicalMeeting, meetingsQuery.data, nowDate])
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchSessions(previewMeeting!.meeting_key, 'auto', signal),
|
||||
enabled: previewMeeting != null,
|
||||
})
|
||||
|
||||
@@ -305,7 +330,7 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', priorYear, 'auto'],
|
||||
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||
queryFn: ({ signal }) => fetchMeetings(priorYear!, 'auto', signal),
|
||||
enabled: priorYear != null && previewMeeting != null,
|
||||
})
|
||||
|
||||
@@ -316,7 +341,7 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorSessionsQuery = useQuery({
|
||||
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchSessions(priorMeeting!.meeting_key, 'auto', signal),
|
||||
enabled: priorMeeting != null,
|
||||
})
|
||||
|
||||
@@ -327,25 +352,25 @@ export function RacePreviewPage() {
|
||||
|
||||
const priorResultsQuery = useQuery({
|
||||
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchResults(priorRaceSession!.session_key, 'auto', signal),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const priorGridQuery = useQuery({
|
||||
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||
queryFn: ({ signal }) => fetchStartingGrid(priorRaceSession!.session_key, 'auto', signal),
|
||||
enabled: priorRaceSession != null,
|
||||
})
|
||||
|
||||
const trackOutlineQuery = useQuery({
|
||||
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||
queryFn: ({ signal }) => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year, signal),
|
||||
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
queryFn: ({ signal }) => fetchChampionshipHub(latestSeason!, signal),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
@@ -359,15 +384,42 @@ export function RacePreviewPage() {
|
||||
const accent = countryAccent(previewMeeting)
|
||||
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||
const identityLoading = !hasCanonicalMeeting && (seasonsQuery.isLoading || meetingsQuery.isLoading)
|
||||
const identityError = !hasCanonicalMeeting && (seasonsQuery.isError || meetingsQuery.isError)
|
||||
const identityRetrying = seasonsQuery.isFetching || meetingsQuery.isFetching
|
||||
|
||||
const retryIdentity = () => {
|
||||
if (seasonsQuery.isError && !seasonsQuery.isFetching) void seasonsQuery.refetch()
|
||||
if (meetingsQuery.isError && !meetingsQuery.isFetching) void meetingsQuery.refetch()
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||
const dataNotice =
|
||||
noticeFromResponse(championshipQuery.data, { includeLocal: true }) ??
|
||||
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
|
||||
noticeFromResponse(priorResultsQuery.data, { includeLocal: false })
|
||||
|
||||
if (identityLoading) {
|
||||
return (
|
||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
||||
<RouteState kind="loading" title="loading preview…" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (identityError) {
|
||||
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||
return (
|
||||
<div className="page error-box" data-testid="preview-error">
|
||||
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-error">
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Preview details unavailable"
|
||||
error={err}
|
||||
onRetry={() => {
|
||||
if (!identityRetrying) retryIdentity()
|
||||
}}
|
||||
retrying={identityRetrying}
|
||||
retryTestId="preview-retry"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -386,7 +438,7 @@ export function RacePreviewPage() {
|
||||
{titleFight.length > 0 && (
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={false}
|
||||
season={latestSeason}
|
||||
@@ -397,20 +449,36 @@ export function RacePreviewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-page" data-testid="preview-page">
|
||||
<PreviewHeader
|
||||
meeting={previewMeeting}
|
||||
sessions={sessions}
|
||||
countdownSession={countdownSession}
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
/>
|
||||
<div
|
||||
className="preview-page"
|
||||
data-testid="preview-page"
|
||||
data-meeting-key={previewMeeting.meeting_key}
|
||||
data-embedded={embedded ? 'true' : undefined}
|
||||
>
|
||||
{dataNotice && (
|
||||
<DataNotice
|
||||
availability={dataNotice}
|
||||
message={noticeMessage(dataNotice)}
|
||||
testId="preview-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Embedded Weekend already shows the canonical countdown header — skip the duplicate. */}
|
||||
{!embedded && (
|
||||
<PreviewHeader
|
||||
meeting={previewMeeting}
|
||||
sessions={sessions}
|
||||
countdownSession={countdownSession}
|
||||
now={nowDate}
|
||||
accent={accent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="preview-grid">
|
||||
<TrackOutlineCard
|
||||
outline={trackOutlineQuery.data}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||
error={trackOutlineQuery.isError ? trackOutlineQuery.error : null}
|
||||
accent={accent}
|
||||
/>
|
||||
|
||||
@@ -419,9 +487,9 @@ export function RacePreviewPage() {
|
||||
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||
error={
|
||||
priorMeetingsQuery.isError
|
||||
? (priorMeetingsQuery.error as Error)
|
||||
? priorMeetingsQuery.error
|
||||
: priorResultsQuery.isError
|
||||
? (priorResultsQuery.error as Error)
|
||||
? priorResultsQuery.error
|
||||
: null
|
||||
}
|
||||
podium={podium}
|
||||
@@ -432,7 +500,7 @@ export function RacePreviewPage() {
|
||||
|
||||
<TitleFightCard
|
||||
loading={championshipQuery.isLoading}
|
||||
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||
error={championshipQuery.isError ? championshipQuery.error : null}
|
||||
drivers={titleFight}
|
||||
sprintWeekend={sprintWeekend}
|
||||
season={latestSeason}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||
import { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||
import { DataNotice } from '../components/RouteState'
|
||||
import { noticeMessage, type DataAvailability } from '../lib/availability'
|
||||
import { resolveViewState } from '../lib/weekendContext'
|
||||
import type {
|
||||
WeekendBriefingItem,
|
||||
@@ -60,6 +62,44 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
function WeekendAvailabilityNotices({
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
onRetry,
|
||||
retrying,
|
||||
}: {
|
||||
availabilityNotice: DataAvailability | null
|
||||
supplementsLimited: boolean
|
||||
onRetry: () => void
|
||||
retrying: boolean
|
||||
}) {
|
||||
// Prefer a single reported freshness notice; Limited for failed supplements
|
||||
// only when freshness itself is not already disclosing a stronger state.
|
||||
if (availabilityNotice) {
|
||||
return (
|
||||
<DataNotice
|
||||
availability={availabilityNotice}
|
||||
message={noticeMessage(availabilityNotice)}
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="weekend-data-notice"
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (supplementsLimited) {
|
||||
return (
|
||||
<DataNotice
|
||||
availability="limited"
|
||||
message="Championship or briefing supplements are unavailable. Weekend schedule context is still shown."
|
||||
onRetry={onRetry}
|
||||
retrying={retrying}
|
||||
testId="weekend-data-notice"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function WeekendPage({
|
||||
preview = false,
|
||||
focusMeetingKey,
|
||||
@@ -71,8 +111,18 @@ export function WeekendPage({
|
||||
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||
focusSessionKey?: number
|
||||
}) {
|
||||
const { context, loadState, error, championship, briefing, now, refetch, isFetching } =
|
||||
useWeekendContext()
|
||||
const {
|
||||
context,
|
||||
loadState,
|
||||
error,
|
||||
championship,
|
||||
briefing,
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
now,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useWeekendContext()
|
||||
const hasFocus =
|
||||
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||
(focusSessionKey != null && focusSessionKey > 0)
|
||||
@@ -108,6 +158,12 @@ export function WeekendPage({
|
||||
{hasFocus && (
|
||||
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
|
||||
)}
|
||||
<WeekendAvailabilityNotices
|
||||
availabilityNotice={availabilityNotice}
|
||||
supplementsLimited={supplementsLimited}
|
||||
onRetry={refetch}
|
||||
retrying={isFetching}
|
||||
/>
|
||||
{renderState(view, { context, now, championship, briefing, preview })}
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BriefingPage } from '../pages/BriefingPage'
|
||||
@@ -208,4 +208,16 @@ describe('BriefingPage digest layout', () => {
|
||||
})
|
||||
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps articles usable and shows Limited when grouping supplements fail', async () => {
|
||||
mockFetchSeasonMeetings.mockRejectedValue(new Error('API 503: meetings failed'))
|
||||
mockFetchHub.mockRejectedValue(new Error('API 503: hub failed'))
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('briefing-data-notice')).toHaveTextContent(/Limited/i)
|
||||
})
|
||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { ChampionshipPage } from '../pages/ChampionshipPage'
|
||||
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub } from '../types'
|
||||
import { rememberResponseAvailability } from '../lib/fetch'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
@@ -226,4 +227,36 @@ describe('ChampionshipPage', () => {
|
||||
expect(rows[1]).toHaveTextContent('Red Bull')
|
||||
expect(rows[1]).toHaveTextContent('9–1')
|
||||
})
|
||||
|
||||
it('shows a non-blocking stale notice above usable standings', async () => {
|
||||
const staleHub = { ...hub }
|
||||
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
|
||||
mockFetchHub.mockResolvedValue(staleHub)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Stale/i)
|
||||
expect(screen.getByTestId('champ-view-drivers')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows a non-blocking partial notice when reported', async () => {
|
||||
const partialHub = { ...hub }
|
||||
rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
|
||||
mockFetchHub.mockResolvedValue(partialHub)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Partial/i)
|
||||
})
|
||||
|
||||
it('shows a non-blocking local notice when reported', async () => {
|
||||
const localHub = { ...hub }
|
||||
rememberResponseAvailability(localHub, { source: 'local', freshness: 'local' })
|
||||
mockFetchHub.mockResolvedValue(localHub)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('championship-data-notice')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('championship-data-notice')).toHaveTextContent(/Local/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
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 { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||
import { ApiError } from '../lib/fetch'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
@@ -174,16 +175,17 @@ const outline: TrackOutline = {
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
function renderPage(props?: { meeting?: Meeting; season?: number; embedded?: boolean }) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const Page = () => <RacePreviewPage {...props} />
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RacePreviewPage />
|
||||
<Page />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: RacePreviewPage })
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Page })
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
@@ -274,4 +276,51 @@ describe('RacePreviewPage', () => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses canonical meeting identity when embedded and skips seasons/meetings selection', 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 []
|
||||
})
|
||||
|
||||
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
|
||||
|
||||
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-embedded', 'true')
|
||||
expect(mockFetchSeasons).not.toHaveBeenCalled()
|
||||
// Current-year meetings selection is skipped; prior-year lookup may still run.
|
||||
expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
|
||||
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
|
||||
expect(screen.queryByTestId('preview-header')).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 }),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preview-error')).not.toHaveTextContent(/API 500|Internal Server Error/i)
|
||||
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||
expect(retry).toBeEnabled()
|
||||
|
||||
mockFetchSeasons.mockResolvedValue([2099])
|
||||
mockFetchMeetings.mockResolvedValue([upcomingMeeting])
|
||||
mockFetchSessions.mockResolvedValue(sessions)
|
||||
|
||||
fireEvent.click(retry)
|
||||
fireEvent.click(retry)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||
// Guarded: one in-flight refetch despite double click while fetching.
|
||||
expect(mockFetchSeasons.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -364,6 +364,86 @@ describe('WeekendPage canonical contract rendering', () => {
|
||||
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre_session embeds Preview with canonical meeting identity (no seasons/meetings re-resolve)', 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',
|
||||
}),
|
||||
])
|
||||
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
||||
expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
|
||||
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-embedded', 'true')
|
||||
// Canonical identity was passed — Preview must not fan out to seasons /
|
||||
// current-season meetings selection. Prior-year lookup for "Last year here"
|
||||
// remains an intentional supplement.
|
||||
expect(mockSeasons).not.toHaveBeenCalled()
|
||||
expect(mockMeetings).not.toHaveBeenCalledWith(2026, expect.anything(), expect.anything())
|
||||
expect(mockSessions).toHaveBeenCalledWith(2, 'auto', expect.anything())
|
||||
})
|
||||
|
||||
it('preview supplement failure keeps the Weekend shell usable with sanitized Retry', 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 }),
|
||||
meeting: next,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// 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'))
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
|
||||
131
frontend/src/test/availability.test.ts
Normal file
131
frontend/src/test/availability.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
apiFetch,
|
||||
clearApiFetchInflight,
|
||||
DATA_FRESHNESS_HEADER,
|
||||
DATA_SOURCE_HEADER,
|
||||
getResponseAvailability,
|
||||
rememberResponseAvailability,
|
||||
} from '../lib/fetch'
|
||||
import {
|
||||
noticeFromFreshness,
|
||||
noticeFromResponse,
|
||||
weekendContextNotice,
|
||||
} from '../lib/availability'
|
||||
import type { WeekendContext } from '../types'
|
||||
|
||||
describe('response availability metadata', () => {
|
||||
beforeEach(() => {
|
||||
clearApiFetchInflight()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
clearApiFetchInflight()
|
||||
})
|
||||
|
||||
it('captures CORS-readable source/freshness headers without changing the payload shape', async () => {
|
||||
const body = { season: 2026, drivers: [{ points: 1 }] }
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[DATA_SOURCE_HEADER]: 'openf1',
|
||||
[DATA_FRESHNESS_HEADER]: 'stale',
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const data = await apiFetch<typeof body>('/api/v1/championship/hub')
|
||||
expect(data).toEqual(body)
|
||||
expect(getResponseAvailability(data)).toEqual({ source: 'openf1', freshness: 'stale' })
|
||||
})
|
||||
|
||||
it('preserves metadata through deduped in-flight subscribers', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[DATA_SOURCE_HEADER]: 'local',
|
||||
[DATA_FRESHNESS_HEADER]: 'partial',
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const a = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
|
||||
const b = apiFetch<{ ok: boolean }>('/api/v1/shared', { dedupeKey: 'meta-shared' })
|
||||
const [ra, rb] = await Promise.all([a, b])
|
||||
expect(ra).toBe(rb)
|
||||
expect(getResponseAvailability(ra)).toEqual({ source: 'local', freshness: 'partial' })
|
||||
})
|
||||
|
||||
it('maps only reported freshness values and never invents stale', () => {
|
||||
expect(noticeFromFreshness('stale')).toBe('stale')
|
||||
expect(noticeFromFreshness('partial')).toBe('partial')
|
||||
expect(noticeFromFreshness('local')).toBe('local')
|
||||
expect(noticeFromFreshness('limited')).toBe('limited')
|
||||
expect(noticeFromFreshness('archive')).toBe('archive')
|
||||
expect(noticeFromFreshness('fresh')).toBeNull()
|
||||
expect(noticeFromFreshness('live')).toBeNull()
|
||||
expect(noticeFromFreshness(undefined)).toBeNull()
|
||||
expect(noticeFromFreshness('local', { includeLocal: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('reads notice from WeakMap metadata attached to a successful payload', () => {
|
||||
const hub = { season: 2025, drivers: [] as unknown[] }
|
||||
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
|
||||
expect(noticeFromResponse(hub)).toBe('stale')
|
||||
})
|
||||
|
||||
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
|
||||
const context: WeekendContext = {
|
||||
season: 2026,
|
||||
temporal_state: 'pre_session',
|
||||
championship_round: 1,
|
||||
total_championship_rounds: 24,
|
||||
next_session: {
|
||||
session: {
|
||||
session_key: 21,
|
||||
session_name: 'FP1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
date_end: '2026-07-24T10:00:00Z',
|
||||
gmt_offset: '',
|
||||
},
|
||||
availability: {
|
||||
source: 'local',
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'partial',
|
||||
freshness: 'partial',
|
||||
limitations: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(weekendContextNotice(context)).toBe('partial')
|
||||
|
||||
const localOnly: WeekendContext = {
|
||||
...context,
|
||||
next_session: {
|
||||
...context.next_session!,
|
||||
availability: {
|
||||
...context.next_session!.availability,
|
||||
local_analysis: 'pending',
|
||||
freshness: 'local',
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(weekendContextNotice(localOnly)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -472,6 +472,8 @@ export type TemporalState =
|
||||
// ContextAvailability mirrors query.ContextAvailability. Every field is present
|
||||
// in a canonical payload except the optional `observed_at`.
|
||||
export interface ContextAvailability {
|
||||
/** Domain / FIA provenance: local | mixed | fia (additive; may be absent on older payloads). */
|
||||
source?: string
|
||||
schedule: string
|
||||
live_transport: string
|
||||
live_session: string
|
||||
|
||||
280
tests/issue-76-availability.spec.ts
Normal file
280
tests/issue-76-availability.spec.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const evidenceDir = path.join('tests', 'evidence', 'issue-76-availability')
|
||||
|
||||
const availability = {
|
||||
source: 'local',
|
||||
schedule: 'available',
|
||||
live_transport: 'unknown',
|
||||
live_session: 'inactive',
|
||||
archive: 'unavailable',
|
||||
local_analysis: 'partial',
|
||||
freshness: 'partial',
|
||||
limitations: [],
|
||||
}
|
||||
|
||||
function meeting(key: number, name: string, start: string) {
|
||||
return {
|
||||
meeting_key: key,
|
||||
meeting_name: name,
|
||||
meeting_official_name: name,
|
||||
location: name,
|
||||
country_code: 'HUN',
|
||||
country_name: 'Hungary',
|
||||
country_flag: '',
|
||||
circuit_key: key,
|
||||
circuit_short_name: name,
|
||||
date_start: start,
|
||||
date_end: start,
|
||||
year: 2026,
|
||||
}
|
||||
}
|
||||
|
||||
function ctxSession(key: number, name: string, start: string, meetingKey: number) {
|
||||
return {
|
||||
session: {
|
||||
session_key: key,
|
||||
session_name: name,
|
||||
session_type: name,
|
||||
meeting_key: meetingKey,
|
||||
date_start: start,
|
||||
date_end: start,
|
||||
gmt_offset: '',
|
||||
},
|
||||
meeting: meeting(meetingKey, 'Hungarian Grand Prix', start),
|
||||
availability,
|
||||
}
|
||||
}
|
||||
|
||||
async function stubPreSession(page: Page, opts?: { failSessions?: boolean; recover?: boolean }) {
|
||||
const next = meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z')
|
||||
await page.route('**/api/v1/weekend-context', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-BoxBox-Data-Source': 'local',
|
||||
'X-BoxBox-Data-Freshness': 'partial',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
season: 2026,
|
||||
temporal_state: 'pre_session',
|
||||
focus_meeting: next,
|
||||
next_meeting: next,
|
||||
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
|
||||
championship_round: 13,
|
||||
total_championship_rounds: 24,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/news**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' }),
|
||||
)
|
||||
await page.route('**/api/v1/championship/hub**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-BoxBox-Data-Source': 'openf1',
|
||||
'X-BoxBox-Data-Freshness': 'stale',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
season: 2026,
|
||||
round: 12,
|
||||
total_rounds: 24,
|
||||
rounds_left: 12,
|
||||
last_race: 'British GP',
|
||||
round_labels: [],
|
||||
drivers: [],
|
||||
teams: [],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
let sessionCalls = 0
|
||||
await page.route('**/api/v1/sessions**', async (route) => {
|
||||
sessionCalls += 1
|
||||
if (opts?.failSessions && !(opts.recover && sessionCalls > 1)) {
|
||||
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.route('**/api/v1/meetings**', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: '[]' }),
|
||||
)
|
||||
await page.route('**/api/v1/track-outline**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'unavailable' }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Issue #76 availability / Preview resilience evidence', () => {
|
||||
test('Weekend pre-session keeps shell usable on Preview supplement failure and recovers', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await stubPreSession(page, { failSessions: true, recover: true })
|
||||
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)
|
||||
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.locator('body')).not.toContainText(/API 500|forced sessions failure/i)
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => {
|
||||
await page.route('**/api/v1/seasons', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2026]) }),
|
||||
)
|
||||
await page.route('**/api/v1/championship/hub**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-BoxBox-Data-Source': 'openf1',
|
||||
'X-BoxBox-Data-Freshness': 'stale',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
season: 2026,
|
||||
round: 6,
|
||||
total_rounds: 24,
|
||||
rounds_left: 18,
|
||||
last_race: 'Monaco GP',
|
||||
round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
|
||||
drivers: [
|
||||
{
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 200,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 8,
|
||||
poles: 4,
|
||||
form: [25, 18, 25, 15, 25],
|
||||
cumulative: [25, 43, 68, 83, 108, 200],
|
||||
round_positions: [1, 2, 1, 3, 1, 1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
},
|
||||
{
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 160,
|
||||
position: 2,
|
||||
wins: 3,
|
||||
podiums: 5,
|
||||
poles: 2,
|
||||
form: [18, 25, 18, 25, 18],
|
||||
cumulative: [18, 43, 61, 86, 104, 160],
|
||||
round_positions: [2, 1, 2, 2, 2, 2],
|
||||
teammate_wins: 6,
|
||||
teammate_losses: 4,
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{ team_name: 'Red Bull', team_colour: '3671c6', points: 260, position: 1, wins: 6 },
|
||||
{ team_name: 'McLaren', team_colour: 'ff8000', points: 220, position: 2, wins: 3 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.setViewportSize({ width: 768, height: 1024 })
|
||||
await page.goto('/championship')
|
||||
await expect(page.getByTestId('championship-data-notice')).toContainText(/Stale/i)
|
||||
await expect(page.getByTestId('champ-view-drivers')).toBeVisible()
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `championship-stale-tablet-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('Briefing Limited notice keeps articles usable', async ({ page }, testInfo) => {
|
||||
await page.route('**/api/v1/news**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-BoxBox-Data-Source': 'local',
|
||||
'X-BoxBox-Data-Freshness': 'local',
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Paddock briefing sample',
|
||||
url: 'https://example.com/a',
|
||||
source: 'Autosport',
|
||||
published_at: '2025-04-01T12:00:00Z',
|
||||
summary: 'Sample article remains usable.',
|
||||
category: 'news',
|
||||
},
|
||||
]),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/seasons', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify([2025]) }),
|
||||
)
|
||||
await page.route('**/api/v1/meetings**', (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'API 503: meetings unavailable' }),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/championship/hub**', (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'API 503: hub unavailable' }),
|
||||
}),
|
||||
)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/briefing')
|
||||
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)
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user