mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Merge pull request #66 from AmanTahiliani/feat/issue-24-rivalry-compare-view
Rivalry compare view (#24)
This commit is contained in:
271
frontend/src/components/RivalryCompare.tsx
Normal file
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { teamColor } from '../utils'
|
||||
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||
import '../styles/rivalry.css'
|
||||
|
||||
const PAD_L = 48
|
||||
const PAD_T = 16
|
||||
const PLOT_W = 882
|
||||
const PLOT_H = 316
|
||||
const GAP_PLOT_H = 200
|
||||
|
||||
function fmtPts(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/** Up to ~7 evenly spaced round labels, always including the last round. */
|
||||
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
|
||||
const step = Math.max(1, Math.ceil(n / 7))
|
||||
const ticks: { x: number; label: string }[] = []
|
||||
for (let i = 0; i < n; i += step) {
|
||||
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||
}
|
||||
const last = labels[n - 1] ?? `R${n}`
|
||||
if (ticks[ticks.length - 1]?.label !== last) {
|
||||
ticks.push({ x: x(n - 1), label: last })
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
|
||||
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
id: 'a' | 'b'
|
||||
label: string
|
||||
drivers: ChampHubDriver[]
|
||||
value: number
|
||||
color: string
|
||||
onChange: (driverNumber: number) => void
|
||||
}
|
||||
|
||||
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
|
||||
return (
|
||||
<label className="rivalry-picker">
|
||||
<span className="rivalry-picker-label">{label}</span>
|
||||
<select
|
||||
className="rivalry-picker-select"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
value={value}
|
||||
data-testid={`rivalry-pick-${id}`}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · {d.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
|
||||
const { drivers } = hub
|
||||
const [pickA, setPickA] = useState<number | null>(null)
|
||||
const [pickB, setPickB] = useState<number | null>(null)
|
||||
|
||||
// Default to the top two in the standings; fall back there if a picked
|
||||
// driver disappears (e.g. season switch re-fetches the hub).
|
||||
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
|
||||
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
|
||||
|
||||
const tally = useMemo(
|
||||
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
|
||||
[a, b, hub.round_labels],
|
||||
)
|
||||
|
||||
if (drivers.length < 2) {
|
||||
return (
|
||||
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
|
||||
Need at least two drivers in the standings to compare a rivalry.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colorA = teamColor(a.team_colour)
|
||||
const colorB = teamColor(b.team_colour)
|
||||
const sameTeam = a.team_name === b.team_name
|
||||
|
||||
const cumA = a.cumulative ?? []
|
||||
const cumB = b.cumulative ?? []
|
||||
const rounds = Math.min(cumA.length, cumB.length)
|
||||
const gaps = gapSeries(cumA, cumB)
|
||||
const lastGap = gaps[gaps.length - 1] ?? 0
|
||||
const strip = tally ? lastRounds(tally, 5) : []
|
||||
|
||||
const pickers = (
|
||||
<div className="rivalry-pickers">
|
||||
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
|
||||
<span className="rivalry-vs mono">vs</span>
|
||||
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (rounds === 0) {
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
<div className="champ-chart-empty">
|
||||
No completed rounds yet — the rivalry will appear after the first race.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Points race scales.
|
||||
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
|
||||
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
|
||||
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
|
||||
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
|
||||
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
|
||||
const pxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
// Gap scales: symmetric around zero.
|
||||
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
|
||||
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
|
||||
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
|
||||
const gxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
|
||||
const gapCaption = gapLeader
|
||||
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
|
||||
: 'Dead level on points.'
|
||||
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
|
||||
<div className="rivalry-h2h" data-testid="rivalry-h2h">
|
||||
<div className="rivalry-h2h-score">
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
|
||||
{a.name_acronym}
|
||||
</span>
|
||||
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
|
||||
{tally ? `${tally.a}–${tally.b}` : '—'}
|
||||
</span>
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
|
||||
{b.name_acronym}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rivalry-h2h-meta">
|
||||
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
|
||||
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
|
||||
</span>
|
||||
{strip.length > 0 && (
|
||||
<div className="rivalry-strip" data-testid="rivalry-strip">
|
||||
<span className="rivalry-strip-label">Last {strip.length}</span>
|
||||
{strip.map((r) => (
|
||||
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
|
||||
<span className="rivalry-chip-round mono">{r.label}</span>
|
||||
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
|
||||
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-points-race">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Points race — {a.name_acronym} vs {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">
|
||||
Rounds 1–{rounds} · {hub.season}
|
||||
</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{pyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{pxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
{[
|
||||
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
|
||||
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
|
||||
].map((s) => (
|
||||
<g key={s.slot}>
|
||||
<polyline
|
||||
points={polyline(s.values, px, py)}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth="2.4"
|
||||
strokeDasharray={s.dash}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
|
||||
<text
|
||||
x={px(s.values.length - 1) + 8}
|
||||
y={py(s.values[s.values.length - 1]) + 4}
|
||||
fill={s.color}
|
||||
className="champ-chart-label"
|
||||
>
|
||||
{s.d.name_acronym}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-gap">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Gap over season — {a.name_acronym} − {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">{gapCaption}</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{gyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line
|
||||
x1={PAD_L}
|
||||
y1={g.y}
|
||||
x2={930}
|
||||
y2={g.y}
|
||||
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
|
||||
strokeWidth={g.label === '0' ? 1.5 : 1}
|
||||
/>
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{gxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
<polyline
|
||||
points={polyline(gaps, px, gy)}
|
||||
fill="none"
|
||||
stroke={colorA}
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="rivalry-caption">
|
||||
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
frontend/src/lib/rivalry.ts
Normal file
61
frontend/src/lib/rivalry.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Pure helpers for the championship rivalry compare view.
|
||||
|
||||
/** One round that counted toward the head-to-head tally. */
|
||||
export interface H2HRound {
|
||||
round: number // 1-based season round
|
||||
label: string
|
||||
posA: number
|
||||
posB: number
|
||||
winner: 'a' | 'b'
|
||||
}
|
||||
|
||||
export interface H2HTally {
|
||||
a: number
|
||||
b: number
|
||||
/** Rounds counted in the tally, in season order. */
|
||||
rounds: H2HRound[]
|
||||
/** Rounds skipped because either driver had no finishing position. */
|
||||
skipped: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-round points gap (a − b) across the rounds both cumulative series
|
||||
* cover. Positive values mean driver A is ahead.
|
||||
*/
|
||||
export function gapSeries(a: number[], b: number[]): number[] {
|
||||
const n = Math.min(a.length, b.length)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Race head-to-head: per round, the lower finishing position wins. Rounds
|
||||
* where either driver has no position (0 / missing) are skipped from the
|
||||
* tally, as are equal positions (defensive — races can't tie).
|
||||
*/
|
||||
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
|
||||
const n = Math.max(posA.length, posB.length)
|
||||
const rounds: H2HRound[] = []
|
||||
let a = 0
|
||||
let b = 0
|
||||
let skipped = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pa = posA[i] ?? 0
|
||||
const pb = posB[i] ?? 0
|
||||
if (pa <= 0 || pb <= 0 || pa === pb) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const winner = pa < pb ? 'a' : 'b'
|
||||
if (winner === 'a') a++
|
||||
else b++
|
||||
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
|
||||
}
|
||||
return { a, b, rounds, skipped }
|
||||
}
|
||||
|
||||
/** Last n counted rounds of a tally, for the mini strip. */
|
||||
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
|
||||
return tally.rounds.slice(-n)
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||
|
||||
const GOLD = '#ffd700'
|
||||
const SILVER = '#c0c0c0'
|
||||
@@ -216,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
>
|
||||
Progression
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||
onClick={() => setView('rivalry')}
|
||||
data-testid="champ-tab-rivalry"
|
||||
>
|
||||
Rivalry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||
@@ -251,6 +260,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
134
frontend/src/styles/rivalry.css
Normal file
134
frontend/src/styles/rivalry.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.rivalry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.rivalry-pickers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rivalry-vs {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.rivalry-picker-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-picker-select {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 13px;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rivalry-picker-select:focus {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.rivalry-h2h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s4) var(--s5);
|
||||
padding: var(--s4) var(--s5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.rivalry-h2h-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-h2h-code {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rivalry-h2h-num {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rivalry-h2h-meta {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rivalry-strip-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin-right: var(--s2);
|
||||
}
|
||||
|
||||
.rivalry-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: var(--s1) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rivalry-chip-round {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-chip-winner {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rivalry-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-caption {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
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,
|
||||
...over,
|
||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 2,
|
||||
wins: 3,
|
||||
cumulative: [18, 36, 54, 80, 120, 160],
|
||||
round_positions: [2, 1, 2, 2, 2, 2],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 16,
|
||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 3,
|
||||
wins: 1,
|
||||
cumulative: [15, 28, 40, 60, 90, 120],
|
||||
round_positions: [3, 3, 3, 0, 3, 3],
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
|
||||
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the rivalry view with the top two drivers preselected', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||
// Default pair is the top two in the standings: VER vs NOR.
|
||||
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||
// VER beats NOR in rounds 1, 3, 5, 6 → 4–2.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the simulator view and projects standings', async () => {
|
||||
renderPage()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
|
||||
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
return {
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 100,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 3,
|
||||
poles: 1,
|
||||
form: [25, 18, 25],
|
||||
cumulative: [25, 43, 68],
|
||||
round_positions: [1, 2, 1],
|
||||
teammate_wins: 3,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
|
||||
return {
|
||||
season: 2025,
|
||||
round: 3,
|
||||
total_rounds: 10,
|
||||
rounds_left: 7,
|
||||
last_race: 'Japan GP',
|
||||
round_labels: ['R1', 'R2', 'R3'],
|
||||
drivers,
|
||||
teams: [],
|
||||
}
|
||||
}
|
||||
|
||||
const hub = makeHub([
|
||||
driver({}),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 90,
|
||||
position: 2,
|
||||
cumulative: [18, 43, 61],
|
||||
round_positions: [2, 1, 2],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 16,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
points: 50,
|
||||
position: 3,
|
||||
cumulative: [15, 28, 40],
|
||||
round_positions: [3, 0, 3],
|
||||
}),
|
||||
])
|
||||
|
||||
describe('RivalryCompare', () => {
|
||||
it('defaults to the top two drivers and shows the H2H score', () => {
|
||||
render(<RivalryCompare hub={hub} />)
|
||||
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||
// VER wins R1 and R3, NOR wins R2.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–1')
|
||||
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||
// Last gap: 68 − 61 = +7 → VER ahead.
|
||||
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recomputes when a different driver is picked and skips missing rounds', () => {
|
||||
render(<RivalryCompare hub={hub} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
|
||||
|
||||
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–0')
|
||||
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state with fewer than two drivers', () => {
|
||||
render(<RivalryCompare hub={makeHub([driver({})])} />)
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
|
||||
})
|
||||
|
||||
it('shows an empty message when no rounds are completed', () => {
|
||||
const empty = makeHub([
|
||||
driver({ cumulative: [], round_positions: [], form: [] }),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
form: [],
|
||||
}),
|
||||
])
|
||||
empty.round = 0
|
||||
empty.round_labels = []
|
||||
|
||||
render(<RivalryCompare hub={empty} />)
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
71
frontend/src/test/rivalry.test.ts
Normal file
71
frontend/src/test/rivalry.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||
|
||||
describe('gapSeries', () => {
|
||||
it('computes per-round a − b', () => {
|
||||
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
|
||||
})
|
||||
|
||||
it('handles negative gaps (b ahead)', () => {
|
||||
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
|
||||
})
|
||||
|
||||
it('truncates to the shorter series', () => {
|
||||
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
|
||||
expect(gapSeries([], [18, 36])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('h2hTally', () => {
|
||||
it('tallies lower-position wins per round', () => {
|
||||
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
|
||||
expect(t.a).toBe(2)
|
||||
expect(t.b).toBe(1)
|
||||
expect(t.skipped).toBe(0)
|
||||
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
|
||||
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
|
||||
})
|
||||
|
||||
it('skips rounds where either driver has no position', () => {
|
||||
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
|
||||
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(1)
|
||||
expect(t.skipped).toBe(2)
|
||||
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
|
||||
})
|
||||
|
||||
it('handles arrays of different lengths (missing tail = skipped)', () => {
|
||||
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(0)
|
||||
expect(t.skipped).toBe(2)
|
||||
})
|
||||
|
||||
it('skips equal positions defensively and falls back on labels', () => {
|
||||
const t = h2hTally([2, 1], [2, 3], ['R1'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(0)
|
||||
expect(t.skipped).toBe(1)
|
||||
expect(t.rounds[0].label).toBe('R2')
|
||||
})
|
||||
|
||||
it('returns an empty tally for empty inputs', () => {
|
||||
const t = h2hTally([], [], [])
|
||||
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastRounds', () => {
|
||||
it('returns the last n counted rounds in order', () => {
|
||||
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
|
||||
const last = lastRounds(t, 5)
|
||||
expect(last).toHaveLength(5)
|
||||
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
it('returns fewer when the tally has fewer counted rounds', () => {
|
||||
const t = h2hTally([1, 0], [2, 0], [])
|
||||
expect(lastRounds(t, 5)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface ChampHubDriver {
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_positions: number[]
|
||||
teammate_wins: number
|
||||
teammate_losses: number
|
||||
}
|
||||
|
||||
@@ -614,8 +614,9 @@ type champHubDriver struct {
|
||||
Wins int `json:"wins"`
|
||||
Podiums int `json:"podiums"`
|
||||
Poles int `json:"poles"`
|
||||
Form []float64 `json:"form"` // last 5 races' points
|
||||
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
||||
Form []float64 `json:"form"` // last 5 races' points
|
||||
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
||||
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
|
||||
TeammateWins int `json:"teammate_wins"`
|
||||
TeammateLosses int `json:"teammate_losses"`
|
||||
}
|
||||
@@ -995,6 +996,10 @@ func aggregateChampionshipHub(
|
||||
if len(form) > 5 {
|
||||
form = form[len(form)-5:]
|
||||
}
|
||||
roundPositions := make([]int, completed)
|
||||
for round := 1; round <= completed; round++ {
|
||||
roundPositions[round-1] = a.finishByRound[round]
|
||||
}
|
||||
info := driverInfo[c.DriverNumber]
|
||||
drivers = append(drivers, champHubDriver{
|
||||
DriverNumber: c.DriverNumber,
|
||||
@@ -1009,6 +1014,7 @@ func aggregateChampionshipHub(
|
||||
Poles: a.poles,
|
||||
Form: form,
|
||||
Cumulative: cumulative[c.DriverNumber],
|
||||
RoundPositions: roundPositions,
|
||||
TeammateWins: twins[c.DriverNumber],
|
||||
TeammateLosses: tloss[c.DriverNumber],
|
||||
})
|
||||
|
||||
@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
|
||||
}
|
||||
|
||||
champ := []models.ChampionshipDriver{
|
||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
||||
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
|
||||
}
|
||||
teams := []models.ChampionshipTeam{
|
||||
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
||||
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
||||
}
|
||||
|
||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
|
||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
|
||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
|
||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
|
||||
races := []meetingRace{
|
||||
{
|
||||
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
||||
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
||||
},
|
||||
// Round 3: not yet run (no results) — should not count as completed.
|
||||
@@ -67,9 +69,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||
}
|
||||
|
||||
// Drivers are sorted by official position: VER, HAM, PER.
|
||||
if len(resp.Drivers) != 3 {
|
||||
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
|
||||
// Drivers are sorted by official position: VER, HAM, PER, NOR.
|
||||
if len(resp.Drivers) != 4 {
|
||||
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
|
||||
}
|
||||
ver := resp.Drivers[0]
|
||||
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
||||
@@ -91,6 +93,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
||||
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
||||
}
|
||||
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
|
||||
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
|
||||
}
|
||||
// VER beat teammate PER in both rounds.
|
||||
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
||||
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
||||
@@ -109,6 +114,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
if per.Poles != 0 {
|
||||
t.Errorf("PER poles = %d, want 0", per.Poles)
|
||||
}
|
||||
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
|
||||
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
|
||||
}
|
||||
|
||||
// HAM has no teammate in the data — no h2h recorded.
|
||||
var ham champHubDriver
|
||||
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
||||
}
|
||||
|
||||
// NOR missed round 1 — position 0 marks the absent round.
|
||||
var nor champHubDriver
|
||||
for _, d := range resp.Drivers {
|
||||
if d.NameAcronym == "NOR" {
|
||||
nor = d
|
||||
}
|
||||
}
|
||||
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
|
||||
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
|
||||
}
|
||||
|
||||
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
||||
if len(resp.Teams) != 2 {
|
||||
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
||||
|
||||
Reference in New Issue
Block a user