diff --git a/frontend/src/components/ChampionshipSimulator.tsx b/frontend/src/components/ChampionshipSimulator.tsx new file mode 100644 index 0000000..7bb449d --- /dev/null +++ b/frontend/src/components/ChampionshipSimulator.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ChampionshipHub } from '../types' +import { teamColor } from '../utils' +import { + assignPosition, + defaultRound, + defaultScenario, + normalizeScenario, + pointsForPosition, + projectStandings, +} from '../lib/simulator' +import type { Scenario } from '../lib/simulator' + +const STORAGE_PREFIX = 'box-box.champ.sim' + +function storageKey(season: number): string { + return `${STORAGE_PREFIX}.${season}` +} + +function loadScenario(hub: ChampionshipHub): Scenario { + try { + const raw = window.localStorage.getItem(storageKey(hub.season)) + if (!raw) return defaultScenario(hub.drivers, hub.rounds_left) + return normalizeScenario(JSON.parse(raw), hub.drivers, hub.rounds_left) + } catch { + return defaultScenario(hub.drivers, hub.rounds_left) + } +} + +function saveScenario(season: number, scenario: Scenario) { + try { + window.localStorage.setItem(storageKey(season), JSON.stringify(scenario)) + } catch { + // storage unavailable (private mode, quota) — simulator still works in memory + } +} + +/** Label for the i-th remaining round (0-based), e.g. "R7" or "Round 7". */ +function roundLabel(hub: ChampionshipHub, index: number): string { + return hub.round_labels[hub.round + index] ?? `Round ${hub.round + index + 1}` +} + +function fmtPts(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(1) +} + +export function ChampionshipSimulator({ hub }: { hub: ChampionshipHub }) { + const [scenario, setScenario] = useState(() => loadScenario(hub)) + const [selected, setSelected] = useState(0) + + // Reload when the season changes (new hub, new storage key). + useEffect(() => { + setScenario(loadScenario(hub)) + setSelected(0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hub.season, hub.rounds_left]) + + useEffect(() => { + if (scenario.length > 0) saveScenario(hub.season, scenario) + }, [hub.season, scenario]) + + const projected = useMemo( + () => projectStandings(hub.drivers, scenario, hub.rounds_left), + [hub.drivers, scenario, hub.rounds_left], + ) + + if (hub.rounds_left <= 0 || hub.drivers.length === 0 || scenario.length === 0) { + return ( +
+ Season complete — nothing left to simulate. +
+ ) + } + + const roundIdx = Math.min(selected, scenario.length - 1) + const round = scenario[roundIdx] + + const setRound = (nextRound: (number | null)[]) => { + setScenario((prev) => prev.map((r, i) => (i === roundIdx ? nextRound : r))) + } + + const driverByNumber = new Map(hub.drivers.map((d) => [d.driver_number, d])) + + return ( +
+
+ What-if simulator — remaining rounds + +
+ +
+ {scenario.map((_, i) => ( + + ))} +
+ +
+
+
+ {roundLabel(hub, roundIdx)} finishing order + +
+ {round.map((driverNumber, p) => { + const driver = driverNumber != null ? driverByNumber.get(driverNumber) : undefined + return ( +
+ P{p + 1} + + + +{pointsForPosition(p + 1)} +
+ ) + })} +
+ +
+
+ Projected standings +
+
+ + + + + + + + + + + + + + {projected.map((row) => { + const d = row.driver + const moved = row.delta !== 0 + return ( + + + + + + + + + + ) + })} + +
PosΔDriverNow+SimProjTitle
+ P{row.projectedPosition} + + {row.delta > 0 && ( + ▲{row.delta} + )} + {row.delta < 0 && ( + ▼{-row.delta} + )} + {row.delta === 0 && } + +
+ + {d.name_acronym} + {d.full_name} +
+
{fmtPts(row.currentPoints)}+{fmtPts(row.simPoints)}{fmtPts(row.projectedPoints)} + + {row.titleAlive ? 'ALIVE' : 'OUT'} + +
+
+
+
+ +

+ Simplified model: every remaining round is scored as a standard Grand Prix + (25-18-15-12-10-8-6-4-2-1, no fastest-lap point). Sprint weekends are ignored. Title status + uses the max-points-remaining bound against the leader's projected total. +

+
+ ) +} diff --git a/frontend/src/lib/simulator.ts b/frontend/src/lib/simulator.ts new file mode 100644 index 0000000..a18d73f --- /dev/null +++ b/frontend/src/lib/simulator.ts @@ -0,0 +1,183 @@ +// Championship simulator: project the drivers' championship over the +// remaining rounds from per-round finishing assignments. +// Pure functions only — no React, no side effects — so everything is unit-testable. + +import type { ChampHubDriver } from '../types' + +/** Current F1 points system for P1–P10. No fastest-lap point (dropped in 2025). */ +export const POINTS_BY_POSITION = [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] as const + +/** Number of points-scoring positions per round. */ +export const SCORING_POSITIONS = POINTS_BY_POSITION.length + +/** Maximum points a single driver can take from one round. */ +export const MAX_POINTS_PER_ROUND = POINTS_BY_POSITION[0] + +/** + * One remaining round: driver numbers assigned to P1–P10 (index 0 = P1). + * `null` means the position is unassigned (nobody scores those points). + */ +export type RoundAssignment = (number | null)[] + +/** One assignment per remaining round, in chronological order. */ +export type Scenario = RoundAssignment[] + +/** Points for a 1-based finishing position; 0 outside the top ten. */ +export function pointsForPosition(position: number): number { + if (!Number.isInteger(position) || position < 1 || position > SCORING_POSITIONS) return 0 + return POINTS_BY_POSITION[position - 1] +} + +/** An empty round: all ten scoring positions unassigned. */ +export function emptyRound(): RoundAssignment { + return Array.from({ length: SCORING_POSITIONS }, () => null) +} + +/** Drivers sorted by current championship order (points desc, position asc). */ +function championshipOrder(drivers: ReadonlyArray): ChampHubDriver[] { + return [...drivers].sort((a, b) => b.points - a.points || a.position - b.position) +} + +/** + * Default assignment for one round: the top ten drivers in current + * championship order finish P1–P10. + */ +export function defaultRound(drivers: ReadonlyArray): RoundAssignment { + const ordered = championshipOrder(drivers) + const round = emptyRound() + for (let i = 0; i < SCORING_POSITIONS; i++) { + round[i] = ordered[i]?.driver_number ?? null + } + return round +} + +/** Default scenario: every remaining round finishes in current championship order. */ +export function defaultScenario(drivers: ReadonlyArray, roundsLeft: number): Scenario { + const rounds = Math.max(0, Math.floor(roundsLeft) || 0) + return Array.from({ length: rounds }, () => defaultRound(drivers)) +} + +/** + * Validate an untrusted (e.g. localStorage) scenario against the current hub. + * Rounds with the wrong shape, unknown driver numbers, or duplicate drivers + * fall back to the default round; the scenario is trimmed/padded to + * `roundsLeft`. Never throws. + */ +export function normalizeScenario( + raw: unknown, + drivers: ReadonlyArray, + roundsLeft: number, +): Scenario { + const fallback = defaultScenario(drivers, roundsLeft) + if (!Array.isArray(raw)) return fallback + + const known = new Set(drivers.map((d) => d.driver_number)) + return fallback.map((defRound, i) => { + const candidate = raw[i] + if (!Array.isArray(candidate) || candidate.length !== SCORING_POSITIONS) return defRound + const seen = new Set() + const round = emptyRound() + for (let p = 0; p < SCORING_POSITIONS; p++) { + const v = candidate[p] + if (v === null) continue + if (typeof v !== 'number' || !known.has(v) || seen.has(v)) return defRound + seen.add(v) + round[p] = v + } + return round + }) +} + +/** Total simulated (extra) points per driver number across the scenario. */ +export function simulatedPoints(scenario: Scenario): Map { + const totals = new Map() + for (const round of scenario) { + if (!Array.isArray(round)) continue + for (let p = 0; p < Math.min(round.length, SCORING_POSITIONS); p++) { + const driverNumber = round[p] + if (driverNumber == null) continue + totals.set(driverNumber, (totals.get(driverNumber) ?? 0) + POINTS_BY_POSITION[p]) + } + } + return totals +} + +export interface ProjectedDriver { + driver: ChampHubDriver + currentPoints: number + simPoints: number + projectedPoints: number + currentPosition: number + projectedPosition: number + /** Positive = moved up the standings, negative = dropped. */ + delta: number + /** Mathematically alive for the title under the max-points-remaining bound. */ + titleAlive: boolean +} + +/** + * Project final standings from current standings plus a scenario. + * Ties on projected points keep the driver with the better current position ahead. + * + * Title math: a driver is mathematically alive if + * current points + 25 × rounds left ≥ current leader's points under the scenario. + * The current leader is always alive by this bound. + */ +export function projectStandings( + drivers: ReadonlyArray, + scenario: Scenario, + roundsLeft: number, +): ProjectedDriver[] { + if (drivers.length === 0) return [] + + const extras = simulatedPoints(scenario) + const ordered = championshipOrder(drivers) + const leader = ordered[0] + const leaderProjected = leader.points + (extras.get(leader.driver_number) ?? 0) + const maxRemaining = MAX_POINTS_PER_ROUND * Math.max(0, roundsLeft) + + const rows = ordered.map((driver, i) => { + const simPoints = extras.get(driver.driver_number) ?? 0 + return { + driver, + currentPoints: driver.points, + simPoints, + projectedPoints: driver.points + simPoints, + currentPosition: i + 1, + projectedPosition: 0, + delta: 0, + titleAlive: driver.points + maxRemaining >= leaderProjected, + } + }) + + rows.sort( + (a, b) => b.projectedPoints - a.projectedPoints || a.currentPosition - b.currentPosition, + ) + rows.forEach((row, i) => { + row.projectedPosition = i + 1 + row.delta = row.currentPosition - row.projectedPosition + }) + return rows +} + +/** + * Assign a driver to a position within one round, returning a new round. + * The driver is removed from any other position it held; assigning `null` + * clears the slot. + */ +export function assignPosition( + round: RoundAssignment, + positionIndex: number, + driverNumber: number | null, +): RoundAssignment { + const next = round.slice(0, SCORING_POSITIONS) + while (next.length < SCORING_POSITIONS) next.push(null) + if (positionIndex < 0 || positionIndex >= SCORING_POSITIONS) return next + if (driverNumber != null) { + for (let p = 0; p < next.length; p++) { + if (next[p] === driverNumber) next[p] = null + } + } + next[positionIndex] = driverNumber + return next +} diff --git a/frontend/src/pages/ChampionshipPage.tsx b/frontend/src/pages/ChampionshipPage.tsx index bdd1dc3..d1c293e 100644 --- a/frontend/src/pages/ChampionshipPage.tsx +++ b/frontend/src/pages/ChampionshipPage.tsx @@ -3,8 +3,9 @@ import { useQuery } from '@tanstack/react-query' import { fetchChampionshipHub, fetchSeasons } from '../api' import { teamColor } from '../utils' import type { ChampHubDriver, ChampionshipHub } from '../types' +import { ChampionshipSimulator } from '../components/ChampionshipSimulator' -type View = 'drivers' | 'constructors' | 'progression' +type View = 'drivers' | 'constructors' | 'progression' | 'simulator' const GOLD = '#ffd700' const SILVER = '#c0c0c0' @@ -208,6 +209,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { > Progression + @@ -228,6 +237,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) { )} {view === 'constructors' && } {view === 'progression' && } + {view === 'simulator' && } ) } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index be335e0..eedbc9a 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -3898,3 +3898,148 @@ a { color: inherit; text-decoration: none; } grid-template-columns: 1fr; } } + +/* ══════════════════════ Championship Simulator ══════════════════════ */ +.champ-sim { + margin-top: 20px; +} +.champ-sim-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); +} +.champ-sim-btn { + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + font-family: var(--f-ui); + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 2px; + cursor: pointer; +} +.champ-sim-btn:hover { + color: var(--text); + border-color: var(--border-2); +} +.champ-sim-rounds { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 12px; +} +.champ-sim-round { + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + font-family: var(--f-mono); + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 2px; + cursor: pointer; +} +.champ-sim-round:hover { + color: var(--text); +} +.champ-sim-round.is-active { + background: var(--red); + border-color: var(--red); + color: #fff; + font-weight: 700; +} +.champ-sim-grid { + display: grid; + grid-template-columns: 340px 1fr; + gap: 14px; + margin-top: 14px; + align-items: start; +} +.champ-sim-editor { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + padding: 10px 12px 12px; +} +.champ-sim-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + margin-bottom: 8px; +} +.champ-sim-editor-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); +} +.champ-sim-slot { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} +.champ-sim-slot-pos { + width: 26px; + font-size: 11px; + font-weight: 700; + color: var(--text-2); +} +.champ-sim-slot-bar { + width: 3px; + height: 16px; + border-radius: 1px; + flex: none; +} +.champ-sim-select { + flex: 1; + min-width: 0; + padding: 4px 6px; + font-size: 11px; + font-family: var(--f-mono); + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; +} +.champ-sim-select:focus { + outline: none; + border-color: var(--border-2); +} +.champ-sim-slot-pts { + width: 32px; + text-align: right; + font-size: 11px; + color: var(--text-3); +} +.champ-sim-table-wrap .champ-scroll { + margin-top: 0; +} +.champ-sim-delta { + font-size: 11px; + font-weight: 700; +} +.champ-sim-delta.up { + color: var(--green); +} +.champ-sim-delta.down { + color: var(--red); +} +.champ-sim-row-moved td { + background: rgba(255, 255, 255, 0.03); +} +.champ-sim-caption { + margin-top: 12px; + font-size: 11px; + color: var(--text-3); + max-width: 720px; +} +@media (max-width: 860px) { + .champ-sim-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/test/ChampionshipPage.test.tsx b/frontend/src/test/ChampionshipPage.test.tsx index 61261ab..75b2833 100644 --- a/frontend/src/test/ChampionshipPage.test.tsx +++ b/frontend/src/test/ChampionshipPage.test.tsx @@ -95,6 +95,7 @@ function renderPage() { describe('ChampionshipPage', () => { beforeEach(() => { vi.clearAllMocks() + window.localStorage.clear() mockFetchSeasons.mockResolvedValue([2025]) mockFetchHub.mockResolvedValue(hub) }) @@ -127,6 +128,18 @@ describe('ChampionshipPage', () => { expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument() }) + it('switches to the simulator view and projects standings', async () => { + renderPage() + + await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument()) + + fireEvent.click(screen.getByTestId('champ-tab-simulator')) + expect(screen.getByTestId('champ-view-simulator')).toBeInTheDocument() + expect(screen.getByTestId('sim-projected')).toBeInTheDocument() + // 4 rounds left, default scenario: VER projects to 200 + 4×25 = 300. + expect(screen.getByText('300')).toBeInTheDocument() + }) + it('shows the empty state when no drivers are returned', async () => { mockFetchHub.mockResolvedValue({ ...hub, drivers: [], teams: [] }) renderPage() diff --git a/frontend/src/test/ChampionshipSimulator.test.tsx b/frontend/src/test/ChampionshipSimulator.test.tsx new file mode 100644 index 0000000..3438fba --- /dev/null +++ b/frontend/src/test/ChampionshipSimulator.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { ChampionshipSimulator } from '../components/ChampionshipSimulator' +import type { ChampHubDriver, ChampionshipHub } from '../types' + +function driver(over: Partial): ChampHubDriver { + return { + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + points: 0, + position: 1, + wins: 0, + podiums: 0, + poles: 0, + form: [], + cumulative: [], + teammate_wins: 0, + teammate_losses: 0, + ...over, + } +} + +const drivers: ChampHubDriver[] = [ + driver({ driver_number: 1, name_acronym: 'VER', points: 200, position: 1 }), + driver({ + driver_number: 4, + name_acronym: 'NOR', + full_name: 'Lando Norris', + team_name: 'McLaren', + team_colour: 'ff8000', + points: 190, + position: 2, + }), + driver({ + driver_number: 16, + name_acronym: 'LEC', + full_name: 'Charles Leclerc', + team_name: 'Ferrari', + team_colour: 'e8002d', + points: 120, + position: 3, + }), +] + +const hub: ChampionshipHub = { + season: 2025, + round: 9, + total_rounds: 10, + rounds_left: 1, + last_race: 'Monaco GP', + round_labels: ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'], + drivers, + teams: [], +} + +describe('ChampionshipSimulator', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('seeds the round by championship order and projects the default scenario', () => { + render() + + expect(screen.getByTestId('champ-view-simulator')).toBeInTheDocument() + // Only 1 remaining round; label falls back to "Round 10" (no label yet). + expect(screen.getByTestId('sim-round-0')).toHaveTextContent('Round 10') + // P1 select seeded with the current leader. + expect(screen.getByTestId('sim-pos-1')).toHaveValue('1') + expect(screen.getByTestId('sim-pos-2')).toHaveValue('4') + + // Default projection: VER 200+25=225 on top, delta arrows absent. + const verRow = screen.getByTestId('sim-row-1') + expect(within(verRow).getByText('225')).toBeInTheDocument() + expect(within(verRow).getByText('P1')).toBeInTheDocument() + }) + + it('updates the projected table when a win is reassigned', () => { + render() + + // Give NOR the win; VER (previous P1) is bumped out of the slot. + fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } }) + + // NOR: 190 + 25 = 215 → P1 with an up arrow; VER stays on 200 → P2 down. + const norRow = screen.getByTestId('sim-row-4') + expect(within(norRow).getByText('215')).toBeInTheDocument() + expect(within(norRow).getByText('P1')).toBeInTheDocument() + expect(within(norRow).getByText('▲1')).toBeInTheDocument() + + const verRow = screen.getByTestId('sim-row-1') + // "200" appears in both Now and Proj columns (no simulated points). + expect(within(verRow).getAllByText('200')).toHaveLength(2) + expect(within(verRow).getByText('+0')).toBeInTheDocument() + expect(within(verRow).getByText('P2')).toBeInTheDocument() + expect(within(verRow).getByText('▼1')).toBeInTheDocument() + + // VER was removed from P1 and holds no slot now. + expect(screen.getByTestId('sim-pos-1')).toHaveValue('4') + }) + + it('shows title alive/eliminated states', () => { + render() + + // Default: VER projects to 225. NOR max = 190+25=215 < 225 → OUT. + expect(within(screen.getByTestId('sim-row-1')).getByText('ALIVE')).toBeInTheDocument() + expect(within(screen.getByTestId('sim-row-4')).getByText('OUT')).toBeInTheDocument() + + // If VER scores nothing, NOR can still catch him: 190+25 ≥ 200 → ALIVE. + fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } }) + expect(within(screen.getByTestId('sim-row-4')).getByText('ALIVE')).toBeInTheDocument() + }) + + it('reset round and reset all restore the default order', () => { + render() + + fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '16' } }) + expect(screen.getByTestId('sim-pos-1')).toHaveValue('16') + + fireEvent.click(screen.getByTestId('sim-reset-round')) + expect(screen.getByTestId('sim-pos-1')).toHaveValue('1') + + fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '16' } }) + fireEvent.click(screen.getByTestId('sim-reset-all')) + expect(screen.getByTestId('sim-pos-1')).toHaveValue('1') + }) + + it('persists the scenario to localStorage keyed by season', () => { + const { unmount } = render() + fireEvent.change(screen.getByTestId('sim-pos-1'), { target: { value: '4' } }) + unmount() + + const stored = window.localStorage.getItem('box-box.champ.sim.2025') + expect(stored).not.toBeNull() + + render() + expect(screen.getByTestId('sim-pos-1')).toHaveValue('4') + }) + + it('survives corrupt localStorage', () => { + window.localStorage.setItem('box-box.champ.sim.2025', '{not json') + render() + expect(screen.getByTestId('sim-pos-1')).toHaveValue('1') + }) + + it('shows the season-complete empty state when no rounds remain', () => { + render() + expect(screen.getByTestId('champ-view-simulator')).toHaveTextContent( + 'Season complete — nothing left to simulate.', + ) + }) + + it('uses round labels beyond the current round when available', () => { + const labelled: ChampionshipHub = { + ...hub, + round: 8, + rounds_left: 2, + round_labels: [...hub.round_labels, 'ABU'], + } + render() + expect(screen.getByTestId('sim-round-0')).toHaveTextContent('R9') + expect(screen.getByTestId('sim-round-1')).toHaveTextContent('ABU') + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 525d80d..a2ecad4 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -4,3 +4,26 @@ Object.defineProperty(window, 'scrollTo', { value: () => {}, writable: true, }) + +// Node 22+ ships an experimental `localStorage` global that shadows jsdom's +// implementation; without `--localstorage-file` it resolves to undefined in +// the test environment. Back-fill a minimal in-memory Storage so components +// that persist state (e.g. the championship simulator) are testable. +if (typeof window !== 'undefined' && !window.localStorage) { + const store = new Map() + const localStorageMock: Storage = { + get length() { + return store.size + }, + clear: () => store.clear(), + getItem: (key) => (store.has(key) ? store.get(key)! : null), + key: (index) => [...store.keys()][index] ?? null, + removeItem: (key) => { + store.delete(key) + }, + setItem: (key, value) => { + store.set(String(key), String(value)) + }, + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }) +} diff --git a/frontend/src/test/simulator.test.ts b/frontend/src/test/simulator.test.ts new file mode 100644 index 0000000..831a709 --- /dev/null +++ b/frontend/src/test/simulator.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_POINTS_PER_ROUND, + POINTS_BY_POSITION, + SCORING_POSITIONS, + assignPosition, + defaultRound, + defaultScenario, + emptyRound, + normalizeScenario, + pointsForPosition, + projectStandings, + simulatedPoints, +} from '../lib/simulator' +import type { Scenario } from '../lib/simulator' +import type { ChampHubDriver } from '../types' + +function driver(over: Partial): ChampHubDriver { + return { + driver_number: 1, + name_acronym: 'VER', + full_name: 'Max Verstappen', + team_name: 'Red Bull', + team_colour: '3671c6', + points: 0, + position: 1, + wins: 0, + podiums: 0, + poles: 0, + form: [], + cumulative: [], + teammate_wins: 0, + teammate_losses: 0, + ...over, + } +} + +const standings: ChampHubDriver[] = [ + driver({ driver_number: 1, name_acronym: 'VER', points: 200, position: 1 }), + driver({ driver_number: 4, name_acronym: 'NOR', points: 190, position: 2 }), + driver({ driver_number: 16, name_acronym: 'LEC', points: 120, position: 3 }), + driver({ driver_number: 44, name_acronym: 'HAM', points: 40, position: 4 }), +] + +describe('pointsForPosition', () => { + it('matches the current F1 points table for P1–P10', () => { + expect(POINTS_BY_POSITION).toEqual([25, 18, 15, 12, 10, 8, 6, 4, 2, 1]) + expect(pointsForPosition(1)).toBe(25) + expect(pointsForPosition(2)).toBe(18) + expect(pointsForPosition(10)).toBe(1) + }) + + it('awards zero outside the top ten and for invalid positions', () => { + expect(pointsForPosition(0)).toBe(0) + expect(pointsForPosition(11)).toBe(0) + expect(pointsForPosition(-3)).toBe(0) + expect(pointsForPosition(2.5)).toBe(0) + }) +}) + +describe('defaultRound / defaultScenario', () => { + it('seeds the round with the current championship order', () => { + const round = defaultRound(standings) + expect(round.length).toBe(SCORING_POSITIONS) + expect(round.slice(0, 4)).toEqual([1, 4, 16, 44]) + expect(round.slice(4)).toEqual([null, null, null, null, null, null]) + }) + + it('sorts by points even when position fields disagree', () => { + const shuffled = [ + driver({ driver_number: 4, points: 190, position: 2 }), + driver({ driver_number: 1, points: 200, position: 1 }), + ] + expect(defaultRound(shuffled).slice(0, 2)).toEqual([1, 4]) + }) + + it('builds one round per remaining round, and none for finished seasons', () => { + expect(defaultScenario(standings, 3)).toHaveLength(3) + expect(defaultScenario(standings, 0)).toHaveLength(0) + expect(defaultScenario(standings, -2)).toHaveLength(0) + expect(defaultScenario([], 2)[0]).toEqual(emptyRound()) + }) +}) + +describe('simulatedPoints', () => { + it('sums points across rounds per driver', () => { + const scenario: Scenario = [defaultRound(standings), defaultRound(standings)] + const totals = simulatedPoints(scenario) + expect(totals.get(1)).toBe(50) + expect(totals.get(4)).toBe(36) + expect(totals.get(16)).toBe(30) + expect(totals.get(44)).toBe(24) + }) + + it('ignores unassigned slots and empty scenarios', () => { + expect(simulatedPoints([]).size).toBe(0) + expect(simulatedPoints([emptyRound()]).size).toBe(0) + }) +}) + +describe('projectStandings', () => { + it('returns an empty array for no drivers', () => { + expect(projectStandings([], [emptyRound()], 3)).toEqual([]) + }) + + it('keeps order and zero deltas under the default (status quo) scenario', () => { + const rows = projectStandings(standings, defaultScenario(standings, 2), 2) + expect(rows.map((r) => r.driver.driver_number)).toEqual([1, 4, 16, 44]) + expect(rows.every((r) => r.delta === 0)).toBe(true) + expect(rows[0].projectedPoints).toBe(200 + 50) + }) + + it('computes projected points, positions, and deltas when the order flips', () => { + // NOR wins both remaining rounds, VER scores nothing. + const win: (number | null)[] = [4, ...Array(9).fill(null)] + const rows = projectStandings(standings, [win, win], 2) + + const nor = rows.find((r) => r.driver.driver_number === 4)! + const ver = rows.find((r) => r.driver.driver_number === 1)! + expect(nor.projectedPoints).toBe(190 + 50) + expect(nor.projectedPosition).toBe(1) + expect(nor.delta).toBe(1) // moved up one place + expect(ver.projectedPosition).toBe(2) + expect(ver.delta).toBe(-1) // dropped one place + }) + + it('breaks projected-points ties by current position', () => { + const pair = [ + driver({ driver_number: 1, name_acronym: 'VER', points: 100, position: 1 }), + driver({ driver_number: 4, name_acronym: 'NOR', points: 90, position: 2 }), + ] + // NOR takes P5 (+10) → both project to 100. Better current position wins the tie. + const round: (number | null)[] = [null, null, null, null, 4, null, null, null, null, null] + const rows = projectStandings(pair, [round], 1) + expect(rows[0].driver.driver_number).toBe(1) + expect(rows[1].driver.driver_number).toBe(4) + expect(rows[0].projectedPoints).toBe(100) + expect(rows[1].projectedPoints).toBe(100) + expect(rows.every((r) => r.delta === 0)).toBe(true) + }) +}) + +describe('title elimination math', () => { + it('marks drivers alive when max remaining points can match the leader projection', () => { + // Default scenario: leader VER projects to 200 + 2×25 = 250. + // HAM max = 40 + 2×25 = 90 < 250 → OUT. NOR max = 190 + 50 = 240 < 250 → OUT. + const rows = projectStandings(standings, defaultScenario(standings, 2), 2) + const byNum = new Map(rows.map((r) => [r.driver.driver_number, r])) + expect(byNum.get(1)!.titleAlive).toBe(true) + expect(byNum.get(4)!.titleAlive).toBe(false) + expect(byNum.get(44)!.titleAlive).toBe(false) + }) + + it('keeps close challengers alive when the leader scores nothing in the scenario', () => { + // Leader scores 0 in both remaining rounds → leader projected stays 200. + const scenario: Scenario = [emptyRound(), emptyRound()] + const rows = projectStandings(standings, scenario, 2) + const byNum = new Map(rows.map((r) => [r.driver.driver_number, r])) + expect(byNum.get(4)!.titleAlive).toBe(true) // 190 + 50 ≥ 200 + expect(byNum.get(16)!.titleAlive).toBe(false) // 120 + 50 < 200 + expect(byNum.get(44)!.titleAlive).toBe(false) // 40 + 50 < 200 + }) + + it('always keeps the current leader alive', () => { + const scenario = defaultScenario(standings, 5) + const rows = projectStandings(standings, scenario, 5) + expect(rows.find((r) => r.driver.driver_number === 1)!.titleAlive).toBe(true) + }) + + it('handles zero rounds left: alive only means already matching the leader', () => { + const rows = projectStandings(standings, [], 0) + expect(rows[0].titleAlive).toBe(true) + expect(rows.slice(1).every((r) => !r.titleAlive)).toBe(true) + }) + + it('uses 25 as the max points per round', () => { + expect(MAX_POINTS_PER_ROUND).toBe(25) + }) +}) + +describe('normalizeScenario', () => { + it('accepts a valid stored scenario', () => { + const stored: Scenario = [ + [4, 1, null, null, null, null, null, null, null, null], + defaultRound(standings), + ] + const result = normalizeScenario(JSON.parse(JSON.stringify(stored)), standings, 2) + expect(result).toEqual(stored) + }) + + it('falls back to defaults for garbage input', () => { + const def = defaultScenario(standings, 2) + expect(normalizeScenario(undefined, standings, 2)).toEqual(def) + expect(normalizeScenario('nope', standings, 2)).toEqual(def) + expect(normalizeScenario({ a: 1 }, standings, 2)).toEqual(def) + expect(normalizeScenario(42, standings, 2)).toEqual(def) + }) + + it('rejects rounds with unknown drivers, duplicates, or the wrong shape', () => { + const def = defaultRound(standings) + const bad: unknown = [ + [999, null, null, null, null, null, null, null, null, null], // unknown driver + [1, 1, null, null, null, null, null, null, null, null], // duplicate + [1, 4], // wrong length + ] + const result = normalizeScenario(bad, standings, 3) + expect(result).toEqual([def, def, def]) + }) + + it('trims or pads to the current rounds_left', () => { + const one: Scenario = [[4, null, null, null, null, null, null, null, null, null]] + expect(normalizeScenario(one, standings, 3)).toHaveLength(3) + expect(normalizeScenario([...one, ...one, ...one], standings, 1)).toHaveLength(1) + }) +}) + +describe('assignPosition', () => { + it('assigns a driver and removes it from its previous slot', () => { + const round = defaultRound(standings) // [1, 4, 16, 44, ...] + const next = assignPosition(round, 0, 4) // NOR to P1 + expect(next[0]).toBe(4) + expect(next[1]).toBeNull() // NOR removed from P2 + expect(next[2]).toBe(16) + expect(round[0]).toBe(1) // input not mutated + }) + + it('clears a slot when assigning null and ignores out-of-range positions', () => { + const round = defaultRound(standings) + expect(assignPosition(round, 0, null)[0]).toBeNull() + expect(assignPosition(round, 99, 4)).toEqual(round) + expect(assignPosition(round, -1, 4)).toEqual(round) + }) + + it('repairs short rounds to the full ten slots', () => { + const next = assignPosition([1], 3, 4) + expect(next).toHaveLength(SCORING_POSITIONS) + expect(next[0]).toBe(1) + expect(next[3]).toBe(4) + }) +})