import { describe, expect, it } from 'vitest'; import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type Picks } from './whatif'; import type { Fixture } from './types'; const slot = (team: string | null, placeholder: string | null = null) => ({ team, placeholder, label: team ?? placeholder ?? '?' }); const fx = (num: number, stage: Fixture['stage'], home: ReturnType, away: ReturnType, over: Partial = {}): Fixture => ({ num, stage, group: null, round: stage, groupRound: null, kickoff: `2026-07-${String(num).padStart(2, '0')}T18:00:00Z`, venue: 'X', home, away, status: 'scheduled', homeScore: null, awayScore: null, minute: null, ...over, } as Fixture); // tiny 4-team bracket: 90/91 are semis feeding final 93 (third place 92) const bracket = (): Fixture[] => [ fx(90, 'sf', slot('Spain'), slot('France')), fx(91, 'sf', slot('Brazil'), slot('Japan')), fx(92, 'third', slot(null, 'L90'), slot(null, 'L91')), fx(93, 'final', slot(null, 'W90'), slot(null, 'W91')), ]; describe('effectiveBracket', () => { it('propagates picks through W/L placeholders', () => { const eff = effectiveBracket(bracket(), { 90: 'home', 91: 'away' }); expect(eff.get(93)).toMatchObject({ home: 'Spain', away: 'Japan' }); expect(eff.get(92)).toMatchObject({ home: 'France', away: 'Brazil' }); }); it('real finished results override picks', () => { const fxs = bracket(); fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 0, awayScore: 2 }); const eff = effectiveBracket(fxs, { 90: 'home' }); // pick says Spain, result says France expect(eff.get(90)).toMatchObject({ winner: 'France', decided: true }); expect(eff.get(93)?.home).toBe('France'); }); it('leaves unresolved slots null without picks', () => { const eff = effectiveBracket(bracket(), {}); expect(eff.get(93)).toMatchObject({ home: null, away: null, winner: null }); }); }); describe('prunePicks', () => { it('drops downstream picks when an upstream pick changes the pairing', () => { const picks: Picks = { 90: 'home', 91: 'home', 93: 'away' }; // final pick: Brazil const pruned = prunePicks(bracket(), { ...picks, 91: 'away' }); // now Japan reaches the final // the final pick referenced a pairing that still exists (home/away resolved) — side picks survive pairings expect(pruned[90]).toBe('home'); expect(pruned[91]).toBe('away'); }); it('drops picks on matches that became decided', () => { const fxs = bracket(); fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 1, awayScore: 0 }); const pruned = prunePicks(fxs, { 90: 'away' }); expect(pruned[90]).toBeUndefined(); }); }); describe('championPath', () => { it('returns the picked champion and their picked matches', () => { const fxs = bracket(); const picks: Picks = { 90: 'home', 91: 'away', 93: 'home' }; const path = championPath(fxs, effectiveBracket(fxs, picks)); expect(path).toMatchObject({ champion: 'Spain', matchNums: [90, 93] }); }); it('is null while the final is unresolved', () => { expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull(); }); }); describe('projectedSeeds + modelBracket', () => { const slot = (team: string | null, placeholder: string | null) => ({ team, placeholder, label: team ?? placeholder ?? '' }); const groupFx = (num: number, group: string, home: string, away: string) => ({ num, stage: 'group' as const, group, round: '', groupRound: 1, kickoff: '2026-06-11T00:00:00Z', venue: 'X', home: slot(home, null), away: slot(away, null), status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null, }); const koFx = (num: number, stage: 'r32' | 'r16', hp: string, ap: string) => ({ num, stage, group: null, round: '', groupRound: null, kickoff: '2026-06-29T00:00:00Z', venue: 'X', home: slot(null, hp), away: slot(null, ap), status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null, }); const odds = (team: string, winGroup: number, qualify: number) => ({ team, winGroup, qualify, reachR16: 0, reachQF: 0, reachSF: 0, reachFinal: 0, champion: 0, }); const fixtures = [ groupFx(1, 'A', 'Alpha', 'Beta'), groupFx(2, 'A', 'Gamma', 'Beta'), groupFx(3, 'B', 'Delta', 'Echo'), groupFx(4, 'B', 'Foxtrot', 'Echo'), koFx(73, 'r32', '1A', '2B'), koFx(74, 'r32', '1B', '3A/B'), koFx(75, 'r16', 'W73', 'W74'), ]; const teamOdds = [ odds('Alpha', 0.7, 0.95), odds('Beta', 0.2, 0.6), odds('Gamma', 0.1, 0.3), odds('Delta', 0.6, 0.9), odds('Echo', 0.3, 0.7), odds('Foxtrot', 0.1, 0.4), ]; it('seeds winners, runners-up and third-place slots from the odds', () => { const seeds = projectedSeeds(fixtures, teamOdds); expect(seeds['1A']).toBe('Alpha'); expect(seeds['2A']).toBe('Beta'); expect(seeds['1B']).toBe('Delta'); // best third by qualify between Gamma (0.3) and Foxtrot (0.4) → Foxtrot expect(seeds['3A/B']).toBe('Foxtrot'); }); it('fills every resolvable pairing with the model-preferred side', () => { const seeds = projectedSeeds(fixtures, teamOdds); // favor the alphabetically-first team so outcomes are deterministic const adv = (_f: unknown, home: string, away: string) => (home < away ? 0.8 : 0.2); const picks = modelBracket(fixtures, seeds, adv); expect(picks[73]).toBe('home'); // Alpha vs Echo → Alpha expect(picks[74]).toBe('home'); // Delta vs Foxtrot → Delta expect(picks[75]).toBe('home'); // Alpha vs Delta → Alpha const eff = effectiveBracket(fixtures, picks, seeds); expect(eff.get(75)?.winner).toBe('Alpha'); }); });