mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -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 { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||
import { meetingIdentity } from '../../lib/weekendContext'
|
||||
import type { DataAvailability } from '../../lib/availability'
|
||||
|
||||
/**
|
||||
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||
@@ -14,7 +15,16 @@ import { meetingIdentity } from '../../lib/weekendContext'
|
||||
* Canonical meeting/session identity is passed into Preview so the nested surface
|
||||
* cannot independently re-resolve a different current weekend.
|
||||
*/
|
||||
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
export function PreSessionView({
|
||||
context,
|
||||
now,
|
||||
shellAvailability = null,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
/** Weekend shell notice already shown — Preview dedupes only an equivalent kind. */
|
||||
shellAvailability?: DataAvailability | null
|
||||
}) {
|
||||
const meeting = context.next_meeting ?? context.focus_meeting
|
||||
const identity = meetingIdentity(meeting)
|
||||
const next = context.next_session?.session
|
||||
@@ -52,6 +62,7 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
embedded
|
||||
meeting={meeting}
|
||||
season={context.season ?? meeting?.year}
|
||||
shellAvailability={shellAvailability}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ export interface UseWeekendContextResult {
|
||||
/** True when championship or news supplements failed while context succeeded. */
|
||||
supplementsLimited: boolean
|
||||
now: Date
|
||||
/** Refetch the canonical weekend-context read. */
|
||||
/** Refetch canonical context and/or failed supplements as appropriate. */
|
||||
refetch: () => void
|
||||
/** True while a canonical refetch is in flight. */
|
||||
/** True while a relevant refetch is in flight. */
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
@@ -88,15 +88,42 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
[canonical],
|
||||
)
|
||||
|
||||
// Keep Limited visible while a failed supplement is refetching so Retry can
|
||||
// expose disabled/aria-busy. React Query v5 clears isError during that refetch.
|
||||
const supplementDegraded = (q: {
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetched: boolean
|
||||
data: unknown
|
||||
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||
|
||||
const supplementsLimited =
|
||||
canonicalReady &&
|
||||
((championshipQuery.isError && !championshipQuery.isFetching) ||
|
||||
(newsQuery.isError && !newsQuery.isFetching))
|
||||
(supplementDegraded(championshipQuery) || supplementDegraded(newsQuery))
|
||||
|
||||
let loadState: WeekendLoadState = 'loading'
|
||||
if (contextQuery.isError) loadState = 'error'
|
||||
else if (canonicalReady) loadState = 'ready'
|
||||
|
||||
const refetch = () => {
|
||||
if (!canonicalReady) {
|
||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||
return
|
||||
}
|
||||
// Freshness notice Retry refreshes the canonical context.
|
||||
if (availabilityNotice && !contextQuery.isFetching) {
|
||||
void contextQuery.refetch()
|
||||
}
|
||||
// Limited Retry must hit the failed supplements — not merely context.
|
||||
// Gate on degraded (error or in-flight post-error refetch), not isError alone.
|
||||
if (championshipQuery.isError && !championshipQuery.isFetching) {
|
||||
void championshipQuery.refetch()
|
||||
}
|
||||
if (newsQuery.isError && !newsQuery.isFetching) {
|
||||
void newsQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
context: canonical,
|
||||
loadState,
|
||||
@@ -106,9 +133,8 @@ export function useWeekendContext(): UseWeekendContextResult {
|
||||
availabilityNotice,
|
||||
supplementsLimited,
|
||||
now: nowDate,
|
||||
refetch: () => {
|
||||
if (!contextQuery.isFetching) void contextQuery.refetch()
|
||||
},
|
||||
isFetching: contextQuery.isFetching,
|
||||
refetch,
|
||||
isFetching:
|
||||
contextQuery.isFetching || championshipQuery.isFetching || newsQuery.isFetching,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,16 @@ export function weekendContextNotice(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded Preview should disclose its own freshness unless the Weekend shell
|
||||
* already shows an equivalent notice (same DataAvailability kind).
|
||||
*/
|
||||
export function shouldShowEmbeddedNotice(
|
||||
notice: DataAvailability | null | undefined,
|
||||
shellNotice: DataAvailability | null | undefined,
|
||||
): boolean {
|
||||
if (!notice) return false
|
||||
if (!shellNotice) return true
|
||||
return notice !== shellNotice
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Bounded fetch helpers for primary-route resilience. */
|
||||
|
||||
import { replaceEqualDeep } from '@tanstack/react-query'
|
||||
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/** CORS-readable success provenance headers from the Go API. */
|
||||
@@ -55,6 +57,35 @@ export function rememberResponseAvailability(
|
||||
responseAvailability.set(data, meta)
|
||||
}
|
||||
|
||||
/** Drop availability metadata from a payload (e.g. after a header-less refetch). */
|
||||
export function clearResponseAvailability(data: object): void {
|
||||
responseAvailability.delete(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query structural sharing that keeps header availability in sync.
|
||||
*
|
||||
* Default `replaceEqualDeep` reuses the previous object when JSON is equal, which
|
||||
* would leave stale WeakMap metadata attached after a stale→fresh (or fresh→partial)
|
||||
* refetch of an identical body. Always re-bind the latest fetch's metadata onto the
|
||||
* object that lands in the query cache.
|
||||
*/
|
||||
export function availabilityAwareStructuralSharing<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 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 ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createAppQueryClient } from './lib/queryClient'
|
||||
import { router } from './router'
|
||||
import './styles/app.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
// Explicit Retry on RouteState — avoid automatic retry storms.
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const queryClient = createAppQueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -517,12 +517,35 @@ export function BriefingPage() {
|
||||
const hasDigest = meetings.length > 0
|
||||
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||
|
||||
const supplementsFetching =
|
||||
meetingsQuery.isFetching || hubQuery.isFetching || seasonsQuery.isFetching
|
||||
// React Query v5 clears isError while a post-error refetch is in flight
|
||||
// (status → pending). Keep Limited mounted so Retry can show aria-busy.
|
||||
const supplementDegraded = (q: {
|
||||
isError: boolean
|
||||
isFetching: boolean
|
||||
isFetched: boolean
|
||||
data: unknown
|
||||
}) => q.isError || (q.isFetching && q.isFetched && q.data == null)
|
||||
const supplementsLimited =
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
((meetingsQuery.isError && !meetingsQuery.isFetching) ||
|
||||
(hubQuery.isError && !hubQuery.isFetching) ||
|
||||
(seasonsQuery.isError && !seasonsQuery.isFetching))
|
||||
(supplementDegraded(meetingsQuery) ||
|
||||
supplementDegraded(hubQuery) ||
|
||||
supplementDegraded(seasonsQuery))
|
||||
const supplementsRetrying = supplementsLimited && supplementsFetching
|
||||
|
||||
const retrySupplements = () => {
|
||||
if (supplementDegraded(meetingsQuery) && !meetingsQuery.isFetching) {
|
||||
void meetingsQuery.refetch()
|
||||
}
|
||||
if (supplementDegraded(hubQuery) && !hubQuery.isFetching) {
|
||||
void hubQuery.refetch()
|
||||
}
|
||||
if (supplementDegraded(seasonsQuery) && !seasonsQuery.isFetching) {
|
||||
void seasonsQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
const newsAvailability = noticeFromResponse(allNews, { includeLocal: true })
|
||||
const hubAvailability = noticeFromResponse(hub, { includeLocal: false })
|
||||
@@ -560,11 +583,8 @@ export function BriefingPage() {
|
||||
<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()
|
||||
}}
|
||||
onRetry={retrySupplements}
|
||||
retrying={supplementsRetrying}
|
||||
testId="briefing-data-notice"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -304,9 +304,14 @@ export function LiveTimingPage() {
|
||||
// feed-health truth rather than "Connection lost" + "Feed healthy".
|
||||
const feedHealth = effectiveFeedHealth(streamStatus, phase)
|
||||
|
||||
// A usable SSE/stream snapshot must not coexist with the fatal initial-state
|
||||
// error — gate once live or retained archive data arrives.
|
||||
const hasUsableStreamData = Boolean(activeSnapshot) || Boolean(archiveSnapshot)
|
||||
const showInitialError = isError && !hasUsableStreamData
|
||||
|
||||
return (
|
||||
<div className="page live-page" data-testid="live-page" data-phase={phase}>
|
||||
{isError && (
|
||||
{showInitialError && (
|
||||
<RouteState
|
||||
kind="error"
|
||||
title="Live timing unavailable"
|
||||
@@ -334,7 +339,7 @@ export function LiveTimingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'connecting' && (isLoading || !liveStateFetched) && (
|
||||
{phase === 'connecting' && (isLoading || !liveStateFetched) && !hasUsableStreamData && (
|
||||
<RouteState
|
||||
kind="loading"
|
||||
title="connecting to live timing…"
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
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 { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import {
|
||||
@@ -272,12 +277,18 @@ export interface RacePreviewPageProps {
|
||||
season?: number
|
||||
/** Embedded under Weekend — identity failures stay non-blocking. */
|
||||
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({
|
||||
meeting: canonicalMeeting,
|
||||
season: canonicalSeason,
|
||||
embedded = false,
|
||||
shellAvailability = null,
|
||||
}: RacePreviewPageProps = {}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
@@ -398,6 +409,17 @@ export function RacePreviewPage({
|
||||
noticeFromResponse(sessionsQuery.data, { includeLocal: false }) ??
|
||||
noticeFromResponse(priorResultsQuery.data, { includeLocal: false })
|
||||
|
||||
const showFreshnessNotice =
|
||||
Boolean(dataNotice) &&
|
||||
(!embedded || shouldShowEmbeddedNotice(dataNotice, shellAvailability))
|
||||
|
||||
const sessionsFailed = sessionsQuery.isError
|
||||
const sessionsRetrying = sessionsQuery.isFetching
|
||||
|
||||
const retrySessions = () => {
|
||||
if (!sessionsQuery.isFetching) void sessionsQuery.refetch()
|
||||
}
|
||||
|
||||
if (identityLoading) {
|
||||
return (
|
||||
<div className={embedded ? 'preview-embedded' : 'page'} data-testid="preview-loading">
|
||||
@@ -455,8 +477,7 @@ export function RacePreviewPage({
|
||||
data-meeting-key={previewMeeting.meeting_key}
|
||||
data-embedded={embedded ? 'true' : undefined}
|
||||
>
|
||||
{/* Weekend shell owns availability disclosure when Preview is embedded. */}
|
||||
{!embedded && dataNotice && (
|
||||
{showFreshnessNotice && dataNotice && (
|
||||
<DataNotice
|
||||
availability={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 && (
|
||||
<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">
|
||||
<TrackOutlineCard
|
||||
outline={trackOutlineQuery.data}
|
||||
|
||||
@@ -23,16 +23,18 @@ interface RenderArgs {
|
||||
briefing: WeekendBriefingItem[]
|
||||
/** When true (the /preview alias), foreground the preparation surface. */
|
||||
preview: boolean
|
||||
/** Availability already disclosed by the Weekend shell (for Preview dedupe). */
|
||||
shellAvailability: DataAvailability | null
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
// redirecting straight back to the same between-races screen.
|
||||
if (preview && context.next_meeting) {
|
||||
return <PreSessionView context={context} now={now} />
|
||||
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
@@ -56,7 +58,7 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||
case 'session_live':
|
||||
return <LiveHandoffView context={context} />
|
||||
case 'pre_session':
|
||||
return <PreSessionView context={context} now={now} />
|
||||
return <PreSessionView context={context} now={now} shellAvailability={shellAvailability} />
|
||||
default:
|
||||
return <WeekendLimited season={context.season} />
|
||||
}
|
||||
@@ -144,6 +146,7 @@ export function WeekendPage({
|
||||
}
|
||||
|
||||
const view = resolveViewState(context)
|
||||
const shellAvailability = availabilityNotice ?? (supplementsLimited ? 'limited' : null)
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -164,7 +167,14 @@ export function WeekendPage({
|
||||
onRetry={refetch}
|
||||
retrying={isFetching}
|
||||
/>
|
||||
{renderState(view, { context, now, championship, briefing, preview })}
|
||||
{renderState(view, {
|
||||
context,
|
||||
now,
|
||||
championship,
|
||||
briefing,
|
||||
preview,
|
||||
shellAvailability,
|
||||
})}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,5 +219,36 @@ describe('BriefingPage digest layout', () => {
|
||||
})
|
||||
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('briefing-error')).not.toBeInTheDocument()
|
||||
|
||||
let meetingsCalls = 1
|
||||
let hubCalls = 1
|
||||
let resolveMeetings!: (value: Meeting[]) => void
|
||||
let resolveHub!: (value: ChampionshipHub) => void
|
||||
mockFetchSeasonMeetings.mockImplementation(
|
||||
() =>
|
||||
new Promise<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()
|
||||
})
|
||||
|
||||
it('shows the inactive weekend context when nothing is live or retained', async () => {
|
||||
it('shows inactive handoff when nothing is live or retained', async () => {
|
||||
renderPage(
|
||||
{ is_live: false, data: null },
|
||||
{
|
||||
@@ -392,4 +392,96 @@ describe('LiveTimingPage', () => {
|
||||
expect(screen.getByTestId('live-handoff-next')).toHaveTextContent('Practice 1'),
|
||||
)
|
||||
})
|
||||
|
||||
it('clears fatal initial error once SSE supplies a live snapshot', async () => {
|
||||
type Listener = (event: { data: string }) => void
|
||||
class RecoveringEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
private listeners = new Map<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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { rememberResponseAvailability } from '../lib/fetch'
|
||||
import { RacePreviewPage } from '../pages/RacePreviewPage'
|
||||
import { ApiError } from '../lib/fetch'
|
||||
import type { ChampHubDriver, ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session, TrackOutline } from '../types'
|
||||
@@ -175,7 +176,12 @@ const outline: TrackOutline = {
|
||||
bounds: { minX: 0, maxX: 1, minY: 0, maxY: 1 },
|
||||
}
|
||||
|
||||
function renderPage(props?: { meeting?: Meeting; season?: number; embedded?: boolean }) {
|
||||
function renderPage(props?: {
|
||||
meeting?: Meeting
|
||||
season?: number
|
||||
embedded?: boolean
|
||||
shellAvailability?: 'local' | 'partial' | 'stale' | 'archive' | 'limited' | 'missing' | null
|
||||
}) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const Page = () => <RacePreviewPage {...props} />
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -288,7 +294,7 @@ describe('RacePreviewPage', () => {
|
||||
return []
|
||||
})
|
||||
|
||||
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
|
||||
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true, shellAvailability: 'partial' })
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '100')
|
||||
@@ -298,10 +304,76 @@ describe('RacePreviewPage', () => {
|
||||
expect(mockFetchMeetings).not.toHaveBeenCalledWith(2099, expect.anything(), expect.anything())
|
||||
expect(mockFetchSessions).toHaveBeenCalledWith(100, 'auto', expect.anything())
|
||||
expect(screen.queryByTestId('preview-header')).not.toBeInTheDocument()
|
||||
// Weekend shell owns availability disclosure — no stacked Preview notice.
|
||||
})
|
||||
|
||||
it('dedupes an equivalent shell notice but shows distinct Preview freshness', async () => {
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey === 100) return sessions
|
||||
if (meetingKey === 90) return [priorRaceSession]
|
||||
return []
|
||||
})
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
|
||||
const staleHub = { ...hub }
|
||||
rememberResponseAvailability(staleHub, { source: 'openf1', freshness: 'stale' })
|
||||
mockFetchChampionshipHub.mockResolvedValue(staleHub)
|
||||
|
||||
const distinct = renderPage({
|
||||
meeting: upcomingMeeting,
|
||||
season: 2099,
|
||||
embedded: true,
|
||||
shellAvailability: 'partial',
|
||||
})
|
||||
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
|
||||
distinct.unmount()
|
||||
|
||||
const partialHub = { ...hub }
|
||||
rememberResponseAvailability(partialHub, { source: 'openf1', freshness: 'partial' })
|
||||
mockFetchChampionshipHub.mockResolvedValue(partialHub)
|
||||
|
||||
renderPage({
|
||||
meeting: upcomingMeeting,
|
||||
season: 2099,
|
||||
embedded: true,
|
||||
shellAvailability: 'partial',
|
||||
})
|
||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('preview-data-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sanitized sessions failure with Retry that refetches once', async () => {
|
||||
let sessionCalls = 0
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey !== 100) return [priorRaceSession]
|
||||
sessionCalls += 1
|
||||
if (sessionCalls === 1) {
|
||||
throw new ApiError('http', 'API 500: sessions boom', { status: 500 })
|
||||
}
|
||||
return sessions
|
||||
})
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
|
||||
renderPage({ meeting: upcomingMeeting, season: 2099, embedded: true })
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('preview-sessions-error')).not.toHaveTextContent(/API 500|sessions boom/i)
|
||||
expect(screen.getByTestId('preview-page')).toBeInTheDocument()
|
||||
|
||||
const retry = screen.getByTestId('preview-sessions-retry')
|
||||
fireEvent.click(retry)
|
||||
fireEvent.click(retry)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1'))
|
||||
expect(sessionCalls).toBe(2)
|
||||
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sanitizes raw HTTP errors and offers a guarded keyboard Retry', async () => {
|
||||
mockFetchSeasons.mockRejectedValue(
|
||||
new ApiError('http', 'API 500: Internal Server Error', { status: 500 }),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
fetchStartingGrid,
|
||||
fetchTrackOutline,
|
||||
} from '../api'
|
||||
import { rememberResponseAvailability } from '../lib/fetch'
|
||||
|
||||
const mockContext = vi.mocked(fetchWeekendContext)
|
||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||
@@ -432,19 +433,99 @@ describe('WeekendPage canonical contract rendering', () => {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Force identity path by omitting meeting prop simulation: sessions for supplements fail,
|
||||
// but shell stays. Actually with canonical meeting, identity never errors — force
|
||||
// championship/track failures instead, and separately test standalone raw error below.
|
||||
mockSessions.mockRejectedValue(new Error('API 500: sessions boom'))
|
||||
mockHub.mockRejectedValue(new Error('API 429: rate limited'))
|
||||
let sessionCalls = 0
|
||||
mockSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey !== 2) return []
|
||||
sessionCalls += 1
|
||||
if (sessionCalls === 1) throw new Error('API 500: sessions boom')
|
||||
return [
|
||||
session({
|
||||
session_key: 21,
|
||||
session_name: 'Practice 1',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('wk-pre-head')).toBeInTheDocument())
|
||||
expect(screen.getByTestId('wk-pre-head')).toHaveTextContent('Hungarian Grand Prix')
|
||||
expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument()
|
||||
// Nested preview still mounts with canonical meeting; section errors are sanitized.
|
||||
await waitFor(() => expect(screen.getByTestId('preview-page')).toBeInTheDocument())
|
||||
expect(screen.queryByText(/API 500|API 429|sessions boom/i)).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTestId('preview-sessions-error')).toBeInTheDocument())
|
||||
expect(screen.queryByText(/API 500|sessions boom/i)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('preview-sessions-retry'))
|
||||
await waitFor(() => expect(screen.getByTestId('preview-schedule')).toHaveTextContent('Practice 1'))
|
||||
expect(sessionCalls).toBe(2)
|
||||
expect(screen.queryByTestId('preview-sessions-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Limited Retry refetches failed championship/news supplements once', async () => {
|
||||
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
|
||||
let hubCalls = 0
|
||||
let newsCalls = 0
|
||||
mockHub.mockImplementation(async () => {
|
||||
hubCalls += 1
|
||||
if (hubCalls === 1) throw new Error('API 503: hub')
|
||||
return hub
|
||||
})
|
||||
mockNews.mockImplementation(async () => {
|
||||
newsCalls += 1
|
||||
if (newsCalls === 1) throw new Error('API 503: news')
|
||||
return []
|
||||
})
|
||||
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Limited/i))
|
||||
const retry = screen.getByTestId('weekend-data-notice').querySelector('button')!
|
||||
expect(retry).toBeEnabled()
|
||||
fireEvent.click(retry)
|
||||
fireEvent.click(retry)
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('weekend-data-notice')).not.toBeInTheDocument())
|
||||
expect(hubCalls).toBe(2)
|
||||
expect(newsCalls).toBe(2)
|
||||
// Canonical context is not the failed resource — only one initial fetch.
|
||||
expect(mockContext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pre_session keeps shell Partial while disclosing distinct Preview stale', async () => {
|
||||
const next = meeting({
|
||||
meeting_key: 2,
|
||||
meeting_name: 'Hungarian Grand Prix',
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
})
|
||||
mockContext.mockResolvedValue(
|
||||
context({
|
||||
temporal_state: 'pre_session',
|
||||
next_meeting: next,
|
||||
focus_meeting: next,
|
||||
next_session: ctxSession({
|
||||
session: session({
|
||||
session_key: 21,
|
||||
session_name: 'Practice 1',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
}),
|
||||
meeting: next,
|
||||
availability: availability({ freshness: 'partial', local_analysis: 'partial' }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
mockSessions.mockResolvedValue([
|
||||
session({
|
||||
session_key: 21,
|
||||
session_name: 'Practice 1',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
}),
|
||||
])
|
||||
rememberResponseAvailability(hub, { source: 'openf1', freshness: 'stale' })
|
||||
mockHub.mockResolvedValue(hub)
|
||||
|
||||
renderAt('/')
|
||||
await waitFor(() => expect(screen.getByTestId('weekend-data-notice')).toHaveTextContent(/Partial/i))
|
||||
await waitFor(() => expect(screen.getByTestId('preview-data-notice')).toHaveTextContent(/Stale/i))
|
||||
})
|
||||
|
||||
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
||||
import {
|
||||
apiFetch,
|
||||
availabilityAwareStructuralSharing,
|
||||
clearApiFetchInflight,
|
||||
DATA_FRESHNESS_HEADER,
|
||||
DATA_SOURCE_HEADER,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
import {
|
||||
noticeFromFreshness,
|
||||
noticeFromResponse,
|
||||
shouldShowEmbeddedNotice,
|
||||
weekendContextNotice,
|
||||
} from '../lib/availability'
|
||||
import type { WeekendContext } from '../types'
|
||||
@@ -85,6 +88,13 @@ describe('response availability metadata', () => {
|
||||
expect(noticeFromResponse(hub)).toBe('stale')
|
||||
})
|
||||
|
||||
it('dedupes only equivalent embedded Preview notices', () => {
|
||||
expect(shouldShowEmbeddedNotice('stale', 'partial')).toBe(true)
|
||||
expect(shouldShowEmbeddedNotice('partial', 'partial')).toBe(false)
|
||||
expect(shouldShowEmbeddedNotice('stale', null)).toBe(true)
|
||||
expect(shouldShowEmbeddedNotice(null, 'partial')).toBe(false)
|
||||
})
|
||||
|
||||
it('derives Weekend Context notices from typed session freshness, skipping routine local', () => {
|
||||
const context: WeekendContext = {
|
||||
season: 2026,
|
||||
@@ -128,4 +138,84 @@ describe('response availability metadata', () => {
|
||||
}
|
||||
expect(weekendContextNotice(localOnly)).toBeNull()
|
||||
})
|
||||
|
||||
it('updates metadata when structural sharing reuses an equal JSON object', () => {
|
||||
const body = { season: 2026, drivers: [{ points: 1 }] }
|
||||
const first = structuredClone(body)
|
||||
const second = structuredClone(body)
|
||||
rememberResponseAvailability(first, { source: 'openf1', freshness: 'stale' })
|
||||
rememberResponseAvailability(second, { source: 'openf1', freshness: 'fresh' })
|
||||
|
||||
const shared = availabilityAwareStructuralSharing(first, second)
|
||||
expect(shared).toBe(first)
|
||||
expect(getResponseAvailability(shared)).toEqual({ source: 'openf1', freshness: 'fresh' })
|
||||
expect(noticeFromResponse(shared)).toBeNull()
|
||||
|
||||
const third = structuredClone(body)
|
||||
rememberResponseAvailability(third, { source: 'openf1', freshness: 'partial' })
|
||||
const sharedAgain = availabilityAwareStructuralSharing(shared, third)
|
||||
expect(sharedAgain).toBe(first)
|
||||
expect(noticeFromResponse(sharedAgain)).toBe('partial')
|
||||
})
|
||||
|
||||
it('QueryObserver stale→fresh and fresh→partial refetches update metadata and notices', async () => {
|
||||
const body = { season: 2026, drivers: [{ points: 42 }] }
|
||||
const sequence: Array<'stale' | 'fresh' | 'partial'> = ['stale', 'fresh', 'partial']
|
||||
let call = 0
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
const freshness = sequence[Math.min(call, sequence.length - 1)]
|
||||
call += 1
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[DATA_SOURCE_HEADER]: 'openf1',
|
||||
[DATA_FRESHNESS_HEADER]: freshness,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
structuralSharing: availabilityAwareStructuralSharing,
|
||||
},
|
||||
},
|
||||
})
|
||||
const observer = new QueryObserver(client, {
|
||||
queryKey: ['availability-observer'],
|
||||
queryFn: () => apiFetch<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('Weekend pre-session keeps shell usable on Preview supplement failure and recovers', async ({
|
||||
test('Weekend pre-session recovers Preview sessions via Retry and keeps distinct notices', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await stubPreSession(page, { failSessions: true, recover: true })
|
||||
let sessionCalls = 0
|
||||
let allowSessions = false
|
||||
await stubPreSession(page)
|
||||
await page.unroute('**/api/v1/sessions**')
|
||||
await page.route('**/api/v1/sessions**', async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.searchParams.get('meeting_key') === '2') {
|
||||
sessionCalls += 1
|
||||
}
|
||||
if (!allowSessions) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'API 500: forced sessions failure' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
session_key: 21,
|
||||
session_name: 'Practice 1',
|
||||
session_type: 'Practice',
|
||||
meeting_key: 2,
|
||||
date_start: '2026-07-24T09:00:00Z',
|
||||
date_end: '2026-07-24T10:00:00Z',
|
||||
gmt_offset: '',
|
||||
},
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
||||
await expect(page.getByTestId('wk-pre-head')).toContainText('Hungarian Grand Prix')
|
||||
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
|
||||
// Weekend shell owns disclosure — embedded Preview must not stack a second notice.
|
||||
await expect(page.getByTestId('preview-data-notice')).toHaveCount(0)
|
||||
// Shell Partial + Preview championship Stale are distinct — both may show.
|
||||
await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
|
||||
await expect(page.getByTestId('preview-page')).toBeVisible()
|
||||
await expect(page.getByTestId('preview-page')).toHaveAttribute('data-meeting-key', '2')
|
||||
// No raw HTTP jargon in the primary Weekend child.
|
||||
await expect(page.getByTestId('preview-sessions-error')).toBeVisible()
|
||||
await expect(page.locator('body')).not.toContainText(/API 500|forced sessions failure/i)
|
||||
// Title fight remains usable from the shared championship supplement.
|
||||
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
|
||||
|
||||
// Recovery: guarded Retry on the shell notice re-runs context; sessions auto-recover
|
||||
// on React Query retry (recover stub). Shell stays usable either way.
|
||||
const retry = page.getByTestId('weekend-data-notice').getByRole('button', { name: 'Retry' })
|
||||
const failedCalls = sessionCalls
|
||||
expect(failedCalls).toBeGreaterThanOrEqual(1)
|
||||
|
||||
allowSessions = true
|
||||
const retry = page.getByTestId('preview-sessions-retry')
|
||||
await expect(retry).toBeEnabled()
|
||||
await retry.click()
|
||||
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
|
||||
await expect(page.getByTestId('preview-page')).toBeVisible()
|
||||
await expect(page.getByTestId('preview-title-fight-card')).toContainText('VER')
|
||||
await expect(page.getByTestId('preview-schedule')).toContainText('Practice 1')
|
||||
await expect(page.getByTestId('preview-sessions-error')).toHaveCount(0)
|
||||
expect(sessionCalls).toBe(failedCalls + 1)
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `weekend-presession-failure-mobile-${testInfo.project.name}.png`),
|
||||
@@ -222,9 +255,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => {
|
||||
path: path.join(evidenceDir, `weekend-presession-partial-desktop-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
})
|
||||
// Desktop evidence must also keep a single Partial notice vocabulary.
|
||||
await expect(page.getByTestId('weekend-data-notice')).toContainText(/Partial/i)
|
||||
await expect(page.getByTestId('preview-data-notice')).toHaveCount(0)
|
||||
await expect(page.getByTestId('preview-data-notice')).toContainText(/Stale/i)
|
||||
})
|
||||
|
||||
test('Championship discloses stale metadata above usable standings', async ({ page }, testInfo) => {
|
||||
@@ -343,6 +375,8 @@ test.describe('Issue #76 availability / Preview resilience evidence', () => {
|
||||
await expect(page.getByTestId('briefing-data-notice')).toContainText(/Limited/i)
|
||||
await expect(page.getByText('Paddock briefing sample')).toBeVisible()
|
||||
await expect(page.locator('body')).not.toContainText(/API 503/i)
|
||||
const retry = page.getByTestId('briefing-data-notice').getByRole('button', { name: 'Retry' })
|
||||
await expect(retry).toBeEnabled()
|
||||
await page.screenshot({
|
||||
path: path.join(evidenceDir, `briefing-limited-mobile-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
|
||||
@@ -452,4 +452,28 @@ test.describe('Live Timing (no session)', () => {
|
||||
await expect(page.getByTestId('live-feed-health')).toContainText(/reconnecting/i)
|
||||
await expect(page.getByTestId('live-feed-health')).not.toContainText(/feed healthy/i)
|
||||
})
|
||||
|
||||
test('clears fatal initial error once SSE supplies a live snapshot', async ({ page }) => {
|
||||
await page.route('**/api/v1/live/state', (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'API 503: live state unavailable' }),
|
||||
}),
|
||||
)
|
||||
await page.route('**/api/v1/live/stream', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'text/event-stream',
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
body: `event: snapshot\ndata: ${JSON.stringify(raceSnapshot)}\n\n`,
|
||||
}),
|
||||
)
|
||||
|
||||
await page.goto('/live')
|
||||
await expect(page.getByText('Timing Tower')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByTestId('live-initial-error')).toHaveCount(0)
|
||||
await expect(page.getByText(/Live timing unavailable/i)).toHaveCount(0)
|
||||
await expect(page.getByTestId('live-page')).toHaveAttribute('data-phase', /^(live|disconnected)$/)
|
||||
await expect(page.locator('.live-tower')).toContainText('VER')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user