mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
feat(#24): Rivalry compare view
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user