mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(frontend): annotate key numbers with meaning (#18)
Add shared Meaning primitive and pure interpretation helpers for interval, tyre age, and championship gap columns on the live tower, tyre deg panel, and championship hub. Thresholds are exported consts (undercut window tied to PIT_LOSS_SECONDS from #13). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
frontend/src/components/Meaning.tsx
Normal file
26
frontend/src/components/Meaning.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import '../styles/meaning.css'
|
||||
|
||||
export interface MeaningProps {
|
||||
value: ReactNode
|
||||
meaning?: string | null
|
||||
/** Long-form explanation for the native tooltip; falls back to meaning. */
|
||||
title?: string | null
|
||||
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||
}
|
||||
|
||||
export function Meaning({ value, meaning, title, tone }: MeaningProps) {
|
||||
if (!meaning) {
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
const tooltip = title ?? meaning
|
||||
const toneClass = tone ? `meaning-caption--${tone}` : ''
|
||||
|
||||
return (
|
||||
<span className="meaning" title={tooltip}>
|
||||
<span className="meaning-value">{value}</span>
|
||||
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
tyreLabel,
|
||||
} from '../../lib/live'
|
||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
||||
import { parseIntervalSeconds } from '../../lib/gapHistory'
|
||||
import { intervalMeaning } from '../../lib/meaning'
|
||||
import { Meaning } from '../Meaning'
|
||||
import { GapSparkline } from './GapSparkline'
|
||||
import { StintHistory } from './StintHistory'
|
||||
import { Pin } from 'lucide-react'
|
||||
@@ -100,7 +103,11 @@ export function TimingTower({
|
||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||
|
||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||
|
||||
const intervalAnnotation =
|
||||
gapMode === 'interval' && isRace && row.Position > 1
|
||||
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||
: null
|
||||
|
||||
const renderSector = (idx: number) => {
|
||||
const sec = driver.Sectors?.[idx]
|
||||
if (!sec) return '-'
|
||||
@@ -149,7 +156,14 @@ export function TimingTower({
|
||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||
{driver.LastLapTime || '-'}
|
||||
</td>
|
||||
<td className="mono">{gapText || '-'}</td>
|
||||
<td className="mono">
|
||||
<Meaning
|
||||
value={gapText || '-'}
|
||||
meaning={intervalAnnotation?.caption}
|
||||
title={intervalAnnotation?.title}
|
||||
tone={intervalAnnotation?.tone}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{isRace && (
|
||||
<td className="spark-cell">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
recordStintSamples,
|
||||
stintInputFromRow,
|
||||
} from '../../lib/tyredeg'
|
||||
import { tyreAgeMeaning } from '../../lib/meaning'
|
||||
import { Meaning } from '../Meaning'
|
||||
import '../../styles/tyredeg.css'
|
||||
|
||||
const TOP_DRIVER_COUNT = 10
|
||||
@@ -92,12 +94,20 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
{visible.map((row) => {
|
||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||
return (
|
||||
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
||||
<span className="tyredeg-pos mono">P{row.Position}</span>
|
||||
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
||||
<span className="drv-code">{driverCode(row)}</span>
|
||||
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>{tyreLabel(row.Tyre)}</span>
|
||||
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
|
||||
<Meaning
|
||||
value={tyreLabel(row.Tyre)}
|
||||
meaning={ageAnnotation?.caption}
|
||||
title={ageAnnotation?.title}
|
||||
tone={ageAnnotation?.tone}
|
||||
/>
|
||||
</span>
|
||||
{model ? (
|
||||
<>
|
||||
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
||||
|
||||
146
frontend/src/lib/meaning.ts
Normal file
146
frontend/src/lib/meaning.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Pure interpretation helpers for pairing numbers with their "so-what".
|
||||
// Thresholds are exported consts so they are cheap to tune in one place.
|
||||
|
||||
import { PIT_LOSS_SECONDS } from './tyredeg'
|
||||
|
||||
/** Gaps under this (seconds) are DRS attack range. */
|
||||
export const INTERVAL_DRS_MAX_SECONDS = 1.0
|
||||
|
||||
/** Lower bound of the undercut window (seconds); contiguous with DRS range. */
|
||||
export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS
|
||||
|
||||
/**
|
||||
* Upper bound of the undercut window (seconds). Kept well below typical pit
|
||||
* loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy.
|
||||
*/
|
||||
export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7)
|
||||
|
||||
/**
|
||||
* Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate
|
||||
* values are conservative — deg varies wildly with conditions.
|
||||
*/
|
||||
export const TYRE_CLIFF_LAPS: Readonly<Record<string, number>> = {
|
||||
SOFT: 18,
|
||||
MEDIUM: 28,
|
||||
HARD: 38,
|
||||
INTERMEDIATE: 20,
|
||||
WET: 15,
|
||||
}
|
||||
|
||||
/** Default cliff when compound is unknown. */
|
||||
export const TYRE_CLIFF_DEFAULT_LAPS = 25
|
||||
|
||||
/** Championship max points per race (winner). */
|
||||
export const MAX_POINTS_PER_ROUND = 25
|
||||
|
||||
export interface MeaningAnnotation {
|
||||
caption: string
|
||||
title: string
|
||||
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||
}
|
||||
|
||||
function cliffLaps(compound: string | null | undefined): number {
|
||||
if (!compound) return TYRE_CLIFF_DEFAULT_LAPS
|
||||
return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS
|
||||
}
|
||||
|
||||
/**
|
||||
* Interval / gap-to-ahead meaning for the live timing tower.
|
||||
* Returns null for leader gaps, out-of-range values, or unparsable input.
|
||||
*/
|
||||
export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null
|
||||
|
||||
if (seconds < INTERVAL_DRS_MAX_SECONDS) {
|
||||
return {
|
||||
caption: 'DRS range',
|
||||
title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`,
|
||||
tone: 'good',
|
||||
}
|
||||
}
|
||||
|
||||
if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) {
|
||||
return {
|
||||
caption: 'undercut window',
|
||||
title: `${INTERVAL_UNDERCUT_MIN_SECONDS}–${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`,
|
||||
tone: 'warn',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tyre-age meaning for deg / stint panels.
|
||||
*/
|
||||
export function tyreAgeMeaning(
|
||||
compound: string | null | undefined,
|
||||
age: number | null | undefined,
|
||||
): MeaningAnnotation | null {
|
||||
if (age == null || !Number.isFinite(age) || age < 0) return null
|
||||
|
||||
const cliff = cliffLaps(compound)
|
||||
const freshEnd = Math.ceil(cliff * 0.25)
|
||||
const midEnd = Math.ceil(cliff * 0.65)
|
||||
|
||||
if (age <= freshEnd) {
|
||||
return {
|
||||
caption: 'fresh',
|
||||
title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`,
|
||||
tone: 'good',
|
||||
}
|
||||
}
|
||||
|
||||
if (age <= midEnd) {
|
||||
return {
|
||||
caption: 'mid-life',
|
||||
title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
const lapsToCliff = cliff - age
|
||||
if (lapsToCliff <= 0) {
|
||||
return {
|
||||
caption: 'past cliff',
|
||||
title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`,
|
||||
tone: 'bad',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
caption: `~${lapsToCliff} laps to cliff`,
|
||||
title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`,
|
||||
tone: 'warn',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Points gap to the driver directly ahead — catchable-or-not v1.
|
||||
*/
|
||||
export function pointsGapMeaning(
|
||||
gapToAhead: number | null | undefined,
|
||||
roundsLeft: number,
|
||||
driverAhead?: string | null,
|
||||
): MeaningAnnotation | null {
|
||||
if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null
|
||||
if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null
|
||||
|
||||
const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND
|
||||
const ahead = driverAhead?.trim() || 'ahead'
|
||||
|
||||
if (gapToAhead > maxCatchable) {
|
||||
return {
|
||||
caption: 'out of reach',
|
||||
title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`,
|
||||
tone: 'bad',
|
||||
}
|
||||
}
|
||||
|
||||
const perRound = Math.ceil(gapToAhead / roundsLeft)
|
||||
return {
|
||||
caption: `~${perRound} pts/round`,
|
||||
title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`,
|
||||
tone: perRound <= 10 ? 'good' : 'warn',
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||
|
||||
@@ -138,6 +140,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
color: teamColor(d.team_colour),
|
||||
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
||||
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
||||
gapAheadNum,
|
||||
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
|
||||
spark: sparkPoints(d.form),
|
||||
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
||||
h2hWin: d.teammate_wins >= d.teammate_losses,
|
||||
@@ -233,7 +237,12 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
</div>
|
||||
|
||||
{view === 'drivers' && (
|
||||
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
|
||||
<DriversView
|
||||
enriched={enriched}
|
||||
leaderPoints={leader.points}
|
||||
titleMath={titleMath}
|
||||
roundsLeft={hub.rounds_left}
|
||||
/>
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||
@@ -248,6 +257,8 @@ interface EnrichedDriver {
|
||||
color: string
|
||||
gapLeader: string
|
||||
gapAhead: string
|
||||
gapAheadNum: number | null
|
||||
driverAhead: string | null
|
||||
spark: string
|
||||
h2h: string
|
||||
h2hWin: boolean
|
||||
@@ -259,10 +270,12 @@ function DriversView({
|
||||
enriched,
|
||||
leaderPoints,
|
||||
titleMath,
|
||||
roundsLeft,
|
||||
}: {
|
||||
enriched: EnrichedDriver[]
|
||||
leaderPoints: number
|
||||
titleMath: string
|
||||
roundsLeft: number
|
||||
}) {
|
||||
const podium = enriched.slice(0, 3)
|
||||
return (
|
||||
@@ -357,7 +370,19 @@ function DriversView({
|
||||
<td className="champ-td-team">{e.d.team_name}</td>
|
||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
||||
<td className="r mono champ-td-dim">{e.gapAhead}</td>
|
||||
<td className="r mono champ-td-dim">
|
||||
{(() => {
|
||||
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
|
||||
return (
|
||||
<Meaning
|
||||
value={e.gapAhead}
|
||||
meaning={gapAnnotation?.caption}
|
||||
title={gapAnnotation?.title}
|
||||
tone={gapAnnotation?.tone}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||
{e.d.wins}
|
||||
</td>
|
||||
|
||||
48
frontend/src/styles/meaning.css
Normal file
48
frontend/src/styles/meaning.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/* Compact value + muted meaning caption (issue #18). */
|
||||
|
||||
.meaning {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: inherit;
|
||||
gap: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meaning-value {
|
||||
/* inherits table cell mono styling from parent */
|
||||
}
|
||||
|
||||
.meaning-caption {
|
||||
font-size: 10px;
|
||||
font-family: var(--f-mono);
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meaning-caption--good {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.meaning-caption--bad {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.meaning-caption--warn {
|
||||
color: var(--yellow, #e8c547);
|
||||
}
|
||||
|
||||
.meaning-caption--neutral {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Table cells: right-align caption under numeric values */
|
||||
td.r .meaning,
|
||||
.champ-td-dim .meaning,
|
||||
.champ-td-muted .meaning {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tyredeg-row .meaning {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -112,6 +112,7 @@ describe('ChampionshipPage', () => {
|
||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
||||
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('switches to constructors and progression views', async () => {
|
||||
|
||||
@@ -189,6 +189,26 @@ describe('TimingTower', () => {
|
||||
expect(screen.getByText(/no driver timing rows/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('annotates DRS-range intervals in race mode', () => {
|
||||
const raceRows = [
|
||||
makeRow('1', 1, 'VER'),
|
||||
makeRow('4', 2, 'NOR', { Interval: '+0.4', GapToLeader: '+0.4' }),
|
||||
]
|
||||
render(
|
||||
<TimingTower
|
||||
rows={raceRows}
|
||||
session={{
|
||||
MeetingName: 'Monaco Grand Prix',
|
||||
CircuitName: 'Monaco',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('DRS range')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the SQ1 cutoff after P17 and marks rows below as at risk', () => {
|
||||
const sprintRows = Array.from({ length: 22 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
|
||||
26
frontend/src/test/Meaning.test.tsx
Normal file
26
frontend/src/test/Meaning.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
|
||||
describe('Meaning', () => {
|
||||
it('renders bare value when meaning is null', () => {
|
||||
render(<Meaning value="+1.234" meaning={null} />)
|
||||
expect(screen.getByText('+1.234')).toBeInTheDocument()
|
||||
expect(screen.queryByText('DRS range')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders value with caption and tooltip title', () => {
|
||||
render(
|
||||
<Meaning
|
||||
value="+0.4"
|
||||
meaning="DRS range"
|
||||
title="Within 1.0s — DRS enabled next straight"
|
||||
tone="good"
|
||||
/>,
|
||||
)
|
||||
const value = screen.getByText('+0.4')
|
||||
expect(value).toBeInTheDocument()
|
||||
expect(screen.getByText('DRS range')).toHaveClass('meaning-caption--good')
|
||||
expect(value.closest('.meaning')).toHaveAttribute('title', 'Within 1.0s — DRS enabled next straight')
|
||||
})
|
||||
})
|
||||
@@ -52,9 +52,18 @@ describe('TyreDegPanel', () => {
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
expect(panel).toHaveTextContent('VER')
|
||||
expect(panel).toHaveTextContent('M +5')
|
||||
expect(panel).toHaveTextContent('fresh')
|
||||
expect(panel).toHaveTextContent('warming up')
|
||||
})
|
||||
|
||||
it('annotates tyre age meaning on stint rows', () => {
|
||||
const rows = [
|
||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||
]
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders slope and rejoin estimate once laps accumulate across snapshots', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
|
||||
71
frontend/src/test/meaning.test.ts
Normal file
71
frontend/src/test/meaning.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INTERVAL_DRS_MAX_SECONDS,
|
||||
INTERVAL_UNDERCUT_MAX_SECONDS,
|
||||
INTERVAL_UNDERCUT_MIN_SECONDS,
|
||||
MAX_POINTS_PER_ROUND,
|
||||
TYRE_CLIFF_LAPS,
|
||||
intervalMeaning,
|
||||
pointsGapMeaning,
|
||||
tyreAgeMeaning,
|
||||
} from '../lib/meaning'
|
||||
|
||||
describe('intervalMeaning', () => {
|
||||
it('returns DRS range below the threshold', () => {
|
||||
expect(intervalMeaning(0.4)?.caption).toBe('DRS range')
|
||||
expect(intervalMeaning(INTERVAL_DRS_MAX_SECONDS - 0.01)?.caption).toBe('DRS range')
|
||||
})
|
||||
|
||||
it('returns undercut window in the middle band', () => {
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MIN_SECONDS)?.caption).toBe('undercut window')
|
||||
expect(intervalMeaning(2.0)?.caption).toBe('undercut window')
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS)?.caption).toBe('undercut window')
|
||||
})
|
||||
|
||||
it('returns null outside known bands', () => {
|
||||
expect(intervalMeaning(INTERVAL_UNDERCUT_MAX_SECONDS + 0.5)).toBeNull()
|
||||
expect(intervalMeaning(10)).toBeNull()
|
||||
expect(intervalMeaning(null)).toBeNull()
|
||||
expect(intervalMeaning(undefined)).toBeNull()
|
||||
expect(intervalMeaning(-1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tyreAgeMeaning', () => {
|
||||
it('labels fresh, mid-life, and laps-to-cliff for SOFT', () => {
|
||||
const cliff = TYRE_CLIFF_LAPS.SOFT
|
||||
expect(tyreAgeMeaning('SOFT', 2)?.caption).toBe('fresh')
|
||||
expect(tyreAgeMeaning('SOFT', Math.ceil(cliff * 0.5))?.caption).toBe('mid-life')
|
||||
expect(tyreAgeMeaning('SOFT', cliff - 2)?.caption).toBe('~2 laps to cliff')
|
||||
expect(tyreAgeMeaning('SOFT', cliff + 5)?.caption).toBe('past cliff')
|
||||
})
|
||||
|
||||
it('handles unknown compounds with defaults', () => {
|
||||
expect(tyreAgeMeaning('UNKNOWN', 3)?.caption).toBe('fresh')
|
||||
expect(tyreAgeMeaning(undefined, 3)?.caption).toBe('fresh')
|
||||
})
|
||||
|
||||
it('returns null for invalid age', () => {
|
||||
expect(tyreAgeMeaning('MEDIUM', null)).toBeNull()
|
||||
expect(tyreAgeMeaning('MEDIUM', -1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsGapMeaning', () => {
|
||||
it('computes catchable pts/round', () => {
|
||||
const result = pointsGapMeaning(40, 4, 'VER')
|
||||
expect(result?.caption).toBe('~10 pts/round')
|
||||
expect(result?.title).toContain('VER')
|
||||
})
|
||||
|
||||
it('marks uncatchable gaps', () => {
|
||||
const max = 3 * MAX_POINTS_PER_ROUND
|
||||
expect(pointsGapMeaning(max + 1, 3, 'VER')?.caption).toBe('out of reach')
|
||||
})
|
||||
|
||||
it('returns null for leader or invalid input', () => {
|
||||
expect(pointsGapMeaning(0, 4)).toBeNull()
|
||||
expect(pointsGapMeaning(10, 0)).toBeNull()
|
||||
expect(pointsGapMeaning(null, 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user